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); + }); + }); +}