Files
PilotVault/Fly App/lib/ui/fly/panels/settings/setting_tiles.dart
T
tajniak81andClaude Opus 5.5 284de5442a Give the Fly App a GO 4-style settings sheet
The "•••" button now opens a right-side glass sheet over the live view
with Safety, Vision, Remote, Video link, Battery, Gimbal and General
tabs. Each row is a one-line spec bound to a KeyManager key: rows hide
when the aircraft does not support their key, writes show at once and
roll back if refused, and ground-only settings lock in flight.

KeyBridge gains argument-less action keys and converts range and LED
settings values. settings_menu_page.dart is retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:13:45 +02:00

297 lines
10 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)),
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> _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')),
],
);
}
}
/// 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;
}