From 6ebc567e974d45bf9e6c2375195963e0a1070a8f Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:24:17 +0200 Subject: [PATCH] Put every flight command behind a SafetyGate Take-off, land, RTH, the landing cancel/confirm and Stop now all go through one SafetyGate (lib/fly/safety_gate.dart). The gate sorts its preconditions into blockers and warnings: connection, flight state, battery against the thresholds, IMU warm-up, compass, critical diagnostics, GPS, home point, SD card and the RTH battery budget. It checks again right before sending. The left rail is now a state machine (lib/fly/action_rail_state.dart): Take-off becomes Land, then Cancel or Confirm landing. RTH becomes Cancel RTH. A Stop button shows while an intelligent mode is flying. Moving the aircraft needs a slide-to-confirm dialog, which can offer Precision Takeoff or set the RTH altitude. A blocked command shows why and has no slider. When the pilot is more than 30 m from home, RTH asks whether to return to them or to the original home point. The HUD gains an error stack. Its cards can be dismissed, it fits the space it has, and a new warning triggers haptic feedback. It also gets a smart-RTH countdown banner with Cancel / Go home now, a forced-landing banner, and an Aircraft Status sheet. The sheet opens from the status chip and appears before each connection's first take-off. It offers Calibrate compass/IMU and Format SD. On the bridge side this adds precisionTakeOff, setHomeLocation, compass and IMU calibration start/stop, formatStorage, and compass health in the telemetry tick. The classic flight screen stays available until the simulator run passes. Co-Authored-By: Claude Opus 5.5 --- .../com/pilotvault/flyapp/CameraBridge.kt | 9 + .../flyapp/FlightControllerBridge.kt | 38 +- Fly App/lib/dji_service.dart | 16 + Fly App/lib/fly/action_rail_state.dart | 92 ++++ Fly App/lib/fly/aircraft_checklist.dart | 245 ++++++++++ Fly App/lib/fly/flight_status.dart | 2 +- Fly App/lib/fly/fly_prefs.dart | 2 +- Fly App/lib/fly/hud_features.dart | 4 + Fly App/lib/fly/safety_gate.dart | 257 +++++++++++ Fly App/lib/fly/state/aircraft_state.dart | 11 + .../lib/ui/fly/dialogs/rth_target_dialog.dart | 95 ++++ .../lib/ui/fly/dialogs/slide_to_confirm.dart | 328 +++++++++++++ Fly App/lib/ui/fly/flight_command_flow.dart | 149 ++++++ Fly App/lib/ui/fly/fly_screen.dart | 50 +- Fly App/lib/ui/fly/left/action_rail.dart | 163 +++---- Fly App/lib/ui/fly/overlays/alert_stack.dart | 166 +++++++ .../lib/ui/fly/overlays/safety_banners.dart | 125 +++++ .../ui/fly/panels/aircraft_status_sheet.dart | 291 ++++++++++++ Fly App/lib/ui/fly/top/status_bar.dart | 10 +- Fly App/lib/ui/pv_icons.dart | 4 + Fly App/test/fly/safety_test.dart | 436 ++++++++++++++++++ 21 files changed, 2395 insertions(+), 98 deletions(-) create mode 100644 Fly App/lib/fly/action_rail_state.dart create mode 100644 Fly App/lib/fly/aircraft_checklist.dart create mode 100644 Fly App/lib/fly/safety_gate.dart create mode 100644 Fly App/lib/ui/fly/dialogs/rth_target_dialog.dart create mode 100644 Fly App/lib/ui/fly/dialogs/slide_to_confirm.dart create mode 100644 Fly App/lib/ui/fly/flight_command_flow.dart create mode 100644 Fly App/lib/ui/fly/overlays/alert_stack.dart create mode 100644 Fly App/lib/ui/fly/overlays/safety_banners.dart create mode 100644 Fly App/lib/ui/fly/panels/aircraft_status_sheet.dart create mode 100644 Fly App/test/fly/safety_test.dart diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt index d1e6d8e..14c157d 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/CameraBridge.kt @@ -91,6 +91,15 @@ class CameraBridge(private val ctx: BridgeCtx) : SubBridge { if (wb == null) ctx.fail(result, "Unsupported white balance") else it.setWhiteBalance(wb, ctx.completion(result)) } + // Erases the card (Aircraft Status sheet, plan P3). `location` picks the SD + // card or internal storage; the UI slide-confirms and refuses while recording. + "formatStorage" -> guard(camera, result) { + val location = when (call.argument("location")) { + "INTERNAL_STORAGE" -> SettingsDefinitions.StorageLocation.INTERNAL_STORAGE + else -> SettingsDefinitions.StorageLocation.SDCARD + } + it.formatStorage(location, ctx.completion(result)) + } "setHistogramEnabled" -> { // Histogram data streaming is model-dependent; accept the toggle so // the UI stays responsive. Real histogram bins are a later add. diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt index 5fdd174..bb6b7e5 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/FlightControllerBridge.kt @@ -28,6 +28,8 @@ class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge { val controller = fc() when (call.method) { "takeOff" -> guard(controller, result) { it.startTakeoff(ctx.completion(result)) } + // Records the take-off spot visually so RTH lands back on it (vision-equipped aircraft). + "precisionTakeOff" -> guard(controller, result) { it.startPrecisionTakeoff(ctx.completion(result)) } "land" -> guard(controller, result) { it.startLanding(ctx.completion(result)) } "confirmLanding" -> guard(controller, result) { it.confirmLanding(ctx.completion(result)) } "cancelLanding" -> guard(controller, result) { it.cancelLanding(ctx.completion(result)) } @@ -64,6 +66,28 @@ class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge { "setHomeToCurrent" -> guard(controller, result) { it.setHomeLocationUsingAircraftCurrentLocation(ctx.completion(result)) } + // The RTH target dialog's "Current Position": the pilot's (phone's) fix. + "setHomeLocation" -> guard(controller, result) { + val lat = call.argument("latitude") + val lon = call.argument("longitude") + if (lat == null || lon == null || !LocationCoordinate2D.isValid(lat, lon)) { + ctx.fail(result, "Invalid home location") + } else { + it.setHomeLocation(LocationCoordinate2D(lat, lon), ctx.completion(result)) + } + } + // Calibration starts (Aircraft Status sheet); the guided wizards are P6b. + "startCompassCalibration" -> guard(controller, result) { + val compass = it.compass + if (compass == null) ctx.fail(result, "No compass on this aircraft") + else compass.startCalibration(ctx.completion(result)) + } + "stopCompassCalibration" -> guard(controller, result) { + val compass = it.compass + if (compass == null) ctx.fail(result, "No compass on this aircraft") + else compass.stopCalibration(ctx.completion(result)) + } + "startIMUCalibration" -> guard(controller, result) { it.startIMUCalibration(ctx.completion(result)) } "setMaxFlightHeight" -> guard(controller, result) { it.setMaxFlightHeight((call.argument("value") ?: 120), ctx.completion(result)) } @@ -107,7 +131,7 @@ class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge { override fun bind(product: BaseProduct?) { if (product !is Aircraft) return product.flightController?.setStateCallback { state -> - val values = telemetry(state) + + val values = telemetry(state) + compass(product.flightController) + ("simulatorActive" to (try { product.flightController?.simulator?.isSimulatorActive } catch (_: Throwable) { null })) ctx.mainHandler.post { ctx.hub.putFast(values) } } @@ -190,6 +214,18 @@ class FlightControllerBridge(private val ctx: BridgeCtx) : SubBridge { ) } + /** Compass health for the Aircraft Status sheet; empty on a product without one. */ + private fun compass(controller: FlightController?): Map = try { + val c = controller?.compass + if (c == null) emptyMap() else mapOf( + "compassHasError" to c.hasError(), + "compassCalibrating" to c.isCalibrating, + "compassCalibrationState" to c.calibrationState?.name, + ) + } catch (_: Throwable) { + emptyMap() + } + /** Great-circle distance in metres. */ private fun haversine(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val r = 6371000.0 diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index 6f6bc2c..d3cb844 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -48,6 +48,9 @@ class DjiService { // ── Flight controller commands ────────────────────────────────────────────── Future takeOff() => _methods.invokeMethod('takeOff'); + + /// Take-off that records the spot visually, so RTH lands back on it. + Future precisionTakeOff() => _methods.invokeMethod('precisionTakeOff'); Future land() => _methods.invokeMethod('land'); Future confirmLanding() => _methods.invokeMethod('confirmLanding'); Future cancelLanding() => _methods.invokeMethod('cancelLanding'); @@ -60,6 +63,16 @@ class DjiService { _methods.invokeMethod('confirmSmartRTH', {'value': goHome}); Future setHomeToCurrent() => _methods.invokeMethod('setHomeToCurrent'); + /// Moves the home point (the RTH target dialog's "Current Position" = the pilot). + Future setHomeLocation(double latitude, double longitude) => + _methods.invokeMethod('setHomeLocation', {'latitude': latitude, 'longitude': longitude}); + + /// Starts compass calibration; `telemetry.compassCalibrationState` then walks + /// HORIZONTAL → VERTICAL → SUCCESSFUL / FAILED as the pilot rotates the aircraft. + Future startCompassCalibration() => _methods.invokeMethod('startCompassCalibration'); + Future stopCompassCalibration() => _methods.invokeMethod('stopCompassCalibration'); + Future startIMUCalibration() => _methods.invokeMethod('startIMUCalibration'); + /// Starts the flight controller's built-in simulator at [latitude]/[longitude] /// (developer only; propellers off). `telemetry.simulatorActive` reflects it. Future startSimulator({required double latitude, required double longitude, int satellites = 12}) => @@ -130,6 +143,9 @@ class DjiService { _methods.invokeMethod('setEV', {'value': value}); Future setWhiteBalance(String value) => _methods.invokeMethod('setWhiteBalance', {'value': value}); + /// Erases the card. location: `SDCARD` | `INTERNAL_STORAGE` + Future formatStorage({String location = 'SDCARD'}) => + _methods.invokeMethod('formatStorage', {'location': location}); Future setHistogramEnabled(bool on) => _methods.invokeMethod('setHistogramEnabled', {'value': on}); diff --git a/Fly App/lib/fly/action_rail_state.dart b/Fly App/lib/fly/action_rail_state.dart new file mode 100644 index 0000000..bd0c673 --- /dev/null +++ b/Fly App/lib/fly/action_rail_state.dart @@ -0,0 +1,92 @@ +import 'state/aircraft_state.dart'; +import 'state/mission_state.dart'; + +/// Every command the flight screen can send that moves the aircraft (or stops +/// it moving). All of them go through `SafetyGate`. +enum FlightCommand { takeOff, land, cancelLanding, confirmLanding, goHome, cancelGoHome, stopMode } + +/// What is running when the rail offers [FlightCommand.stopMode], i.e. which +/// operator "Stop" has to address. +enum ActiveMode { none, waypoint, activeTrack, other } + +/// The left rail's slots for the current flight state (plan P3, GO 4's +/// `fpv_leftmenu`): one primary button that walks Take-off → Land → Cancel +/// landing / Confirm landing, an RTH slot that turns into Cancel RTH, and a +/// Stop button while an intelligent mode flies the aircraft. +/// +/// Pure: read off the flight controller and mission state, so the whole state +/// machine is unit-tested without a widget. +class RailState { + const RailState({required this.primary, this.rth, this.mode = ActiveMode.none, this.enabled = true}); + + /// Take-off, Land, Cancel landing or Confirm landing. + final FlightCommand primary; + + /// Go home or Cancel RTH; null on the ground. + final FlightCommand? rth; + + /// The intelligent mode a Stop button would end; [ActiveMode.none] hides it. + final ActiveMode mode; + + /// False until the flight controller has reported: the buttons show but do nothing. + final bool enabled; + + bool get showStop => mode != ActiveMode.none; + + /// Flight-controller modes in which an intelligent mode is flying the aircraft. + static const Set _waypointModes = {'GPS_WAYPOINT'}; + static const Set _trackModes = {'ACTIVE_TRACK', 'ACTIVE_TRACK_SPOTLIGHT'}; + static const Set _otherModes = {'GPS_HOT_POINT', 'GPS_FOLLOW_ME', 'TAP_FLY', 'DRAW', 'TERRAIN_FOLLOW'}; + + static RailState of({required bool connected, required AircraftState a, MissionState? mission}) { + final bool reported = connected && a.updatedAt != null; + final bool airborne = a.isFlying == true; + final String mode = a.flightModeEnum ?? ''; + final bool goingHome = a.goingHome == true || mode == 'GO_HOME'; + + final FlightCommand primary; + if (a.landingConfirmationNeeded == true || mode == 'CONFIRM_LANDING') { + primary = FlightCommand.confirmLanding; + } else if (mode == 'AUTO_LANDING') { + primary = FlightCommand.cancelLanding; + } else if (airborne) { + primary = FlightCommand.land; + } else { + primary = FlightCommand.takeOff; + } + + return RailState( + primary: primary, + rth: airborne ? (goingHome ? FlightCommand.cancelGoHome : FlightCommand.goHome) : null, + mode: airborne ? activeMode(a, mission) : ActiveMode.none, + enabled: reported, + ); + } + + /// The intelligent mode in control, from the bridge's mission state first + /// (it knows which operator it started) and the flight mode second. + static ActiveMode activeMode(AircraftState a, MissionState? mission) { + switch (mission?.state) { + case 'executing': + return ActiveMode.waypoint; + case 'tracking': + case 'quickshot': + return ActiveMode.activeTrack; + } + final String mode = a.flightModeEnum ?? ''; + if (_waypointModes.contains(mode)) return ActiveMode.waypoint; + if (_trackModes.contains(mode)) return ActiveMode.activeTrack; + if (_otherModes.contains(mode)) return ActiveMode.other; + return ActiveMode.none; + } + + @override + bool operator ==(Object other) => + other is RailState && other.primary == primary && other.rth == rth && other.mode == mode && other.enabled == enabled; + + @override + int get hashCode => Object.hash(primary, rth, mode, enabled); + + @override + String toString() => 'RailState($primary, rth: $rth, mode: $mode, enabled: $enabled)'; +} diff --git a/Fly App/lib/fly/aircraft_checklist.dart b/Fly App/lib/fly/aircraft_checklist.dart new file mode 100644 index 0000000..eca1c40 --- /dev/null +++ b/Fly App/lib/fly/aircraft_checklist.dart @@ -0,0 +1,245 @@ +import 'package:flutter/foundation.dart'; + +import '../flight_model.dart'; +import 'flight_status.dart'; +import 'state/alert_state.dart'; +import 'state/battery_state.dart'; +import 'state/camera_state.dart'; +import 'state/link_state.dart'; +import 'state/vision_state.dart'; + +/// A row's traffic light on the Aircraft Status sheet. +enum CheckState { ok, caution, danger, unknown } + +/// What a row's button does. +enum ChecklistAction { calibrateCompass, stopCompassCalibration, calibrateImu, formatSd } + +@immutable +class ChecklistItem { + const ChecklistItem(this.id, this.label, this.value, this.state, {this.detail, this.action}); + + final String id; + final String label; + final String value; + final CheckState state; + + /// The live fault behind a caution / danger, when one explains it. + final String? detail; + final ChecklistAction? action; + + @override + String toString() => 'ChecklistItem($id: $value, $state${action == null ? '' : ', $action'})'; +} + +/// The flight controller's distance and altitude limits, read on demand from +/// the KeyManager when the sheet opens (they are not in the telemetry tick). +@immutable +class FlightLimits { + const FlightLimits({this.maxHeight, this.maxRadius, this.maxRadiusEnabled}); + + final int? maxHeight; // m + final int? maxRadius; // m + final bool? maxRadiusEnabled; +} + +/// The Aircraft Status sheet's rows (plan P3, GO 4's `checklist_content_view`), +/// read off the telemetry sub-models and the live diagnostics. +/// +/// Pure, so the grading is unit-tested. Rows for hardware the connected +/// product lacks (vision sensors, gimbal, camera storage, an RC link) are left +/// out, the same way the HUD hides them. +abstract final class AircraftChecklist { + static final RegExp _imu = RegExp(r'\bimu\b|accelerometer|gyro', caseSensitive: false); + static final RegExp _compass = RegExp(r'compass|magnet', caseSensitive: false); + static final RegExp _esc = RegExp(r'\besc\b|motor|propeller', caseSensitive: false); + + static List build(FlightModel m, {FlightLimits limits = const FlightLimits()}) { + final List alerts = m.alerts.all; + DiagnosticAlert? worst(bool Function(DiagnosticAlert a) match) { + for (final DiagnosticAlert a in alerts) { + if (match(a)) return a; // [AlertState.all] is most severe first. + } + return null; + } + + final FlightStatus status = FlightStatus.of(connected: m.connected, a: m.aircraft, alerts: m.alerts); + final bool flying = m.aircraft.isFlying == true; + final List items = [ + ChecklistItem('overall', 'Overall status', status.text, _fromStatus(status.level)), + ChecklistItem( + 'mode', + 'Flight mode', + m.aircraft.flightMode ?? '—', + m.aircraft.flightMode == null + ? CheckState.unknown + : ((m.aircraft.gpsSignalLevel ?? 0) >= FlightStatus.minGpsLevel ? CheckState.ok : CheckState.caution), + ), + _compassRow(m, worst((DiagnosticAlert a) => _compass.hasMatch(a.reason)), flying), + _faultRow( + 'imu', + 'IMU', + worst((DiagnosticAlert a) => _imu.hasMatch(a.reason)), + preheating: m.aircraft.imuPreheating == true, + reported: m.aircraft.updatedAt != null, + action: flying ? null : ChecklistAction.calibrateImu, + ), + ChecklistItem( + 'maxHeight', + 'Max altitude', + limits.maxHeight == null ? '—' : '${limits.maxHeight} m', + limits.maxHeight == null ? CheckState.unknown : CheckState.ok, + ), + ChecklistItem( + 'maxRadius', + 'Max distance', + limits.maxRadiusEnabled == false ? 'No limit' : (limits.maxRadius == null ? '—' : '${limits.maxRadius} m'), + limits.maxRadiusEnabled == null && limits.maxRadius == null ? CheckState.unknown : CheckState.ok, + ), + _faultRow('esc', 'ESC', worst((DiagnosticAlert a) => _esc.hasMatch(a.reason)), reported: m.aircraft.updatedAt != null), + if (m.profile.hasVps || !m.profile.connected) _visionRow(m.vision, worst((DiagnosticAlert a) => a.component == 'VISION')), + if (m.profile.hasRcLink || !m.profile.connected) + ChecklistItem('rcMode', 'RC mode', _rcMode(m.links), m.links.updatedAt == null ? CheckState.unknown : CheckState.ok), + _radioRow(m.links, wifi: m.profile.hasWifiLink), + if (!m.profile.hasWifiLink) _rcBatteryRow(m.links), + _batteryRow(m.battery, worst((DiagnosticAlert a) => a.component == 'BATTERY')), + if (m.profile.hasGimbal || !m.profile.connected) + _faultRow('gimbal', 'Gimbal', worst((DiagnosticAlert a) => a.component == 'GIMBAL'), + reported: m.gimbal.updatedAt != null), + if (m.profile.hasCamera || !m.profile.connected) _storageRow(m.camera, flying), + ]; + return items; + } + + /// The sheet's header colour: the worst row. + static CheckState overall(List items) { + if (items.any((ChecklistItem i) => i.state == CheckState.danger)) return CheckState.danger; + if (items.any((ChecklistItem i) => i.state == CheckState.caution)) return CheckState.caution; + if (items.every((ChecklistItem i) => i.state == CheckState.unknown)) return CheckState.unknown; + return CheckState.ok; + } + + static CheckState _fromStatus(StatusLevel l) => switch (l) { + StatusLevel.ready => CheckState.ok, + StatusLevel.caution => CheckState.caution, + StatusLevel.danger => CheckState.danger, + StatusLevel.neutral => CheckState.unknown, + }; + + static CheckState _fromSeverity(AlertSeverity s) => switch (s) { + AlertSeverity.critical => CheckState.danger, + AlertSeverity.warning => CheckState.caution, + AlertSeverity.info => CheckState.ok, + }; + + static ChecklistItem _compassRow(FlightModel m, DiagnosticAlert? alert, bool flying) { + final ChecklistAction? calibrate = flying ? null : ChecklistAction.calibrateCompass; + if (m.aircraft.compassCalibrating == true) { + return ChecklistItem('compass', 'Compass', _calibrationStep(m.aircraft.compassCalibrationState), CheckState.caution, + action: ChecklistAction.stopCompassCalibration); + } + if (m.aircraft.compassHasError == true) { + return ChecklistItem('compass', 'Compass', 'Error', CheckState.danger, detail: alert?.reason, action: calibrate); + } + if (alert != null) { + return ChecklistItem('compass', 'Compass', 'Interference', _fromSeverity(alert.severity), detail: alert.reason, action: calibrate); + } + if (m.aircraft.compassHasError == null && m.aircraft.updatedAt == null) { + return const ChecklistItem('compass', 'Compass', '—', CheckState.unknown); + } + return ChecklistItem('compass', 'Compass', 'Normal', CheckState.ok, action: calibrate); + } + + static String _calibrationStep(String? s) => switch (s) { + 'HORIZONTAL' => 'Calibrating: rotate level', + 'VERTICAL' => 'Calibrating: rotate nose down', + 'SUCCESSFUL' => 'Calibrated', + 'FAILED' => 'Calibration failed', + _ => 'Calibrating', + }; + + static ChecklistItem _faultRow( + String id, + String label, + DiagnosticAlert? alert, { + bool preheating = false, + required bool reported, + ChecklistAction? action, + }) { + if (alert != null && alert.severity != AlertSeverity.info) { + return ChecklistItem(id, label, alert.severity == AlertSeverity.critical ? 'Error' : 'Warning', _fromSeverity(alert.severity), + detail: alert.reason, action: action); + } + if (preheating) return ChecklistItem(id, label, 'Warming up', CheckState.caution); + if (!reported) return ChecklistItem(id, label, '—', CheckState.unknown); + return ChecklistItem(id, label, 'Normal', CheckState.ok, action: action); + } + + static ChecklistItem _visionRow(VisionState v, DiagnosticAlert? alert) { + if (alert != null) { + return ChecklistItem('vision', 'Vision sensors', 'Warning', _fromSeverity(alert.severity), detail: alert.reason); + } + if (v.updatedAt == null || v.sensors.isEmpty) return const ChecklistItem('vision', 'Vision sensors', '—', CheckState.unknown); + final Iterable r = v.sensors.values; + if (r.any((VisionReading s) => s.dangerous)) return const ChecklistItem('vision', 'Vision sensors', 'Obstacle close', CheckState.danger); + if (r.every((VisionReading s) => s.isDisabled)) return const ChecklistItem('vision', 'Vision sensors', 'Off', CheckState.caution); + return const ChecklistItem('vision', 'Vision sensors', 'Normal', CheckState.ok); + } + + static String _rcMode(LinkState l) => switch (l.rcMode) { + null => l.updatedAt == null ? '—' : 'Normal', + 'MASTER' || 'MASTER_SUB' => 'Master${l.masterSlaveConnected == false ? ' (slave not linked)' : ''}', + 'SLAVE' || 'SLAVE_SUB' => 'Slave', + 'NORMAL' => 'Normal', + final String m => m, + }; + + static ChecklistItem _radioRow(LinkState l, {required bool wifi}) { + final int? up = l.uplinkQuality, down = l.downlinkQuality; + final int? q = up == null ? down : (down == null ? up : (up < down ? up : down)); + if (q == null) return ChecklistItem('radio', wifi ? 'Wi-Fi signal' : 'Radio signal', '—', CheckState.unknown); + final String value = wifi ? '$q%' : 'RC ${up ?? '—'}% · HD ${down ?? '—'}%'; + return ChecklistItem('radio', wifi ? 'Wi-Fi signal' : 'Radio signal', value, + q < 20 ? CheckState.danger : (q < 40 ? CheckState.caution : CheckState.ok)); + } + + static ChecklistItem _rcBatteryRow(LinkState l) { + final int? p = l.rcBatteryPercent; + if (p == null) return const ChecklistItem('rcBattery', 'RC battery', '—', CheckState.unknown); + return ChecklistItem('rcBattery', 'RC battery', '$p%${l.rcCharging == true ? ' ⚡' : ''}', + p <= 10 ? CheckState.danger : (p <= 25 ? CheckState.caution : CheckState.ok)); + } + + static ChecklistItem _batteryRow(BatteryState b, DiagnosticAlert? alert) { + final int? p = b.percent; + if (p == null) return const ChecklistItem('battery', 'Aircraft battery', '—', CheckState.unknown); + final double? t = b.temperature; + final String value = [ + '$p%', + if (t != null) '${t.toStringAsFixed(0)} °C', + if (b.isMultiPack) '${b.packs.length} packs', + ].join(' · '); + CheckState s = p <= (b.seriousLowWarningThreshold ?? 15) + ? CheckState.danger + : (p <= (b.lowWarningThreshold ?? 30) ? CheckState.caution : CheckState.ok); + // Too cold to fly hard, or overheating. + if (t != null && (t < 15 || t > 60) && s == CheckState.ok) s = CheckState.caution; + if (alert != null && alert.severity != AlertSeverity.info && s.index < _fromSeverity(alert.severity).index) { + s = _fromSeverity(alert.severity); + } + final String? detail = alert?.reason ?? (t != null && t < 15 ? 'Cold battery: let it warm up before flying hard.' : null); + return ChecklistItem('battery', 'Aircraft battery', value, s, detail: detail); + } + + static ChecklistItem _storageRow(CameraState c, bool flying) { + final StorageInfo? s = c.storage; + // Never offer to erase the card mid-flight or while it is being written. + final ChecklistAction? format = flying || c.isRecording || s == null || !s.inserted ? null : ChecklistAction.formatSd; + if (s == null) return const ChecklistItem('sd', 'SD card', '—', CheckState.unknown); + if (!s.usable) { + return ChecklistItem('sd', 'SD card', s.status, s.inserted && !s.error ? CheckState.caution : CheckState.danger, action: format); + } + final int? mb = s.remainingMB; + final String free = mb == null ? 'OK' : (mb >= 1024 ? '${(mb / 1024).toStringAsFixed(1)} GB free' : '$mb MB free'); + return ChecklistItem('sd', 'SD card', free, mb != null && mb < 1024 ? CheckState.caution : CheckState.ok, action: format); + } +} diff --git a/Fly App/lib/fly/flight_status.dart b/Fly App/lib/fly/flight_status.dart index 5e28070..716877d 100644 --- a/Fly App/lib/fly/flight_status.dart +++ b/Fly App/lib/fly/flight_status.dart @@ -31,7 +31,7 @@ class FlightStatus { } if (mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING') return const FlightStatus('Landing', StatusLevel.caution); if (mode == 'AUTO_TAKEOFF' || mode == 'ASSISTED_TAKEOFF') return const FlightStatus('Taking off', StatusLevel.ready); - if (a.seriousLowBattery == true) return const FlightStatus('Critically low battery', StatusLevel.danger); + if (a.seriousLowBattery == true) return const FlightStatus('Battery critical', StatusLevel.danger); if (a.lowBattery == true) return const FlightStatus('Low battery', StatusLevel.caution); if (worst == AlertSeverity.critical) { return FlightStatus(flying ? 'Warning' : 'Cannot take off', StatusLevel.danger); diff --git a/Fly App/lib/fly/fly_prefs.dart b/Fly App/lib/fly/fly_prefs.dart index cab9f94..5e96c38 100644 --- a/Fly App/lib/fly/fly_prefs.dart +++ b/Fly App/lib/fly/fly_prefs.dart @@ -7,7 +7,7 @@ abstract final class FlyPrefs { static const String _perfKey = 'fly.perfOverlay'; /// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept - /// reachable until P3's safety flows land (plan P2). + /// reachable until P3's safety flows pass their simulator run (plan P2/P3). static final ValueNotifier classicHud = ValueNotifier(false); /// Flutter's performance overlay on the flight screen, for the P2 "no jank at diff --git a/Fly App/lib/fly/hud_features.dart b/Fly App/lib/fly/hud_features.dart index fb255e2..d13438b 100644 --- a/Fly App/lib/fly/hud_features.dart +++ b/Fly App/lib/fly/hud_features.dart @@ -76,6 +76,9 @@ abstract final class HudFeatures { 'overlay.radar', 'Vision radar', 'Overlays', (ProductProfile p) => p.hasObstacleSensing); static final HudFeature virtualSticks = HudFeature( 'control.virtualSticks', 'On-screen sticks', 'Control', (ProductProfile p) => p.supportsVirtualStick); + /// Precision take-off records the spot with the downward cameras. + static final HudFeature precisionTakeoff = HudFeature( + 'control.precisionTakeoff', 'Precision Takeoff option', 'Control', (ProductProfile p) => p.hasVps); static final HudFeature simulator = HudFeature( 'control.simulator', 'Simulator toggle', 'Control', (ProductProfile p) => p.hasSimulator); @@ -107,6 +110,7 @@ abstract final class HudFeatures { vpsReadout, visionRadar, virtualSticks, + precisionTakeoff, simulator, ...modes, ]; diff --git a/Fly App/lib/fly/safety_gate.dart b/Fly App/lib/fly/safety_gate.dart new file mode 100644 index 0000000..3d00b93 --- /dev/null +++ b/Fly App/lib/fly/safety_gate.dart @@ -0,0 +1,257 @@ +import 'package:flutter/foundation.dart'; + +import '../dji_service.dart'; +import '../flight_model.dart'; +import 'action_rail_state.dart'; +import 'flight_status.dart'; +import 'hud_geometry.dart'; +import 'state/aircraft_state.dart'; +import 'state/alert_state.dart'; + +export 'action_rail_state.dart' show FlightCommand; + +/// How hard a precondition stops a command. +enum CheckLevel { + /// Shown on the confirm dialog; the pilot may still slide. + warn, + + /// The command is refused; the dialog says why and has no slider. + block, +} + +@immutable +class GateCheck { + const GateCheck(this.level, this.message); + + const GateCheck.warn(this.message) : level = CheckLevel.warn; + const GateCheck.block(this.message) : level = CheckLevel.block; + + final CheckLevel level; + final String message; + + bool get blocks => level == CheckLevel.block; + + @override + bool operator ==(Object other) => other is GateCheck && other.level == level && other.message == message; + + @override + int get hashCode => Object.hash(level, message); + + @override + String toString() => '${level.name}: $message'; +} + +/// What [SafetyGate.check] found for one command. +@immutable +class GateVerdict { + const GateVerdict(this.command, this.checks); + + final FlightCommand command; + final List checks; + + bool get allowed => !checks.any((GateCheck c) => c.blocks); + List get blockers => [for (final GateCheck c in checks) if (c.blocks) c]; + List get warnings => [for (final GateCheck c in checks) if (!c.blocks) c]; +} + +/// Thrown by [SafetyGate.run] when a command's preconditions fail at the +/// moment it would be sent (the state may have moved on since the dialog). +class SafetyGateRefused implements Exception { + const SafetyGateRefused(this.verdict); + + final GateVerdict verdict; + + @override + String toString() => verdict.blockers.map((GateCheck c) => c.message).join(' '); +} + +/// The one place every aircraft-moving command passes through (plan §1.5, P3). +/// +/// [check] grades the preconditions — connected, flight state, GPS, battery, +/// live faults — into blockers and warnings for the slide-to-confirm dialog; +/// [run] re-checks and then sends the command. The UI never calls the +/// `DjiService` flight commands directly, so this class is what the safety +/// tests pin down. +class SafetyGate { + SafetyGate({required this.model, required this.dji}); + + final FlightModel model; + final DjiService dji; + + /// RTH altitude limits the flight controller accepts (MSDK `setGoHomeHeightInMeters`). + static const int minRthHeight = 20; + static const int maxRthHeight = 500; + + /// Below this RTH altitude the dialog warns about obstacles on the way home. + static const int lowRthHeight = 30; + + /// How far the pilot must be from the home point before RTH asks where to go. + static const double rthTargetChoiceDistance = 30; + + AircraftState get _a => model.aircraft; + + GateVerdict check(FlightCommand command) { + final List checks = []; + if (!model.connected) { + checks.add(const GateCheck.block('No aircraft connected.')); + return GateVerdict(command, checks); + } + if (_a.updatedAt == null) { + checks.add(const GateCheck.block('Waiting for the flight controller to report.')); + return GateVerdict(command, checks); + } + switch (command) { + case FlightCommand.takeOff: + _takeOff(checks); + case FlightCommand.land: + if (_a.isFlying != true) checks.add(const GateCheck.block('The aircraft is not flying.')); + if (_a.goingHome == true) checks.add(const GateCheck.warn('This ends Return to Home and lands where the aircraft is now.')); + case FlightCommand.cancelLanding: + if (_a.flightModeEnum != 'AUTO_LANDING') checks.add(const GateCheck.block('The aircraft is not landing.')); + case FlightCommand.confirmLanding: + if (_a.landingConfirmationNeeded != true && _a.flightModeEnum != 'CONFIRM_LANDING') { + checks.add(const GateCheck.block('The aircraft is not waiting for a landing confirmation.')); + } + case FlightCommand.goHome: + _goHome(checks); + case FlightCommand.cancelGoHome: + if (_a.goingHome != true && _a.flightModeEnum != 'GO_HOME') { + checks.add(const GateCheck.block('The aircraft is not returning to home.')); + } + case FlightCommand.stopMode: + switch (RailState.activeMode(_a, model.mission)) { + case ActiveMode.none: + checks.add(const GateCheck.block('No intelligent flight mode is running.')); + case ActiveMode.other: + checks.add(const GateCheck.block('This mode cannot be stopped from the app yet. Press the pause button on the remote.')); + case ActiveMode.waypoint: + case ActiveMode.activeTrack: + break; + } + } + return GateVerdict(command, checks); + } + + void _takeOff(List checks) { + if (_a.isFlying == true) { + checks.add(const GateCheck.block('The aircraft is already flying.')); + return; + } + final int? percent = model.battery.percent; + final int critical = model.battery.seriousLowWarningThreshold ?? 15; + final int low = model.battery.lowWarningThreshold ?? 30; + if (_a.seriousLowBattery == true || (percent != null && percent <= critical)) { + checks.add(GateCheck.block('Battery critically low${percent == null ? '' : ' ($percent%)'}. Replace or charge it.')); + } else if (_a.lowBattery == true || (percent != null && percent <= low)) { + checks.add(GateCheck.warn('Battery low${percent == null ? '' : ' ($percent%)'}: a short flight only.')); + } + if (_a.imuPreheating == true) checks.add(const GateCheck.block('IMU is warming up. Wait until it is ready.')); + if (_a.compassCalibrating == true) checks.add(const GateCheck.block('Compass calibration is in progress.')); + if (_a.compassHasError == true) checks.add(const GateCheck.block('Compass error. Calibrate the compass or move away from metal.')); + // Faults the aircraft itself reports: critical ones refuse, the worst warning is shown. + final Iterable faults = model.alerts.all.where((DiagnosticAlert d) => d.severity == AlertSeverity.critical); + for (final DiagnosticAlert d in faults.take(2)) { + checks.add(GateCheck.block(d.reason)); + } + final bool gps = (_a.gpsSignalLevel ?? 0) >= FlightStatus.minGpsLevel; + if (!gps) { + checks.add(GateCheck.warn(_a.visionPositioningBeingUsed == true + ? 'Weak GPS: the aircraft holds position by vision only, low over textured ground.' + : 'Weak GPS: the aircraft will fly in ATTI mode and drift with the wind.')); + } + if (_a.homeLocationSet != true) { + checks.add(const GateCheck.warn('No home point recorded yet: Return to Home will not be available.')); + } + for (final DiagnosticAlert d in model.alerts.all.where((DiagnosticAlert d) => d.severity == AlertSeverity.warning).take(1)) { + checks.add(GateCheck.warn(d.reason)); + } + if (model.profile.hasCamera && model.camera.storage != null && !model.camera.storage!.usable) { + checks.add(GateCheck.warn('${model.camera.storage!.status}: photos and video will not be saved.')); + } + } + + void _goHome(List checks) { + if (_a.isFlying != true) { + checks.add(const GateCheck.block('The aircraft is not flying.')); + return; + } + if (_a.goingHome == true) { + checks.add(const GateCheck.block('The aircraft is already returning to home.')); + return; + } + if (_a.homeLocationSet != true) { + checks.add(const GateCheck.block('No home point recorded. Fly back manually.')); + } + if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) { + checks.add(const GateCheck.block('GPS too weak for Return to Home. Fly back manually.')); + } + final int? percent = model.battery.percent; + final int? needed = _a.goHome?.batteryNeededToGoHome; + if (percent != null && needed != null && needed > 0 && percent < needed) { + checks.add(GateCheck.warn('Battery ($percent%) is below what the trip home needs ($needed%). It may land on the way.')); + } + final double? h = _a.goHomeHeight; + if (h != null && h < lowRthHeight && !model.profile.hasObstacleSensing) { + checks.add(GateCheck.warn('RTH altitude is ${h.round()} m and this aircraft cannot see obstacles.')); + } + } + + /// True when the pilot stands far enough from the recorded home point that + /// RTH should ask whether to come back to them instead (GO 4's RTH target). + bool get offersRthTarget => (pilotToHome ?? 0) > rthTargetChoiceDistance; + + /// Metres between the pilot (the phone's fix) and the recorded home point. + double? get pilotToHome { + final double? pLat = model.phoneLatitude, pLon = model.phoneLongitude; + final double? hLat = _a.homeLatitude, hLon = _a.homeLongitude; + if (pLat == null || pLon == null || hLat == null || hLon == null) return null; + return HudGeometry.distance(pLat, pLon, hLat, hLon); + } + + /// Answers the flight controller's low-battery smart-RTH countdown: true + /// flies home now, false cancels it. Ignored once the countdown has ended. + Future answerSmartRth({required bool goHome}) async { + if (_a.goHome?.smartRTHCountingDown != true) return; + await dji.confirmSmartRTH(goHome); + } + + /// Re-checks [command] and sends it. For take-off, [precision] asks for a + /// precision take-off; for RTH, [rthHeight] is written first when it differs + /// from the current setting and [homeToPilot] moves the home point to the + /// phone's position. + Future run(FlightCommand command, {bool precision = false, int? rthHeight, bool homeToPilot = false}) async { + final GateVerdict v = check(command); + if (!v.allowed) throw SafetyGateRefused(v); + switch (command) { + case FlightCommand.takeOff: + await (precision ? dji.precisionTakeOff() : dji.takeOff()); + case FlightCommand.land: + await dji.land(); + case FlightCommand.cancelLanding: + await dji.cancelLanding(); + case FlightCommand.confirmLanding: + await dji.confirmLanding(); + case FlightCommand.goHome: + if (homeToPilot && model.phoneLatitude != null && model.phoneLongitude != null) { + await dji.setHomeLocation(model.phoneLatitude!, model.phoneLongitude!); + } + if (rthHeight != null) { + final int h = rthHeight.clamp(minRthHeight, maxRthHeight); + if (h != _a.goHomeHeight?.round()) await dji.setGoHomeHeight(h); + } + await dji.startGoHome(); + case FlightCommand.cancelGoHome: + await dji.cancelGoHome(); + case FlightCommand.stopMode: + switch (RailState.activeMode(_a, model.mission)) { + case ActiveMode.waypoint: + await dji.stopWaypointMission(); + case ActiveMode.activeTrack: + await dji.stopActiveTrack(); + case ActiveMode.none: + case ActiveMode.other: + break; // refused by check() + } + } + } +} diff --git a/Fly App/lib/fly/state/aircraft_state.dart b/Fly App/lib/fly/state/aircraft_state.dart index ec8431c..f84da9f 100644 --- a/Fly App/lib/fly/state/aircraft_state.dart +++ b/Fly App/lib/fly/state/aircraft_state.dart @@ -118,6 +118,12 @@ class AircraftState extends ChangeNotifier { /// The flight controller's built-in simulator is running (developer only). bool? simulatorActive; + bool? compassHasError; + bool? compassCalibrating; + + /// `NOT_CALIBRATING` / `HORIZONTAL` / `VERTICAL` / `SUCCESSFUL` / `FAILED`. + String? compassCalibrationState; + DateTime? updatedAt; /// VPS height when the sensor is actually in use, else null. @@ -166,6 +172,9 @@ class AircraftState extends ChangeNotifier { failsafe = asBool(t['isFailsafe']); orientationMode = asString(t['orientationMode']); simulatorActive = asBool(t['simulatorActive']); + compassHasError = asBool(t['compassHasError']); + compassCalibrating = asBool(t['compassCalibrating']); + compassCalibrationState = asString(t['compassCalibrationState']); updatedAt = DateTime.now(); notifyListeners(); } @@ -175,6 +184,8 @@ class AircraftState extends ChangeNotifier { isFlying = motorsOn = homeLocationSet = ultrasonicBeingUsed = ultrasonicError = null; visionPositioningBeingUsed = landingConfirmationNeeded = goingHome = null; lowBattery = seriousLowBattery = reachedMaxHeight = reachedMaxRadius = imuPreheating = failsafe = simulatorActive = null; + compassHasError = compassCalibrating = null; + compassCalibrationState = null; flightMode = flightModeEnum = goHomeExecutionState = batteryThresholdBehavior = windWarning = orientationMode = null; altitude = latitude = longitude = homeLatitude = homeLongitude = homeDistance = null; horizontalSpeed = verticalSpeed = heading = pitch = roll = velocityX = velocityY = velocityZ = null; diff --git a/Fly App/lib/ui/fly/dialogs/rth_target_dialog.dart b/Fly App/lib/ui/fly/dialogs/rth_target_dialog.dart new file mode 100644 index 0000000..ce00950 --- /dev/null +++ b/Fly App/lib/ui/fly/dialogs/rth_target_dialog.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; + +import '../../../theme.dart'; +import '../../pv_icons.dart'; +import '../hud_kit.dart'; + +/// Where Return to Home flies to. +enum RthTarget { + /// The home point recorded at take-off. + originalHome, + + /// The pilot's position now (the phone's GPS); the home point is moved first. + pilot, +} + +/// GO 4's RTH target choice, offered when the pilot has walked more than +/// `SafetyGate.rthTargetChoiceDistance` from the recorded home point. +/// Resolves to null when dismissed. +Future showRthTargetDialog(BuildContext context, {required double pilotToHomeM}) { + return showDialog( + context: context, + barrierColor: const Color(0x99050A14), + builder: (BuildContext context) => Dialog( + backgroundColor: const Color(0xF20E1A33), + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14), side: const BorderSide(color: Glass.hairline)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 460), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 16), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + const Text('Return to where?', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 17, fontWeight: FontWeight.w700, color: Glass.ink)), + const SizedBox(height: 6), + Text('You are ${pilotToHomeM.round()} m from the home point recorded at take-off.', + style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: Color(0xFFB5C2DA))), + const SizedBox(height: 14), + Row(children: [ + Expanded( + child: _TargetTile( + key: const ValueKey('rth-target-pilot'), + icon: 'user', + title: 'Current Position', + body: 'Move the home point to you, then return.', + onTap: () => Navigator.of(context).pop(RthTarget.pilot), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _TargetTile( + key: const ValueKey('rth-target-home'), + icon: 'home', + title: 'Original Home Point', + body: 'Return to where the aircraft took off.', + onTap: () => Navigator.of(context).pop(RthTarget.originalHome), + ), + ), + ]), + ]), + ), + ), + ), + ); +} + +class _TargetTile extends StatelessWidget { + const _TargetTile({super.key, required this.icon, required this.title, required this.body, required this.onTap}); + + final String icon; + final String title; + final String body; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Glass.pillStrong, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Glass.hairline), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + PVIcon(icon, size: 22, color: Glass.ink), + const SizedBox(height: 8), + Text(title, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w700, color: Glass.ink)), + const SizedBox(height: 2), + Text(body, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 11, color: HudText.dim, height: 1.3)), + ]), + ), + ); + } +} diff --git a/Fly App/lib/ui/fly/dialogs/slide_to_confirm.dart b/Fly App/lib/ui/fly/dialogs/slide_to_confirm.dart new file mode 100644 index 0000000..ca2bef2 --- /dev/null +++ b/Fly App/lib/ui/fly/dialogs/slide_to_confirm.dart @@ -0,0 +1,328 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../fly/safety_gate.dart'; +import '../../../theme.dart'; +import '../../pv_icons.dart'; +import '../hud_kit.dart'; + +/// A number the pilot sets inside the confirm dialog (the RTH altitude), with +/// −/+ steppers rather than a keyboard, which would cover the landscape HUD. +@immutable +class SlideNumber { + const SlideNumber({required this.label, required this.initial, required this.min, required this.max, this.step = 5, this.unit = 'm'}); + + final String label; + final int initial; + final int min; + final int max; + final int step; + final String unit; +} + +/// What the pilot chose on a confirmed dialog. +@immutable +class SlideResult { + const SlideResult({this.checked = false, this.number}); + + final bool checked; + final int? number; +} + +/// Everything the slide-to-confirm dialog (plan P3, GO 4's `fpv_leftmenu_dlg`) +/// shows: icon, title, description, the gate's warnings or blockers, an +/// optional checkbox and number, and the slider. +@immutable +class SlideConfirmSpec { + const SlideConfirmSpec({ + required this.icon, + required this.title, + required this.description, + required this.sliderLabel, + this.color = Glass.accent, + this.checks = const [], + this.checkbox, + this.checkboxHint, + this.number, + }); + + final String icon; + final String title; + final String description; + final String sliderLabel; + final Color color; + final List checks; + + /// Label of an optional checkbox ("Precision Takeoff"). + final String? checkbox; + final String? checkboxHint; + final SlideNumber? number; + + bool get blocked => checks.any((GateCheck c) => c.blocks); +} + +/// Shows [spec]; resolves to the pilot's choices once the slider reaches the +/// end, or null when dismissed. A [SlideConfirmSpec.blocked] dialog has no +/// slider, only the reasons and Close. +Future showSlideToConfirm(BuildContext context, SlideConfirmSpec spec) { + return showDialog( + context: context, + barrierColor: const Color(0x99050A14), + builder: (BuildContext context) => SlideConfirmDialog(spec: spec), + ); +} + +class SlideConfirmDialog extends StatefulWidget { + const SlideConfirmDialog({super.key, required this.spec}); + + final SlideConfirmSpec spec; + + @override + State createState() => _SlideConfirmDialogState(); +} + +class _SlideConfirmDialogState extends State { + bool _checked = false; + late int? _number = widget.spec.number?.initial; + + SlideConfirmSpec get _s => widget.spec; + + void _step(int delta) { + final SlideNumber n = _s.number!; + setState(() => _number = ((_number ?? n.initial) + delta).clamp(n.min, n.max)); + } + + @override + Widget build(BuildContext context) { + final List blockers = [for (final GateCheck c in _s.checks) if (c.blocks) c]; + final List warnings = [for (final GateCheck c in _s.checks) if (!c.blocks) c]; + return Dialog( + backgroundColor: const Color(0xF20E1A33), + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14), side: const BorderSide(color: Glass.hairline)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 16), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Row(children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration(color: blockers.isEmpty ? _s.color : HudColors.danger, shape: BoxShape.circle), + alignment: Alignment.center, + child: PVIcon(_s.icon, size: 18, color: Colors.white), + ), + const SizedBox(width: 10), + Expanded( + child: Text(_s.title, + style: const TextStyle(fontFamily: PV.fontSans, fontSize: 17, fontWeight: FontWeight.w700, color: Glass.ink)), + ), + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: const Padding(padding: EdgeInsets.all(4), child: PVIcon('close', size: 18, color: HudText.dim)), + ), + ]), + const SizedBox(height: 10), + Text(_s.description, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: Color(0xFFB5C2DA), height: 1.35)), + for (final GateCheck c in [...blockers, ...warnings]) _CheckLine(check: c), + if (blockers.isEmpty && _s.checkbox != null) ...[ + const SizedBox(height: 8), + InkWell( + onTap: () => setState(() => _checked = !_checked), + child: Row(children: [ + SizedBox( + width: 28, + height: 28, + child: Checkbox(value: _checked, onChanged: (bool? v) => setState(() => _checked = v ?? false)), + ), + const SizedBox(width: 6), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(_s.checkbox!, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, fontWeight: FontWeight.w600, color: Glass.ink)), + if (_s.checkboxHint != null) + Text(_s.checkboxHint!, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 11, color: HudText.dim)), + ]), + ), + ]), + ), + ], + if (blockers.isEmpty && _s.number != null) ...[ + const SizedBox(height: 10), + Row(children: [ + Expanded( + child: Text(_s.number!.label, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: Glass.ink)), + ), + _StepButton(icon: '−', onTap: () => _step(-_s.number!.step)), + SizedBox( + width: 72, + child: Text('$_number ${_s.number!.unit}', textAlign: TextAlign.center, style: HudText.value), + ), + _StepButton(icon: '+', onTap: () => _step(_s.number!.step)), + ]), + ], + const SizedBox(height: 14), + if (blockers.isEmpty) + SlideToConfirmTrack( + label: _s.sliderLabel, + color: _s.color, + onConfirmed: () => Navigator.of(context).pop(SlideResult(checked: _checked, number: _number)), + ) + else + Align( + alignment: Alignment.centerRight, + child: TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Close')), + ), + ]), + ), + ), + ); + } +} + +class _CheckLine extends StatelessWidget { + const _CheckLine({required this.check}); + + final GateCheck check; + + @override + Widget build(BuildContext context) { + final Color c = check.blocks ? HudColors.danger : HudColors.caution; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: const EdgeInsets.only(top: 1), child: PVIcon('alert', size: 14, color: c)), + const SizedBox(width: 6), + Expanded(child: Text(check.message, style: TextStyle(fontFamily: PV.fontSans, fontSize: 12, color: c, height: 1.3))), + ]), + ); + } +} + +class _StepButton extends StatelessWidget { + const _StepButton({required this.icon, required this.onTap}); + + final String icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 34, + height: 30, + decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(8), border: Border.all(color: Glass.hairline)), + alignment: Alignment.center, + child: Text(icon, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 18, color: Glass.ink, height: 1)), + ), + ); + } +} + +/// The slider itself: drag the thumb to the far end to confirm. Let go early +/// and it springs back, so a brush of the thumb never sends a command. +class SlideToConfirmTrack extends StatefulWidget { + const SlideToConfirmTrack({super.key, required this.label, required this.onConfirmed, this.color = Glass.accent}); + + final String label; + final VoidCallback onConfirmed; + final Color color; + + static const Key thumbKey = ValueKey('slide-thumb'); + + /// How far along the track counts as confirmed. + static const double threshold = 0.92; + + @override + State createState() => _SlideToConfirmTrackState(); +} + +class _SlideToConfirmTrackState extends State with SingleTickerProviderStateMixin { + static const double _height = 48; + static const double _thumb = 42; + + late final AnimationController _c = AnimationController(vsync: this, duration: const Duration(milliseconds: 220)); + bool _done = false; + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + void _drag(DragUpdateDetails d, double travel) { + if (_done || travel <= 0) return; + _c.value = (_c.value + d.delta.dx / travel).clamp(0.0, 1.0); + } + + void _release() { + if (_done) return; + if (_c.value >= SlideToConfirmTrack.threshold) { + _done = true; + _c.value = 1; + HapticFeedback.mediumImpact(); + widget.onConfirmed(); + } else { + _c.animateBack(0, curve: Curves.easeOut); + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (BuildContext context, BoxConstraints box) { + final double travel = box.maxWidth - _thumb - 6; + return Container( + height: _height, + decoration: BoxDecoration( + color: const Color(0x33081020), + borderRadius: BorderRadius.circular(_height / 2), + border: Border.all(color: widget.color.withValues(alpha: 0.6)), + ), + child: AnimatedBuilder( + animation: _c, + builder: (BuildContext context, _) => Stack(children: [ + Positioned( + left: 0, + top: 0, + bottom: 0, + width: 3 + _thumb + _c.value * travel, + child: Container( + decoration: BoxDecoration( + color: widget.color.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(_height / 2), + ), + ), + ), + Center( + child: Opacity( + opacity: (1 - _c.value * 1.6).clamp(0.0, 1.0), + child: Padding( + padding: const EdgeInsets.only(left: _thumb), + child: Text(widget.label.toUpperCase(), style: HudText.label.copyWith(fontSize: 11, color: Glass.ink)), + ), + ), + ), + Positioned( + left: 3 + _c.value * travel, + top: (_height - 2 - _thumb) / 2, + child: GestureDetector( + key: SlideToConfirmTrack.thumbKey, + onHorizontalDragUpdate: (DragUpdateDetails d) => _drag(d, travel), + onHorizontalDragEnd: (_) => _release(), + onHorizontalDragCancel: _release, + child: Container( + width: _thumb, + height: _thumb, + decoration: BoxDecoration(color: widget.color, shape: BoxShape.circle), + alignment: Alignment.center, + child: const PVIcon('chevronsRight', size: 20, color: Colors.white), + ), + ), + ), + ]), + ), + ); + }); + } +} diff --git a/Fly App/lib/ui/fly/flight_command_flow.dart b/Fly App/lib/ui/fly/flight_command_flow.dart new file mode 100644 index 0000000..b4f135c --- /dev/null +++ b/Fly App/lib/ui/fly/flight_command_flow.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; + +import '../../fly/hud_features.dart'; +import '../../fly/product_profile.dart'; +import '../../fly/safety_gate.dart'; +import '../../theme.dart'; +import 'dialogs/rth_target_dialog.dart'; +import 'dialogs/slide_to_confirm.dart'; +import 'hud_kit.dart'; + +/// Turns a rail press into the GO 4 confirm flow (plan P3): ask the +/// [SafetyGate], offer the Aircraft Status sheet before the first take-off of a +/// connection, ask the RTH target when the pilot has moved, show the +/// slide-to-confirm dialog, then let the gate send the command. +/// +/// Commands that stop the aircraft moving (cancel landing, cancel RTH, stop an +/// intelligent mode) skip the slider: they make it hover, and a pilot reaching +/// for them is in a hurry. They still go through the gate. +class FlightCommandFlow { + FlightCommandFlow({required this.gate, required this.onError, this.reviewBeforeTakeOff}); + + final SafetyGate gate; + final void Function(String label, Object error) onError; + + /// Opens the Aircraft Status sheet offering take-off; resolves true when the + /// pilot chose to continue. + final Future Function(BuildContext context)? reviewBeforeTakeOff; + + bool _reviewed = false; + + /// The status sheet is offered again before the next connection's first take-off. + void resetReview() => _reviewed = false; + + /// Take-off's own review step is done (the sheet was opened from the status chip). + void markReviewed() => _reviewed = true; + + static String labelOf(FlightCommand c) => switch (c) { + FlightCommand.takeOff => 'Take off', + FlightCommand.land => 'Land', + FlightCommand.cancelLanding => 'Cancel landing', + FlightCommand.confirmLanding => 'Confirm landing', + FlightCommand.goHome => 'Return to Home', + FlightCommand.cancelGoHome => 'Cancel RTH', + FlightCommand.stopMode => 'Stop', + }; + + static bool _immediate(FlightCommand c) => + c == FlightCommand.cancelLanding || c == FlightCommand.cancelGoHome || c == FlightCommand.stopMode; + + Future request(BuildContext context, FlightCommand command) async { + GateVerdict verdict = gate.check(command); + + if (_immediate(command) && verdict.allowed) { + await _send(command); + return; + } + + if (command == FlightCommand.takeOff && verdict.allowed && !_reviewed && reviewBeforeTakeOff != null) { + _reviewed = true; + final bool go = await reviewBeforeTakeOff!(context); + if (!go || !context.mounted) return; + verdict = gate.check(command); // the sheet may have been open a while + } + + RthTarget target = RthTarget.originalHome; + if (command == FlightCommand.goHome && verdict.allowed && gate.offersRthTarget) { + final RthTarget? t = await showRthTargetDialog(context, pilotToHomeM: gate.pilotToHome!); + if (t == null || !context.mounted) return; + target = t; + } + + if (!context.mounted) return; + final SlideResult? r = await showSlideToConfirm(context, _spec(context, command, verdict, target)); + if (r == null) return; + await _send( + command, + precision: r.checked, + rthHeight: command == FlightCommand.goHome ? r.number : null, + homeToPilot: target == RthTarget.pilot, + ); + } + + Future _send(FlightCommand command, {bool precision = false, int? rthHeight, bool homeToPilot = false}) async { + try { + await gate.run(command, precision: precision, rthHeight: rthHeight, homeToPilot: homeToPilot); + } catch (e) { + onError(labelOf(command), e); + } + } + + SlideConfirmSpec _spec(BuildContext context, FlightCommand c, GateVerdict v, RthTarget target) { + switch (c) { + case FlightCommand.takeOff: + final ProductProfile p = ProductProfileScope.of(context); + final bool precision = p.connected && HudFeatures.precisionTakeoff.enabledFor(p); + return SlideConfirmSpec( + icon: 'takeoff', + title: 'Take off', + description: 'The aircraft will take off and hover at about 1.2 m. Keep people clear of the propellers.', + sliderLabel: 'Slide to take off', + checks: v.checks, + checkbox: precision ? 'Precision Takeoff' : null, + checkboxHint: precision ? 'Records the take-off spot with the downward cameras, so RTH lands back on it.' : null, + ); + case FlightCommand.land: + return SlideConfirmSpec( + icon: 'land', + title: 'Land', + description: 'The aircraft will descend and land where it is now. Make sure the ground below is clear and level.', + sliderLabel: 'Slide to land', + color: HudColors.caution, + checks: v.checks, + ); + case FlightCommand.confirmLanding: + return SlideConfirmSpec( + icon: 'land', + title: 'Confirm landing', + description: 'The aircraft is hovering just above the ground and needs your OK to touch down. Check the spot is flat and clear.', + sliderLabel: 'Slide to land', + color: HudColors.caution, + checks: v.checks, + ); + case FlightCommand.goHome: + final int current = (gate.model.aircraft.goHomeHeight ?? 60).round().clamp(SafetyGate.minRthHeight, SafetyGate.maxRthHeight); + return SlideConfirmSpec( + icon: 'rth', + title: 'Return to Home', + description: target == RthTarget.pilot + ? 'The home point moves to your position. The aircraft climbs to the RTH altitude, flies back to you and lands.' + : 'The aircraft climbs to the RTH altitude, flies back to the home point and lands.', + sliderLabel: 'Slide to return home', + checks: v.checks, + number: SlideNumber(label: 'RTH altitude', initial: current, min: SafetyGate.minRthHeight, max: SafetyGate.maxRthHeight), + ); + case FlightCommand.cancelLanding: + case FlightCommand.cancelGoHome: + case FlightCommand.stopMode: + // Only reached when the gate refused: show why. + return SlideConfirmSpec( + icon: 'stop', + title: labelOf(c), + description: 'This cannot be done right now.', + sliderLabel: labelOf(c), + color: Glass.accent, + checks: v.checks, + ); + } + } +} diff --git a/Fly App/lib/ui/fly/fly_screen.dart b/Fly App/lib/ui/fly/fly_screen.dart index a2a5ec2..0d10e2a 100644 --- a/Fly App/lib/ui/fly/fly_screen.dart +++ b/Fly App/lib/ui/fly/fly_screen.dart @@ -6,6 +6,7 @@ import '../../flight_model.dart'; import '../../fly/flight_trail.dart'; import '../../fly/fly_prefs.dart'; import '../../fly/hud_features.dart'; +import '../../fly/safety_gate.dart'; import '../../theme.dart'; import '../camera_settings_page.dart'; import '../capture_modes_page.dart'; @@ -13,10 +14,14 @@ import '../settings_menu_page.dart'; import 'bottom/attitude_ball.dart'; import 'bottom/minimap.dart'; import 'bottom/telemetry_strip.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 'overlays/alert_stack.dart'; +import 'overlays/safety_banners.dart'; +import 'panels/aircraft_status_sheet.dart'; import 'right/camera_column.dart'; import 'top/camera_info_strip.dart'; import 'top/smart_battery_bar.dart'; @@ -33,6 +38,11 @@ import 'top/status_bar.dart'; /// /// 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. class FlyScreen extends StatefulWidget { const FlyScreen({super.key, required this.model, required this.dji}); @@ -46,6 +56,13 @@ class FlyScreen extends StatefulWidget { 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; bool _grid = false; @@ -64,10 +81,26 @@ class _FlyScreenState extends State { DeviceOrientation.landscapeRight, ]); SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + _wasConnected = _m.connected; + _m.addListener(_onModel); + } + + /// A new connection gets its own pre-flight review. + void _onModel() { + if (_m.connected == _wasConnected) return; + _wasConnected = _m.connected; + if (!_m.connected) _flow.resetReview(); + } + + 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); _trail.dispose(); _minimap.dispose(); SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); @@ -189,6 +222,7 @@ class _FlyScreenState extends State { child: StatusBar( model: _m, onBack: () => Navigator.of(context).maybePop(), + onStatus: () => _openStatus(context), onMore: () => _push(SettingsMenuPage(model: _m, dji: _dji)), ), ), @@ -204,16 +238,26 @@ class _FlyScreenState extends State { TelemetryStrip(aircraft: _m.aircraft), ]), ), + // ── 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: 104, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + SafetyBanners(gate: _gate, onError: _error), + Flexible(child: AlertStack(alerts: _m.alerts, onMore: () => _openStatus(context))), + ]), + ), // ── Left rail ───────────────────────────────────────────────────────── Positioned( left: 0, top: 60, child: ActionRail( - aircraft: _m.aircraft, - dji: _dji, + model: _m, + flow: _flow, grid: _grid, onGrid: () => setState(() => _grid = !_grid), - onError: _error, ), ), if (_mapBig) Positioned(right: 70, top: 80, child: MinimapControls(controller: _minimap, trail: _trail)), diff --git a/Fly App/lib/ui/fly/left/action_rail.dart b/Fly App/lib/ui/fly/left/action_rail.dart index 85b9a58..db5be68 100644 --- a/Fly App/lib/ui/fly/left/action_rail.dart +++ b/Fly App/lib/ui/fly/left/action_rail.dart @@ -1,121 +1,102 @@ import 'package:flutter/material.dart'; -import '../../../dji_service.dart'; -import '../../../fly/state/aircraft_state.dart'; +import '../../../flight_model.dart'; +import '../../../fly/action_rail_state.dart'; import '../../../theme.dart'; import '../../pv_icons.dart'; +import '../flight_command_flow.dart'; import '../hud_kit.dart'; -/// The left rail: take-off / land and RTH, then the grid toggle. -/// -/// Interim for P2 — the same two commands and plain confirm dialog as the -/// classic screen. P3 replaces this with the state machine (Take-off ⇄ Land ⇄ -/// Cancel landing, RTH ⇄ Cancel RTH, Stop mode) behind the SafetyGate and the -/// slide-to-confirm dialog. +/// The left rail (plan P3): the [RailState] machine's buttons — Take-off ⇄ +/// Land ⇄ Cancel / Confirm landing, RTH ⇄ Cancel RTH while airborne, Stop +/// while an intelligent mode flies — then the grid toggle. Every press goes +/// through [FlightCommandFlow], i.e. the SafetyGate and slide-to-confirm. class ActionRail extends StatelessWidget { - const ActionRail({ - super.key, - required this.aircraft, - required this.dji, - required this.grid, - required this.onGrid, - required this.onError, - }); + const ActionRail({super.key, required this.model, required this.flow, required this.grid, required this.onGrid}); - final AircraftState aircraft; - final DjiService dji; + final FlightModel model; + final FlightCommandFlow flow; final bool grid; final VoidCallback onGrid; - final void Function(String label, Object error) onError; - Future _run(String label, Future Function() action) async { - try { - await action(); - } catch (e) { - onError(label, e); - } - } - - Future _takeoffOrLand(BuildContext context) async { - if (aircraft.isFlying == true) { - if (await _confirm(context, 'Land now', 'The aircraft will descend and land at its current position.')) { - await _run('Land', dji.land); - } - } else { - if (await _confirm(context, 'Take off', 'The aircraft will take off and hover at ~1.2 m.')) { - await _run('Take off', dji.takeOff); - } - } - } - - Future _rth(BuildContext context) async { - if (await _confirm(context, 'Return to Home', 'The aircraft will fly back to its recorded home point and land.')) { - await _run('Return to Home', dji.startGoHome); - } - } - - Future _confirm(BuildContext context, String title, String body) async { - final bool? r = await showDialog( - context: context, - builder: (BuildContext ctx) => AlertDialog( - backgroundColor: const Color(0xFF10203F), - title: Text(title, style: const TextStyle(color: Glass.ink, fontFamily: PV.fontSans, fontWeight: FontWeight.w700)), - content: Text(body, style: const TextStyle(color: Color(0xFF8FA0BE), fontFamily: PV.fontSans)), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')), - FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(title)), - ], - ), - ); - return r ?? false; - } + static ({String icon, String label, Color color}) _look(FlightCommand c) => switch (c) { + FlightCommand.takeOff => (icon: 'takeoff', label: 'TAKE OFF', color: Glass.accent), + FlightCommand.land => (icon: 'land', label: 'LAND', color: Glass.pillStrong), + FlightCommand.cancelLanding => (icon: 'close', label: 'CANCEL LAND', color: HudColors.caution), + FlightCommand.confirmLanding => (icon: 'check', label: 'CONFIRM LAND', color: HudColors.caution), + FlightCommand.goHome => (icon: 'rth', label: 'RTH', color: Glass.pillStrong), + FlightCommand.cancelGoHome => (icon: 'close', label: 'CANCEL RTH', color: HudColors.caution), + FlightCommand.stopMode => (icon: 'stop', label: 'STOP', color: HudColors.danger), + }; @override Widget build(BuildContext context) { - return Column(mainAxisSize: MainAxisSize.min, children: [ - ListenableBuilder( - listenable: aircraft, - builder: (BuildContext context, _) { - final bool flying = aircraft.isFlying == true; - return _RailButton( - icon: flying ? 'home' : 'takeoff', - label: flying ? 'LAND' : 'TAKE OFF', - color: flying ? const Color(0xE6D64545) : Glass.accent, - onTap: () => _takeoffOrLand(context), + return ListenableBuilder( + listenable: Listenable.merge([model.aircraft, model.mission]), + builder: (BuildContext context, _) { + final RailState rail = RailState.of(connected: model.connected, a: model.aircraft, mission: model.mission); + Widget button(FlightCommand c) { + final ({String icon, String label, Color color}) l = _look(c); + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: RailButton( + key: ValueKey('rail-${c.name}'), + icon: l.icon, + label: l.label, + color: l.color, + enabled: rail.enabled, + onTap: () => flow.request(context, c), + ), ); - }, - ), - const SizedBox(height: 10), - _RailButton(icon: 'rth', label: 'RTH', onTap: () => _rth(context)), - const SizedBox(height: 10), - _RailButton(icon: 'grid', label: 'GRID', color: grid ? Glass.accent : Glass.pillStrong, onTap: onGrid), - ]); + } + + return Column(mainAxisSize: MainAxisSize.min, children: [ + button(rail.primary), + if (rail.rth != null) button(rail.rth!), + if (rail.showStop) button(FlightCommand.stopMode), + // Four buttons would reach the minimap on a phone; Stop outranks the grid. + if (!rail.showStop) + RailButton(icon: 'grid', label: 'GRID', color: grid ? Glass.accent : Glass.pillStrong, onTap: onGrid), + ]); + }, + ); } } -class _RailButton extends StatelessWidget { - const _RailButton({required this.icon, required this.label, required this.onTap, this.color = Glass.pillStrong}); +class RailButton extends StatelessWidget { + const RailButton({ + super.key, + required this.icon, + required this.label, + required this.onTap, + this.color = Glass.pillStrong, + this.enabled = true, + }); final String icon; final String label; final VoidCallback onTap; final Color color; + final bool enabled; @override Widget build(BuildContext context) { return GestureDetector( - onTap: onTap, - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))), - alignment: Alignment.center, - child: PVIcon(icon, size: 20, color: Colors.white), - ), - const SizedBox(height: 2), - Text(label, style: HudText.label.copyWith(fontSize: 7.5)), - ]), + onTap: enabled ? onTap : null, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all(color: const Color(0x2EFFFFFF))), + alignment: Alignment.center, + child: PVIcon(icon, size: 20, color: Colors.white), + ), + const SizedBox(height: 2), + Text(label, style: HudText.label.copyWith(fontSize: 7.5)), + ]), + ), ); } } diff --git a/Fly App/lib/ui/fly/overlays/alert_stack.dart b/Fly App/lib/ui/fly/overlays/alert_stack.dart new file mode 100644 index 0000000..4ea36c8 --- /dev/null +++ b/Fly App/lib/ui/fly/overlays/alert_stack.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../fly/state/alert_state.dart'; +import '../../../theme.dart'; +import '../../pv_icons.dart'; +import '../hud_kit.dart'; + +/// The error stack (plan P3, GO 4's `fpv_error_pop_item`): the aircraft's +/// live diagnostics as up to [maxCards] dismissible cards, most severe first. +/// +/// [AlertState] already de-duplicates by code and keeps a dismissed card hidden +/// until its fault clears; this widget adds the haptic buzz when a new warning +/// or critical fault appears. Info-level messages stay in the tip marquee only. +/// +/// It shows as many cards as fit the height it is given; what does not fit +/// collapses into a "+N more" pill ([onMore], the Aircraft Status sheet). +class AlertStack extends StatefulWidget { + const AlertStack({super.key, required this.alerts, this.width = 280, this.onMore}); + + final AlertState alerts; + final double width; + final VoidCallback? onMore; + + static const int maxCards = 6; + + /// A two-line card plus its gap, for working out how many fit. + static const double cardExtent = 41; + + @override + State createState() => _AlertStackState(); +} + +class _AlertStackState extends State { + DateTime? _seen; + + @override + void initState() { + super.initState(); + _seen = widget.alerts.updatedAt; + widget.alerts.addListener(_onAlerts); + } + + @override + void didUpdateWidget(AlertStack old) { + super.didUpdateWidget(old); + if (old.alerts != widget.alerts) { + old.alerts.removeListener(_onAlerts); + widget.alerts.addListener(_onAlerts); + } + } + + @override + void dispose() { + widget.alerts.removeListener(_onAlerts); + super.dispose(); + } + + /// Buzzes once per bridge update that added a warning or worse — not on a + /// dismissal, which notifies too but leaves [AlertState.updatedAt] alone. + void _onAlerts() { + final AlertState a = widget.alerts; + if (a.updatedAt == _seen) return; + _seen = a.updatedAt; + AlertSeverity? worst; + for (final DiagnosticAlert d in a.all) { + if (!a.lastAdded.contains(d.key)) continue; + if (worst == null || d.severity.index > worst.index) worst = d.severity; + } + if (worst == AlertSeverity.critical) { + HapticFeedback.heavyImpact(); + } else if (worst == AlertSeverity.warning) { + HapticFeedback.mediumImpact(); + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: widget.alerts, + builder: (BuildContext context, _) { + final List all = [ + for (final DiagnosticAlert a in widget.alerts.active) + if (a.severity != AlertSeverity.info) a, + ]; + return LayoutBuilder(builder: (BuildContext context, BoxConstraints box) { + final int room = box.maxHeight.isFinite ? (box.maxHeight / AlertStack.cardExtent).floor() : AlertStack.maxCards; + final int fit = room.clamp(1, AlertStack.maxCards); + // When some must go, the last slot becomes the "+N more" pill. + final int shown = all.length <= fit ? all.length : fit - 1; + final int hidden = all.length - shown; + return SizedBox( + width: widget.width, + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + for (final DiagnosticAlert a in all.take(shown)) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: AlertCard( + key: ValueKey('alert-${a.key}'), + alert: a, + onClose: () => widget.alerts.dismiss(a.key), + ), + ), + if (hidden > 0) + GlassPill( + key: const ValueKey('alert-more'), + height: 24, + color: const Color(0xCC0B1426), + onTap: widget.onMore, + child: Text('+$hidden more', style: HudText.sans.copyWith(fontSize: 11, color: AlertCard.colorOf(all[shown].severity))), + ), + ]), + ); + }); + }, + ); + } +} + +class AlertCard extends StatelessWidget { + const AlertCard({super.key, required this.alert, required this.onClose}); + + final DiagnosticAlert alert; + final VoidCallback onClose; + + static Color colorOf(AlertSeverity s) => switch (s) { + AlertSeverity.critical => HudColors.danger, + AlertSeverity.warning => HudColors.caution, + AlertSeverity.info => Glass.ink, + }; + + @override + Widget build(BuildContext context) { + final Color c = colorOf(alert.severity); + return Container( + decoration: BoxDecoration( + color: const Color(0xCC0B1426), + borderRadius: BorderRadius.circular(8), + border: Border(left: BorderSide(color: c, width: 3)), + ), + padding: const EdgeInsets.fromLTRB(8, 5, 2, 5), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding(padding: const EdgeInsets.only(top: 1), child: PVIcon('alert', size: 13, color: c)), + const SizedBox(width: 6), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Text(alert.reason, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HudText.sans.copyWith(fontSize: 11.5, fontWeight: FontWeight.w700, color: c)), + if (alert.solution != null) + Text(alert.solution!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: HudText.sans.copyWith(fontSize: 10.5, fontWeight: FontWeight.w400, color: HudText.dim)), + ]), + ), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onClose, + child: const Padding(padding: EdgeInsets.fromLTRB(6, 0, 6, 4), child: PVIcon('close', size: 13, color: HudText.dim)), + ), + ]), + ); + } +} diff --git a/Fly App/lib/ui/fly/overlays/safety_banners.dart b/Fly App/lib/ui/fly/overlays/safety_banners.dart new file mode 100644 index 0000000..8cf0074 --- /dev/null +++ b/Fly App/lib/ui/fly/overlays/safety_banners.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import '../../../fly/safety_gate.dart'; +import '../../../fly/state/aircraft_state.dart'; +import '../../pv_icons.dart'; +import '../hud_kit.dart'; + +/// The low-battery flow's banners (plan P3), above the error stack: +/// +/// * the flight controller's **smart-RTH countdown** — battery is down to what +/// the trip home needs — with *Go home now* and *Cancel*; +/// * the **critical-battery forced landing**, which the pilot can only steer. +class SafetyBanners extends StatelessWidget { + const SafetyBanners({super.key, required this.gate, required this.onError, this.width = 280}); + + final SafetyGate gate; + final void Function(String label, Object error) onError; + final double width; + + AircraftState get _a => gate.model.aircraft; + + Future _answer(bool goHome) async { + try { + await gate.answerSmartRth(goHome: goHome); + } catch (e) { + onError(goHome ? 'Return to Home' : 'Cancel RTH', e); + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _a, + builder: (BuildContext context, _) { + final bool flying = _a.isFlying == true; + final bool forcedLanding = + flying && (_a.seriousLowBattery == true || _a.batteryThresholdBehavior == 'LAND_IMMEDIATELY'); + final bool countdown = flying && _a.goHome?.smartRTHCountingDown == true; + if (!forcedLanding && !countdown) return const SizedBox.shrink(); + return SizedBox( + width: width, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + if (forcedLanding) + const _Banner( + key: ValueKey('banner-forced-landing'), + color: HudColors.danger, + title: 'Critically low battery: landing', + body: 'The aircraft is landing where it is. Steer it clear of obstacles; do not climb.', + ) + else if (countdown) + _Banner( + key: const ValueKey('banner-smart-rth'), + color: HudColors.caution, + title: 'Low battery: returning home in ${_a.goHome?.smartRTHCountdown ?? 0} s', + body: 'Battery is down to what the trip home needs.', + actions: [ + _BannerButton(label: 'Cancel', onTap: () => _answer(false)), + const SizedBox(width: 6), + _BannerButton(label: 'Go home now', primary: true, onTap: () => _answer(true)), + ], + ), + const SizedBox(height: 4), + ]), + ); + }, + ); + } +} + +class _Banner extends StatelessWidget { + const _Banner({super.key, required this.color, required this.title, required this.body, this.actions = const []}); + + final Color color; + final String title; + final String body; + final List actions; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration(color: color.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.fromLTRB(9, 6, 7, 6), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + const PVIcon('battery', size: 14, color: Colors.white), + const SizedBox(width: 6), + Expanded( + child: Text(title, style: HudText.sans.copyWith(color: Colors.white, fontWeight: FontWeight.w700)), + ), + ]), + const SizedBox(height: 2), + Text(body, style: HudText.sans.copyWith(color: Colors.white, fontSize: 10.5, fontWeight: FontWeight.w400)), + if (actions.isNotEmpty) ...[ + const SizedBox(height: 5), + Row(mainAxisAlignment: MainAxisAlignment.end, children: actions), + ], + ]), + ); + } +} + +class _BannerButton extends StatelessWidget { + const _BannerButton({required this.label, required this.onTap, this.primary = false}); + + final String label; + final VoidCallback onTap; + final bool primary; + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: primary ? Colors.white : const Color(0x33000000), + borderRadius: BorderRadius.circular(6), + ), + child: Text(label, + style: HudText.sans.copyWith(fontSize: 11, color: primary ? const Color(0xFF7A4A10) : Colors.white, fontWeight: FontWeight.w700)), + ), + ); + } +} diff --git a/Fly App/lib/ui/fly/panels/aircraft_status_sheet.dart b/Fly App/lib/ui/fly/panels/aircraft_status_sheet.dart new file mode 100644 index 0000000..9d4b6a7 --- /dev/null +++ b/Fly App/lib/ui/fly/panels/aircraft_status_sheet.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../dji_service.dart'; +import '../../../flight_model.dart'; +import '../../../fly/aircraft_checklist.dart'; +import '../../../fly/state/parse.dart'; +import '../../../theme.dart'; +import '../../pv_icons.dart'; +import '../dialogs/slide_to_confirm.dart'; +import '../hud_kit.dart'; + +/// Opens the Aircraft Status sheet over the right of the flight screen. +/// +/// With [offerTakeOff] — the first take-off of a connection — the sheet ends +/// in *Continue to take-off*, and the future resolves true when the pilot +/// pressed it. Otherwise it resolves false when closed. +Future showAircraftStatusSheet( + BuildContext context, { + required FlightModel model, + required DjiService dji, + required void Function(String label, Object error) onError, + bool offerTakeOff = false, +}) async { + final bool? r = await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Aircraft status', + barrierColor: const Color(0x66050A14), + transitionDuration: const Duration(milliseconds: 200), + pageBuilder: (BuildContext context, _, __) => Align( + alignment: Alignment.centerRight, + child: AircraftStatusSheet(model: model, dji: dji, onError: onError, offerTakeOff: offerTakeOff), + ), + transitionBuilder: (BuildContext context, Animation a, _, Widget child) => SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate(CurvedAnimation(parent: a, curve: Curves.easeOut)), + child: child, + ), + ); + return r ?? false; +} + +/// The Aircraft Status sheet (plan P3, GO 4's `checklist_content_view`): one +/// traffic-light row per subsystem from [AircraftChecklist], with Calibrate +/// and Format where they apply. Opens from the status chip, and before the +/// first take-off. +class AircraftStatusSheet extends StatefulWidget { + const AircraftStatusSheet({ + super.key, + required this.model, + required this.dji, + required this.onError, + this.offerTakeOff = false, + }); + + final FlightModel model; + final DjiService dji; + final void Function(String label, Object error) onError; + final bool offerTakeOff; + + static const double width = 380; + + @override + State createState() => _AircraftStatusSheetState(); +} + +class _AircraftStatusSheetState extends State { + FlightLimits _limits = const FlightLimits(); + + @override + void initState() { + super.initState(); + _readLimits(); + } + + /// The limits are not in the telemetry tick; read them once per opening. + Future _readLimits() async { + if (!widget.model.connected) return; + Future read(String key) async { + try { + return await widget.dji.getKey(DjiKey.flightController(key)); + } catch (_) { + return null; + } + } + + final List v = await Future.wait(>[ + read('MAX_FLIGHT_HEIGHT'), + read('MAX_FLIGHT_RADIUS'), + read('MAX_FLIGHT_RADIUS_ENABLED'), + ]); + if (!mounted) return; + setState(() => _limits = FlightLimits(maxHeight: asInt(v[0]), maxRadius: asInt(v[1]), maxRadiusEnabled: asBool(v[2]))); + } + + Future _act(ChecklistAction action) async { + final ({SlideConfirmSpec? spec, String label, Future Function() run}) a = switch (action) { + ChecklistAction.calibrateCompass => ( + label: 'Compass calibration', + run: widget.dji.startCompassCalibration, + spec: const SlideConfirmSpec( + icon: 'compass', + title: 'Calibrate compass', + description: 'Stand away from metal, cars and power lines. Hold the aircraft level and turn it a full circle, ' + 'then hold it nose-down and turn again. The Compass row shows each step.', + sliderLabel: 'Slide to start', + ), + ), + ChecklistAction.stopCompassCalibration => (label: 'Stop calibration', run: widget.dji.stopCompassCalibration, spec: null), + ChecklistAction.calibrateImu => ( + label: 'IMU calibration', + run: widget.dji.startIMUCalibration, + spec: const SlideConfirmSpec( + icon: 'gauge', + title: 'Calibrate IMU', + description: 'Put the aircraft on a flat, level surface with the motors off and do not touch it. ' + 'Calibration takes 5–10 minutes; some aircraft ask to be turned onto each side.', + sliderLabel: 'Slide to start', + ), + ), + ChecklistAction.formatSd => ( + label: 'Format SD', + run: widget.dji.formatStorage, + spec: const SlideConfirmSpec( + icon: 'sdcard', + title: 'Format SD card', + description: 'Every photo and video on the card is erased. This cannot be undone.', + sliderLabel: 'Slide to erase', + color: HudColors.danger, + ), + ), + }; + if (a.spec != null) { + final SlideResult? ok = await showSlideToConfirm(context, a.spec!); + if (ok == null) return; + } + try { + await a.run(); + } catch (e) { + widget.onError(a.label, e); + } + } + + static Color _color(CheckState s) => switch (s) { + CheckState.ok => HudColors.ready, + CheckState.caution => HudColors.caution, + CheckState.danger => HudColors.danger, + CheckState.unknown => HudColors.off, + }; + + static String _actionLabel(ChecklistAction a) => switch (a) { + ChecklistAction.calibrateCompass || ChecklistAction.calibrateImu => 'Calibrate', + ChecklistAction.stopCompassCalibration => 'Stop', + ChecklistAction.formatSd => 'Format', + }; + + @override + Widget build(BuildContext context) { + final double height = MediaQuery.sizeOf(context).height; + return Material( + color: const Color(0xF20B1426), + child: SizedBox( + width: AircraftStatusSheet.width, + height: height, + child: SafeArea( + left: false, + child: ListenableBuilder( + listenable: widget.model, + builder: (BuildContext context, _) { + final List items = AircraftChecklist.build(widget.model, limits: _limits); + final CheckState overall = AircraftChecklist.overall(items); + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Container( + padding: const EdgeInsets.fromLTRB(16, 10, 8, 10), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: _color(overall), width: 2))), + child: Row(children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(color: _color(overall), shape: BoxShape.circle)), + const SizedBox(width: 8), + const Expanded( + child: Text('Aircraft Status', + style: TextStyle(fontFamily: PV.fontSans, fontSize: 16, fontWeight: FontWeight.w700, color: Glass.ink)), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(false), + icon: const PVIcon('close', size: 18, color: HudText.dim), + ), + ]), + ), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: items.length, + separatorBuilder: (_, __) => const Divider(height: 1, color: Glass.hairline, indent: 16, endIndent: 16), + itemBuilder: (BuildContext context, int i) => _Row( + item: items[i], + color: _color(items[i].state), + actionLabel: items[i].action == null ? null : _actionLabel(items[i].action!), + onAction: items[i].action == null ? null : () => _act(items[i].action!), + ), + ), + ), + if (widget.offerTakeOff) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: FilledButton( + key: const ValueKey('status-continue-takeoff'), + style: FilledButton.styleFrom( + backgroundColor: overall == CheckState.danger ? HudColors.danger : Glass.accent, + minimumSize: const Size.fromHeight(44), + ), + onPressed: () { + HapticFeedback.selectionClick(); + Navigator.of(context).pop(true); + }, + child: const Text('Continue to take-off', + style: TextStyle(fontFamily: PV.fontSans, fontWeight: FontWeight.w700)), + ), + ), + ]); + }, + ), + ), + ), + ); + } +} + +class _Row extends StatelessWidget { + const _Row({required this.item, required this.color, this.actionLabel, this.onAction}); + + final ChecklistItem item; + final Color color; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 10, 8), + child: Row(children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 10), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Expanded( + child: Text(item.label, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 13, color: Glass.ink)), + ), + Expanded( + child: Text( + item.value, + textAlign: TextAlign.right, + style: TextStyle( + fontFamily: PV.fontSans, + fontSize: 13, + fontWeight: FontWeight.w600, + color: item.state == CheckState.ok || item.state == CheckState.unknown ? Glass.ink : color, + ), + ), + ), + ]), + if (item.detail != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text(item.detail!, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 11, color: HudText.dim)), + ), + ]), + ), + const SizedBox(width: 8), + // A fixed slot, so every row's value lines up whether or not it has a button. + SizedBox( + width: 82, + child: actionLabel == null + ? null + : OutlinedButton( + key: ValueKey('status-action-${item.id}'), + onPressed: onAction, + style: OutlinedButton.styleFrom( + foregroundColor: Glass.ink, + side: const BorderSide(color: Glass.hairline), + padding: const EdgeInsets.symmetric(horizontal: 10), + minimumSize: const Size(0, 30), + visualDensity: VisualDensity.compact, + ), + child: Text(actionLabel!, style: const TextStyle(fontFamily: PV.fontSans, fontSize: 12)), + ), + ), + ]), + ); + } +} diff --git a/Fly App/lib/ui/fly/top/status_bar.dart b/Fly App/lib/ui/fly/top/status_bar.dart index 336d213..4be036c 100644 --- a/Fly App/lib/ui/fly/top/status_bar.dart +++ b/Fly App/lib/ui/fly/top/status_bar.dart @@ -89,7 +89,15 @@ class _StatusChip extends StatelessWidget { color: HudColors.forLevel(s.level), onTap: onTap, padding: const EdgeInsets.symmetric(horizontal: 10), - child: Text(s.text, style: HudText.sans.copyWith(color: Colors.white, fontWeight: FontWeight.w700)), + // Capped, so a long status can never push the bar into overflow. + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 170), + child: Text(s.text, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: HudText.sans.copyWith(color: Colors.white, fontWeight: FontWeight.w700)), + ), ); }, ); diff --git a/Fly App/lib/ui/pv_icons.dart b/Fly App/lib/ui/pv_icons.dart index 45aa4f6..c0a7da0 100644 --- a/Fly App/lib/ui/pv_icons.dart +++ b/Fly App/lib/ui/pv_icons.dart @@ -34,6 +34,10 @@ class PvPaths { 'drone': 'M6 6l3.5 3.5M18 6l-3.5 3.5M6 18l3.5-3.5M18 18l-3.5-3.5M6 6a2.5 2.5 0 1 0-.01-.01M18 6a2.5 2.5 0 1 0-.01 0M6 18a2.5 2.5 0 1 0-.01 0M18 18a2.5 2.5 0 1 0-.01 0M9.5 9.5h5v5h-5z', 'takeoff': 'M12 20V8m0 0-4 4m4-4 4 4M4 4h16', + 'land': 'M12 4v12m0 0-4-4m4 4 4-4M4 20h16', + 'stop': 'M7 7h10v10H7z', + 'alert': 'M12 3 2 20h20L12 3zM12 10v4M12 17h.01', + 'chevronsRight': 'M6 6l6 6-6 6M13 6l6 6-6 6', 'rth': 'M12 3a9 9 0 1 0 9 9M12 3v6l4 2M20 4l-3 1 1-3', 'map': 'M9 4 3 6v14l6-2 6 2 6-2V4l-6 2-6-2zM9 4v14M15 6v14', 'pin': 'M12 21s7-6.4 7-12A7 7 0 0 0 5 9c0 5.6 7 12 7 12zM12 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4z', diff --git a/Fly App/test/fly/safety_test.dart b/Fly App/test/fly/safety_test.dart new file mode 100644 index 0000000..0472958 --- /dev/null +++ b/Fly App/test/fly/safety_test.dart @@ -0,0 +1,436 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pilotvault_fly/dji_service.dart'; +import 'package:pilotvault_fly/flight_model.dart'; +import 'package:pilotvault_fly/fly/action_rail_state.dart'; +import 'package:pilotvault_fly/fly/aircraft_checklist.dart'; +import 'package:pilotvault_fly/fly/product_profile.dart'; +import 'package:pilotvault_fly/fly/safety_gate.dart'; +import 'package:pilotvault_fly/fly/state/aircraft_state.dart'; +import 'package:pilotvault_fly/fly/state/mission_state.dart'; +import 'package:pilotvault_fly/fly/telemetry_reducer.dart'; +import 'package:pilotvault_fly/ui/fly/dialogs/slide_to_confirm.dart'; +import 'package:pilotvault_fly/ui/fly/fly_screen.dart'; +import 'package:pilotvault_fly/ui/fly/panels/aircraft_status_sheet.dart'; + +import 'profile_fixtures.dart'; +import 'telemetry_fixtures.dart'; + +/// Records every bridge call instead of reaching the platform channel. +class FakeDjiService extends DjiService { + final List calls = []; + Object? failWith; + + Future _record(String call) async { + calls.add(call); + if (failWith != null) throw failWith!; + } + + @override + Future takeOff() => _record('takeOff'); + @override + Future precisionTakeOff() => _record('precisionTakeOff'); + @override + Future land() => _record('land'); + @override + Future cancelLanding() => _record('cancelLanding'); + @override + Future confirmLanding() => _record('confirmLanding'); + @override + Future startGoHome() => _record('startGoHome'); + @override + Future cancelGoHome() => _record('cancelGoHome'); + @override + Future confirmSmartRTH(bool goHome) => _record('confirmSmartRTH($goHome)'); + @override + Future setGoHomeHeight(int m) => _record('setGoHomeHeight($m)'); + @override + Future setHomeLocation(double latitude, double longitude) => _record('setHomeLocation'); + @override + Future stopWaypointMission() => _record('stopWaypointMission'); + @override + Future stopActiveTrack() => _record('stopActiveTrack'); + @override + Future startCompassCalibration() => _record('startCompassCalibration'); + @override + Future formatStorage({String location = 'SDCARD'}) => _record('formatStorage'); + @override + Future getKey(DjiKey key) async => switch (key.key) { + 'MAX_FLIGHT_HEIGHT' => 120, + 'MAX_FLIGHT_RADIUS' => 500, + 'MAX_FLIGHT_RADIUS_ENABLED' => true, + _ => null, + }; +} + +Map grounded() => telemetryTick() + ..['isFlying'] = false + ..['areMotorsOn'] = false + ..['flightTimeSeconds'] = 0; + +FlightModel modelWith(Map tick, {Map? profile, List> alerts = const >[]}) { + final FlightModel m = FlightModel() + ..connected = true + ..profile = ProductProfile.fromBridge(profile ?? phantom4); + final TelemetryReducer r = TelemetryReducer(m); + r.apply(tick); + if (alerts.isNotEmpty) r.apply(diagnostics(alerts)); + return m; +} + +AircraftState aircraft(Map tick) => AircraftState()..apply(tick); + +void main() { + group('RailState', () { + RailState rail(Map tick, {bool connected = true, String mission = 'idle'}) => + RailState.of(connected: connected, a: aircraft(tick), mission: MissionState()..state = mission); + + test('on the ground: Take-off only', () { + expect(rail(grounded()), const RailState(primary: FlightCommand.takeOff)); + }); + + test('before the flight controller reports, the rail is disabled', () { + expect(RailState.of(connected: true, a: AircraftState()).enabled, isFalse); + expect(rail(grounded(), connected: false).enabled, isFalse); + }); + + test('airborne: Land plus RTH', () { + expect(rail(telemetryTick()), const RailState(primary: FlightCommand.land, rth: FlightCommand.goHome)); + }); + + test('landing: Cancel landing, then Confirm landing when the aircraft asks', () { + expect(rail(telemetryTick()..['flightModeEnum'] = 'AUTO_LANDING').primary, FlightCommand.cancelLanding); + expect(rail(telemetryTick()..['isLandingConfirmationNeeded'] = true).primary, FlightCommand.confirmLanding); + expect(rail(telemetryTick()..['flightModeEnum'] = 'CONFIRM_LANDING').primary, FlightCommand.confirmLanding); + }); + + test('going home: RTH turns into Cancel RTH, Land stays', () { + final RailState r = rail(telemetryTick() + ..['isGoingHome'] = true + ..['flightModeEnum'] = 'GO_HOME'); + expect(r.primary, FlightCommand.land); + expect(r.rth, FlightCommand.cancelGoHome); + }); + + test('an intelligent mode adds Stop, aimed at the right operator', () { + expect(rail(telemetryTick(), mission: 'tracking').mode, ActiveMode.activeTrack); + expect(rail(telemetryTick(), mission: 'executing').mode, ActiveMode.waypoint); + expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_WAYPOINT').mode, ActiveMode.waypoint); + expect(rail(telemetryTick()..['flightModeEnum'] = 'TAP_FLY').mode, ActiveMode.other); + expect(rail(telemetryTick(), mission: 'uploading').showStop, isFalse); + expect(rail(grounded(), mission: 'tracking').showStop, isFalse, reason: 'nothing to stop on the ground'); + }); + }); + + group('SafetyGate.check', () { + List blockers(FlightModel m, FlightCommand c) => + SafetyGate(model: m, dji: FakeDjiService()).check(c).blockers.map((GateCheck g) => g.message).toList(); + List warnings(FlightModel m, FlightCommand c) => + SafetyGate(model: m, dji: FakeDjiService()).check(c).warnings.map((GateCheck g) => g.message).toList(); + + test('nothing passes without an aircraft', () { + final FlightModel m = FlightModel(); + for (final FlightCommand c in FlightCommand.values) { + expect(SafetyGate(model: m, dji: FakeDjiService()).check(c).allowed, isFalse, reason: '$c'); + } + }); + + test('a healthy aircraft on the ground may take off with no remarks', () { + final GateVerdict v = SafetyGate(model: modelWith(grounded()), dji: FakeDjiService()).check(FlightCommand.takeOff); + expect(v.allowed, isTrue); + expect(v.checks, isEmpty); + }); + + test('take-off: refused when flying, critically low, preheating or faulted', () { + expect(blockers(modelWith(telemetryTick()), FlightCommand.takeOff), isNotEmpty); + expect(blockers(modelWith(grounded()..['battery'] = (telemetryTick(battery: 12)['battery'])), FlightCommand.takeOff).single, + contains('critically low (12%)')); + expect(blockers(modelWith(grounded()..['isIMUPreheating'] = true), FlightCommand.takeOff).single, contains('IMU')); + expect(blockers(modelWith(grounded()..['compassHasError'] = true), FlightCommand.takeOff).single, contains('Compass')); + expect(blockers(modelWith(grounded(), alerts: >[motorBlocked]), FlightCommand.takeOff), ['Motor blocked.']); + }); + + test('take-off: warns on low battery, weak GPS, no home point and a bad SD card', () { + expect(warnings(modelWith(grounded()..['battery'] = (telemetryTick(battery: 25)['battery'])), FlightCommand.takeOff).single, + contains('Battery low (25%)')); + final FlightModel weak = modelWith(grounded() + ..['gpsSignalLevel'] = 1 + ..['isHomeLocationSet'] = false); + expect(warnings(weak, FlightCommand.takeOff), hasLength(2)); + expect(warnings(weak, FlightCommand.takeOff).first, contains('ATTI')); + final Map noSd = grounded() + ..['storage'] = {'inserted': false}; + expect(warnings(modelWith(noSd), FlightCommand.takeOff).single, contains('No SD')); + expect(warnings(modelWith(grounded(), alerts: >[sdSlow]), FlightCommand.takeOff).single, 'SD card write speed slow.'); + }); + + test('RTH: needs to be flying with a home point and GPS', () { + expect(SafetyGate(model: modelWith(telemetryTick()), dji: FakeDjiService()).check(FlightCommand.goHome).allowed, isTrue); + expect(blockers(modelWith(grounded()), FlightCommand.goHome), isNotEmpty); + expect(blockers(modelWith(telemetryTick()..['isHomeLocationSet'] = false), FlightCommand.goHome).single, contains('home point')); + expect(blockers(modelWith(telemetryTick()..['gpsSignalLevel'] = 2), FlightCommand.goHome).single, contains('GPS')); + expect(blockers(modelWith(telemetryTick()..['isGoingHome'] = true), FlightCommand.goHome).single, contains('already')); + }); + + test('RTH: warns when the battery cannot make it or the altitude is low without sensing', () { + expect(warnings(modelWith(telemetryTick(battery: 10)), FlightCommand.goHome).single, contains('below what the trip home needs')); + expect(warnings(modelWith(telemetryTick()..['goHomeHeight'] = 20, profile: sparkWifi), FlightCommand.goHome), isEmpty, + reason: 'Spark has front sensing'); + // A model the matrix does not know: no obstacle sensing assumed. + final FlightModel blind = modelWith(telemetryTick()..['goHomeHeight'] = 20, profile: {...phantom4, 'model': 'SOMETHING_NEW'}); + expect(blind.profile.hasObstacleSensing, isFalse); + expect(warnings(blind, FlightCommand.goHome).single, contains('cannot see obstacles')); + }); + + test('cancel / confirm commands only when the state they answer is live', () { + final FlightModel flying = modelWith(telemetryTick()); + expect(blockers(flying, FlightCommand.cancelLanding), isNotEmpty); + expect(blockers(flying, FlightCommand.confirmLanding), isNotEmpty); + expect(blockers(flying, FlightCommand.cancelGoHome), isNotEmpty); + expect(blockers(flying, FlightCommand.stopMode), isNotEmpty); + expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'AUTO_LANDING'), FlightCommand.cancelLanding), isEmpty); + expect(blockers(modelWith(telemetryTick()..['isLandingConfirmationNeeded'] = true), FlightCommand.confirmLanding), isEmpty); + expect(blockers(modelWith(telemetryTick()..['isGoingHome'] = true), FlightCommand.cancelGoHome), isEmpty); + expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'TAP_FLY'), FlightCommand.stopMode).single, contains('pause button')); + }); + + test('the RTH target is offered once the pilot is more than 30 m from home', () { + final FlightModel m = modelWith(telemetryTick()); + final SafetyGate g = SafetyGate(model: m, dji: FakeDjiService()); + expect(g.offersRthTarget, isFalse, reason: 'no phone fix'); + m + ..phoneLatitude = 55.6760 + ..phoneLongitude = 12.5682; // ~13 m from home + expect(g.offersRthTarget, isFalse); + m.phoneLatitude = 55.6765; // ~56 m + expect(g.offersRthTarget, isTrue); + }); + }); + + group('SafetyGate.run', () { + test('sends take-off, or precision take-off when asked', () async { + final FakeDjiService dji = FakeDjiService(); + final SafetyGate g = SafetyGate(model: modelWith(grounded()), dji: dji); + await g.run(FlightCommand.takeOff); + await g.run(FlightCommand.takeOff, precision: true); + expect(dji.calls, ['takeOff', 'precisionTakeOff']); + }); + + test('refuses without calling the bridge when the state moved on', () async { + final FakeDjiService dji = FakeDjiService(); + final SafetyGate g = SafetyGate(model: modelWith(telemetryTick()), dji: dji); + await expectLater(g.run(FlightCommand.takeOff), throwsA(isA())); + expect(dji.calls, isEmpty); + }); + + test('RTH writes the home point and altitude first, clamped, and skips an unchanged altitude', () async { + final FakeDjiService dji = FakeDjiService(); + final FlightModel m = modelWith(telemetryTick()) + ..phoneLatitude = 55.6765 + ..phoneLongitude = 12.5682; + final SafetyGate g = SafetyGate(model: m, dji: dji); + await g.run(FlightCommand.goHome, rthHeight: 80, homeToPilot: true); + expect(dji.calls, ['setHomeLocation', 'setGoHomeHeight(80)', 'startGoHome']); + dji.calls.clear(); + await g.run(FlightCommand.goHome, rthHeight: 60); + expect(dji.calls, ['startGoHome'], reason: 'the fixture already has 60 m'); + dji.calls.clear(); + await g.run(FlightCommand.goHome, rthHeight: 5); + expect(dji.calls.first, 'setGoHomeHeight(20)'); + }); + + test('Stop addresses the operator that is running', () async { + final FakeDjiService dji = FakeDjiService(); + final FlightModel m = modelWith(telemetryTick()); + m.mission.state = 'tracking'; + await SafetyGate(model: m, dji: dji).run(FlightCommand.stopMode); + m.mission.state = 'executing'; + await SafetyGate(model: m, dji: dji).run(FlightCommand.stopMode); + expect(dji.calls, ['stopActiveTrack', 'stopWaypointMission']); + }); + + test('the smart-RTH answer only goes out during the countdown', () async { + final FakeDjiService dji = FakeDjiService(); + await SafetyGate(model: modelWith(telemetryTick()), dji: dji).answerSmartRth(goHome: true); + expect(dji.calls, isEmpty); + await SafetyGate(model: modelWith(telemetryTick(smartRTHState: 'COUNTING_DOWN')), dji: dji).answerSmartRth(goHome: false); + expect(dji.calls, ['confirmSmartRTH(false)']); + }); + }); + + group('AircraftChecklist', () { + ChecklistItem row(List items, String id) => items.firstWhere((ChecklistItem i) => i.id == id); + + test('a healthy Phantom 4 on the ground is all green, with Calibrate and Format offered', () { + final List items = AircraftChecklist.build(modelWith(grounded()), + limits: const FlightLimits(maxHeight: 120, maxRadius: 500, maxRadiusEnabled: true)); + expect(AircraftChecklist.overall(items), CheckState.ok); + expect(row(items, 'compass').action, ChecklistAction.calibrateCompass); + expect(row(items, 'imu').action, ChecklistAction.calibrateImu); + expect(row(items, 'sd').action, ChecklistAction.formatSd); + expect(row(items, 'maxHeight').value, '120 m'); + expect(row(items, 'radio').value, 'RC 88% · HD 74%'); + }); + + test('no calibration or format while flying or recording', () { + final List flying = AircraftChecklist.build(modelWith(telemetryTick())); + expect(row(flying, 'compass').action, isNull); + expect(row(flying, 'sd').action, isNull); + final FlightModel rec = modelWith(grounded()); + rec.camera.isRecording = true; + expect(row(AircraftChecklist.build(rec), 'sd').action, isNull); + }); + + test('faults colour their own rows', () { + final List items = AircraftChecklist.build( + modelWith(grounded(), alerts: >[compassInterference, motorBlocked])); + expect(row(items, 'compass').state, CheckState.caution); + expect(row(items, 'compass').detail, 'Compass interference.'); + expect(row(items, 'esc').state, CheckState.danger); + expect(AircraftChecklist.overall(items), CheckState.danger); + }); + + test('a running compass calibration shows its step and offers Stop', () { + final ChecklistItem c = row( + AircraftChecklist.build(modelWith(grounded() + ..['compassCalibrating'] = true + ..['compassCalibrationState'] = 'VERTICAL')), + 'compass'); + expect(c.value, contains('nose down')); + expect(c.action, ChecklistAction.stopCompassCalibration); + }); + + test('Spark on Wi-Fi: Wi-Fi signal row, no RC rows', () { + final List items = AircraftChecklist.build(modelWith(grounded(), profile: sparkWifi)); + expect(row(items, 'radio').label, 'Wi-Fi signal'); + expect(items.any((ChecklistItem i) => i.id == 'rcMode' || i.id == 'rcBattery'), isFalse); + }); + }); + + group('Safety flows on the HUD', () { + Future pump(WidgetTester tester, FlightModel m, DjiService dji) async { + tester.view.physicalSize = const Size(844, 390); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji))); + await tester.pump(const Duration(milliseconds: 50)); + } + + Future slide(WidgetTester tester) async { + await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0)); + await tester.pumpAndSettle(); + } + + testWidgets('first take-off: status sheet, then slide; the second skips the sheet', (WidgetTester tester) async { + final FakeDjiService dji = FakeDjiService(); + await pump(tester, modelWith(grounded()), dji); + expect(find.text('TAKE OFF'), findsOneWidget); + expect(find.text('RTH'), findsNothing); + + await tester.tap(find.text('TAKE OFF')); + await tester.pumpAndSettle(); + expect(find.text('Aircraft Status'), findsOneWidget); + expect(find.text('120 m'), findsOneWidget, reason: 'limits read from the KeyManager'); + await tester.tap(find.byKey(const ValueKey('status-continue-takeoff'))); + await tester.pumpAndSettle(); + expect(find.text('Precision Takeoff'), findsOneWidget, reason: 'Phantom 4 has downward vision'); + + // A short drag springs back and sends nothing. + await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(60, 0)); + await tester.pumpAndSettle(); + expect(dji.calls, isEmpty); + await slide(tester); + expect(dji.calls, ['takeOff']); + + await tester.tap(find.text('TAKE OFF')); + await tester.pumpAndSettle(); + expect(find.text('Aircraft Status'), findsNothing); + expect(find.byKey(SlideToConfirmTrack.thumbKey), findsOneWidget); + }); + + testWidgets('a blocked take-off says why and has no slider', (WidgetTester tester) async { + final FakeDjiService dji = FakeDjiService(); + await pump(tester, modelWith(grounded()..['isIMUPreheating'] = true), dji); + await tester.tap(find.text('TAKE OFF')); + await tester.pumpAndSettle(); + expect(find.textContaining('IMU is warming up'), findsOneWidget); + expect(find.byKey(SlideToConfirmTrack.thumbKey), findsNothing); + expect(dji.calls, isEmpty); + }); + + testWidgets('RTH far from home asks the target, then sends altitude + RTH', (WidgetTester tester) async { + final FakeDjiService dji = FakeDjiService(); + final FlightModel m = modelWith(telemetryTick()) + ..phoneLatitude = 55.6765 + ..phoneLongitude = 12.5682; + await pump(tester, m, dji); + expect(find.text('LAND'), findsOneWidget); + await tester.tap(find.text('RTH')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('rth-target-pilot'))); + await tester.pumpAndSettle(); + expect(find.text('60 m'), findsOneWidget); + await tester.tap(find.text('+')); + await tester.pump(); + await slide(tester); + expect(dji.calls, ['setHomeLocation', 'setGoHomeHeight(65)', 'startGoHome']); + }); + + testWidgets('Cancel RTH goes straight out, no slider', (WidgetTester tester) async { + final FakeDjiService dji = FakeDjiService(); + await pump(tester, modelWith(telemetryTick()..['isGoingHome'] = true), dji); + await tester.tap(find.text('CANCEL RTH')); + await tester.pumpAndSettle(); + expect(dji.calls, ['cancelGoHome']); + }); + + testWidgets('error stack: one card per fault, dismissible, info stays out', (WidgetTester tester) async { + final FlightModel m = modelWith(telemetryTick(), alerts: >[ + compassInterference, + motorBlocked, + {'component': 'GIMBAL', 'code': 7, 'reason': 'Gimbal calibrating'}, + ]); + await pump(tester, m, FakeDjiService()); + expect(find.byKey(const ValueKey('alert-FLIGHT_CONTROLLER/1301/0')), findsOneWidget); + expect(find.byKey(const ValueKey('alert-FLIGHT_CONTROLLER/1004/0')), findsOneWidget); + expect(find.byKey(const ValueKey('alert-GIMBAL/7/0')), findsNothing); + await tester.tap(find.descendant( + of: find.byKey(const ValueKey('alert-FLIGHT_CONTROLLER/1004/0')), matching: find.byType(GestureDetector)).last); + await tester.pump(); + expect(find.byKey(const ValueKey('alert-FLIGHT_CONTROLLER/1004/0')), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('smart-RTH countdown banner answers the flight controller', (WidgetTester tester) async { + final FakeDjiService dji = FakeDjiService(); + await pump(tester, modelWith(telemetryTick(smartRTHState: 'COUNTING_DOWN')..['goHome']['smartRTHCountdown'] = 9), dji); + expect(find.text('Low battery: returning home in 9 s'), findsOneWidget); + await tester.tap(find.text('Go home now')); + await tester.pump(); + expect(dji.calls, ['confirmSmartRTH(true)']); + }); + + testWidgets('critical battery in flight shows the forced-landing banner', (WidgetTester tester) async { + await pump(tester, modelWith(telemetryTick()..['isSeriousLowBattery'] = true), FakeDjiService()); + expect(find.text('Critically low battery: landing'), findsOneWidget); + }); + + testWidgets('the status chip opens the sheet; Format SD slide-confirms', (WidgetTester tester) async { + final FakeDjiService dji = FakeDjiService(); + await pump(tester, modelWith(grounded()), dji); + await tester.tap(find.text('Ready to Go (GPS)')); + await tester.pumpAndSettle(); + expect(find.text('Aircraft Status'), findsOneWidget); + expect(find.byKey(const ValueKey('status-continue-takeoff')), findsNothing); + final Finder format = find.byKey(const ValueKey('status-action-sd')); + await tester.scrollUntilVisible(format, 80, + scrollable: find.descendant(of: find.byType(AircraftStatusSheet), matching: find.byType(Scrollable))); + await tester.tap(format); + await tester.pumpAndSettle(); + expect(find.text('Format SD card'), findsOneWidget); + await slide(tester); + expect(dji.calls, ['formatStorage']); + expect(tester.takeException(), isNull); + }); + }); +}