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 createState() => _CameraColumnState(); } class _CameraColumnState extends State { 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 _run(String label, Future Function() action) async { try { await action(); } catch (e) { widget.onError(label, e); } } Future _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 _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 _toggleAeLock() { final bool next = !(_cam.aeLock ?? false); return writeCameraSetting( _cam, label: 'AE lock', pending: {'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: [ _Pill( key: const ValueKey('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: [ _modeBtn('image', active: !video, onTap: () => _setMode(false), key: const ValueKey('mode-photo')), _modeBtn('video', active: video, onTap: () => _setMode(true), key: const ValueKey('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('camera-menu'), label: 'MENU', width: 44, height: 28, onTap: widget.onCameraMenu), const SizedBox(height: 8), Row(mainAxisSize: MainAxisSize.min, children: [ Requires( HudFeatures.playback, child: _Round(key: const ValueKey('playback'), icon: 'play', onTap: widget.onPlayback), ), const SizedBox(width: 4), _Round(key: const ValueKey('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 createState() => _ShutterButtonState(); } class _ShutterButtonState extends State 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('shutter'), onTap: widget.onTap, child: SizedBox( width: 60, height: 60, child: Stack(alignment: Alignment.center, children: [ 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), ), ); }