Files
PilotVault/Fly App/lib/ui/fly/panels/settings/setting_tiles.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

385 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}