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>
490 lines
17 KiB
Dart
490 lines
17 KiB
Dart
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;
|
||
}
|