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

483 lines
14 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 '../../../../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)),
),
),
),
);
}
}