Put every flight command behind a SafetyGate
Take-off, land, RTH, the landing cancel/confirm and Stop now all go through one SafetyGate (lib/fly/safety_gate.dart). The gate sorts its preconditions into blockers and warnings: connection, flight state, battery against the thresholds, IMU warm-up, compass, critical diagnostics, GPS, home point, SD card and the RTH battery budget. It checks again right before sending. The left rail is now a state machine (lib/fly/action_rail_state.dart): Take-off becomes Land, then Cancel or Confirm landing. RTH becomes Cancel RTH. A Stop button shows while an intelligent mode is flying. Moving the aircraft needs a slide-to-confirm dialog, which can offer Precision Takeoff or set the RTH altitude. A blocked command shows why and has no slider. When the pilot is more than 30 m from home, RTH asks whether to return to them or to the original home point. The HUD gains an error stack. Its cards can be dismissed, it fits the space it has, and a new warning triggers haptic feedback. It also gets a smart-RTH countdown banner with Cancel / Go home now, a forced-landing banner, and an Aircraft Status sheet. The sheet opens from the status chip and appears before each connection's first take-off. It offers Calibrate compass/IMU and Format SD. On the bridge side this adds precisionTakeOff, setHomeLocation, compass and IMU calibration start/stop, formatStorage, and compass health in the telemetry tick. The classic flight screen stays available until the simulator run passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
590f2a6152
commit
6ebc567e97
21 files changed
+2395
-98
No files matched your search
@@ -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<String>("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.
|
||||
|
||||
+37
-1
@@ -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<Double>("latitude")
|
||||
val lon = call.argument<Double>("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<Int>("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<String, Any?> = 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
|
||||
|
||||
@@ -48,6 +48,9 @@ class DjiService {
|
||||
|
||||
// ── Flight controller commands ──────────────────────────────────────────────
|
||||
Future<void> takeOff() => _methods.invokeMethod<void>('takeOff');
|
||||
|
||||
/// Take-off that records the spot visually, so RTH lands back on it.
|
||||
Future<void> precisionTakeOff() => _methods.invokeMethod<void>('precisionTakeOff');
|
||||
Future<void> land() => _methods.invokeMethod<void>('land');
|
||||
Future<void> confirmLanding() => _methods.invokeMethod<void>('confirmLanding');
|
||||
Future<void> cancelLanding() => _methods.invokeMethod<void>('cancelLanding');
|
||||
@@ -60,6 +63,16 @@ class DjiService {
|
||||
_methods.invokeMethod<void>('confirmSmartRTH', <String, dynamic>{'value': goHome});
|
||||
Future<void> setHomeToCurrent() => _methods.invokeMethod<void>('setHomeToCurrent');
|
||||
|
||||
/// Moves the home point (the RTH target dialog's "Current Position" = the pilot).
|
||||
Future<void> setHomeLocation(double latitude, double longitude) =>
|
||||
_methods.invokeMethod<void>('setHomeLocation', <String, dynamic>{'latitude': latitude, 'longitude': longitude});
|
||||
|
||||
/// Starts compass calibration; `telemetry.compassCalibrationState` then walks
|
||||
/// HORIZONTAL → VERTICAL → SUCCESSFUL / FAILED as the pilot rotates the aircraft.
|
||||
Future<void> startCompassCalibration() => _methods.invokeMethod<void>('startCompassCalibration');
|
||||
Future<void> stopCompassCalibration() => _methods.invokeMethod<void>('stopCompassCalibration');
|
||||
Future<void> startIMUCalibration() => _methods.invokeMethod<void>('startIMUCalibration');
|
||||
|
||||
/// Starts the flight controller's built-in simulator at [latitude]/[longitude]
|
||||
/// (developer only; propellers off). `telemetry.simulatorActive` reflects it.
|
||||
Future<void> startSimulator({required double latitude, required double longitude, int satellites = 12}) =>
|
||||
@@ -130,6 +143,9 @@ class DjiService {
|
||||
_methods.invokeMethod<void>('setEV', <String, dynamic>{'value': value});
|
||||
Future<void> setWhiteBalance(String value) =>
|
||||
_methods.invokeMethod<void>('setWhiteBalance', <String, dynamic>{'value': value});
|
||||
/// Erases the card. location: `SDCARD` | `INTERNAL_STORAGE`
|
||||
Future<void> formatStorage({String location = 'SDCARD'}) =>
|
||||
_methods.invokeMethod<void>('formatStorage', <String, dynamic>{'location': location});
|
||||
Future<void> setHistogramEnabled(bool on) =>
|
||||
_methods.invokeMethod<void>('setHistogramEnabled', <String, dynamic>{'value': on});
|
||||
|
||||
|
||||
@@ -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<String> _waypointModes = <String>{'GPS_WAYPOINT'};
|
||||
static const Set<String> _trackModes = <String>{'ACTIVE_TRACK', 'ACTIVE_TRACK_SPOTLIGHT'};
|
||||
static const Set<String> _otherModes = <String>{'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)';
|
||||
}
|
||||
@@ -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<ChecklistItem> build(FlightModel m, {FlightLimits limits = const FlightLimits()}) {
|
||||
final List<DiagnosticAlert> 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<ChecklistItem> items = <ChecklistItem>[
|
||||
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<ChecklistItem> 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<VisionReading> 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 = <String>[
|
||||
'$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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<bool> classicHud = ValueNotifier<bool>(false);
|
||||
|
||||
/// Flutter's performance overlay on the flight screen, for the P2 "no jank at
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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<GateCheck> checks;
|
||||
|
||||
bool get allowed => !checks.any((GateCheck c) => c.blocks);
|
||||
List<GateCheck> get blockers => <GateCheck>[for (final GateCheck c in checks) if (c.blocks) c];
|
||||
List<GateCheck> get warnings => <GateCheck>[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<GateCheck> checks = <GateCheck>[];
|
||||
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<GateCheck> 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<DiagnosticAlert> 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<GateCheck> 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<void> 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<void> 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RthTarget?> showRthTargetDialog(BuildContext context, {required double pilotToHomeM}) {
|
||||
return showDialog<RthTarget>(
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
Expanded(
|
||||
child: _TargetTile(
|
||||
key: const ValueKey<String>('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<String>('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: <Widget>[
|
||||
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)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 <GateCheck>[],
|
||||
this.checkbox,
|
||||
this.checkboxHint,
|
||||
this.number,
|
||||
});
|
||||
|
||||
final String icon;
|
||||
final String title;
|
||||
final String description;
|
||||
final String sliderLabel;
|
||||
final Color color;
|
||||
final List<GateCheck> 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<SlideResult?> showSlideToConfirm(BuildContext context, SlideConfirmSpec spec) {
|
||||
return showDialog<SlideResult>(
|
||||
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<SlideConfirmDialog> createState() => _SlideConfirmDialogState();
|
||||
}
|
||||
|
||||
class _SlideConfirmDialogState extends State<SlideConfirmDialog> {
|
||||
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<GateCheck> blockers = <GateCheck>[for (final GateCheck c in _s.checks) if (c.blocks) c];
|
||||
final List<GateCheck> warnings = <GateCheck>[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: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
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 <GateCheck>[...blockers, ...warnings]) _CheckLine(check: c),
|
||||
if (blockers.isEmpty && _s.checkbox != null) ...<Widget>[
|
||||
const SizedBox(height: 8),
|
||||
InkWell(
|
||||
onTap: () => setState(() => _checked = !_checked),
|
||||
child: Row(children: <Widget>[
|
||||
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: <Widget>[
|
||||
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) ...<Widget>[
|
||||
const SizedBox(height: 10),
|
||||
Row(children: <Widget>[
|
||||
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: <Widget>[
|
||||
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<String>('slide-thumb');
|
||||
|
||||
/// How far along the track counts as confirmed.
|
||||
static const double threshold = 0.92;
|
||||
|
||||
@override
|
||||
State<SlideToConfirmTrack> createState() => _SlideToConfirmTrackState();
|
||||
}
|
||||
|
||||
class _SlideToConfirmTrackState extends State<SlideToConfirmTrack> 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: <Widget>[
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<bool> 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<void> 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<void> _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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<FlyScreen> {
|
||||
late final FlightTrail _trail = FlightTrail(widget.model.aircraft);
|
||||
final MinimapController _minimap = MinimapController();
|
||||
late final SafetyGate _gate = SafetyGate(model: widget.model, dji: widget.dji);
|
||||
late final FlightCommandFlow _flow = FlightCommandFlow(
|
||||
gate: _gate,
|
||||
onError: _error,
|
||||
reviewBeforeTakeOff: (BuildContext context) => _openStatus(context, offerTakeOff: true),
|
||||
);
|
||||
bool _wasConnected = false;
|
||||
|
||||
bool _mapBig = false;
|
||||
bool _grid = false;
|
||||
@@ -64,10 +81,26 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
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<bool> _openStatus(BuildContext context, {bool offerTakeOff = false}) {
|
||||
// Looking at the sheet on purpose counts as the pre-flight review.
|
||||
if (!offerTakeOff) _flow.markReviewed();
|
||||
return showAircraftStatusSheet(context, model: _m, dji: _dji, onError: _error, offerTakeOff: offerTakeOff);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_m.removeListener(_onModel);
|
||||
_trail.dispose();
|
||||
_minimap.dispose();
|
||||
SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.portraitUp]);
|
||||
@@ -189,6 +222,7 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
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<FlyScreen> {
|
||||
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: <Widget>[
|
||||
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)),
|
||||
|
||||
@@ -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<void> _run(String label, Future<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
onError(label, e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<bool> _confirm(BuildContext context, String title, String body) async {
|
||||
final bool? r = await showDialog<bool>(
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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(<Listenable>[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<String>('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: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<AlertStack> createState() => _AlertStackState();
|
||||
}
|
||||
|
||||
class _AlertStackState extends State<AlertStack> {
|
||||
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<DiagnosticAlert> all = <DiagnosticAlert>[
|
||||
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: <Widget>[
|
||||
for (final DiagnosticAlert a in all.take(shown))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: AlertCard(
|
||||
key: ValueKey<String>('alert-${a.key}'),
|
||||
alert: a,
|
||||
onClose: () => widget.alerts.dismiss(a.key),
|
||||
),
|
||||
),
|
||||
if (hidden > 0)
|
||||
GlassPill(
|
||||
key: const ValueKey<String>('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: <Widget>[
|
||||
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: <Widget>[
|
||||
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)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> _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: <Widget>[
|
||||
if (forcedLanding)
|
||||
const _Banner(
|
||||
key: ValueKey<String>('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<String>('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: <Widget>[
|
||||
_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 <Widget>[]});
|
||||
|
||||
final Color color;
|
||||
final String title;
|
||||
final String body;
|
||||
final List<Widget> 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: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
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) ...<Widget>[
|
||||
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<bool> 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<bool>(
|
||||
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<double> a, _, Widget child) => SlideTransition(
|
||||
position: Tween<Offset>(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<AircraftStatusSheet> createState() => _AircraftStatusSheetState();
|
||||
}
|
||||
|
||||
class _AircraftStatusSheetState extends State<AircraftStatusSheet> {
|
||||
FlightLimits _limits = const FlightLimits();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_readLimits();
|
||||
}
|
||||
|
||||
/// The limits are not in the telemetry tick; read them once per opening.
|
||||
Future<void> _readLimits() async {
|
||||
if (!widget.model.connected) return;
|
||||
Future<Object?> read(String key) async {
|
||||
try {
|
||||
return await widget.dji.getKey(DjiKey.flightController(key));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final List<Object?> v = await Future.wait(<Future<Object?>>[
|
||||
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<void> _act(ChecklistAction action) async {
|
||||
final ({SlideConfirmSpec? spec, String label, Future<void> 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<ChecklistItem> items = AircraftChecklist.build(widget.model, limits: _limits);
|
||||
final CheckState overall = AircraftChecklist.overall(items);
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
|
||||
decoration: BoxDecoration(border: Border(bottom: BorderSide(color: _color(overall), width: 2))),
|
||||
child: Row(children: <Widget>[
|
||||
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<String>('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: <Widget>[
|
||||
Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
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<String>('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)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<String> calls = <String>[];
|
||||
Object? failWith;
|
||||
|
||||
Future<void> _record(String call) async {
|
||||
calls.add(call);
|
||||
if (failWith != null) throw failWith!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> takeOff() => _record('takeOff');
|
||||
@override
|
||||
Future<void> precisionTakeOff() => _record('precisionTakeOff');
|
||||
@override
|
||||
Future<void> land() => _record('land');
|
||||
@override
|
||||
Future<void> cancelLanding() => _record('cancelLanding');
|
||||
@override
|
||||
Future<void> confirmLanding() => _record('confirmLanding');
|
||||
@override
|
||||
Future<void> startGoHome() => _record('startGoHome');
|
||||
@override
|
||||
Future<void> cancelGoHome() => _record('cancelGoHome');
|
||||
@override
|
||||
Future<void> confirmSmartRTH(bool goHome) => _record('confirmSmartRTH($goHome)');
|
||||
@override
|
||||
Future<void> setGoHomeHeight(int m) => _record('setGoHomeHeight($m)');
|
||||
@override
|
||||
Future<void> setHomeLocation(double latitude, double longitude) => _record('setHomeLocation');
|
||||
@override
|
||||
Future<void> stopWaypointMission() => _record('stopWaypointMission');
|
||||
@override
|
||||
Future<void> stopActiveTrack() => _record('stopActiveTrack');
|
||||
@override
|
||||
Future<void> startCompassCalibration() => _record('startCompassCalibration');
|
||||
@override
|
||||
Future<void> formatStorage({String location = 'SDCARD'}) => _record('formatStorage');
|
||||
@override
|
||||
Future<Object?> getKey(DjiKey key) async => switch (key.key) {
|
||||
'MAX_FLIGHT_HEIGHT' => 120,
|
||||
'MAX_FLIGHT_RADIUS' => 500,
|
||||
'MAX_FLIGHT_RADIUS_ENABLED' => true,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> grounded() => telemetryTick()
|
||||
..['isFlying'] = false
|
||||
..['areMotorsOn'] = false
|
||||
..['flightTimeSeconds'] = 0;
|
||||
|
||||
FlightModel modelWith(Map<String, dynamic> tick, {Map<String, dynamic>? profile, List<Map<String, Object?>> alerts = const <Map<String, Object?>>[]}) {
|
||||
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<String, dynamic> tick) => AircraftState()..apply(tick);
|
||||
|
||||
void main() {
|
||||
group('RailState', () {
|
||||
RailState rail(Map<String, dynamic> 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<String> blockers(FlightModel m, FlightCommand c) =>
|
||||
SafetyGate(model: m, dji: FakeDjiService()).check(c).blockers.map((GateCheck g) => g.message).toList();
|
||||
List<String> 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: <Map<String, Object?>>[motorBlocked]), FlightCommand.takeOff), <String>['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<String, dynamic> noSd = grounded()
|
||||
..['storage'] = <Object?, Object?>{'inserted': false};
|
||||
expect(warnings(modelWith(noSd), FlightCommand.takeOff).single, contains('No SD'));
|
||||
expect(warnings(modelWith(grounded(), alerts: <Map<String, Object?>>[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: <String, dynamic>{...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, <String>['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<SafetyGateRefused>()));
|
||||
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, <String>['setHomeLocation', 'setGoHomeHeight(80)', 'startGoHome']);
|
||||
dji.calls.clear();
|
||||
await g.run(FlightCommand.goHome, rthHeight: 60);
|
||||
expect(dji.calls, <String>['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, <String>['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, <String>['confirmSmartRTH(false)']);
|
||||
});
|
||||
});
|
||||
|
||||
group('AircraftChecklist', () {
|
||||
ChecklistItem row(List<ChecklistItem> 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<ChecklistItem> 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<ChecklistItem> 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<ChecklistItem> items = AircraftChecklist.build(
|
||||
modelWith(grounded(), alerts: <Map<String, Object?>>[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<ChecklistItem> 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<void> 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<void> 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<String>('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, <String>['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<String>('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, <String>['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, <String>['cancelGoHome']);
|
||||
});
|
||||
|
||||
testWidgets('error stack: one card per fault, dismissible, info stays out', (WidgetTester tester) async {
|
||||
final FlightModel m = modelWith(telemetryTick(), alerts: <Map<String, Object?>>[
|
||||
compassInterference,
|
||||
motorBlocked,
|
||||
<String, Object?>{'component': 'GIMBAL', 'code': 7, 'reason': 'Gimbal calibrating'},
|
||||
]);
|
||||
await pump(tester, m, FakeDjiService());
|
||||
expect(find.byKey(const ValueKey<String>('alert-FLIGHT_CONTROLLER/1301/0')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey<String>('alert-FLIGHT_CONTROLLER/1004/0')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey<String>('alert-GIMBAL/7/0')), findsNothing);
|
||||
await tester.tap(find.descendant(
|
||||
of: find.byKey(const ValueKey<String>('alert-FLIGHT_CONTROLLER/1004/0')), matching: find.byType(GestureDetector)).last);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey<String>('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, <String>['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<String>('status-continue-takeoff')), findsNothing);
|
||||
final Finder format = find.byKey(const ValueKey<String>('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, <String>['formatStorage']);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user