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>
483 lines
14 KiB
Dart
483 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../../../../dji_service.dart';
|
||
import '../../../../fly/camera_menu.dart';
|
||
import '../../../../fly/product_profile.dart';
|
||
import '../../../../fly/state/camera_state.dart';
|
||
import '../../../../theme.dart';
|
||
import '../../dialogs/slide_to_confirm.dart';
|
||
import '../../hud_kit.dart';
|
||
import '../camera_write.dart';
|
||
|
||
/// Renders one [CameraRow] spec (plan P4's row kit: choice, switch, stepper,
|
||
/// action). Writes go through [writeCameraSetting], so every row shows its
|
||
/// pending value at once and falls back to what the camera reports.
|
||
class CameraRowTile extends StatelessWidget {
|
||
const CameraRowTile({
|
||
super.key,
|
||
required this.row,
|
||
required this.profile,
|
||
required this.camera,
|
||
required this.dji,
|
||
required this.onError,
|
||
});
|
||
|
||
final CameraRow row;
|
||
final ProductProfile profile;
|
||
final CameraState camera;
|
||
final DjiService dji;
|
||
final void Function(String label, Object error) onError;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final CameraRow r = row;
|
||
return switch (r) {
|
||
ChoiceRow() => ChoiceTile(
|
||
key: ValueKey<String>('row-${r.id}'),
|
||
label: r.label,
|
||
options: <(String, String)>[for (final CameraOption o in r.options(profile, camera)) (o.value, o.label)],
|
||
selected: r.current(camera),
|
||
pending: r.writes.any(camera.isPending),
|
||
onSelect: (String v) => writeCameraSetting(
|
||
camera,
|
||
label: r.label,
|
||
pending: r.pending(v),
|
||
send: () => r.apply(dji, camera, v),
|
||
onError: onError,
|
||
),
|
||
),
|
||
SwitchRow() => SwitchTile(
|
||
key: ValueKey<String>('row-${r.id}'),
|
||
label: r.label,
|
||
value: r.current(camera),
|
||
onChanged: (bool on) => writeCameraSetting(
|
||
camera,
|
||
label: r.label,
|
||
pending: <String, Object?>{r.id: on},
|
||
send: () => r.apply(dji, on),
|
||
onError: onError,
|
||
),
|
||
),
|
||
StepperRow() => _StepperTile(
|
||
key: ValueKey<String>('row-${r.id}'),
|
||
row: r,
|
||
value: r.current(camera),
|
||
onChanged: (int v) => writeCameraSetting(
|
||
camera,
|
||
label: r.label,
|
||
pending: <String, Object?>{r.id: v},
|
||
send: () => r.apply(dji, camera, v),
|
||
onError: onError,
|
||
),
|
||
),
|
||
ActionRow() => _ActionTile(key: ValueKey<String>('row-${r.id}'), row: r, camera: camera, dji: dji, onError: onError),
|
||
};
|
||
}
|
||
}
|
||
|
||
/// A row that shows its current choice and expands into chips. Also used for
|
||
/// the menu's device-local display settings.
|
||
class ChoiceTile extends StatefulWidget {
|
||
const ChoiceTile({
|
||
super.key,
|
||
required this.label,
|
||
required this.options,
|
||
required this.selected,
|
||
required this.onSelect,
|
||
this.pending = false,
|
||
this.hint,
|
||
});
|
||
|
||
final String label;
|
||
final String? hint;
|
||
|
||
/// (value, label) pairs.
|
||
final List<(String, String)> options;
|
||
final String? selected;
|
||
final ValueChanged<String> onSelect;
|
||
|
||
/// A write is waiting for the camera's answer.
|
||
final bool pending;
|
||
|
||
@override
|
||
State<ChoiceTile> createState() => _ChoiceTileState();
|
||
}
|
||
|
||
class _ChoiceTileState extends State<ChoiceTile> {
|
||
bool _open = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
String current = '—';
|
||
for (final (String v, String l) in widget.options) {
|
||
if (v == widget.selected) current = l;
|
||
}
|
||
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||
_RowFrame(
|
||
label: widget.label,
|
||
hint: widget.hint,
|
||
onTap: widget.options.isEmpty ? null : () => setState(() => _open = !_open),
|
||
trailing: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
if (widget.pending)
|
||
const Padding(
|
||
padding: EdgeInsets.only(right: 6),
|
||
child: SizedBox(width: 10, height: 10, child: CircularProgressIndicator(strokeWidth: 1.5, color: HudText.dim)),
|
||
),
|
||
Text(current, style: HudText.mono.copyWith(color: Glass.accent, fontWeight: FontWeight.w700)),
|
||
const SizedBox(width: 4),
|
||
_Chevron(open: _open),
|
||
]),
|
||
),
|
||
if (_open)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
|
||
child: Wrap(spacing: 6, runSpacing: 6, children: <Widget>[
|
||
for (final (String v, String l) in widget.options)
|
||
GestureDetector(
|
||
key: ValueKey<String>('opt-$v'),
|
||
onTap: () {
|
||
setState(() => _open = false);
|
||
if (v != widget.selected) widget.onSelect(v);
|
||
},
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: v == widget.selected ? Glass.accent : Glass.pill,
|
||
borderRadius: BorderRadius.circular(7),
|
||
border: Border.all(color: v == widget.selected ? Glass.accent : Glass.hairline),
|
||
),
|
||
child: Text(l, style: HudText.sans),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
|
||
class _Chevron extends StatelessWidget {
|
||
const _Chevron({required this.open});
|
||
final bool open;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => CustomPaint(size: const Size(10, 10), painter: _ChevronPainter(open));
|
||
}
|
||
|
||
class _ChevronPainter extends CustomPainter {
|
||
const _ChevronPainter(this.open);
|
||
final bool open;
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final Path p = open
|
||
? (Path()
|
||
..moveTo(1, 7)
|
||
..lineTo(5, 3)
|
||
..lineTo(9, 7))
|
||
: (Path()
|
||
..moveTo(1, 3)
|
||
..lineTo(5, 7)
|
||
..lineTo(9, 3));
|
||
canvas.drawPath(
|
||
p,
|
||
Paint()
|
||
..color = HudText.dim
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 1.4,
|
||
);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _ChevronPainter old) => old.open != open;
|
||
}
|
||
|
||
class _RowFrame extends StatelessWidget {
|
||
const _RowFrame({required this.label, required this.trailing, this.onTap, this.hint});
|
||
|
||
final String label;
|
||
final Widget trailing;
|
||
final VoidCallback? onTap;
|
||
final String? hint;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(minHeight: 44),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||
child: Row(children: <Widget>[
|
||
Expanded(
|
||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||
Text(label, style: HudText.sans.copyWith(fontSize: 13)),
|
||
if (hint != null) ...<Widget>[
|
||
const SizedBox(height: 2),
|
||
Text(hint!, style: HudText.label),
|
||
],
|
||
]),
|
||
),
|
||
trailing,
|
||
]),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// An on / off row; a null [value] (not reported yet) shows as off.
|
||
class SwitchTile extends StatelessWidget {
|
||
const SwitchTile({super.key, required this.label, required this.value, required this.onChanged, this.hint, this.pending = false});
|
||
|
||
final String label;
|
||
final bool? value;
|
||
final ValueChanged<bool> onChanged;
|
||
final String? hint;
|
||
|
||
/// A write is waiting for the component's answer.
|
||
final bool pending;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => _RowFrame(
|
||
label: label,
|
||
hint: hint,
|
||
onTap: () => onChanged(!(value ?? false)),
|
||
trailing: SizedBox(
|
||
height: 24,
|
||
child: Switch(
|
||
value: value ?? false,
|
||
onChanged: onChanged,
|
||
activeThumbColor: Colors.white,
|
||
activeTrackColor: pending ? HudText.dim : Glass.accent,
|
||
inactiveThumbColor: HudText.dim,
|
||
inactiveTrackColor: HudColors.off,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// A device-local on/off (display settings), with the same look as camera rows.
|
||
class PrefSwitchTile extends StatelessWidget {
|
||
const PrefSwitchTile({super.key, required this.label, required this.value, required this.onChanged});
|
||
|
||
final String label;
|
||
final bool value;
|
||
final ValueChanged<bool> onChanged;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => SwitchTile(label: label, value: value, onChanged: onChanged);
|
||
}
|
||
|
||
class _StepperTile extends StatelessWidget {
|
||
const _StepperTile({super.key, required this.row, required this.value, required this.onChanged});
|
||
|
||
final StepperRow row;
|
||
final int? value;
|
||
final ValueChanged<int> onChanged;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final int? v = value;
|
||
bool can(int delta) => v != null && (v + delta) >= row.min && (v + delta) <= row.max;
|
||
return StepperTile(
|
||
id: row.id,
|
||
label: row.label,
|
||
shown: v == null ? '—' : '${v > 0 && row.min < 0 ? '+' : ''}$v${row.unit.isEmpty ? '' : ' ${row.unit}'}',
|
||
canDown: can(-row.step),
|
||
canUp: can(row.step),
|
||
onStep: (int dir) => onChanged(v! + dir * row.step),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A − value + row. [onStep] gets −1 or +1; the caller owns the arithmetic,
|
||
/// so it fits integer settings and relative nudges (gimbal fine-tune) alike.
|
||
class StepperTile extends StatelessWidget {
|
||
const StepperTile({
|
||
super.key,
|
||
required this.id,
|
||
required this.label,
|
||
required this.shown,
|
||
required this.canDown,
|
||
required this.canUp,
|
||
required this.onStep,
|
||
this.hint,
|
||
this.pending = false,
|
||
this.onValueTap,
|
||
});
|
||
|
||
/// Keys the buttons as `step-<id>-minus` / `step-<id>-plus`.
|
||
final String id;
|
||
final String label;
|
||
final String shown;
|
||
final bool canDown;
|
||
final bool canUp;
|
||
final ValueChanged<int> onStep;
|
||
final String? hint;
|
||
final bool pending;
|
||
|
||
/// Tapping the value (keyed `value-<id>`) — to type a far-off number.
|
||
final VoidCallback? onValueTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
Widget step(String label, int dir) {
|
||
final bool enabled = dir > 0 ? canUp : canDown;
|
||
return Opacity(
|
||
opacity: enabled ? 1 : 0.35,
|
||
child: GestureDetector(
|
||
key: ValueKey<String>('step-$id-${dir > 0 ? 'plus' : 'minus'}'),
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: enabled ? () => onStep(dir) : null,
|
||
child: Container(
|
||
width: 30,
|
||
height: 28,
|
||
decoration:
|
||
BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(7), border: Border.all(color: Glass.hairline)),
|
||
alignment: Alignment.center,
|
||
child: Text(label, style: HudText.value),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return _RowFrame(
|
||
label: label,
|
||
hint: hint,
|
||
trailing: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||
step('−', -1),
|
||
GestureDetector(
|
||
key: ValueKey<String>('value-$id'),
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onValueTap,
|
||
child: SizedBox(
|
||
width: 66,
|
||
height: 28,
|
||
child: Center(
|
||
child: Text(
|
||
shown,
|
||
textAlign: TextAlign.center,
|
||
style: HudText.mono.copyWith(color: pending ? HudText.dim : Glass.accent, fontWeight: FontWeight.w700),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
step('+', 1),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ActionTile extends StatelessWidget {
|
||
const _ActionTile({super.key, required this.row, required this.camera, required this.dji, required this.onError});
|
||
|
||
final ActionRow row;
|
||
final CameraState camera;
|
||
final DjiService dji;
|
||
final void Function(String label, Object error) onError;
|
||
|
||
Future<void> _run(BuildContext context) async {
|
||
if (row.confirmTitle != null) {
|
||
final SlideResult? ok = await showSlideToConfirm(
|
||
context,
|
||
SlideConfirmSpec(
|
||
icon: row.danger ? 'sdcard' : 'aperture',
|
||
title: row.confirmTitle!,
|
||
description: row.confirmText ?? '',
|
||
sliderLabel: row.danger ? 'Slide to erase' : 'Slide to confirm',
|
||
color: row.danger ? HudColors.danger : Glass.accent,
|
||
),
|
||
);
|
||
if (ok == null) return;
|
||
}
|
||
try {
|
||
await row.run(dji);
|
||
} catch (e) {
|
||
onError(row.label, e);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final String? blocked = row.blockedReason(camera);
|
||
return ButtonTile(
|
||
id: row.id,
|
||
label: row.label,
|
||
button: row.button,
|
||
hint: blocked,
|
||
danger: row.danger,
|
||
onTap: blocked == null ? () => _run(context) : null,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A read-only label + value row.
|
||
class ValueTile extends StatelessWidget {
|
||
const ValueTile({super.key, required this.label, required this.value, this.hint, this.onTap, this.valueColor});
|
||
|
||
final String label;
|
||
final String value;
|
||
final String? hint;
|
||
final VoidCallback? onTap;
|
||
final Color? valueColor;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => _RowFrame(
|
||
label: label,
|
||
hint: hint,
|
||
onTap: onTap,
|
||
trailing: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 200),
|
||
child: Text(
|
||
value,
|
||
textAlign: TextAlign.right,
|
||
overflow: TextOverflow.ellipsis,
|
||
maxLines: 2,
|
||
style: HudText.mono.copyWith(color: valueColor ?? (onTap != null ? Glass.accent : HudText.dim)),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// A row with one outlined button, keyed `action-<id>`; a null [onTap] greys it out.
|
||
class ButtonTile extends StatelessWidget {
|
||
const ButtonTile({
|
||
super.key,
|
||
required this.id,
|
||
required this.label,
|
||
required this.button,
|
||
required this.onTap,
|
||
this.hint,
|
||
this.danger = false,
|
||
});
|
||
|
||
final String id;
|
||
final String label;
|
||
final String button;
|
||
final VoidCallback? onTap;
|
||
final String? hint;
|
||
final bool danger;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final Color color = danger ? HudColors.danger : Glass.accent;
|
||
return _RowFrame(
|
||
label: label,
|
||
hint: hint,
|
||
trailing: Opacity(
|
||
opacity: onTap != null ? 1 : 0.35,
|
||
child: GestureDetector(
|
||
key: ValueKey<String>('action-$id'),
|
||
onTap: onTap,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(7), border: Border.all(color: color)),
|
||
child: Text(button, style: HudText.sans.copyWith(color: color)),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|