import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart' show LatLng; import '../../dji_service.dart'; import '../../flight_model.dart'; import '../../fly/calibration.dart' show CalibrationKind; import '../../fly/flight_trail.dart'; import '../../fly/fly_zone_loader.dart'; import '../../fly/fly_prefs.dart'; import '../../fly/gimbal_control.dart' show GimbalActions; import '../../fly/hud_features.dart'; import '../../fly/modes/mission_library.dart'; import '../../fly/modes/mission_sync.dart' show ApiMissionRemote; import '../../fly/modes/orbit_modes.dart'; import '../../fly/modes/vision_modes.dart'; import '../../fly/state/link_extras.dart' show RcButtonPress; import '../../fly/state/parse.dart' show asString; import '../../fly/modes/waypoint_editor.dart'; import '../../fly/modes/waypoint_mission.dart' show MissionLimits; import '../../fly/product_profile.dart'; import '../../fly/safety_gate.dart'; import '../../fly/state/mission_state.dart'; import '../../fly/virtual_sticks.dart'; import '../../theme.dart'; import '../album_page.dart'; import '../capture_modes_page.dart'; import '../pv_icons.dart'; import 'bottom/attitude_ball.dart'; import 'bottom/minimap.dart'; import 'bottom/telemetry_strip.dart'; import 'control/virtual_stick_pads.dart'; import 'flight_command_flow.dart'; import 'hud_kit.dart'; import 'layers/video_layer.dart'; import 'left/action_rail.dart'; import 'left/gimbal_slider.dart'; import 'modes/mode_panels.dart'; import 'modes/mode_picker.dart'; import 'modes/vision/vision_layers.dart'; import 'modes/vision/vision_panel.dart'; import 'modes/waypoints/point_editor.dart'; import 'modes/waypoints/waypoint_layers.dart'; import 'modes/waypoints/waypoint_panels.dart'; import 'modes/waypoints/waypoint_sheets.dart'; import 'modes/waypoints/waypoint_start.dart'; import 'overlays/airsense.dart'; import 'overlays/alert_stack.dart'; import 'overlays/camera_touch_layer.dart'; import 'overlays/fly_zone_licences.dart'; import 'overlays/fly_zones.dart'; import 'overlays/gimbal_drag_layer.dart'; import 'overlays/live_stream.dart'; import 'overlays/histogram_view.dart'; import 'overlays/safety_banners.dart'; import 'overlays/vision_radar.dart'; import 'panels/aircraft_status_sheet.dart'; import 'panels/camera_menu/camera_menu_sheet.dart'; import 'panels/camera_quick_exposure.dart'; import 'panels/camera_write.dart'; import 'panels/gimbal_sheet.dart'; import 'panels/settings/calibration/calibration_hub.dart' show openCalibration; import 'panels/settings/settings_sheet.dart'; import 'right/camera_column.dart'; import 'right/zoom_column.dart'; import 'top/camera_info_strip.dart'; import 'top/smart_battery_bar.dart'; import 'top/status_bar.dart'; /// The flight HUD v2 (plan P2), replacing [FlightControlPage]. /// /// A stack of layers in `fpv_litchi`'s order — video → visual overlays → map → /// camera column → gimbal → top bar → bottom attitude → left rail → minimap — /// where every readout listens to its own telemetry sub-model, so a 10 Hz tick /// rebuilds only what moved. The HUD is laid out once in design pixels and /// scaled to phone or tablet by [HudCanvas]; capability-gated parts use /// [Requires] against the connected [ProductProfile]. /// /// Tapping the minimap swaps it with the video: the map goes full-bleed (and /// becomes interactive) while the feed shrinks into the corner, and back. /// /// Safety (plan P3): every aircraft-moving command on the rail goes through /// one [SafetyGate] via [FlightCommandFlow]; the error stack and low-battery /// banners sit under the top bar; the status chip opens the Aircraft Status /// sheet, which is also offered before each connection's first take-off. /// /// Camera (plan P4): the camera column, the zoom column on zoom cameras, the /// quick exposure panel (from the camera info strip), the tabbed camera menu, /// tap-to-focus / tap-to-meter on the video and the histogram. /// /// Gimbal (plan P5): the pitch slider with its value bubble, press-and-drag /// on the video as a speed joystick, and the gimbal settings sheet from the /// button under the slider. /// /// Intelligent modes (plan P7): the rail's MODES button opens the picker. /// Waypoints turns the map big and editable — tap to add, the point editor /// along the bottom, the tool column on the right — and the run panel at the /// head of the left column follows the upload and the flight, whatever the /// screen shows. The route stays on the map after leaving the mode. /// /// Radar, sticks, AirSense (plan P8): the vision radar around the picture's /// centre; the rail's STICKS button hands the aircraft to two on-screen stick /// pads through the SafetyGate (simulator first); AirSense warnings above the /// error stack and the planes it hears on the map; DJI GEO fly zones on the /// map with their banner, and the radar ring on the attitude ball. /// /// Secondary screens (plan P9): the camera column's playback button opens the /// album over the live view (videos play on the aircraft into the feed), and /// a LIVE pill heads the left column while an RTMP stream runs. class FlyScreen extends StatefulWidget { const FlyScreen({super.key, required this.model, required this.dji, this.waypoints, this.onOpenFlightLogs}); final FlightModel model; final DjiService dji; /// The Waypoints editor to use; the screen makes (and disposes) its own when null. final WaypointEditor? waypoints; /// Opens the flight records (Settings → General → Flight logs). final VoidCallback? onOpenFlightLogs; @override State createState() => _FlyScreenState(); } class _FlyScreenState extends State { late final FlightTrail _trail = FlightTrail(widget.model.aircraft); final MinimapController _minimap = MinimapController(); late final SafetyGate _gate = SafetyGate(model: widget.model, dji: widget.dji); late final FlightCommandFlow _flow = FlightCommandFlow( gate: _gate, onError: _error, reviewBeforeTakeOff: (BuildContext context) => _openStatus(context, offerTakeOff: true), ); bool _wasConnected = false; bool _mapBig = false; /// The intelligent mode whose editor is open, or null. IntelligentMode? _mode; late final WaypointEditor _waypoints = widget.waypoints ?? WaypointEditor(); final VisionSession _vision = VisionSession(); late final MissionLibrary _library = MissionLibrary(remote: ApiMissionRemote())..load(); MissionLimits _limits = const MissionLimits(); int _seenFinishes = 0; /// The on-screen sticks (plan P8); [_sticksOpen] mirrors its `open` for layout. late final VirtualStickController _sticks = VirtualStickController(send: _gate.sendSticks)..mode = FlyPrefs.stickMode.value; bool _sticksOpen = false; /// Keeps the GEO zones around the aircraft loaded (plan P8). late final FlyZoneLoader _zones = FlyZoneLoader(model: widget.model, dji: widget.dji); /// POI / Follow Me / Hyperlapse set-up (plan P7). final ModeSetup _setup = ModeSetup(); /// Sends the phone's position to Follow Me while it runs. Timer? _followTimer; /// The waypoint the current route was interrupted before, if it was. int? _resumeFrom; int _seenModeFinishes = 0; /// The live-feed cache is recording (plan P6): the aircraft flies and the pref is on. bool _caching = false; /// The last focus mode seen, for the first-manual-focus lens prompt. String? _focusMode; bool _lensPromptShown = false; /// Waypoints is open on the big map and nothing is flying: taps edit. bool get _editing { final WaypointProgress run = _m.mission.waypoint; return _mode == IntelligentMode.waypoint && _mapBig && !run.active && !run.uploading; } /// The picture-in-picture box, in HUD canvas pixels from the bottom-left corner. static const Size _pipSize = Size(150, 90); /// An on-screen stick pad's diameter, in HUD canvas pixels. static const double _stickPad = 110; FlightModel get _m => widget.model; DjiService get _dji => widget.dji; @override void initState() { super.initState(); SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); _wasConnected = _m.connected; _m.addListener(_onModel); _seenFinishes = _m.mission.waypointFinishes; _seenModeFinishes = _m.mission.modeFinishes; _m.mission.addListener(_onMission); _m.aircraft.addListener(_onSticksAircraft); _m.aircraft.addListener(_onCacheAircraft); FlyPrefs.videoCache.addListener(_onCacheAircraft); _m.rcButton.addListener(_onRcButton); _focusMode = asString(_m.camera.setting('focusMode')); _m.camera.addListener(_onCamera); _sticks.addListener(_onSticks); FlyPrefs.stickMode.addListener(_onStickMode); _zones.start(); } // ── Video cache (plan P6) ─────────────────────────────────────────────────── /// Records the live feed while the aircraft flies, when the pref is on. void _onCacheAircraft() { final bool want = FlyPrefs.videoCache.value && _m.connected && _m.aircraft.isFlying == true; if (want == _caching) return; _caching = want; _dji.setVideoCacheRecording(want, limitMb: FlyPrefs.videoCacheLimitMb.value).catchError((Object _) {}); } // ── C1 / C2 (plan P6) ─────────────────────────────────────────────────────── void _onRcButton() { final RcButtonPress? p = _m.rcButton.value; if (p == null || !mounted) return; _runButtonAction(FlyPrefs.rcButton(p.button)); } Future _runButtonAction(RcButtonAction a) async { try { switch (a) { case RcButtonAction.none: break; case RcButtonAction.gimbalRecenter: await _dji.resetGimbal(); case RcButtonAction.gimbalToggleDown: // Down when roughly level, level when pointing down. final double pitch = _m.gimbal.pitch ?? 0; await _dji.rotateGimbalPitch(pitch < -45 ? 0 : GimbalActions.faceDownPitch(_m.profile.gimbal?.pitch)); case RcButtonAction.mapSwap: setState(() => _mapBig = !_mapBig); case RcButtonAction.cameraSettings: await showCameraMenu(context, model: _m, dji: _dji, onError: _error); case RcButtonAction.aircraftStatus: await _openStatus(context); case RcButtonAction.aeLock: final bool on = _m.camera.aeLock != true; await writeCameraSetting(_m.camera, label: 'AE lock', pending: {'aeLock': on}, send: () => _dji.setCameraValue('aeLock', on), onError: _error); case RcButtonAction.radar: await FlyPrefs.setShowRadar(!FlyPrefs.showRadar.value); } } catch (e) { _error(a.label, e); } } // ── First manual focus: offer the lens calibration (plan P6b) ────────────── void _onCamera() { final String? mode = asString(_m.camera.setting('focusMode')); if (mode == _focusMode) return; final bool toManual = mode == 'MANUAL' && _focusMode != null; _focusMode = mode; if (!toManual || _lensPromptShown || !mounted) return; final String camera = _m.profile.camera?.displayName ?? _m.model ?? 'camera'; if (!HudFeatures.calibrationLens.enabledFor(_m.profile) || FlyPrefs.lensCalibrated.value.contains(camera)) return; _lensPromptShown = true; _offerLensCalibration(camera); } Future _offerLensCalibration(String camera) async { final bool? go = await showDialog( context: context, builder: (BuildContext ctx) => AlertDialog( backgroundColor: const Color(0xFF10203F), title: Text('Calibrate the lens?', style: HudText.sans.copyWith(fontSize: 15)), content: Text( 'Manual focus is only accurate once the lens has been calibrated on this camera. It takes a minute, on the ground.', style: HudText.sans.copyWith(fontWeight: FontWeight.w400), ), actions: [ TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Not now')), FilledButton(key: const ValueKey('lens-prompt-go'), onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Calibrate')), ], ), ); if (go != true || !mounted) return; await openCalibration(context, CalibrationKind.lens, model: _m, dji: _dji, onError: _error); if (_m.calibration.lensResult == 'COMPLETED') await FlyPrefs.markLensCalibrated(camera); } void _onStickMode() => _sticks.mode = FlyPrefs.stickMode.value; /// The flight controller can end virtual-stick mode on its own (the remote's /// mode switch, RTH, landing): the controller closes, and the loop is told to stop. void _onSticksAircraft() { final String mode = _m.aircraft.flightModeEnum ?? ''; _sticks.onAircraft( enabled: _m.aircraft.virtualStickEnabled, goingHome: _m.aircraft.goingHome == true || mode == 'GO_HOME', landing: mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING', ); } void _onSticks() { if (_sticks.open == _sticksOpen) return; final String? reason = _sticks.closedReason; if (!_sticks.open && reason != null) { _gate.disableVirtualSticks().catchError((Object _) {}); if (mounted) { ScaffoldMessenger.of(context) ..clearSnackBars() ..showSnackBar(SnackBar(content: Text('On-screen sticks off: $reason'))); } } if (mounted) setState(() => _sticksOpen = _sticks.open); } Future _toggleSticks(BuildContext context) async { if (_sticks.open) { _sticks.close(); try { await _gate.disableVirtualSticks(); } catch (e) { _error('On-screen sticks', e); } return; } await startVisionMode( context, title: 'On-screen sticks', icon: 'rc', description: '${FlyPrefs.stickMode.value.label}. Lift your thumbs and the aircraft hovers. Tap STICKS again to hand back to the remote.', checks: _gate.checkVirtualSticks(), start: () async { await _gate.enableVirtualSticks(); _sticks.opened(); }, onError: _error, ); } /// Says how a waypoint mission ended, and leaves edit mode when one starts. void _onMission() { final MissionState ms = _m.mission; if (ms.waypointFinishes != _seenFinishes) { _seenFinishes = ms.waypointFinishes; final String? r = ms.waypointResult; if (mounted && r != null) { ScaffoldMessenger.of(context) ..clearSnackBars() ..showSnackBar(SnackBar(content: Text(r.isEmpty ? 'Waypoint mission complete' : 'Waypoint mission ended: $r'))); } // A mission cut short may be resumed from where it stopped. _checkResume(); } if (ms.modeFinishes != _seenModeFinishes) { _seenModeFinishes = ms.modeFinishes; final String? r = ms.modeResult; if (mounted && r != null && r.isNotEmpty) { ScaffoldMessenger.of(context) ..clearSnackBars() ..showSnackBar(SnackBar(content: Text('Mode ended: $r'))); } } // Follow Me needs the phone's position for as long as it follows. final bool following = ms.followMe.active; if (following && _followTimer == null) { _followTimer = Timer.periodic(FollowMeSpec.updateEvery, (_) => _gate.updateFollowMe().catchError((Object _) {})); } else if (!following && _followTimer != null) { _followTimer!.cancel(); _followTimer = null; } // Execution events arrive at ~10 Hz; rebuild the screen only when editing turns on or off. final bool editing = _editing; if (mounted && editing != _wasEditing) setState(() => _wasEditing = editing); } bool _wasEditing = false; Future _openModes(BuildContext context) async { final IntelligentMode? m = await showModePicker(context, profile: _m.profile); if (m == null || !mounted) return; switch (m) { case IntelligentMode.waypoint: case IntelligentMode.waypointV2: setState(() { _mode = IntelligentMode.waypoint; _mapBig = true; }); _minimap.setFollow(_waypoints.mission.points.isEmpty); final MissionLimits limits = await _gate.missionLimits(); if (mounted) setState(() => _limits = limits); _checkResume(); case IntelligentMode.activeTrack: case IntelligentMode.tapFly: case IntelligentMode.quickShots: // Vision modes work on the live video. _vision.clearPending(); setState(() { _mode = m; _mapBig = false; }); if (m == IntelligentMode.activeTrack && _m.connected) { try { _vision.setRetreat(await _dji.getActiveTrackRetreat()); } catch (_) { _vision.setRetreat(null); // not reported: the chip starts off } } case IntelligentMode.pointOfInterest: // The centre is picked on the map; it stays big until GO. setState(() { _mode = m; _mapBig = !_m.mission.hotpoint.active; }); if (_setup.poi.center == null && _m.aircraft.latitude != null && _m.aircraft.longitude != null) { _minimap.setFollow(true); } case IntelligentMode.followMe: case IntelligentMode.courseLock: case IntelligentMode.homeLock: case IntelligentMode.tripod: case IntelligentMode.hyperlapse: setState(() { _mode = m; _mapBig = false; }); } } /// Asks whether the route on screen was cut short, to offer RESUME. Future _checkResume() async { final int? i = await _gate.interruptionFor(_waypoints.mission); if (mounted && i != _resumeFrom) setState(() => _resumeFrom = i); } void _resume(BuildContext context) { final int? i = _resumeFrom; if (i == null) return; startWaypointFlow(context, gate: _gate, spec: _waypoints.mission.from(i), onError: _error); } void _exitMode() => setState(() { _mode = null; _mapBig = false; _waypoints.select(null); }); bool get _visionMode => _mode == IntelligentMode.activeTrack || _mode == IntelligentMode.tapFly || _mode == IntelligentMode.quickShots; /// Leaving a vision mode while it flies stops it first (GO 4's Exit). Future _exitVision(BuildContext context) async { final MissionState ms = _m.mission; final bool running = _mode == IntelligentMode.tapFly ? ms.tapFly.active : ms.activeTrack.active; if (running) await _flow.request(context, FlightCommand.stopMode); _vision.clearPending(); if (mounted) setState(() => _mode = null); } /// Leaving POI or Follow Me while it flies stops it first, like the vision modes. Future _exitAuto(BuildContext context) async { final MissionState ms = _m.mission; final bool running = (_mode == IntelligentMode.pointOfInterest && ms.hotpoint.active) || (_mode == IntelligentMode.followMe && ms.followMe.active); if (running) await _flow.request(context, FlightCommand.stopMode); if (mounted) { setState(() { _mode = null; _mapBig = false; }); } } void _goQuickShot(BuildContext context, Rect box) { final QuickShot shot = _vision.quickShot; startVisionMode( context, title: 'QuickShot · ${shot.label}', icon: 'dronie', description: '${shot.blurb}. The aircraft records, flies the shot and comes back. Stop, the sticks or the remote\'s pause button end it.', // QuickShots always get the slider: the aircraft flies far on its own. checks: [..._gate.checkQuickShot(shot), if (_gate.checkQuickShot(shot).isEmpty) const GateCheck.warn('The aircraft flies the whole shot on its own.')], start: () async { await _gate.startQuickShot(box, shot); _vision.setPendingBox(null); }, onError: _error, ); } void _goTrack(BuildContext context, Rect box, {int? subject}) { final TrackMode mode = _vision.trackMode; startVisionMode( context, title: 'ActiveTrack · ${mode.label}', icon: 'crosshair', description: 'The aircraft follows the boxed subject. Stop, the remote\'s pause button, or the sticks take back control.', checks: _gate.checkActiveTrack(mode), start: () async { // A subject the aircraft sensed itself is started by its index, not a drawn box. await (subject != null ? _gate.startActiveTrackSubject(subject, mode) : _gate.startActiveTrack(box, mode)); _vision.setPendingBox(null); }, onError: _error, ); } void _goTapFly(BuildContext context, Offset target) { final TapFlyDirection d = _vision.direction; startVisionMode( context, title: 'TapFly · ${d.label}', icon: 'chevronsRight', description: 'The aircraft flies towards the point at ${_vision.speed.toStringAsFixed(0)} m/s until you stop it.', checks: _gate.checkTapFly(d), start: () async { await _gate.startTapFly(target, d, speed: _vision.speed); _vision.setPendingPoint(null); }, onError: _error, ); } Future _confirmTrack(bool yes) async { try { await _gate.confirmActiveTrack(yes: yes); } catch (e) { _error('ActiveTrack', e); } } /// A new connection gets its own pre-flight review. void _onModel() { if (_m.connected == _wasConnected) return; _wasConnected = _m.connected; if (!_m.connected) { _flow.resetReview(); _sticks.close(); } } Future _openStatus(BuildContext context, {bool offerTakeOff = false}) { // Looking at the sheet on purpose counts as the pre-flight review. if (!offerTakeOff) _flow.markReviewed(); return showAircraftStatusSheet(context, model: _m, dji: _dji, onError: _error, offerTakeOff: offerTakeOff); } @override void dispose() { _m.removeListener(_onModel); _m.mission.removeListener(_onMission); _m.aircraft.removeListener(_onSticksAircraft); _m.aircraft.removeListener(_onCacheAircraft); FlyPrefs.videoCache.removeListener(_onCacheAircraft); _m.rcButton.removeListener(_onRcButton); _m.camera.removeListener(_onCamera); FlyPrefs.stickMode.removeListener(_onStickMode); _followTimer?.cancel(); // The cache records only while this screen flies. if (_caching) _dji.setVideoCacheRecording(false).catchError((Object _) {}); _setup.dispose(); _zones.dispose(); // Leaving the screen hands the aircraft back to the remote. if (_sticks.open) _gate.disableVirtualSticks().catchError((Object _) {}); _sticks.dispose(); if (widget.waypoints == null) _waypoints.dispose(); _vision.dispose(); _library.dispose(); _trail.dispose(); _minimap.dispose(); SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); super.dispose(); } void _error(String label, Object e) { if (!mounted) return; final String reason = e is PlatformException ? (e.message ?? e.code) : e.toString(); ScaffoldMessenger.of(context) ..clearSnackBars() ..showSnackBar(SnackBar(content: Text('$label failed: $reason'))); } void _push(Widget page) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => page)); @override Widget build(BuildContext context) { final MediaQueryData mq = MediaQuery.of(context); final double s = HudScale.scaleFor(mq.size.shortestSide); final Rect area = HudCanvas.screenRect(mq); final double canvasHeight = area.height / s; final Rect pip = HudCanvas.toScreen(mq, Rect.fromLTWH(0, canvasHeight - _pipSize.height, _pipSize.width, _pipSize.height)); final Rect full = Offset.zero & mq.size; _wasEditing = _editing; final Widget video = ListenableBuilder( key: const ValueKey('video'), listenable: _m, builder: (BuildContext context, _) => VideoLayer(connected: _m.connected), ); final Widget map = FlyMap( key: const ValueKey('map'), model: _m, trail: _trail, controller: _minimap, interactive: _mapBig, layers: [ // Zones under the route, planes on top. flyZoneMapLayer(_m.flyZones), flyZoneLicenceLayer(_m.flyZones), flyZoneHeightLayer(_m.flyZones), ...waypointMapLayers(editor: _waypoints, mission: _m.mission, editable: _editing), if (_mode == IntelligentMode.pointOfInterest || _m.mission.hotpoint.active) ...poiMapLayers(_setup, _m.mission), airSenseMapLayer(_m.airSense), ], onTap: (LatLng at) { if (_editing) { _waypoints.tapMap(at); } else if (_mode == IntelligentMode.pointOfInterest && !_m.mission.hotpoint.active) { _setup.setPoi(_setup.poi.copyWith(center: at)); } }, ); final Widget body = Stack(fit: StackFit.expand, children: [ // The background view, then whatever sits in the corner box. Keyed, so // the swap moves the native video view instead of recreating it. if (!_mapBig) ...[ Positioned.fromRect(key: const ValueKey('video-slot'), rect: full, child: video), Positioned.fill(child: VisualOverlays(video: _m.videoSize)), // A vision mode owns every touch on the video: a box or a point. if (_mode == IntelligentMode.activeTrack || _mode == IntelligentMode.quickShots) Positioned.fill( child: ActiveTrackLayer( mission: _m.mission, session: _vision, video: _m.videoSize, onGo: (Rect box, int? subject) => _mode == IntelligentMode.quickShots ? _goQuickShot(context, box) : _goTrack(context, box, subject: subject), onConfirm: _confirmTrack, ), ) else if (_mode == IntelligentMode.tapFly) Positioned.fill( child: TapFlyLayer(mission: _m.mission, session: _vision, video: _m.videoSize, onGo: (Offset p) => _goTapFly(context, p)), ) else ...[ // Under the camera touch layer, in the same gesture arena: drags aim // the gimbal, taps and long presses still reach the camera. Positioned.fill( child: Requires( HudFeatures.gimbalDrag, child: ListenableBuilder( listenable: _m, builder: (BuildContext context, _) => GimbalDragLayer( gimbal: _m.gimbal, dji: _dji, connected: _m.connected, yaw: HudFeatures.gimbalYaw.enabledFor(ProductProfileScope.of(context)), onError: _error, ), ), ), ), Positioned.fill( child: Requires( HudFeatures.cameraControls, child: ListenableBuilder( listenable: _m, builder: (BuildContext context, _) => CameraTouchLayer(camera: _m.camera, dji: _dji, connected: _m.connected, onError: _error, video: _m.videoSize), ), ), ), ], // Obstacles around the picture's centre; touch-transparent. Positioned.fill( child: Requires( HudFeatures.visionRadar, whenDisconnected: false, child: VisionRadarOverlay(vision: _m.vision, aircraft: _m.aircraft, video: _m.videoSize), ), ), Positioned.fromRect(key: const ValueKey('map-slot'), rect: pip, child: map), ] else ...[ Positioned.fromRect(key: const ValueKey('map-slot'), rect: full, child: map), Positioned.fromRect(key: const ValueKey('video-slot'), rect: pip, child: video), ], Positioned.fill(child: HudCanvas(builder: _hud)), Positioned( left: 0, right: 0, bottom: 0, child: IgnorePointer( child: ValueListenableBuilder( valueListenable: FlyPrefs.perfOverlay, builder: (BuildContext context, bool on, _) => on ? PerformanceOverlay.allEnabled() : const SizedBox.shrink(), ), ), ), ]); return Scaffold( backgroundColor: const Color(0xFF0A1120), body: ListenableBuilder( listenable: _m, // Only the scope is rebuilt per tick; it notifies [Requires] below it // when the profile object itself changes. builder: (BuildContext context, Widget? child) => ProductProfileScope(profile: _m.profile, child: child!), child: body, ), ); } /// The open mode's panel at the head of the left column, for the modes /// that are neither Waypoints nor a vision mode. Widget? _modePanel(BuildContext context) { final MissionState ms = _m.mission; // A POI or Follow Me flying keeps its panel even after the mode was left. final IntelligentMode? m = _mode ?? (ms.hotpoint.active ? IntelligentMode.pointOfInterest : ms.followMe.active ? IntelligentMode.followMe : null); return switch (m) { IntelligentMode.pointOfInterest => PoiPanel(model: _m, setup: _setup, gate: _gate, flow: _flow, dji: _dji, onExit: () => _exitAuto(context), onError: _error), IntelligentMode.followMe => FollowMePanel(model: _m, setup: _setup, gate: _gate, flow: _flow, onExit: () => _exitAuto(context), onError: _error), IntelligentMode.courseLock || IntelligentMode.homeLock => OrientationPanel(mode: m!, model: _m, gate: _gate, onExit: _exitMode, onError: _error), IntelligentMode.tripod => SlowModePanel(model: _m, dji: _dji, gate: _gate, onExit: _exitMode, onError: _error), IntelligentMode.hyperlapse => HyperlapsePanel(model: _m, setup: _setup, gate: _gate, onExit: _exitMode, onError: _error), _ => null, }; } Widget _hud(BuildContext context, Size canvas, double scale) { return Stack(clipBehavior: Clip.none, children: [ // ── Camera column (right) ───────────────────────────────────────────── Positioned( right: 0, top: 76, bottom: 0, child: Center( child: Requires( HudFeatures.cameraControls, child: CameraColumn( model: _m, dji: _dji, onError: _error, onCameraMenu: () => showCameraMenu(context, model: _m, dji: _dji, onError: _error), onPlayback: () => Navigator.of(context).push(AlbumPage.route(model: _m, dji: _dji)), onCaptureModes: () => _push(CaptureModesPage(model: _m, dji: _dji)), ), ), ), ), // ── Zoom column (zoom cameras only) ─────────────────────────────────── // The right stick pad takes its place while the sticks fly. if (!_mapBig && !_sticksOpen) Positioned( right: 68, top: 76, bottom: 0, child: Center( child: Requires( HudFeatures.zoomColumn, whenDisconnected: false, child: ZoomColumn(camera: _m.camera, dji: _dji, onError: _error), ), ), ), if (!_editing) Positioned( right: 68, top: StatusBar.height + SmartBatteryBar.height + 32, child: Requires(HudFeatures.histogram, child: HistogramView(camera: _m.camera)), ), // ── Gimbal ──────────────────────────────────────────────────────────── Positioned( left: 54, top: 64, bottom: _pipSize.height + 24, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Requires( HudFeatures.gimbalPitch, child: Builder( builder: (BuildContext context) => GimbalSlider( gimbal: _m.gimbal, dji: _dji, range: ProductProfileScope.of(context).gimbal?.pitch, onError: _error, ), ), ), ), const SizedBox(height: 8), Requires( HudFeatures.gimbalSettings, child: GlassPill( key: const ValueKey('gimbal-settings-button'), height: 28, padding: const EdgeInsets.symmetric(horizontal: 6), onTap: () => showGimbalSettings(context, model: _m, dji: _dji, onError: _error), child: const PVIcon('gimbal', size: 16, color: Glass.ink), ), ), ]), ), // ── Top bar, battery bar, camera info ───────────────────────────────── Positioned( left: 0, right: 0, top: 0, child: StatusBar( model: _m, onBack: () => Navigator.of(context).maybePop(), onStatus: () => _openStatus(context), onMore: () => showFlySettings( context, model: _m, dji: _dji, onError: _error, onOpenStatus: () => _openStatus(context), onClearRoute: _trail.clear, onOpenFlightLogs: widget.onOpenFlightLogs, ), ), ), Positioned(left: 0, right: 0, top: StatusBar.height + 3, child: SmartBatteryBar(model: _m)), // Exposure means nothing while planning a route on the map. if (!_editing) Positioned( right: 0, top: StatusBar.height + SmartBatteryBar.height + 5, child: CameraInfoStrip( camera: _m.camera, onTap: () => showQuickExposure(context, model: _m, dji: _dji, onError: _error), ), ), // ── Bottom: attitude ball + telemetry ───────────────────────────────── // Gives way to the point editor while a waypoint or POI is selected. Positioned( left: _pipSize.width + 10, bottom: 0, child: ListenableBuilder( listenable: _waypoints, builder: (BuildContext context, Widget? child) => _editing && (_waypoints.selected != null || _waypoints.selectedPoi != null) ? const SizedBox.shrink() : child!, // The left stick pad takes the attitude ball's place while the sticks fly. child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ if (_sticksOpen) const SizedBox(width: _stickPad + 10) else AttitudeBall(model: _m, size: 84), const SizedBox(width: 6), TelemetryStrip(aircraft: _m.aircraft), ]), ), ), // ── On-screen sticks (plan P8) ──────────────────────────────────────── if (_sticksOpen) Positioned( left: _pipSize.width + 10, bottom: 0, child: ListenableBuilder( listenable: _sticks, builder: (BuildContext context, _) => StickPad( key: const ValueKey('stick-left'), size: _stickPad, value: _sticks.left, onChanged: _sticks.setLeft, caption: stickCaptions(_sticks.mode).left, ), ), ), if (_sticksOpen) Positioned( right: 70, bottom: 0, child: ListenableBuilder( listenable: _sticks, builder: (BuildContext context, _) => StickPad( key: const ValueKey('stick-right'), size: _stickPad, value: _sticks.right, onChanged: _sticks.setRight, caption: stickCaptions(_sticks.mode).right, ), ), ), // ── Mission run panel / route board, error stack, low-battery banners ─ // Bounded above the attitude ball; the stack fits what is left under the banners. Positioned( left: 108, top: StatusBar.height + SmartBatteryBar.height + 8, bottom: _sticksOpen ? _stickPad + 18 : 104, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ LiveBadge(live: _m.liveStream, dji: _dji, onError: _error), WaypointRunPanel(model: _m, editor: _waypoints, gate: _gate, flow: _flow, onError: _error), // Palm control (Spark) flies the aircraft from the pilot's hand: say so. ListenableBuilder( listenable: _m.vision, builder: (BuildContext context, _) => !_m.vision.palmActive ? const SizedBox.shrink() : Padding( padding: const EdgeInsets.only(bottom: 6), child: GlassPill( key: const ValueKey('palm-control'), color: Glass.pillStrong, child: Row(mainAxisSize: MainAxisSize.min, children: [ const PVIcon('user', size: 12, color: Glass.ink), const SizedBox(width: 6), Text('PALM CONTROL', style: HudText.label.copyWith(color: Glass.ink, fontWeight: FontWeight.w700)), const SizedBox(width: 6), Text(_m.vision.palmLabel.toUpperCase(), style: HudText.label.copyWith(color: HudColors.ready)), ]), ), ), ), if (_visionMode) Padding( padding: const EdgeInsets.only(bottom: 6), child: VisionModePanel( tapFly: _mode == IntelligentMode.tapFly, quickShots: _mode == IntelligentMode.quickShots, model: _m, session: _vision, gate: _gate, flow: _flow, onExit: () => _exitVision(context), onError: _error, ), ), if (_modePanel(context) case final Widget panel) Padding(padding: const EdgeInsets.only(bottom: 6), child: panel), if (_editing) Padding( padding: const EdgeInsets.only(bottom: 6), child: WaypointBoard( editor: _waypoints, limits: _limits, onTap: () => showRouteSettings(context, editor: _waypoints, limits: _limits), resumeFrom: _resumeFrom, onResume: () => _resume(context), ), ), Requires( HudFeatures.airSense, child: AirSenseBanner(airSense: _m.airSense, onTap: () => showAirSenseSheet(context, airSense: _m.airSense)), ), Requires( HudFeatures.flyZones, child: FlyZoneBanner( zones: _m.flyZones, onTap: () => showFlyZoneSheet(context, model: _m, gate: _gate, onError: _error), ), ), SafetyBanners(gate: _gate, onError: _error), Flexible(child: AlertStack(alerts: _m.alerts, onMore: () => _openStatus(context))), ]), ), // ── Left rail ───────────────────────────────────────────────────────── Positioned( left: 0, top: 60, child: ActionRail( model: _m, flow: _flow, modeActive: _mode != null, onModes: () => _openModes(context), trailing: Requires( HudFeatures.virtualSticks, whenDisconnected: false, child: SticksButton(open: _sticksOpen, onTap: () => _toggleSticks(context)), ), ), ), if (_mapBig) Positioned(right: _editing ? 122 : 70, top: 80, child: MinimapControls(controller: _minimap, trail: _trail)), // ── Waypoints editor: tools + point editor ──────────────────────────── if (_editing) ...[ Positioned( right: 70, top: 76, child: WaypointTools( editor: _waypoints, onStart: () => startWaypointFlow(context, gate: _gate, spec: _waypoints.mission, onError: _error), onSettings: () => showRouteSettings(context, editor: _waypoints, limits: _limits), onLibrary: () => showMissionLibrary(context, editor: _waypoints, library: _library), onExit: _exitMode, ), ), Positioned( left: _pipSize.width + 10, right: 124, bottom: 0, child: Builder( builder: (BuildContext context) { final ProductProfile p = ProductProfileScope.of(context); return PointEditorPanel( editor: _waypoints, gimbalMin: p.gimbal?.pitch?.min ?? -90, gimbalMax: p.gimbal?.pitch?.max ?? 30, ); }, ), ), ], // ── Minimap / video corner box ──────────────────────────────────────── Positioned( left: 0, bottom: 0, width: _pipSize.width, height: _pipSize.height, child: _PipFrame(label: _mapBig ? 'VIDEO' : 'MAP', onTap: () => setState(() => _mapBig = !_mapBig)), ), ]); } } /// The corner box's frame and tap target. The content underneath is the real /// map or video layer, placed at the same spot by [FlyScreen]. class _PipFrame extends StatelessWidget { const _PipFrame({required this.label, required this.onTap}); final String label; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), border: Border.all(color: const Color(0x66EAF0FA)), ), alignment: Alignment.topLeft, padding: const EdgeInsets.all(4), child: Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(3)), child: Text(label, style: HudText.label.copyWith(color: Glass.ink)), ), ), ); } }