Files
PilotVault/Fly App/lib/ui/fly/panels/camera_menu/camera_rows.dart
T
tajniak81andClaude Opus 5.5 c1770fff73 Give the Fly App GO 4-style camera controls
One settings table in CameraBridge watches every camera value into the
telemetry tick and backs a generic setCameraValue, so the tabbed camera
menu (Exposure / Photo / Video / Other) is built from row specs and the
camera's own capability ranges. Writes show at once and fall back to what
the camera reports. Adds the quick exposure panel (Auto/A/S/M, ISO slider,
shutter and aperture wheels, EV strip, metered EV in M), a new camera
column with record timer and interval/pano progress, a zoom column for
zoom cameras, tap-to-focus / tap-to-meter, and a live histogram.

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

359 lines
11 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,
]),
),
),
);
}
}
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;
Widget step(String label, int delta) {
final bool enabled = v != null && (v + delta) >= row.min && (v + delta) <= row.max;
return Opacity(
opacity: enabled ? 1 : 0.35,
child: GestureDetector(
key: ValueKey<String>('step-${row.id}-${delta > 0 ? 'plus' : 'minus'}'),
behavior: HitTestBehavior.opaque,
onTap: enabled ? () => onChanged(v + delta) : 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),
),
),
);
}
final String shown = v == null ? '—' : '${v > 0 && row.min < 0 ? '+' : ''}$v${row.unit.isEmpty ? '' : ' ${row.unit}'}';
return _RowFrame(
label: row.label,
trailing: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
step('−', -row.step),
SizedBox(
width: 66,
child: Text(shown, textAlign: TextAlign.center, style: HudText.mono.copyWith(color: Glass.accent, fontWeight: FontWeight.w700)),
),
step('+', row.step),
]),
);
}
}
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);
final Color color = row.danger ? HudColors.danger : Glass.accent;
return _RowFrame(
label: row.label,
hint: blocked,
trailing: Opacity(
opacity: blocked == null ? 1 : 0.35,
child: GestureDetector(
key: ValueKey<String>('action-${row.id}'),
onTap: blocked == null ? () => _run(context) : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(7), border: Border.all(color: color)),
child: Text(row.button, style: HudText.sans.copyWith(color: color)),
),
),
),
);
}
}