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/flight_trail.dart'; import '../../fly/fly_zone_loader.dart'; import '../../fly/fly_prefs.dart'; import '../../fly/hud_features.dart'; import '../../fly/modes/mission_library.dart'; import '../../fly/modes/mission_sync.dart' show ApiMissionRemote; import '../../fly/modes/vision_modes.dart'; 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_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/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/gimbal_sheet.dart'; 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. class FlyScreen extends StatefulWidget { const FlyScreen({super.key, required this.model, required this.dji, this.waypoints}); final FlightModel model; final DjiService dji; /// The Waypoints editor to use; the screen makes (and disposes) its own when null. final WaypointEditor? waypoints; @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); /// 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; _m.mission.addListener(_onMission); _m.aircraft.addListener(_onSticksAircraft); _sticks.addListener(_onSticks); FlyPrefs.stickMode.addListener(_onStickMode); _zones.start(); } 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'))); } } // 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: setState(() { _mode = m; _mapBig = true; }); _minimap.setFollow(_waypoints.mission.points.isEmpty); final MissionLimits limits = await _gate.missionLimits(); if (mounted) setState(() => _limits = limits); case IntelligentMode.activeTrack: case IntelligentMode.tapFly: // 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 } } default: break; // the picker only returns built modes } } void _exitMode() => setState(() { _mode = null; _mapBig = false; _waypoints.select(null); }); bool get _visionMode => _mode == IntelligentMode.activeTrack || _mode == IntelligentMode.tapFly; /// 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); } 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); FlyPrefs.stickMode.removeListener(_onStickMode); _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), airSenseMapLayer(_m.airSense), ], onTap: (LatLng at) { if (_editing) _waypoints.tapMap(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) Positioned.fill( child: ActiveTrackLayer( mission: _m.mission, session: _vision, video: _m.videoSize, onGo: (Rect box, int? subject) => _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, ), ); } 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: () => _push(AlbumPage(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, ), ), ), 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: [ 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, model: _m, session: _vision, gate: _gate, flow: _flow, onExit: () => _exitVision(context), onError: _error, ), ), if (_editing) Padding( padding: const EdgeInsets.only(bottom: 6), child: WaypointBoard( editor: _waypoints, limits: _limits, onTap: () => showRouteSettings(context, editor: _waypoints, limits: _limits), ), ), 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)), ), ), ); } }