Files
PilotVault/Fly App/lib/ui/fly/fly_screen.dart
T
tajniak81andClaude Opus 5.5 cfab444c65 Fix what the first secondary-screen run on the Mavic Pro turned up
- Leaving the Album put the camera in video mode even when it had been in
  photo mode: it restored FlightModel.captureMode, which only follows the HUD
  switch. It now restores the mode the camera reported before it was taken.
- Playing a video on the aircraft drew the player over the whole HUD. The
  Album now sets FlightModel.hudHidden, and the flight screen shows only the
  feed, full-screen, while it is set (the HUD stays mounted, offstage).
- Map tiles that failed once stayed as dark squares. Every map now uses one
  OpenStreetMap provider whose client also retries dropped connections, and
  failed tiles are fetched again when panned back into view.
- Find My Drone saved simulated positions as the aircraft's last fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:13:24 +02:00

1060 lines
43 KiB
Dart

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).
///
/// 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<FlyScreen> createState() => _FlyScreenState();
}
class _FlyScreenState extends State<FlyScreen> {
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>[
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.hudHidden.addListener(_onHudHidden);
_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<void> _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: <String, Object?>{'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<void> _offerLensCalibration(String camera) async {
final bool? go = await showDialog<bool>(
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: <Widget>[
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Not now')),
FilledButton(key: const ValueKey<String>('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<void> _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<void> _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<void> _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<void> _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<void> _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: <GateCheck>[..._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<void> _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<bool> _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.hudHidden.removeListener(_onHudHidden);
_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>[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<void>(builder: (_) => page));
void _onHudHidden() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final MediaQueryData mq = MediaQuery.of(context);
final bool bare = _m.hudHidden.value;
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<String>('video'),
listenable: _m,
builder: (BuildContext context, _) => VideoLayer(connected: _m.connected),
);
final Widget mapView = FlyMap(
key: const ValueKey<String>('map'),
model: _m,
trail: _trail,
controller: _minimap,
interactive: _mapBig,
layers: <Widget>[
// 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 map = Offstage(offstage: bare, child: mapView);
final Widget body = Stack(fit: StackFit.expand, children: <Widget>[
// The background view, then whatever sits in the corner box. Keyed, so
// the swap moves the native video view instead of recreating it.
if (bare) ...<Widget>[
Positioned.fromRect(key: const ValueKey<String>('video-slot'), rect: full, child: video),
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: pip, child: map),
] else if (!_mapBig) ...<Widget>[
Positioned.fromRect(key: const ValueKey<String>('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 ...<Widget>[
// 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<String>('map-slot'), rect: pip, child: map),
] else ...<Widget>[
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: full, child: map),
Positioned.fromRect(key: const ValueKey<String>('video-slot'), rect: pip, child: video),
],
// Offstage rather than removed, so the HUD's own state survives.
Positioned.fill(child: Offstage(offstage: bare, child: HudCanvas(builder: _hud))),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: IgnorePointer(
child: ValueListenableBuilder<bool>(
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: <Widget>[
// ── 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: <Widget>[
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<String>('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: <Widget>[
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<String>('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<String>('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: <Widget>[
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<String>('palm-control'),
color: Glass.pillStrong,
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
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) ...<Widget>[
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)),
),
),
);
}
}