Files
PilotVault/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart
T
tajniak81andClaude Opus 5.5 60c2fd7f64 Give the Fly App GO 4-style gimbal controls
Plan P5: a value bubble on the pitch slider while dragging, press-and-drag
on the video as a speed joystick (re-sent at 10 Hz, zero on release), and a
gimbal settings sheet: mode, 30° extension, yaw follow, recenter / face down /
yaw recenter, roll fine-tune, auto calibration and advanced speed/smoothness
rows gated by the gimbal's capability ranges.

GimbalBridge reports mode, trim, calibration and stop/overload state in the
gimbal telemetry group, and getter-only settings in a new gimbalSettings slow
group. The reported-plus-pending overlay moves out of CameraState into a
SettingsOverlay mixin shared by camera and gimbal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:22:42 +02:00

435 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 '../../../../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,
});
final String label;
/// (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,
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});
final String label;
final bool? value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) => _RowFrame(
label: label,
onTap: () => onChanged(!(value ?? false)),
trailing: SizedBox(
height: 24,
child: Switch(
value: value ?? false,
onChanged: onChanged,
activeThumbColor: Colors.white,
activeTrackColor: 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,
});
/// 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;
@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),
SizedBox(
width: 66,
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 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)),
),
),
),
);
}
}