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

319 lines
11 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../dji_service.dart';
import '../../../flight_model.dart';
import '../../../fly/camera_menu.dart';
import '../../../fly/hud_features.dart';
import '../../../fly/state/camera_state.dart';
import '../../../theme.dart';
import '../../pv_icons.dart';
import '../hud_kit.dart';
import '../panels/camera_write.dart';
/// The right camera column (plan P4): AE lock · photo / video switch ·
/// shutter (photo, record with its timer, interval / panorama progress ring) ·
/// MENU · playback and capture modes.
///
/// The shutter follows the camera's own state: while an interval, burst or
/// panorama is running it shows a stop square and the press stops it.
class CameraColumn extends StatefulWidget {
const CameraColumn({
super.key,
required this.model,
required this.dji,
required this.onCameraMenu,
required this.onPlayback,
required this.onCaptureModes,
required this.onError,
});
final FlightModel model;
final DjiService dji;
final VoidCallback onCameraMenu;
final VoidCallback onPlayback;
final VoidCallback onCaptureModes;
final void Function(String label, Object error) onError;
@override
State<CameraColumn> createState() => _CameraColumnState();
}
class _CameraColumnState extends State<CameraColumn> {
FlightModel get _m => widget.model;
CameraState get _cam => _m.camera;
/// The camera's own mode once it has reported one; the switch's choice before that.
bool get _video => _cam.mode == null ? _m.captureMode == CaptureMode.video : _cam.isVideoMode;
Future<void> _run(String label, Future<void> Function() action) async {
try {
await action();
} catch (e) {
widget.onError(label, e);
}
}
Future<void> _shutter() async {
if (_video) {
await _run(_cam.isRecording ? 'Stop recording' : 'Start recording',
_cam.isRecording ? widget.dji.stopRecordVideo : widget.dji.startRecordVideo);
} else if (_cam.shootingContinuous) {
await _run('Stop shooting', widget.dji.stopShootPhoto);
} else {
await _run('Shoot photo', widget.dji.startShootPhoto);
}
}
Future<void> _setMode(bool video) async {
if (video == _video) return;
setState(() => _m.captureMode = video ? CaptureMode.video : CaptureMode.photo);
_cam.setModeOptimistic(video ? 'RECORD_VIDEO' : 'SHOOT_PHOTO');
await _run('Set camera mode', () => widget.dji.setCameraMode(video ? 'video' : 'photo'));
}
Future<void> _toggleAeLock() {
final bool next = !(_cam.aeLock ?? false);
return writeCameraSetting(
_cam,
label: 'AE lock',
pending: <String, Object?>{'aeLock': next},
send: () => widget.dji.setCameraValue('aeLock', next),
onError: widget.onError,
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _cam,
builder: (BuildContext context, _) {
final bool video = _video;
final bool locked = _cam.settings['aeLock'] == true;
return Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
_Pill(
key: const ValueKey<String>('ae-lock'),
label: 'AE',
color: locked ? Glass.accent : Glass.pillStrong,
width: 44,
height: 26,
onTap: _toggleAeLock,
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)),
child: Column(children: <Widget>[
_modeBtn('image', active: !video, onTap: () => _setMode(false), key: const ValueKey<String>('mode-photo')),
_modeBtn('video', active: video, onTap: () => _setMode(true), key: const ValueKey<String>('mode-video')),
]),
),
const SizedBox(height: 8),
ShutterButton(camera: _cam, video: video, onTap: _shutter),
SizedBox(
height: 14,
child: Center(child: Text(_caption(video), style: HudText.label.copyWith(color: Glass.ink))),
),
const SizedBox(height: 6),
_Pill(key: const ValueKey<String>('camera-menu'), label: 'MENU', width: 44, height: 28, onTap: widget.onCameraMenu),
const SizedBox(height: 8),
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
Requires(
HudFeatures.playback,
child: _Round(key: const ValueKey<String>('playback'), icon: 'play', onTap: widget.onPlayback),
),
const SizedBox(width: 4),
_Round(key: const ValueKey<String>('capture-modes'), icon: 'aperture', onTap: widget.onCaptureModes),
]),
]);
},
);
}
/// Under the shutter: the record timer, or the photo mode.
String _caption(bool video) {
if (video) return _cam.isRecording ? _clock(_cam.recordSeconds) : '';
if (_cam.shootingPanorama) return 'PANO ${_cam.panoramaProgress}%';
final String? mode = _cam.shootPhotoMode;
if (mode == null || mode == 'SINGLE') return '';
final String label = CameraLabels.of(mode).toUpperCase();
return switch (mode) {
'INTERVAL' when _cam.intervalSeconds != null => '$label ${_cam.intervalSeconds}s',
'BURST' when _cam.setting('burstCount') is String => '$label ${CameraLabels.of(_cam.setting('burstCount')! as String)}',
'AEB' when _cam.setting('aebCount') is String => '$label ${CameraLabels.of(_cam.setting('aebCount')! as String)}',
_ => label,
};
}
static String _clock(int s) => '${(s ~/ 60).toString().padLeft(2, '0')}:${(s % 60).toString().padLeft(2, '0')}';
Widget _modeBtn(String icon, {required bool active, required VoidCallback onTap, Key? key}) => GestureDetector(
key: key,
onTap: onTap,
child: Container(
width: 38,
height: 26,
margin: const EdgeInsets.symmetric(vertical: 1),
decoration: BoxDecoration(color: active ? Glass.accent : Colors.transparent, borderRadius: BorderRadius.circular(7)),
alignment: Alignment.center,
child: PVIcon(icon, size: 15, stroke: 1.8, color: Glass.ink),
),
);
}
/// The shutter: white disc for photo, red for video, a red square while
/// recording, and a stop square inside a progress ring while an interval or
/// panorama runs (interval: one turn per interval; panorama: its progress).
class ShutterButton extends StatefulWidget {
const ShutterButton({super.key, required this.camera, required this.video, required this.onTap});
final CameraState camera;
final bool video;
final VoidCallback onTap;
@override
State<ShutterButton> createState() => _ShutterButtonState();
}
class _ShutterButtonState extends State<ShutterButton> with SingleTickerProviderStateMixin {
late final AnimationController _interval = AnimationController(vsync: this);
@override
void didUpdateWidget(covariant ShutterButton old) {
super.didUpdateWidget(old);
_syncInterval();
}
@override
void initState() {
super.initState();
_syncInterval();
}
void _syncInterval() {
final CameraState c = widget.camera;
final int seconds = c.intervalSeconds ?? 0;
if (!widget.video && c.shootingInterval && seconds > 0) {
final Duration d = Duration(seconds: seconds);
if (!_interval.isAnimating || _interval.duration != d) {
_interval.duration = d;
_interval.repeat();
}
} else if (_interval.isAnimating) {
_interval.stop();
_interval.value = 0;
}
}
@override
void dispose() {
_interval.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final CameraState c = widget.camera;
final bool recording = widget.video && c.isRecording;
final bool running = !widget.video && c.shootingContinuous;
final double? ring = !running
? null
: c.shootingPanorama
? c.panoramaProgress / 100
: null; // interval: animated below; burst: no progress to show
Widget inner;
if (recording || running) {
inner = Container(width: 22, height: 22, decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(5)));
} else {
inner = Container(width: 44, height: 44, decoration: BoxDecoration(color: widget.video ? Glass.rec : Colors.white, shape: BoxShape.circle));
}
return GestureDetector(
key: const ValueKey<String>('shutter'),
onTap: widget.onTap,
child: SizedBox(
width: 60,
height: 60,
child: Stack(alignment: Alignment.center, children: <Widget>[
Container(decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: const Color(0xD9FFFFFF), width: 4))),
if (running)
AnimatedBuilder(
animation: _interval,
builder: (BuildContext context, _) => CustomPaint(
size: const Size(60, 60),
painter: _RingPainter(ring ?? (c.shootingInterval ? _interval.value : 1)),
),
),
inner,
]),
),
);
}
}
class _RingPainter extends CustomPainter {
const _RingPainter(this.progress);
final double progress;
@override
void paint(Canvas canvas, Size size) {
final Rect r = (Offset.zero & size).deflate(2);
canvas.drawArc(
r,
-math.pi / 2,
2 * math.pi * progress.clamp(0, 1),
false,
Paint()
..color = Glass.accent
..style = PaintingStyle.stroke
..strokeWidth = 4
..strokeCap = StrokeCap.round,
);
}
@override
bool shouldRepaint(covariant _RingPainter old) => old.progress != progress;
}
class _Pill extends StatelessWidget {
const _Pill({super.key, required this.label, required this.onTap, this.color = Glass.pillStrong, this.width = 44, this.height = 28});
final String label;
final VoidCallback onTap;
final Color color;
final double width;
final double height;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
width: width,
height: height,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(8), border: Border.all(color: Glass.hairline)),
alignment: Alignment.center,
child: Text(label, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, fontWeight: FontWeight.w700, color: Glass.ink)),
),
);
}
class _Round extends StatelessWidget {
const _Round({super.key, required this.icon, required this.onTap});
final String icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
width: 30,
height: 30,
decoration: BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle, border: Border.all(color: Glass.hairline)),
alignment: Alignment.center,
child: PVIcon(icon, size: 14, stroke: 1.8, color: Glass.ink),
),
);
}