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

490 lines
17 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 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../dji_service.dart';
import '../../../flight_model.dart';
import '../../../fly/camera_exposure.dart';
import '../../../fly/hud_features.dart';
import '../../../fly/product_profile.dart';
import '../../../fly/state/camera_state.dart';
import '../../../theme.dart';
import '../hud_kit.dart';
import 'camera_write.dart';
/// Opens the quick exposure panel (plan P4) beside the camera column, over the
/// live view. Opened from the camera info strip; the camera menu's Exposure tab
/// embeds the same [QuickExposurePanel].
Future<void> showQuickExposure(
BuildContext context, {
required FlightModel model,
required DjiService dji,
required void Function(String label, Object error) onError,
}) {
return showGeneralDialog<void>(
context: context,
barrierDismissible: true,
barrierLabel: 'Exposure',
barrierColor: const Color(0x33050A14),
transitionDuration: const Duration(milliseconds: 160),
pageBuilder: (BuildContext context, _, __) => SafeArea(
child: Align(
alignment: Alignment.centerRight,
child: Padding(
// Clear of the camera column, which stays usable to the right.
padding: const EdgeInsets.only(right: 76),
child: ListenableBuilder(
listenable: model,
builder: (BuildContext context, Widget? child) => ProductProfileScope(profile: model.profile, child: child!),
child: Material(
color: const Color(0xF20B1426),
borderRadius: BorderRadius.circular(14),
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: QuickExposurePanel.width,
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: QuickExposurePanel(camera: model.camera, dji: dji, onError: onError),
),
),
),
),
),
),
),
transitionBuilder: (BuildContext context, Animation<double> a, _, Widget child) => FadeTransition(opacity: a, child: child),
);
}
/// Auto / A / S / M, ISO slider with Auto, shutter and aperture wheels, EV −/+
/// over an EV strip — and in M the meter's verdict where EV would be. Every
/// control is enabled per [ExposureMode] and built from the camera's own
/// capability ranges.
class QuickExposurePanel extends StatelessWidget {
const QuickExposurePanel({super.key, required this.camera, required this.dji, required this.onError});
final CameraState camera;
final DjiService dji;
final void Function(String label, Object error) onError;
static const double width = 320;
@override
Widget build(BuildContext context) {
final CameraProfile? profile = ProductProfileScope.of(context).camera;
return ListenableBuilder(
listenable: camera,
builder: (BuildContext context, _) {
final List<ExposureMode> modes = ExposureOptions.modes(profile);
final ExposureMode mode = ExposureMode.fromWire(camera.exposureMode) ?? ExposureMode.auto;
final List<String> isos = ExposureOptions.isos(profile);
final bool isoAuto = camera.isoSetting == 'AUTO';
final List<String> evs = ExposureOptions.evs(profile);
final List<String> apertures = ExposureOptions.apertures(profile);
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
_ModeSegments(modes: modes, selected: mode, onSelect: (ExposureMode m) => _setMode(m)),
const SizedBox(height: 12),
_Labelled(
label: 'ISO',
value: isoAuto ? 'AUTO ${camera.iso ?? ''}'.trim() : (camera.iso ?? '—'),
trailing: ExposureOptions.isoAutoAvailable(profile) && mode.isoEditable
? _Chip(label: 'AUTO', on: isoAuto, onTap: () => _setIso(isoAuto ? (camera.iso ?? isos.first) : 'AUTO'))
: null,
child: _IsoSlider(
isos: isos,
current: camera.iso,
enabled: mode.isoEditable && !isoAuto,
onChanged: _setIso,
),
),
_Labelled(
label: 'SHUTTER',
value: camera.shutter ?? '—',
child: HudWheel(
key: const ValueKey<String>('wheel-shutter'),
values: ExposureOptions.shutters(profile),
current: camera.shutter,
enabled: mode.shutterEditable,
onSelected: (String v) => _send('Shutter', () => dji.setShutterSpeed(v)),
),
),
if (apertures.isNotEmpty)
Requires(
HudFeatures.apertureReadout,
child: _Labelled(
label: 'APERTURE',
value: camera.aperture ?? '—',
child: HudWheel(
key: const ValueKey<String>('wheel-aperture'),
values: apertures,
current: camera.aperture,
enabled: mode.apertureEditable,
onSelected: (String v) => _send('Aperture', () => dji.setAperture(v)),
),
),
),
_Labelled(
label: mode.evEditable ? 'EV' : 'METERED',
value: camera.ev ?? '—',
valueColor: mode.evEditable ? null : _verdictColor(meterVerdict(camera)),
child: Row(children: <Widget>[
_StepButton(
key: const ValueKey<String>('ev-minus'),
label: '−',
onTap: mode.evEditable ? () => _stepEv(evs, -1) : null,
),
const SizedBox(width: 6),
Expanded(
child: SizedBox(
height: 26,
child: CustomPaint(painter: EvStripPainter(evs: evs, current: ExposureOptions.evValue(camera.ev), metered: !mode.evEditable)),
),
),
const SizedBox(width: 6),
_StepButton(
key: const ValueKey<String>('ev-plus'),
label: '+',
onTap: mode.evEditable ? () => _stepEv(evs, 1) : null,
),
]),
),
]);
},
);
}
Future<void> _send(String label, Future<void> Function() call) async {
try {
await call();
} catch (e) {
onError(label, e);
}
}
Future<void> _setMode(ExposureMode m) => writeCameraSetting(
camera,
label: 'Exposure mode',
pending: <String, Object?>{'exposureMode': m.wire},
send: () => dji.setCameraValue('exposureMode', m.wire),
onError: onError,
);
Future<void> _setIso(String v) => writeCameraSetting(
camera,
label: 'ISO',
pending: <String, Object?>{'isoSetting': v},
send: () => dji.setISO(v),
onError: onError,
);
Future<void> _stepEv(List<String> evs, int direction) async {
final String? next = ExposureOptions.stepEv(camera.ev, evs, direction);
if (next == null) return;
await _send('EV', () => dji.setEV(next));
}
static Color _verdictColor(MeterVerdict v) => switch (v) {
MeterVerdict.ok => HudColors.ready,
MeterVerdict.under || MeterVerdict.over => HudColors.caution,
};
}
class _ModeSegments extends StatelessWidget {
const _ModeSegments({required this.modes, required this.selected, required this.onSelect});
final List<ExposureMode> modes;
final ExposureMode selected;
final ValueChanged<ExposureMode> onSelect;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)),
child: Row(children: <Widget>[
for (final ExposureMode m in modes)
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onSelect(m),
child: Container(
key: ValueKey<String>('exposure-${m.name}'),
height: 30,
decoration: BoxDecoration(color: m == selected ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)),
alignment: Alignment.center,
child: Text(m.label, style: HudText.sans.copyWith(fontWeight: FontWeight.w700)),
),
),
),
]),
);
}
}
/// A control with its label and current value above it.
class _Labelled extends StatelessWidget {
const _Labelled({required this.label, required this.value, required this.child, this.trailing, this.valueColor});
final String label;
final String value;
final Widget child;
final Widget? trailing;
final Color? valueColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: <Widget>[
Row(children: <Widget>[
Text(label, style: HudText.label),
const SizedBox(width: 8),
Text(value, style: HudText.mono.copyWith(fontWeight: FontWeight.w700, color: valueColor)),
const Spacer(),
if (trailing != null) trailing!,
]),
const SizedBox(height: 4),
child,
]),
);
}
}
class _Chip extends StatelessWidget {
const _Chip({required this.label, required this.on, required this.onTap});
final String label;
final bool on;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: on ? Glass.accent : Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: on ? Glass.accent : Glass.hairline),
),
child: Text(label, style: HudText.monoSmall.copyWith(fontWeight: FontWeight.w700)),
),
);
}
class _StepButton extends StatelessWidget {
const _StepButton({super.key, required this.label, required this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => Opacity(
opacity: onTap == null ? 0.35 : 1,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: 34,
height: 30,
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(8), border: Border.all(color: Glass.hairline)),
alignment: Alignment.center,
child: Text(label, style: HudText.value),
),
),
);
}
/// ISO as a stepped slider over the camera's numeric ISOs; sends on release.
class _IsoSlider extends StatefulWidget {
const _IsoSlider({required this.isos, required this.current, required this.enabled, required this.onChanged});
final List<String> isos;
final String? current;
final bool enabled;
final ValueChanged<String> onChanged;
@override
State<_IsoSlider> createState() => _IsoSliderState();
}
class _IsoSliderState extends State<_IsoSlider> {
double? _drag;
@override
Widget build(BuildContext context) {
final int n = widget.isos.length;
final double value = _drag ?? ExposureOptions.nearest(widget.isos, widget.current).toDouble();
return SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 3,
activeTrackColor: Glass.accent,
inactiveTrackColor: HudColors.off,
thumbColor: Colors.white,
overlayShape: SliderComponentShape.noOverlay,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
),
child: SizedBox(
height: 24,
child: Slider(
key: const ValueKey<String>('iso-slider'),
value: value.clamp(0, math.max(0, n - 1)).toDouble(),
max: math.max(1, n - 1).toDouble(),
divisions: math.max(1, n - 1),
onChanged: widget.enabled && n > 1 ? (double v) => setState(() => _drag = v) : null,
onChangeEnd: widget.enabled && n > 1
? (double v) {
setState(() => _drag = null);
widget.onChanged(widget.isos[v.round().clamp(0, n - 1)]);
}
: null,
),
),
);
}
}
/// A horizontal value wheel (GO 4's shutter / aperture dial): a rotated
/// [ListWheelScrollView]. It follows the camera's value while untouched and
/// reports the pilot's choice once a drag settles.
class HudWheel extends StatefulWidget {
const HudWheel({
super.key,
required this.values,
required this.current,
required this.onSelected,
this.enabled = true,
this.itemWidth = 58,
this.height = 34,
});
final List<String> values;
final String? current;
final ValueChanged<String> onSelected;
final bool enabled;
final double itemWidth;
final double height;
@override
State<HudWheel> createState() => _HudWheelState();
}
class _HudWheelState extends State<HudWheel> {
late final FixedExtentScrollController _c =
FixedExtentScrollController(initialItem: ExposureOptions.nearest(widget.values, widget.current));
bool _userDragging = false;
@override
void didUpdateWidget(covariant HudWheel old) {
super.didUpdateWidget(old);
if (_userDragging || !_c.hasClients) return;
final int target = ExposureOptions.nearest(widget.values, widget.current);
if (target != _c.selectedItem) {
_c.animateToItem(target, duration: const Duration(milliseconds: 180), curve: Curves.easeOut);
}
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
bool _onScroll(ScrollNotification n) {
if (n is ScrollStartNotification && n.dragDetails != null) _userDragging = true;
if (n is ScrollEndNotification && _userDragging) {
_userDragging = false;
final int i = _c.selectedItem.clamp(0, widget.values.length - 1);
if (widget.values.isNotEmpty && widget.values[i] != widget.current) widget.onSelected(widget.values[i]);
}
return false;
}
@override
Widget build(BuildContext context) {
return Opacity(
opacity: widget.enabled ? 1 : 0.4,
child: IgnorePointer(
ignoring: !widget.enabled,
child: Container(
height: widget.height,
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8)),
child: Stack(children: <Widget>[
NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: RotatedBox(
quarterTurns: 3,
child: ListWheelScrollView.useDelegate(
controller: _c,
itemExtent: widget.itemWidth,
diameterRatio: 2.4,
perspective: 0.004,
physics: const FixedExtentScrollPhysics(),
childDelegate: ListWheelChildBuilderDelegate(
childCount: widget.values.length,
builder: (BuildContext context, int i) => RotatedBox(
quarterTurns: 1,
child: Center(
child: Text(widget.values[i], style: HudText.mono.copyWith(fontWeight: FontWeight.w700), maxLines: 1),
),
),
),
),
),
),
// The selection window.
IgnorePointer(
child: Center(
child: Container(
width: widget.itemWidth,
decoration: BoxDecoration(border: Border.all(color: Glass.accent, width: 1.5), borderRadius: BorderRadius.circular(6)),
),
),
),
]),
),
),
);
}
}
/// The EV scale: a tick per third of a stop across the camera's range, whole
/// stops taller, and a marker at the current (or, in M, metered) value.
class EvStripPainter extends CustomPainter {
const EvStripPainter({required this.evs, required this.current, this.metered = false});
final List<String> evs;
final double? current;
final bool metered;
@override
void paint(Canvas canvas, Size size) {
final List<double> vs = <double>[for (final String e in evs) ExposureOptions.evValue(e) ?? 0];
final double lo = vs.isEmpty ? -3 : vs.reduce(math.min), hi = vs.isEmpty ? 3 : vs.reduce(math.max);
if (hi <= lo) return;
double x(double v) => 6 + (v - lo) / (hi - lo) * (size.width - 12);
final Paint tick = Paint()
..color = HudText.dim
..strokeWidth = 1;
final double base = size.height - 4;
for (final double v in vs) {
final bool whole = (v - v.roundToDouble()).abs() < 0.05;
canvas.drawLine(Offset(x(v), base), Offset(x(v), base - (whole ? 9 : 4)), tick..color = v.abs() < 0.05 ? Glass.ink : HudText.dim);
}
final double? c = current;
if (c == null) return;
final double cx = x(c.clamp(lo, hi));
final Color color = metered ? (c.abs() < 0.7 ? HudColors.ready : HudColors.caution) : Glass.accent;
// Space Mono has no ▼ glyph, so the marker is painted.
final Path marker = Path()
..moveTo(cx - 5, 2)
..lineTo(cx + 5, 2)
..lineTo(cx, 9)
..close();
canvas.drawPath(marker, Paint()..color = color);
}
@override
bool shouldRepaint(covariant EvStripPainter old) => old.current != current || old.metered != metered || old.evs != evs;
}