import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../flight_model.dart'; import '../theme.dart'; import 'dji_video_view.dart'; /// Landscape live-flight overlay — mirrors the ui_kit/fly "Flight control · /// landscape" mockup. Locks to landscape while shown and restores portrait on /// exit. Camera-feed HUD is composited over a painted placeholder feed; real /// telemetry (satellites, battery, altitude, mode) is bound where available. class FlightControlPage extends StatefulWidget { const FlightControlPage({super.key, required this.model}); final FlightModel model; @override State createState() => _FlightControlPageState(); } class _FlightControlPageState extends State { Timer? _recTimer; int _recSeconds = 0; String _mode = 'Video'; @override void initState() { super.initState(); SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); _recTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() => _recSeconds++); }); } @override void dispose() { _recTimer?.cancel(); SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.dispose(); } String get _recLabel { final String mm = (_recSeconds ~/ 60).toString().padLeft(2, '0'); final String ss = (_recSeconds % 60).toString().padLeft(2, '0'); return '$mm:$ss'; } @override Widget build(BuildContext context) { final FlightModel m = widget.model; return Scaffold( backgroundColor: const Color(0xFF0A1120), body: AnimatedBuilder( animation: m, builder: (BuildContext context, _) { return Stack( children: [ // Background: live DJI camera feed when a product is connected, // painted placeholder otherwise. The HUD layers below paint over // it since they come later in the stack. Positioned.fill( child: m.connected ? const DjiVideoView() : const CustomPaint(painter: _FeedPainter()), ), // Center reticle const Center(child: Icon(Icons.add, size: 30, color: Color(0xB3FFFFFF))), // Top bar Positioned( top: 12, left: 14, right: 14, child: _topBar(m), ), // Left rail Positioned( left: 14, top: 58, child: Column( children: [ _sideBtn(Icons.control_camera, active: true), const SizedBox(height: 10), _sideBtn(Icons.wb_sunny_outlined), const SizedBox(height: 10), _sideBtn(Icons.camera_outlined), const SizedBox(height: 10), _sideBtn(Icons.grid_on), ], ), ), // Gimbal pitch slider Positioned( left: 70, top: 58, bottom: 96, child: _gimbalSlider(), ), // Right camera controls Positioned( right: 16, top: 0, bottom: 0, child: Center(child: _cameraControls()), ), // Bottom-left minimap Positioned(left: 14, bottom: 12, child: _minimap()), // Bottom-center telemetry Positioned( bottom: 14, left: 0, right: 0, child: Center(child: _telemetry(m)), ), // RTH button Positioned(right: 92, bottom: 20, child: _rthButton()), ], ); }, ), ); } // ── Top bar ────────────────────────────────────────────────────────────── Widget _topBar(FlightModel m) { return Row( children: [ GestureDetector( onTap: () => Navigator.of(context).maybePop(), child: _pill(child: const Icon(Icons.chevron_left, 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( (m.flightMode != null && m.flightMode!.isNotEmpty) ? m.flightMode! : 'N', 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 Icon(Icons.satellite_alt, size: 14, color: Glass.sat), const SizedBox(width: 4), _mono(m.satellites?.toString() ?? '0'), ])), const SizedBox(width: 8), _pill(child: Row(mainAxisSize: MainAxisSize.min, children: const [ Icon(Icons.sensors, size: 14, color: Glass.ink), SizedBox(width: 4), Text('HD', style: TextStyle(fontFamily: PV.fontMono, fontSize: 12, color: Glass.ink)), ])), const Spacer(), _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ _mono('REC'), const SizedBox(width: 6), Container(width: 7, height: 7, decoration: const BoxDecoration(color: Glass.rec, shape: BoxShape.circle)), const SizedBox(width: 6), _mono(_recLabel), ])), const SizedBox(width: 8), _pill(child: Row(mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.battery_full, size: 16, color: Glass.sat), const SizedBox(width: 4), _mono(m.batteryPercent == null ? '—' : '${m.batteryPercent}%'), ])), const SizedBox(width: 8), _pill(child: const Icon(Icons.settings, size: 16, color: Glass.ink)), ], ); } // ── 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(IconData icon, {bool active = false}) { return Container( width: 42, height: 42, decoration: BoxDecoration( color: active ? Glass.accent : Glass.pill, borderRadius: BorderRadius.circular(12), border: Border.all(color: Glass.hairline), ), child: Icon(icon, size: 20, color: Glass.ink), ); } Widget _gimbalSlider() { return SizedBox( width: 16, child: Stack( alignment: Alignment.topCenter, children: [ Container(width: 6, decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6))), const Align( alignment: Alignment(0, -0.24), child: _Thumb(), ), ], ), ); } Widget _cameraControls() { return Column( mainAxisSize: MainAxisSize.min, children: [ 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), // Shutter Container( width: 62, height: 62, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: const Color(0xD9FFFFFF), width: 4), ), child: Center( child: Container( width: 26, height: 26, decoration: BoxDecoration(color: Glass.rec, borderRadius: BorderRadius.circular(7)), ), ), ), const SizedBox(height: 14), // Mode switch Container( padding: const EdgeInsets.all(3), decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(10)), child: Column( children: [ _modeBtn(Icons.photo_outlined, 'Photo'), _modeBtn(Icons.videocam_outlined, 'Video'), _modeBtn(Icons.panorama_outlined, 'Pano'), ], ), ), ], ); } Widget _modeBtn(IconData icon, String mode) { final bool active = _mode == mode; return GestureDetector( onTap: () => setState(() => _mode = 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), ), child: Icon(icon, size: 17, color: Glass.ink), ), ); } Widget _minimap() { return 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())), const Positioned( top: 6, left: 8, child: Row(children: [ Icon(Icons.home_outlined, size: 12, color: Glass.ink), SizedBox(width: 5), Text('RTH 340m', style: TextStyle(fontFamily: PV.fontMono, fontSize: 10, color: Glass.ink)), ]), ), ], ), ); } Widget _telemetry(FlightModel m) { final List<(String, String, String)> fields = <(String, String, String)>[ ('H', m.altitude == null ? '—' : m.altitude!.toStringAsFixed(1), 'm'), ('D', '—', 'm'), ('H.S', '—', 'm/s'), ('V.S', '—', '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 Container( width: 44, height: 44, decoration: BoxDecoration( color: Glass.pillStrong, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF)), ), child: const Icon(Icons.home_outlined, size: 20, color: Glass.ink), ); } } 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))], ), ); } } /// 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; // Sky → ground gradient. 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); // Horizon haze. 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); // Distant buildings just under the horizon. 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); // Perspective ground grid. 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); } // Tracked-subject bracket, centered. final Paint subj = Paint() ..color = Glass.subject ..style = PaintingStyle.stroke ..strokeWidth = 2; final double bxw = 118, bxh = 82; final Rect box = Rect.fromCenter(center: Offset(vx, horizon + 8), width: bxw, height: bxh); const double c = 14; // Four corner brackets. 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; }