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>
335 lines
11 KiB
Dart
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();
|
|
},
|
|
),
|
|
]);
|
|
}
|
|
}
|