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>
385 lines
13 KiB
Dart
385 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import '../../../../dji_service.dart';
|
||
import '../../../../fly/settings/settings_spec.dart';
|
||
import '../../../../fly/state/key_state.dart';
|
||
import '../../../../theme.dart';
|
||
import '../../dialogs/slide_to_confirm.dart';
|
||
import '../../hud_kit.dart';
|
||
import '../camera_menu/camera_rows.dart';
|
||
import '../camera_write.dart';
|
||
|
||
/// Writes one KeyManager setting the way every camera / gimbal row does: show
|
||
/// it at once, send, roll back with [onError] if the aircraft refuses.
|
||
Future<void> writeKeySetting(
|
||
KeyState keys,
|
||
DjiService dji,
|
||
DjiKey key,
|
||
Object value, {
|
||
required String label,
|
||
required void Function(String label, Object error) onError,
|
||
}) =>
|
||
writeCameraSetting(
|
||
keys,
|
||
label: label,
|
||
pending: <String, Object?>{key.id: value},
|
||
send: () => dji.setKey(key, value),
|
||
onError: onError,
|
||
);
|
||
|
||
/// Renders one [SettingSpec] with the shared row kit. [custom] draws the
|
||
/// sheet's own rows ([CustomSpec]).
|
||
class SettingRowTile extends StatelessWidget {
|
||
const SettingRowTile({
|
||
super.key,
|
||
required this.spec,
|
||
required this.settings,
|
||
required this.dji,
|
||
required this.onError,
|
||
required this.custom,
|
||
});
|
||
|
||
final SettingSpec spec;
|
||
final SettingsContext settings;
|
||
final DjiService dji;
|
||
final void Function(String label, Object error) onError;
|
||
final Widget Function(CustomSpec spec) custom;
|
||
|
||
KeyState get _keys => settings.keys;
|
||
|
||
Future<void> _write(DjiKey key, Object value) =>
|
||
writeKeySetting(_keys, dji, key, value, label: spec.label, onError: onError);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final String? locked = spec.lockedReason(settings);
|
||
final SettingSpec s = spec;
|
||
final Widget tile = switch (s) {
|
||
GroupSpec() => Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||
child: Text(s.label, style: HudText.label),
|
||
),
|
||
KeySwitchSpec() => SwitchTile(
|
||
key: ValueKey<String>('row-${s.id}'),
|
||
label: s.label,
|
||
hint: locked ?? s.hint,
|
||
value: s.current(_keys),
|
||
pending: _keys.pendingFor(s.key),
|
||
onChanged: (bool on) => _write(s.key, on),
|
||
),
|
||
KeyChoiceSpec() => ChoiceTile(
|
||
key: ValueKey<String>('row-${s.id}'),
|
||
label: s.label,
|
||
hint: locked ?? s.hint,
|
||
options: s.optionsFor(_keys),
|
||
selected: s.current(_keys),
|
||
pending: _keys.pendingFor(s.key),
|
||
onSelect: (String v) => _write(s.key, v),
|
||
),
|
||
KeyNumberSpec() => _NumberTile(spec: s, keys: _keys, locked: locked, onSet: (int v) => _write(s.key, v)),
|
||
KeyReadoutSpec() => ValueTile(key: ValueKey<String>('row-${s.id}'), label: s.label, hint: s.hint, value: s.shown(_keys)),
|
||
KeyTextSpec() => ValueTile(
|
||
key: ValueKey<String>('row-${s.id}'),
|
||
label: s.label,
|
||
hint: locked ?? s.hint,
|
||
value: s.shown(_keys),
|
||
onTap: locked == null ? () => _editText(context, s) : null,
|
||
),
|
||
KeyActionSpec() => ButtonTile(
|
||
key: ValueKey<String>('row-${s.id}'),
|
||
id: s.id,
|
||
label: s.label,
|
||
hint: locked ?? s.hint,
|
||
button: s.button,
|
||
onTap: locked == null ? () => _runAction(context, s) : null,
|
||
),
|
||
CustomSpec() => custom(s),
|
||
};
|
||
// Steppers and buttons grey their own controls; the rest take no input.
|
||
if (locked != null && (s is KeySwitchSpec || s is KeyChoiceSpec)) {
|
||
return IgnorePointer(child: Opacity(opacity: 0.5, child: tile));
|
||
}
|
||
return tile;
|
||
}
|
||
|
||
Future<void> _editText(BuildContext context, KeyTextSpec t) async {
|
||
final String? typed = await showDialog<String>(
|
||
context: context,
|
||
builder: (BuildContext ctx) => TextSettingDialog(
|
||
label: t.label,
|
||
initial: t.secret ? '' : (t.current(_keys) ?? ''),
|
||
secret: t.secret,
|
||
maxLength: t.maxLength,
|
||
),
|
||
);
|
||
if (typed == null || typed == t.current(_keys)) return;
|
||
if (t.send != null) {
|
||
try {
|
||
await t.send!(dji, typed);
|
||
} catch (e) {
|
||
onError(t.label, e);
|
||
}
|
||
return;
|
||
}
|
||
await _write(t.key, typed);
|
||
}
|
||
|
||
Future<void> _runAction(BuildContext context, KeyActionSpec a) async {
|
||
if (a.confirmTitle != null) {
|
||
final SlideResult? ok = await showSlideToConfirm(
|
||
context,
|
||
SlideConfirmSpec(icon: 'link', title: a.confirmTitle!, description: a.confirmText ?? '', sliderLabel: 'Slide to confirm'),
|
||
);
|
||
if (ok == null) return;
|
||
}
|
||
try {
|
||
await dji.performKeyAction(a.key);
|
||
} catch (e) {
|
||
onError(a.label, e);
|
||
}
|
||
}
|
||
}
|
||
|
||
class _NumberTile extends StatelessWidget {
|
||
const _NumberTile({required this.spec, required this.keys, required this.locked, required this.onSet});
|
||
|
||
final KeyNumberSpec spec;
|
||
final KeyState keys;
|
||
final String? locked;
|
||
final ValueChanged<int> onSet;
|
||
|
||
String _shown(int? v) => v == null ? '—' : '$v${spec.unit.isEmpty ? '' : ' ${spec.unit}'}';
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final int? v = spec.current(keys);
|
||
final int? down = spec.stepped(keys, -1), up = spec.stepped(keys, 1);
|
||
final String? hint = locked ?? spec.hint ?? (spec.feet && v != null ? feetHint(v) : null);
|
||
return StepperTile(
|
||
key: ValueKey<String>('row-${spec.id}'),
|
||
id: spec.id,
|
||
label: spec.label,
|
||
hint: hint,
|
||
shown: _shown(v),
|
||
pending: keys.pendingFor(spec.key),
|
||
canDown: locked == null && down != null,
|
||
canUp: locked == null && up != null,
|
||
onStep: (int dir) {
|
||
final int? next = dir > 0 ? up : down;
|
||
if (next != null) onSet(next);
|
||
},
|
||
onValueTap: locked == null && v != null ? () => _type(context, v) : null,
|
||
);
|
||
}
|
||
|
||
/// Types a value — distance limits run to thousands of metres.
|
||
Future<void> _type(BuildContext context, int current) async {
|
||
final (int lo, int hi) = spec.range(keys);
|
||
final int? picked = await showDialog<int>(
|
||
context: context,
|
||
builder: (BuildContext ctx) => _NumberDialog(label: spec.label, unit: spec.unit, initial: current, min: lo, max: hi),
|
||
);
|
||
if (picked != null && picked != current) onSet(picked);
|
||
}
|
||
}
|
||
|
||
class _NumberDialog extends StatefulWidget {
|
||
const _NumberDialog({required this.label, required this.unit, required this.initial, required this.min, required this.max});
|
||
|
||
final String label;
|
||
final String unit;
|
||
final int initial;
|
||
final int min;
|
||
final int max;
|
||
|
||
@override
|
||
State<_NumberDialog> createState() => _NumberDialogState();
|
||
}
|
||
|
||
class _NumberDialogState extends State<_NumberDialog> {
|
||
late final TextEditingController _text = TextEditingController(text: '${widget.initial}');
|
||
|
||
@override
|
||
void dispose() {
|
||
_text.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _submit() {
|
||
final int? n = int.tryParse(_text.text.trim());
|
||
Navigator.of(context).pop(n?.clamp(widget.min, widget.max));
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final String unit = widget.unit.isEmpty ? '' : ' ${widget.unit}';
|
||
return AlertDialog(
|
||
backgroundColor: const Color(0xFF10203F),
|
||
title: Text(widget.label, style: HudText.sans.copyWith(fontSize: 15)),
|
||
content: TextField(
|
||
key: const ValueKey<String>('number-input'),
|
||
controller: _text,
|
||
autofocus: true,
|
||
keyboardType: TextInputType.number,
|
||
inputFormatters: <TextInputFormatter>[FilteringTextInputFormatter.digitsOnly],
|
||
style: HudText.value.copyWith(fontSize: 20),
|
||
decoration: InputDecoration(
|
||
suffixText: widget.unit,
|
||
helperText: '${widget.min} – ${widget.max}$unit',
|
||
helperStyle: HudText.label,
|
||
),
|
||
onSubmitted: (_) => _submit(),
|
||
),
|
||
actions: <Widget>[
|
||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||
FilledButton(key: const ValueKey<String>('number-set'), onPressed: _submit, child: const Text('Set')),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Types a string setting. The dialog's own State owns the controller (one
|
||
/// disposed right after `await showDialog` crashes the exit animation).
|
||
class TextSettingDialog extends StatefulWidget {
|
||
const TextSettingDialog({super.key, required this.label, required this.initial, this.secret = false, this.maxLength = 32});
|
||
|
||
final String label;
|
||
final String initial;
|
||
final bool secret;
|
||
final int maxLength;
|
||
|
||
@override
|
||
State<TextSettingDialog> createState() => _TextSettingDialogState();
|
||
}
|
||
|
||
class _TextSettingDialogState extends State<TextSettingDialog> {
|
||
late final TextEditingController _text = TextEditingController(text: widget.initial);
|
||
bool _show = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_text.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _submit() {
|
||
final String v = _text.text.trim();
|
||
Navigator.of(context).pop(v.isEmpty ? null : v);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
backgroundColor: const Color(0xFF10203F),
|
||
title: Text(widget.label, style: HudText.sans.copyWith(fontSize: 15)),
|
||
content: TextField(
|
||
key: const ValueKey<String>('text-input'),
|
||
controller: _text,
|
||
autofocus: true,
|
||
obscureText: widget.secret && !_show,
|
||
maxLength: widget.maxLength,
|
||
style: HudText.value.copyWith(fontSize: 16),
|
||
decoration: InputDecoration(
|
||
suffixIcon: widget.secret
|
||
? IconButton(
|
||
onPressed: () => setState(() => _show = !_show),
|
||
icon: Icon(_show ? Icons.visibility_off : Icons.visibility, size: 18),
|
||
)
|
||
: null,
|
||
),
|
||
onSubmitted: (_) => _submit(),
|
||
),
|
||
actions: <Widget>[
|
||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')),
|
||
FilledButton(key: const ValueKey<String>('text-set'), onPressed: _submit, child: const Text('Set')),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// GO 4's stick-mode picture: what each stick does in Mode 1 / 2 / 3.
|
||
class StickModeDiagram extends StatelessWidget {
|
||
const StickModeDiagram({super.key, required this.mode});
|
||
|
||
/// `STYLE_1` / `STYLE_2` / `STYLE_3`; anything else draws nothing.
|
||
final String? mode;
|
||
|
||
static const (String, String) _pitchRoll = ('Forward / back', 'Left / right');
|
||
static const (String, String) _throttleYaw = ('Up / down', 'Turn');
|
||
static const (String, String) _pitchYaw = ('Forward / back', 'Turn');
|
||
static const (String, String) _throttleRoll = ('Up / down', 'Left / right');
|
||
|
||
/// (left stick, right stick), each (vertical, horizontal).
|
||
static ((String, String), (String, String))? layoutFor(String? mode) => switch (mode) {
|
||
'STYLE_1' => (_pitchYaw, _throttleRoll),
|
||
'STYLE_2' => (_throttleYaw, _pitchRoll),
|
||
'STYLE_3' => (_pitchRoll, _throttleYaw),
|
||
_ => null,
|
||
};
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final ((String, String), (String, String))? l = layoutFor(mode);
|
||
if (l == null) return const SizedBox.shrink();
|
||
return Padding(
|
||
key: const ValueKey<String>('stick-diagram'),
|
||
padding: const EdgeInsets.fromLTRB(16, 2, 16, 10),
|
||
child: Row(children: <Widget>[
|
||
Expanded(child: _Stick(title: 'LEFT STICK', axes: l.$1)),
|
||
const SizedBox(width: 10),
|
||
Expanded(child: _Stick(title: 'RIGHT STICK', axes: l.$2)),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Stick extends StatelessWidget {
|
||
const _Stick({required this.title, required this.axes});
|
||
|
||
final String title;
|
||
final (String, String) axes;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.all(8),
|
||
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8), border: Border.all(color: Glass.hairline)),
|
||
child: Row(children: <Widget>[
|
||
const CustomPaint(size: Size(30, 30), painter: _StickPainter()),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||
Text(title, style: HudText.label),
|
||
const SizedBox(height: 3),
|
||
Text('Up-down: ${axes.$1}', style: HudText.sans.copyWith(fontSize: 10.5, fontWeight: FontWeight.w500)),
|
||
const SizedBox(height: 2),
|
||
Text('Sideways: ${axes.$2}', style: HudText.sans.copyWith(fontSize: 10.5, fontWeight: FontWeight.w500)),
|
||
]),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StickPainter extends CustomPainter {
|
||
const _StickPainter();
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final Offset c = size.center(Offset.zero);
|
||
final double r = size.shortestSide / 2;
|
||
final Paint line = Paint()
|
||
..color = HudText.dim
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 1.2;
|
||
canvas.drawCircle(c, r - 1, line);
|
||
canvas.drawLine(c.translate(0, -r + 4), c.translate(0, r - 4), line);
|
||
canvas.drawLine(c.translate(-r + 4, 0), c.translate(r - 4, 0), line);
|
||
canvas.drawCircle(c, 4, Paint()..color = Glass.accent);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||
}
|