Files
PilotVault/Fly App/lib/ui/fly/panels/settings/link_rows.dart
T
tajniak81andClaude Opus 5.5 636da1ac2c Give the Fly App every remaining GO 4 flight mode and the missing settings
Intelligent modes: Point of Interest (orbit on the map, live radius and
speed), Follow Me (follows the phone), QuickShots on a boxed subject,
Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and
Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and
Circle. Every mode has a first-use tutorial and stops from the rail.
Waypoints can resume an interrupted mission, change cruise speed in
flight and check terrain clearance (Open-Meteo, on tap).

Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI
output, a link spectrum chart, Wi-Fi name and password, aircraft name,
video cache, battery warning history and pairing, non-smart cell
voltages. Calibration: compass interference meter, per-IMU calibration,
lens infinity focus and a first-manual-focus prompt. Also an RTK chip,
gimbal movement presets and motor tuning, Precision Takeoff gated on the
flight controller, and the precise GEO database's server version.

Built and unit/widget-tested only; nothing has flown yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:06:16 +02:00

335 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../dji_service.dart';
import '../../../../flight_model.dart';
import '../../../../fly/fly_prefs.dart';
import '../../../../fly/state/link_extras.dart';
import '../../../../theme.dart';
import '../../hud_kit.dart';
import '../camera_menu/camera_rows.dart';
import 'setting_tiles.dart' show TextSettingDialog;
/// GO 4's RC → Button Customization (plan P6): what C1 and C2 do. Kept on
/// the phone; the flight screen acts on the remote's presses.
class CustomButtonRows extends StatelessWidget {
const CustomButtonRows({super.key});
@override
Widget build(BuildContext context) {
final List<(String, String)> options = <(String, String)>[for (final RcButtonAction a in RcButtonAction.values) (a.name, a.label)];
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
for (final RcButton b in RcButton.values)
ValueListenableBuilder<RcButtonAction>(
valueListenable: b == RcButton.c1 ? FlyPrefs.c1 : FlyPrefs.c2,
builder: (BuildContext context, RcButtonAction a, _) => ChoiceTile(
key: ValueKey<String>('row-rc${b.label}'),
label: '${b.label} button',
options: options,
selected: a.name,
onSelect: (String v) => FlyPrefs.setRcButton(b, RcButtonAction.values.byName(v)),
),
),
]);
}
}
/// A slave remote looks for masters in range and joins one with its password.
class MasterSearchRows extends StatefulWidget {
const MasterSearchRows({super.key, required this.model, required this.dji, required this.onError});
final FlightModel model;
final DjiService dji;
final void Function(String label, Object error) onError;
@override
State<MasterSearchRows> createState() => _MasterSearchRowsState();
}
class _MasterSearchRowsState extends State<MasterSearchRows> {
bool _searching = false;
@override
void dispose() {
if (_searching) widget.dji.searchMasters(false).catchError((Object _) {});
super.dispose();
}
Future<void> _toggle() async {
final bool on = !_searching;
setState(() => _searching = on);
if (on) widget.model.rcMasters.value = const <RcPeer>[];
try {
await widget.dji.searchMasters(on);
} catch (e) {
if (mounted) setState(() => _searching = false);
widget.onError('Search for master', e);
}
}
Future<void> _join(RcPeer m) async {
final String? password = await showDialog<String>(
context: context,
builder: (BuildContext ctx) => TextSettingDialog(label: 'Password for ${m.name ?? 'master'}', initial: '', secret: true, maxLength: 4),
);
if (password == null) return;
try {
final String? r = await widget.dji.connectToMaster(id: m.id, name: m.name ?? '', password: password);
if (!mounted) return;
final String msg = switch (r) {
'ACCEPTED' => 'Joined ${m.name ?? 'the master'}',
'PASSWORD_ERROR' => 'Wrong password',
'REJECTED' => 'The master refused',
'MAXIMUM_CAPACITY' => 'The master has no room for another slave',
'TIMEOUT' => 'The master did not answer',
_ => 'Could not join ($r)',
};
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(SnackBar(content: Text(msg)));
} catch (e) {
widget.onError('Join master', e);
}
}
@override
Widget build(BuildContext context) {
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
ButtonTile(
id: 'searchMasters',
label: _searching ? 'Searching for masters…' : 'Find a master remote',
button: _searching ? 'Stop' : 'Search',
onTap: _toggle,
),
ValueListenableBuilder<List<RcPeer>>(
valueListenable: widget.model.rcMasters,
builder: (BuildContext context, List<RcPeer> masters, _) => Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
for (final RcPeer m in masters)
ValueTile(
key: ValueKey<String>('master-${m.id}'),
label: m.name ?? 'Remote ${m.id}',
hint: m.signal == null ? null : 'Signal ${m.signal} %',
value: 'Join',
onTap: () => _join(m),
),
]),
),
]);
}
}
/// A master remote lists the slaves that joined it.
class SlaveListRow extends StatefulWidget {
const SlaveListRow({super.key, required this.dji, required this.onError});
final DjiService dji;
final void Function(String label, Object error) onError;
@override
State<SlaveListRow> createState() => _SlaveListRowState();
}
class _SlaveListRowState extends State<SlaveListRow> {
List<RcPeer>? _slaves;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final List<Map<String, dynamic>> l = await widget.dji.getSlaves();
if (mounted) setState(() => _slaves = <RcPeer>[for (final Map<String, dynamic> m in l) RcPeer.fromMap(m)]);
} catch (_) {
if (mounted) setState(() => _slaves = const <RcPeer>[]);
}
}
@override
Widget build(BuildContext context) {
final List<RcPeer>? s = _slaves;
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
ValueTile(
key: const ValueKey<String>('row-slaves'),
label: 'Slaves',
value: s == null ? '…' : (s.isEmpty ? 'None' : '${s.length}'),
onTap: _load,
),
for (final RcPeer p in s ?? const <RcPeer>[])
ValueTile(label: p.name ?? 'Remote ${p.id}', value: p.signal == null ? '—' : '${p.signal} %'),
]);
}
}
/// The video link's interference as a bar chart (GO 4's transmission
/// spectrum): taller bars are noisier channels; the quietest is marked, the
/// one to pick in Custom mode. Listens only while it is on screen.
class SpectrumChart extends StatefulWidget {
const SpectrumChart({super.key, required this.model, required this.dji, required this.connected});
final FlightModel model;
final DjiService dji;
final bool connected;
@override
State<SpectrumChart> createState() => _SpectrumChartState();
}
class _SpectrumChartState extends State<SpectrumChart> {
@override
void initState() {
super.initState();
if (widget.connected) widget.dji.watchSpectrum(true).catchError((Object _) {});
}
@override
void dispose() {
widget.dji.watchSpectrum(false).catchError((Object _) {});
super.dispose();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Spectrum?>(
valueListenable: widget.model.spectrum,
builder: (BuildContext context, Spectrum? s, _) {
if (s == null || s.bars.isEmpty) {
return ValueTile(
key: const ValueKey<String>('row-spectrum'),
label: 'Interference',
value: widget.connected ? 'Listening…' : '—',
);
}
final SpectrumBar? q = s.quietest;
return Padding(
key: const ValueKey<String>('row-spectrum'),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
Row(children: <Widget>[
Expanded(child: Text('INTERFERENCE', style: HudText.label)),
if (q != null) Text('Quietest: ${q.label}', style: HudText.monoSmall.copyWith(color: HudColors.ready)),
]),
const SizedBox(height: 6),
SizedBox(height: 70, child: CustomPaint(size: Size.infinite, painter: SpectrumPainter(s))),
]),
);
},
);
}
}
class SpectrumPainter extends CustomPainter {
SpectrumPainter(this.spectrum);
final Spectrum spectrum;
/// dBm drawn as an empty bar (quiet) and a full one (loud).
static const double floor = -110, ceiling = -60;
@override
void paint(Canvas canvas, Size size) {
final List<SpectrumBar> bars = spectrum.bars;
if (bars.isEmpty) return;
final double w = size.width / bars.length;
final SpectrumBar? q = spectrum.quietest;
for (int i = 0; i < bars.length; i++) {
final double t = ((bars[i].power - floor) / (ceiling - floor)).clamp(0.05, 1.0);
final Color c = identical(bars[i], q)
? HudColors.ready
: t > 0.7
? HudColors.danger
: t > 0.4
? HudColors.caution
: Glass.sat;
final double h = size.height * t;
canvas.drawRect(Rect.fromLTWH(i * w + w * 0.15, size.height - h, w * 0.7, h), Paint()..color = c);
}
}
@override
bool shouldRepaint(SpectrumPainter old) => old.spectrum != spectrum;
}
/// GO 4's video cache: record the live feed to the phone while flying, with a
/// size cap and Clear.
class VideoCacheRows extends StatefulWidget {
const VideoCacheRows({super.key, required this.dji, required this.onError});
final DjiService dji;
final void Function(String label, Object error) onError;
static const List<int> limits = <int>[1024, 2048, 4096, 8192, 16384];
@override
State<VideoCacheRows> createState() => _VideoCacheRowsState();
}
class _VideoCacheRowsState extends State<VideoCacheRows> {
Map<String, dynamic>? _info;
@override
void initState() {
super.initState();
_refresh();
}
Future<void> _refresh() async {
try {
final Map<String, dynamic> i = await widget.dji.getVideoCacheInfo();
if (mounted) setState(() => _info = i);
} on MissingPluginException {
// No native side (tests).
} catch (_) {}
}
static String _size(num? bytes) {
final double b = (bytes ?? 0).toDouble();
if (b >= 1 << 30) return '${(b / (1 << 30)).toStringAsFixed(1)} GB';
return '${(b / (1 << 20)).toStringAsFixed(0)} MB';
}
@override
Widget build(BuildContext context) {
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
ValueListenableBuilder<bool>(
valueListenable: FlyPrefs.videoCache,
builder: (BuildContext context, bool on, _) => SwitchTile(
key: const ValueKey<String>('row-videoCache'),
label: 'Cache the live video',
hint: 'Records the feed to this phone while flying (raw H.264, plays in VLC)',
value: on,
onChanged: FlyPrefs.setVideoCache,
),
),
ValueListenableBuilder<int>(
valueListenable: FlyPrefs.videoCacheLimitMb,
builder: (BuildContext context, int mb, _) => ChoiceTile(
key: const ValueKey<String>('row-videoCacheLimit'),
label: 'Cache size',
hint: 'The oldest recordings go first',
options: <(String, String)>[for (final int l in VideoCacheRows.limits) ('$l', '${l ~/ 1024} GB')],
selected: '$mb',
onSelect: (String v) => FlyPrefs.setVideoCacheLimitMb(int.parse(v)),
),
),
ButtonTile(
id: 'clearVideoCache',
label: 'Clear video cache',
hint: _info == null ? null : '${_info!['files'] ?? 0} recordings · ${_size(_info!['bytes'] as num?)}',
button: 'Clear',
danger: true,
onTap: () async {
try {
await widget.dji.clearVideoCache();
} catch (e) {
widget.onError('Clear video cache', e);
}
_refresh();
},
),
]);
}
}