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>
297 lines
10 KiB
Dart
297 lines
10 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)),
|
||
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;
|
||
}
|