import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../dji_service.dart'; import '../flight_model.dart'; import '../theme.dart'; import 'camera_settings_page.dart'; import 'capture_modes_page.dart'; import 'dji_video_view.dart'; import 'map_page.dart'; import 'pv_icons.dart'; import 'settings_menu_page.dart'; /// Landscape live-flight overlay — mirrors the v2 "Main flight view" mockup. /// Live camera feed behind a glass HUD; real telemetry bound throughout, and /// the shutter / mode switch / gimbal slider / RTH / take-off controls issue /// real SDK commands via [DjiService]. class FlightControlPage extends StatefulWidget { const FlightControlPage({super.key, required this.model, required this.dji}); final FlightModel model; final DjiService dji; @override State createState() => _FlightControlPageState(); } class _FlightControlPageState extends State { Timer? _recTimer; int _recSeconds = 0; // Gimbal pitch slider: fraction 0 (top, +30°) … 1 (bottom, −90°). double _gimbalFrac = 0.24; bool _grid = false; DjiService get _dji => widget.dji; FlightModel get _m => widget.model; @override void initState() { super.initState(); SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); } @override void dispose() { _recTimer?.cancel(); SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.dispose(); } String _fmt(int s) => '${(s ~/ 60).toString().padLeft(2, '0')}:${(s % 60).toString().padLeft(2, '0')}'; void _snack(String msg) { if (!mounted) return; ScaffoldMessenger.of(context) ..clearSnackBars() ..showSnackBar(SnackBar(content: Text(msg))); } Future _run(String label, Future Function() action) async { try { await action(); } catch (e) { _snack('$label failed: ${_reason(e)}'); } } String _reason(Object e) => e is PlatformException ? (e.message ?? e.code) : e.toString(); // ── Commands ─────────────────────────────────────────────────────────────── Future _toggleShutter() async { if (_m.captureMode == CaptureMode.video) { if (_m.isRecording) { _stopRecTimer(); await _run('Stop recording', _dji.stopRecordVideo); } else { _startRecTimer(); await _run('Start recording', _dji.startRecordVideo); } } else { await _run('Shoot photo', _dji.startShootPhoto); } } void _startRecTimer() { _recSeconds = 0; _recTimer?.cancel(); _recTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() => _recSeconds++); }); } void _stopRecTimer() { _recTimer?.cancel(); _recTimer = null; } Future _setCaptureMode(CaptureMode mode) async { setState(() => _m.captureMode = mode); final String wire = mode == CaptureMode.video ? 'video' : 'photo'; await _run('Set camera mode', () => _dji.setCameraMode(wire)); } Future _rth() async { final bool ok = await _confirm('Return to Home', 'The aircraft will fly back to its recorded home point and land.'); if (ok) await _run('Return to Home', _dji.startGoHome); } Future _toggleTakeoff() async { if (_m.isFlying == true) { final bool ok = await _confirm('Land now', 'The aircraft will descend and land at its current position.'); if (ok) await _run('Land', _dji.land); } else { final bool ok = await _confirm('Take off', 'The aircraft will take off and hover at ~1.2 m.'); if (ok) await _run('Take off', _dji.takeOff); } } Future _confirm(String title, String body) async { final bool? r = await showDialog( context: context, builder: (BuildContext ctx) => AlertDialog( backgroundColor: const Color(0xFF10203F), title: Text(title, style: const TextStyle(color: Glass.ink, fontFamily: PV.fontSans, fontWeight: FontWeight.w700)), content: Text(body, style: const TextStyle(color: Color(0xFF8FA0BE), fontFamily: PV.fontSans)), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(title)), ], ), ); return r ?? false; } void _onGimbalDrag(double frac) { setState(() => _gimbalFrac = frac.clamp(0.0, 1.0)); final double pitch = 30 - _gimbalFrac * 120; // +30 (top) … −90 (bottom) _run('Gimbal', () => _dji.rotateGimbalPitch(pitch)); } void _openCaptureModes() { Navigator.of(context).push(MaterialPageRoute( builder: (_) => CaptureModesPage(model: _m, dji: _dji), )); } void _openCameraSettings() { Navigator.of(context).push(MaterialPageRoute( builder: (_) => CameraSettingsPage(model: _m, dji: _dji), )); } void _openSettingsMenu() { Navigator.of(context).push(MaterialPageRoute( builder: (_) => SettingsMenuPage(model: _m, dji: _dji), )); } void _openMap() { Navigator.of(context).push(MaterialPageRoute( builder: (_) => MapPage(model: _m, dji: _dji), )); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFF0A1120), body: AnimatedBuilder( animation: _m, builder: (BuildContext context, _) { return Stack( children: [ Positioned.fill( child: _m.connected ? const DjiVideoView() : const CustomPaint(painter: _FeedPainter()), ), if (_grid) const Positioned.fill(child: IgnorePointer(child: CustomPaint(painter: _GridPainter()))), const Center(child: PVIcon('crosshair', size: 30, stroke: 1.1, color: Color(0xB3FFFFFF))), Positioned(top: 12, left: 14, right: 14, child: _topBar()), Positioned(left: 14, top: 58, child: _leftRail()), Positioned(left: 70, top: 58, bottom: 96, child: _gimbalSlider()), Positioned(right: 16, top: 0, bottom: 0, child: Center(child: _cameraControls())), Positioned(left: 14, bottom: 12, child: _minimap()), Positioned(bottom: 14, left: 0, right: 0, child: Center(child: _telemetry())), Positioned(right: 92, bottom: 20, child: _rthButton()), Positioned(right: 92, bottom: 72, child: _takeoffButton()), ], ); }, ), ); } // ── Top bar ────────────────────────────────────────────────────────────── Widget _topBar() { final String mode = (_m.flightMode != null && _m.flightMode!.isNotEmpty) ? _m.flightMode! : 'N'; return Row(children: [ GestureDetector( onTap: () => Navigator.of(context).maybePop(), child: _pill(child: const PVIcon('chevronLeft', size: 16, color: Glass.ink)), ), const SizedBox(width: 8), Container( height: 26, padding: const EdgeInsets.symmetric(horizontal: 11), decoration: BoxDecoration(color: Glass.accent, borderRadius: BorderRadius.circular(8)), alignment: Alignment.center, child: Text(mode, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.4, color: Colors.white)), ), const SizedBox(width: 8), _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ const PVIcon('satellite', size: 14, color: Glass.sat), const SizedBox(width: 4), _mono(_m.satellites?.toString() ?? '0'), ])), const SizedBox(width: 8), _pill(child: const PVIcon('obstacle', size: 13, color: Glass.sat)), const SizedBox(width: 8), _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ const PVIcon('radio', size: 14, color: Glass.ink), const SizedBox(width: 4), _mono('HD'), ])), const Spacer(), _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ _mono('REC'), const SizedBox(width: 6), Container(width: 7, height: 7, decoration: BoxDecoration(color: _m.isRecording ? Glass.rec : const Color(0x66D64545), shape: BoxShape.circle)), const SizedBox(width: 6), _mono(_fmt(_m.isRecording ? _recSeconds : 0)), ])), const SizedBox(width: 8), _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ const PVIcon('battery', size: 16, color: Glass.sat), const SizedBox(width: 4), _mono(_m.batteryPercent == null ? '—' : '${_m.batteryPercent}%'), ])), const SizedBox(width: 8), GestureDetector(onTap: _openSettingsMenu, child: _pill(child: const PVIcon('more', size: 16, color: Glass.ink))), ]); } Widget _leftRail() { return Column(children: [ _sideBtn('gimbal', active: true, onTap: _openCameraSettings), const SizedBox(height: 10), _sideBtn('sun', onTap: _openCameraSettings), const SizedBox(height: 10), _sideBtn('aperture', onTap: _openCaptureModes), const SizedBox(height: 10), _sideBtn('grid', active: _grid, onTap: () => setState(() => _grid = !_grid)), ]); } // ── Reusable glass pieces ──────────────────────────────────────────────── Widget _pill({required Widget child}) { return Container( height: 26, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(8)), alignment: Alignment.center, child: child, ); } Widget _mono(String t) => Text(t, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink)); Widget _sideBtn(String icon, {bool active = false, VoidCallback? onTap}) { return GestureDetector( onTap: onTap, child: Container( width: 42, height: 42, decoration: BoxDecoration( color: active ? Glass.accent : Glass.pill, borderRadius: BorderRadius.circular(12), border: Border.all(color: Glass.hairline), ), alignment: Alignment.center, child: PVIcon(icon, size: 20, stroke: 1.8, color: Glass.ink), ), ); } Widget _gimbalSlider() { return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) { final double h = c.maxHeight; return GestureDetector( behavior: HitTestBehavior.opaque, onVerticalDragUpdate: (DragUpdateDetails d) => _onGimbalDrag(d.localPosition.dy / h), onTapDown: (TapDownDetails d) => _onGimbalDrag(d.localPosition.dy / h), child: SizedBox( width: 16, child: Stack(children: [ Align( alignment: Alignment.topCenter, child: Container(width: 6, height: h, decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6))), ), Positioned( top: (_gimbalFrac * h - 8).clamp(0.0, h - 16), left: 0, child: const _Thumb(), ), ]), ), ); }); } Widget _cameraControls() { return Column(mainAxisSize: MainAxisSize.min, children: [ GestureDetector( onTap: _openCameraSettings, child: Container( width: 46, height: 46, decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0x99FFFFFF), width: 2), gradient: const LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFF2A4E86), Color(0xFF12201A)]), ), ), ), const SizedBox(height: 14), GestureDetector( onTap: _toggleShutter, child: Container( width: 62, height: 62, decoration: BoxDecoration(shape: BoxShape.circle, border: Border.all(color: const Color(0xD9FFFFFF), width: 4)), child: Center( child: _m.captureMode == CaptureMode.video && _m.isRecording ? Container(width: 24, height: 24, decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(5))) : Container(width: 46, height: 46, decoration: BoxDecoration(color: _m.captureMode == CaptureMode.video ? Glass.rec : Colors.white, shape: BoxShape.circle)), ), ), ), const SizedBox(height: 14), Container( padding: const EdgeInsets.all(3), decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)), child: Column(children: [ _modeBtn('image', CaptureMode.photo), _modeBtn('video', CaptureMode.video), _modeBtn('film', CaptureMode.pano), ]), ), ]); } Widget _modeBtn(String icon, CaptureMode mode) { final bool active = _m.captureMode == mode; return GestureDetector( onTap: () => _setCaptureMode(mode), child: Container( width: 40, height: 30, 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: 17, stroke: 1.8, color: Glass.ink), ), ); } Widget _minimap() { final String rth = _m.homeDistance == null ? 'RTH —' : 'RTH ${_m.homeDistance!.toStringAsFixed(0)}m'; return GestureDetector( onTap: _openMap, child: Container( width: 148, height: 78, decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(12), border: Border.all(color: Glass.hairline)), child: Stack(children: [ const Positioned.fill(child: CustomPaint(painter: _MinimapPainter())), Positioned(top: 6, left: 8, child: Row(children: [ const PVIcon('home', size: 12, color: Glass.ink), const SizedBox(width: 5), Text(rth, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink)), ])), ]), ), ); } Widget _telemetry() { String f(double? v, {int d = 1}) => v == null ? '—' : v.toStringAsFixed(d); final List<(String, String, String)> fields = <(String, String, String)>[ ('H', f(_m.altitude), 'm'), ('D', f(_m.homeDistance, d: 0), 'm'), ('H.S', f(_m.horizontalSpeed), 'm/s'), ('V.S', f(_m.verticalSpeed), 'm/s'), ]; return Container( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(12)), child: Row(mainAxisSize: MainAxisSize.min, children: [ for (int i = 0; i < fields.length; i++) ...[ if (i > 0) const SizedBox(width: 20), Column(mainAxisSize: MainAxisSize.min, children: [ Text(fields[i].$1, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, letterSpacing: 0.8, color: Color(0x99EAF0FA))), const SizedBox(height: 2), Text.rich(TextSpan(children: [ TextSpan(text: fields[i].$2, style: const TextStyle(fontFamily: PV.fontMono, fontSize: 15, fontWeight: FontWeight.w700, color: Glass.ink)), TextSpan(text: ' ${fields[i].$3}', style: const TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Color(0x99EAF0FA))), ])), ]), ], ]), ); } Widget _rthButton() { return GestureDetector( onTap: _rth, child: Container( width: 44, height: 44, decoration: BoxDecoration(color: Glass.pillStrong, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))), alignment: Alignment.center, child: const PVIcon('rth', size: 20, color: Glass.ink), ), ); } Widget _takeoffButton() { final bool flying = _m.isFlying == true; return GestureDetector( onTap: _toggleTakeoff, child: Container( width: 44, height: 44, decoration: BoxDecoration(color: flying ? const Color(0xE6D64545) : Glass.accent, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))), alignment: Alignment.center, child: PVIcon(flying ? 'home' : 'takeoff', size: 20, color: Colors.white), ), ); } } class _Thumb extends StatelessWidget { const _Thumb(); @override Widget build(BuildContext context) { return Container( width: 16, height: 16, decoration: const BoxDecoration( color: Glass.ink, shape: BoxShape.circle, boxShadow: [BoxShadow(color: Color(0x80000000), blurRadius: 3, offset: Offset(0, 1))], ), ); } } /// Rule-of-thirds grid overlay toggled from the left rail. class _GridPainter extends CustomPainter { const _GridPainter(); @override void paint(Canvas canvas, Size size) { final Paint p = Paint() ..color = const Color(0x33FFFFFF) ..strokeWidth = 1; for (int i = 1; i < 3; i++) { final double x = size.width * i / 3, y = size.height * i / 3; canvas.drawLine(Offset(x, 0), Offset(x, size.height), p); canvas.drawLine(Offset(0, y), Offset(size.width, y), p); } } @override bool shouldRepaint(covariant _GridPainter oldDelegate) => false; } /// Painted placeholder camera feed: graded sky→ground, perspective grid, haze, /// distant buildings, and a yellow tracked-subject bracket. class _FeedPainter extends CustomPainter { const _FeedPainter(); @override void paint(Canvas canvas, Size size) { final double w = size.width, h = size.height; final double horizon = h * 0.52; final Rect full = Offset.zero & size; final Paint sky = Paint() ..shader = const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFF2A4E86), Color(0xFF3E6199), Color(0xFF1C2A1E), Color(0xFF0E1710)], stops: [0.0, 0.51, 0.54, 1.0], ).createShader(full); canvas.drawRect(full, sky); final Paint haze = Paint() ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [const Color(0x808FB4D8), const Color(0x008FB4D8)], ).createShader(Rect.fromLTWH(0, horizon - 30, w, 60)); canvas.drawRect(Rect.fromLTWH(0, horizon - 30, w, 60), haze); final Paint bld = Paint()..color = const Color(0xE612201A); void building(double x, double y, double bw, double bh) => canvas.drawRect(Rect.fromLTWH(x * w, horizon + y, bw, bh), bld); building(0.10, -40, 46, 40); building(0.17, -52, 30, 52); building(0.74, -46, 54, 46); building(0.83, -34, 34, 34); final Paint grid = Paint() ..color = const Color(0x297FE0B0) ..strokeWidth = 1; for (int i = 1; i <= 5; i++) { final double t = i / 5.0; final double y = horizon + (h - horizon) * t * t; canvas.drawLine(Offset(0, y), Offset(w, y), grid); } final double vx = w / 2; for (int k = -6; k <= 6; k += 2) { final double bx = vx + k * (w * 0.16); canvas.drawLine(Offset(vx + k * 10, horizon), Offset(bx, h), grid); } final Paint subj = Paint() ..color = Glass.subject ..style = PaintingStyle.stroke ..strokeWidth = 2; const double bxw = 118, bxh = 82; final Rect box = Rect.fromCenter(center: Offset(vx, horizon + 8), width: bxw, height: bxh); const double c = 14; canvas.drawPath(Path()..moveTo(box.left + c, box.top)..lineTo(box.left, box.top)..lineTo(box.left, box.top + c), subj); canvas.drawPath(Path()..moveTo(box.right - c, box.top)..lineTo(box.right, box.top)..lineTo(box.right, box.top + c), subj); canvas.drawPath(Path()..moveTo(box.left + c, box.bottom)..lineTo(box.left, box.bottom)..lineTo(box.left, box.bottom - c), subj); canvas.drawPath(Path()..moveTo(box.right - c, box.bottom)..lineTo(box.right, box.bottom)..lineTo(box.right, box.bottom - c), subj); } @override bool shouldRepaint(covariant _FeedPainter oldDelegate) => false; } class _MinimapPainter extends CustomPainter { const _MinimapPainter(); @override void paint(Canvas canvas, Size size) { final Path route = Path() ..moveTo(20, 60) ..cubicTo(50, 40, 70, 30, 120, 24); final Paint line = Paint() ..color = const Color(0xFF5B93F5) ..style = PaintingStyle.stroke ..strokeWidth = 2; canvas.drawPath(route, line); canvas.drawCircle(const Offset(20, 60), 4, Paint()..color = Glass.sat); canvas.drawCircle(const Offset(120, 24), 4, Paint()..color = const Color(0xFF5B93F5)); } @override bool shouldRepaint(covariant _MinimapPainter oldDelegate) => false; }