Give the Fly App GO 4-style intelligent modes and a Waypoints editor
P7a of the GO 4-parity plan. The rail's GRID button becomes MODES, which opens a picker of the product's intelligent flight modes with first-use terms; Waypoints is the first one built. Waypoints opens on the big map: tap to add waypoints or POIs, long-press to drag, "+" to insert on a leg, and a point editor for altitude, speed, heading (route / custom / POI), gimbal (set or aim at POI), camera action and hover, and turn radius, with a swipe-to-adjust readout. Route settings, a local mission library and undo sit in a tool column. Start runs the SafetyGate's pre-start check in the slide-to-confirm dialog, then uploads and starts; a run panel shows upload and flight progress with Pause, Resume and Stop. MissionBridge now listens to the waypoint operator and builds the full V1 mission (per-waypoint headings, turn direction, gimbal pitch, actions). Not yet run on a simulator or aircraft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a892bfb209
commit
43a66df8df
22 files changed
+4276
-83
No files matched your search
@@ -1,22 +1,49 @@
|
||||
package com.pilotvault.flyapp
|
||||
|
||||
import android.graphics.RectF
|
||||
import dji.common.error.DJIError
|
||||
import dji.common.mission.activetrack.ActiveTrackMission
|
||||
import dji.common.mission.activetrack.ActiveTrackMode
|
||||
import dji.common.mission.activetrack.QuickShotMode
|
||||
import dji.common.mission.waypoint.Waypoint
|
||||
import dji.common.mission.waypoint.WaypointAction
|
||||
import dji.common.mission.waypoint.WaypointActionType
|
||||
import dji.common.mission.waypoint.WaypointMission
|
||||
import dji.common.mission.waypoint.WaypointMissionDownloadEvent
|
||||
import dji.common.mission.waypoint.WaypointMissionExecutionEvent
|
||||
import dji.common.mission.waypoint.WaypointMissionFinishedAction
|
||||
import dji.common.mission.waypoint.WaypointMissionFlightPathMode
|
||||
import dji.common.mission.waypoint.WaypointMissionGotoWaypointMode
|
||||
import dji.common.mission.waypoint.WaypointMissionHeadingMode
|
||||
import dji.common.mission.waypoint.WaypointMissionUploadEvent
|
||||
import dji.common.mission.waypoint.WaypointTurnMode
|
||||
import dji.common.model.LocationCoordinate2D
|
||||
import dji.sdk.base.BaseProduct
|
||||
import dji.sdk.mission.activetrack.ActiveTrackOperator
|
||||
import dji.sdk.mission.waypoint.WaypointMissionOperator
|
||||
import dji.sdk.mission.waypoint.WaypointMissionOperatorListener
|
||||
import dji.sdk.sdkmanager.DJISDKManager
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Intelligent-flight & waypoint missions.
|
||||
* Intelligent-flight & waypoint missions (plan P7).
|
||||
*
|
||||
* - Waypoint route (from the Map screen) → [WaypointMissionOperator].
|
||||
* - Waypoints → [WaypointMissionOperator]. `uploadWaypointMission` takes the
|
||||
* resolved mission the Dart editor builds (`WaypointMissionSpec.toBridge`):
|
||||
*
|
||||
* ```
|
||||
* {waypoints: [{lat, lon, altitude, heading?, turnMode, speed, cornerRadius,
|
||||
* gimbalPitch?, actions: [{type: WaypointActionType, param}]}],
|
||||
* autoFlightSpeed, maxFlightSpeed, finishedAction, headingMode,
|
||||
* flightPathMode, gotoFirstWaypointMode, exitMissionOnRCSignalLost,
|
||||
* gimbalPitchRotation, repeatTimes, pointOfInterest?: {lat, lon}}
|
||||
* ```
|
||||
*
|
||||
* The operator's listener is published as `mission` events with
|
||||
* `operator: 'waypoint'`: `state` (WaypointMissionState), `upload`
|
||||
* {index, total, summary}, `execution` {target, reached, executeState,
|
||||
* total}, `event` (`start` / `finish`) and `error`.
|
||||
* - ActiveTrack subject tracking → [ActiveTrackOperator].
|
||||
* - DJI-Fly QuickShots (Dronie/Rocket/Circle/Helix/Boomerang/Asteroid) → an
|
||||
* ActiveTrack mission in [ActiveTrackMode.QUICK_SHOT] with the matching
|
||||
@@ -30,13 +57,88 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
private fun trackOp(): ActiveTrackOperator? =
|
||||
DJISDKManager.getInstance().missionControl?.activeTrackOperator
|
||||
|
||||
private var listenedOp: WaypointMissionOperator? = null
|
||||
|
||||
private val waypointListener = object : WaypointMissionOperatorListener {
|
||||
override fun onDownloadUpdate(event: WaypointMissionDownloadEvent) {}
|
||||
|
||||
override fun onUploadUpdate(event: WaypointMissionUploadEvent) {
|
||||
val p = event.progress
|
||||
emitWaypoint(
|
||||
state = event.currentState?.name,
|
||||
upload = p?.let { mapOf("index" to it.uploadedWaypointIndex, "total" to it.totalWaypointCount, "summary" to it.isSummaryUploaded) },
|
||||
error = event.error,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onExecutionUpdate(event: WaypointMissionExecutionEvent) {
|
||||
val p = event.progress
|
||||
emitWaypoint(
|
||||
state = event.currentState?.name,
|
||||
execution = p?.let {
|
||||
mapOf(
|
||||
"target" to it.targetWaypointIndex,
|
||||
"reached" to it.isWaypointReached,
|
||||
"executeState" to it.executeState?.name,
|
||||
"total" to it.totalWaypointCount,
|
||||
)
|
||||
},
|
||||
error = event.error,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onExecutionStart() {
|
||||
emitWaypoint(state = waypointOp()?.currentState?.name, event = "start")
|
||||
}
|
||||
|
||||
override fun onExecutionFinish(error: DJIError?) {
|
||||
emitWaypoint(state = waypointOp()?.currentState?.name, event = "finish", error = error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emitWaypoint(
|
||||
state: String?,
|
||||
upload: Map<String, Any?>? = null,
|
||||
execution: Map<String, Any?>? = null,
|
||||
event: String? = null,
|
||||
error: DJIError? = null,
|
||||
) {
|
||||
val m = linkedMapOf<String, Any?>("type" to "mission", "operator" to "waypoint")
|
||||
if (state != null) m["state"] = state
|
||||
if (upload != null) m["upload"] = upload
|
||||
if (execution != null) m["execution"] = execution
|
||||
if (event != null) m["event"] = event
|
||||
if (error != null) m["error"] = error.description
|
||||
ctx.emit(m)
|
||||
}
|
||||
|
||||
override fun bind(product: BaseProduct?) {
|
||||
val op = waypointOp() ?: return
|
||||
if (listenedOp !== op) {
|
||||
listenedOp?.removeListener(waypointListener)
|
||||
op.addListener(waypointListener)
|
||||
listenedOp = op
|
||||
}
|
||||
emitWaypoint(state = op.currentState?.name)
|
||||
}
|
||||
|
||||
override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean {
|
||||
when (call.method) {
|
||||
"uploadWaypointMission" -> uploadWaypoint(call, result)
|
||||
"getWaypointMissionState" -> {
|
||||
val op = waypointOp()
|
||||
result.success(op?.currentState?.name)
|
||||
if (op != null) emitWaypoint(state = op.currentState?.name)
|
||||
}
|
||||
"startWaypointMission" -> waypoint(result) { it.startMission(ctx.completion(result)) }
|
||||
"stopWaypointMission" -> waypoint(result) { it.stopMission(ctx.completion(result)) }
|
||||
"pauseWaypointMission" -> waypoint(result) { it.pauseMission(ctx.completion(result)) }
|
||||
"resumeWaypointMission" -> waypoint(result) { it.resumeMission(ctx.completion(result)) }
|
||||
"cancelWaypointUpload" -> waypoint(result) { it.cancelUploadingMission(ctx.completion(result)) }
|
||||
"setWaypointAutoFlightSpeed" -> waypoint(result) {
|
||||
val speed = (call.argument<Number>("speed") ?: 8).toFloat()
|
||||
it.setAutoFlightSpeed(speed, ctx.completion(result))
|
||||
}
|
||||
"startActiveTrack" -> startActiveTrack(call, result)
|
||||
"stopActiveTrack" -> track(result) { it.stopTracking(ctx.completion(result)) }
|
||||
"startQuickShot" -> startQuickShot(call, result)
|
||||
@@ -55,41 +157,78 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge {
|
||||
block(op)
|
||||
}
|
||||
|
||||
private inline fun <reified T : Enum<T>> enumArg(call: MethodCall, name: String, fallback: T): T {
|
||||
val raw = call.argument<String>(name) ?: return fallback
|
||||
return try {
|
||||
java.lang.Enum.valueOf(T::class.java, raw)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadWaypoint(call: MethodCall, result: MethodChannel.Result) {
|
||||
val op = waypointOp() ?: run { ctx.fail(result, "Waypoint missions unavailable"); return }
|
||||
val raw = call.argument<List<Map<String, Any>>>("points") ?: emptyList()
|
||||
if (raw.size < 2) {
|
||||
val raw = call.argument<List<Map<String, Any?>>>("waypoints") ?: emptyList()
|
||||
if (raw.size < WaypointMission.MIN_WAYPOINT_COUNT) {
|
||||
ctx.fail(result, "A route needs at least 2 waypoints")
|
||||
return
|
||||
}
|
||||
val speed = (call.argument<Double>("speed") ?: 8.0).toFloat()
|
||||
val finish = try {
|
||||
WaypointMissionFinishedAction.valueOf(call.argument<String>("finishAction") ?: "NO_ACTION")
|
||||
} catch (_: IllegalArgumentException) {
|
||||
WaypointMissionFinishedAction.NO_ACTION
|
||||
|
||||
val waypoints = ArrayList<Waypoint>(raw.size)
|
||||
for (p in raw) {
|
||||
val wp = Waypoint(
|
||||
(p["lat"] as Number).toDouble(),
|
||||
(p["lon"] as Number).toDouble(),
|
||||
(p["altitude"] as? Number)?.toFloat() ?: 50f,
|
||||
)
|
||||
(p["heading"] as? Number)?.let { wp.heading = it.toInt() }
|
||||
wp.turnMode = if (p["turnMode"] == "COUNTER_CLOCKWISE") WaypointTurnMode.COUNTER_CLOCKWISE else WaypointTurnMode.CLOCKWISE
|
||||
(p["speed"] as? Number)?.let { wp.speed = it.toFloat() }
|
||||
(p["cornerRadius"] as? Number)?.let { wp.cornerRadiusInMeters = it.toFloat() }
|
||||
(p["gimbalPitch"] as? Number)?.let { wp.gimbalPitch = it.toFloat() }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val actions = p["actions"] as? List<Map<String, Any?>> ?: emptyList()
|
||||
for (a in actions) {
|
||||
val type = try {
|
||||
WaypointActionType.valueOf(a["type"] as String)
|
||||
} catch (_: Exception) {
|
||||
continue
|
||||
}
|
||||
wp.addAction(WaypointAction(type, (a["param"] as? Number)?.toInt() ?: 0))
|
||||
}
|
||||
waypoints.add(wp)
|
||||
}
|
||||
|
||||
val waypoints = raw.map { p ->
|
||||
val lat = (p["lat"] as Number).toDouble()
|
||||
val lon = (p["lon"] as Number).toDouble()
|
||||
val alt = (p["altitude"] as? Number)?.toFloat() ?: 50f
|
||||
Waypoint(lat, lon, alt)
|
||||
}
|
||||
|
||||
val mission = WaypointMission.Builder()
|
||||
.autoFlightSpeed(speed)
|
||||
.maxFlightSpeed(maxOf(speed, 10f))
|
||||
.finishedAction(finish)
|
||||
.waypointList(ArrayList(waypoints))
|
||||
val headingMode = enumArg(call, "headingMode", WaypointMissionHeadingMode.AUTO)
|
||||
val builder = WaypointMission.Builder()
|
||||
.autoFlightSpeed((call.argument<Number>("autoFlightSpeed") ?: 8).toFloat())
|
||||
.maxFlightSpeed((call.argument<Number>("maxFlightSpeed") ?: 15).toFloat())
|
||||
.finishedAction(enumArg(call, "finishedAction", WaypointMissionFinishedAction.NO_ACTION))
|
||||
.headingMode(headingMode)
|
||||
.flightPathMode(enumArg(call, "flightPathMode", WaypointMissionFlightPathMode.NORMAL))
|
||||
.gotoFirstWaypointMode(enumArg(call, "gotoFirstWaypointMode", WaypointMissionGotoWaypointMode.SAFELY))
|
||||
.setExitMissionOnRCSignalLostEnabled(call.argument<Boolean>("exitMissionOnRCSignalLost") ?: true)
|
||||
.setGimbalPitchRotationEnabled(call.argument<Boolean>("gimbalPitchRotation") ?: false)
|
||||
.repeatTimes(call.argument<Number>("repeatTimes")?.toInt() ?: 1)
|
||||
.waypointList(waypoints)
|
||||
.waypointCount(waypoints.size)
|
||||
.build()
|
||||
call.argument<Map<String, Any?>>("pointOfInterest")?.let { poi ->
|
||||
builder.setPointOfInterest(
|
||||
LocationCoordinate2D((poi["lat"] as Number).toDouble(), (poi["lon"] as Number).toDouble()),
|
||||
)
|
||||
}
|
||||
|
||||
val loadErr = op.loadMission(mission)
|
||||
builder.checkParameters()?.let {
|
||||
ctx.fail(result, it.description)
|
||||
return
|
||||
}
|
||||
val loadErr = op.loadMission(builder.build())
|
||||
if (loadErr != null) {
|
||||
ctx.fail(result, loadErr.description)
|
||||
return
|
||||
}
|
||||
ctx.emit(mapOf("type" to "mission", "state" to "uploading"))
|
||||
// Completes once every waypoint is on the aircraft; progress arrives
|
||||
// through the listener meanwhile.
|
||||
op.uploadMission(ctx.completion(result))
|
||||
}
|
||||
|
||||
|
||||
@@ -263,22 +263,24 @@ class DjiService {
|
||||
Future<void> resetGimbalControlParameters() => _methods.invokeMethod<void>('resetGimbalControlParameters');
|
||||
|
||||
// ── Missions ────────────────────────────────────────────────────────────────
|
||||
/// points: [{lat, lon, altitude}], finishAction: NO_ACTION|GO_HOME|AUTO_LAND|GO_FIRST_WAYPOINT
|
||||
Future<void> uploadWaypointMission(
|
||||
List<Map<String, dynamic>> points, {
|
||||
double speed = 8,
|
||||
String finishAction = 'NO_ACTION',
|
||||
}) =>
|
||||
_methods.invokeMethod<void>('uploadWaypointMission', <String, dynamic>{
|
||||
'points': points,
|
||||
'speed': speed,
|
||||
'finishAction': finishAction,
|
||||
});
|
||||
/// Loads and uploads a waypoint mission — `WaypointMissionSpec.toBridge()`'s
|
||||
/// map (see `MissionBridge.kt`). Resolves once every waypoint is on the
|
||||
/// aircraft; progress arrives as `mission` events with `operator: 'waypoint'`.
|
||||
Future<void> uploadWaypointMission(Map<String, Object?> mission) =>
|
||||
_methods.invokeMethod<void>('uploadWaypointMission', mission);
|
||||
Future<void> cancelWaypointUpload() => _methods.invokeMethod<void>('cancelWaypointUpload');
|
||||
|
||||
/// The operator's `WaypointMissionState` name; also re-published as an event.
|
||||
Future<String?> getWaypointMissionState() => _methods.invokeMethod<String>('getWaypointMissionState');
|
||||
Future<void> startWaypointMission() => _methods.invokeMethod<void>('startWaypointMission');
|
||||
Future<void> stopWaypointMission() => _methods.invokeMethod<void>('stopWaypointMission');
|
||||
Future<void> pauseWaypointMission() => _methods.invokeMethod<void>('pauseWaypointMission');
|
||||
Future<void> resumeWaypointMission() => _methods.invokeMethod<void>('resumeWaypointMission');
|
||||
|
||||
/// Changes the cruise speed of the mission in flight (m/s, −15…15).
|
||||
Future<void> setWaypointAutoFlightSpeed(double speed) =>
|
||||
_methods.invokeMethod<void>('setWaypointAutoFlightSpeed', <String, dynamic>{'speed': speed});
|
||||
|
||||
/// Rect is normalized 0..1 in the live view. mode: TRACE|PROFILE|SPOTLIGHT|QUICK_SHOT
|
||||
Future<void> startActiveTrack(
|
||||
double x,
|
||||
|
||||
@@ -30,6 +30,7 @@ abstract final class FlyPrefs {
|
||||
static const String _centerKey = 'fly.centerPoint';
|
||||
static const String _longPressKey = 'fly.longPress';
|
||||
static const String _routeKey = 'fly.showRoute';
|
||||
static const String _termsKey = 'fly.modeTerms';
|
||||
|
||||
/// Open the pre-P2 `FlightControlPage` instead of the HUD v2 — kept
|
||||
/// reachable until P3's safety flows pass their simulator run (plan P2/P3).
|
||||
@@ -46,6 +47,9 @@ abstract final class FlyPrefs {
|
||||
/// Draw the flown trail on the map (settings → General).
|
||||
static final ValueNotifier<bool> showRoute = ValueNotifier<bool>(true);
|
||||
|
||||
/// Intelligent flight modes whose first-use terms the pilot accepted (plan P7), by name.
|
||||
static final ValueNotifier<Set<String>> acceptedModeTerms = ValueNotifier<Set<String>>(<String>{});
|
||||
|
||||
static SharedPreferences? _prefs;
|
||||
|
||||
static Future<void> load() async {
|
||||
@@ -57,6 +61,7 @@ abstract final class FlyPrefs {
|
||||
centerPoint.value = _prefs!.getBool(_centerKey) ?? true;
|
||||
longPress.value = _byName(LongPressAction.values, _prefs!.getString(_longPressKey)) ?? LongPressAction.meter;
|
||||
showRoute.value = _prefs!.getBool(_routeKey) ?? true;
|
||||
acceptedModeTerms.value = (_prefs!.getStringList(_termsKey) ?? const <String>[]).toSet();
|
||||
} catch (_) {
|
||||
// Storage unavailable: run on the defaults.
|
||||
}
|
||||
@@ -92,6 +97,12 @@ abstract final class FlyPrefs {
|
||||
await _prefs?.setBool(_routeKey, on);
|
||||
}
|
||||
|
||||
static Future<void> acceptModeTerms(String mode) async {
|
||||
if (acceptedModeTerms.value.contains(mode)) return;
|
||||
acceptedModeTerms.value = <String>{...acceptedModeTerms.value, mode};
|
||||
await _prefs?.setStringList(_termsKey, acceptedModeTerms.value.toList()..sort());
|
||||
}
|
||||
|
||||
static T? _byName<T extends Enum>(List<T> values, String? name) {
|
||||
for (final T v in values) {
|
||||
if (v.name == name) return v;
|
||||
|
||||
@@ -133,7 +133,9 @@ abstract final class HudFeatures {
|
||||
static final HudFeature visionSensorGuide = HudFeature(
|
||||
'calibration.vision', 'Vision sensor calibration (guide)', 'Calibration', (ProductProfile p) => p.hasVps);
|
||||
|
||||
// ── Intelligent flight modes ──────────────────────────────────────────────
|
||||
// ── Intelligent flight modes (plan P7) ────────────────────────────────────
|
||||
static final HudFeature modePicker = HudFeature(
|
||||
'mode.picker', 'Intelligent Flight Modes button', 'Intelligent modes', (ProductProfile p) => p.modes.isNotEmpty);
|
||||
static final List<HudFeature> modes = <HudFeature>[
|
||||
for (final IntelligentMode m in IntelligentMode.values)
|
||||
HudFeature('mode.${m.name}', _modeLabel(m), 'Intelligent modes', (ProductProfile p) => p.supportsMode(m)),
|
||||
@@ -185,6 +187,7 @@ abstract final class HudFeatures {
|
||||
calibrationGravityCenter,
|
||||
calibrationPropeller,
|
||||
visionSensorGuide,
|
||||
modePicker,
|
||||
...modes,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import '../product_profile.dart';
|
||||
|
||||
/// The Intelligent Flight Modes picker's catalogue (plan P7, GO 4's
|
||||
/// `DJILeftMenuNavigationView` + `gs_main_view`).
|
||||
///
|
||||
/// Which modes a product has comes from [ProductProfile.modes] (the product
|
||||
/// matrix until runtime capture confirms it); which of those this app can fly
|
||||
/// yet is [ModeInfo.built]. A mode that is supported but not built is listed
|
||||
/// greyed out rather than hidden, so the pilot sees what is coming.
|
||||
class ModeInfo {
|
||||
const ModeInfo({
|
||||
required this.mode,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.group,
|
||||
required this.blurb,
|
||||
required this.terms,
|
||||
this.built = false,
|
||||
});
|
||||
|
||||
final IntelligentMode mode;
|
||||
final String label;
|
||||
|
||||
/// A `PVIcon` name.
|
||||
final String icon;
|
||||
|
||||
/// `Map`, `Vision`, `Shots` or `Orientation`, as the picker groups them.
|
||||
final String group;
|
||||
|
||||
/// One line under the tile.
|
||||
final String blurb;
|
||||
|
||||
/// The first-use disclaimer: what the pilot stays responsible for.
|
||||
final List<String> terms;
|
||||
|
||||
/// This app can fly the mode.
|
||||
final bool built;
|
||||
}
|
||||
|
||||
abstract final class ModeCatalog {
|
||||
static const List<String> _common = <String>[
|
||||
'You remain responsible for the flight. Keep the aircraft in sight and be ready to take over with the remote.',
|
||||
'The aircraft flies itself in this mode. Push the remote\'s pause button, or tap Stop, to make it hover.',
|
||||
];
|
||||
|
||||
static const List<ModeInfo> all = <ModeInfo>[
|
||||
ModeInfo(
|
||||
mode: IntelligentMode.waypoint,
|
||||
label: 'Waypoints',
|
||||
icon: 'route',
|
||||
group: 'Map',
|
||||
blurb: 'Plan a route on the map and fly it.',
|
||||
built: true,
|
||||
terms: <String>[
|
||||
..._common,
|
||||
'The route flies straight between waypoints at the altitudes you set. It does not avoid terrain, trees or buildings: check the route and its altitudes before you start.',
|
||||
'If the remote loses signal the aircraft follows the route\'s signal-lost setting.',
|
||||
],
|
||||
),
|
||||
ModeInfo(
|
||||
mode: IntelligentMode.waypointV2,
|
||||
label: 'Waypoints 2.0',
|
||||
icon: 'route',
|
||||
group: 'Map',
|
||||
blurb: 'Enterprise waypoint missions.',
|
||||
terms: _common,
|
||||
),
|
||||
ModeInfo(
|
||||
mode: IntelligentMode.pointOfInterest,
|
||||
label: 'Point of Interest',
|
||||
icon: 'circle',
|
||||
group: 'Map',
|
||||
blurb: 'Circle a subject at a set radius.',
|
||||
terms: _common,
|
||||
),
|
||||
ModeInfo(
|
||||
mode: IntelligentMode.followMe,
|
||||
label: 'Follow Me',
|
||||
icon: 'user',
|
||||
group: 'Map',
|
||||
blurb: 'The aircraft follows this phone.',
|
||||
terms: _common,
|
||||
),
|
||||
ModeInfo(
|
||||
mode: IntelligentMode.activeTrack,
|
||||
label: 'ActiveTrack',
|
||||
icon: 'crosshair',
|
||||
group: 'Vision',
|
||||
blurb: 'Draw a box around a subject to follow it.',
|
||||
terms: _common,
|
||||
),
|
||||
ModeInfo(
|
||||
mode: IntelligentMode.tapFly,
|
||||
label: 'TapFly',
|
||||
icon: 'chevronsRight',
|
||||
group: 'Vision',
|
||||
blurb: 'Tap the video to fly that way.',
|
||||
terms: _common,
|
||||
),
|
||||
ModeInfo(
|
||||
mode: IntelligentMode.quickShots,
|
||||
label: 'QuickShots',
|
||||
icon: 'dronie',
|
||||
group: 'Shots',
|
||||
blurb: 'Dronie, Rocket, Circle, Helix…',
|
||||
terms: _common,
|
||||
),
|
||||
ModeInfo(
|
||||
mode: IntelligentMode.hyperlapse,
|
||||
label: 'Hyperlapse',
|
||||
icon: 'hyperlapse',
|
||||
group: 'Shots',
|
||||
blurb: 'A moving timelapse.',
|
||||
terms: _common,
|
||||
),
|
||||
ModeInfo(
|
||||
mode: IntelligentMode.courseLock,
|
||||
label: 'Course Lock',
|
||||
icon: 'compass',
|
||||
group: 'Orientation',
|
||||
blurb: 'Forward stays the current heading.',
|
||||
terms: _common,
|
||||
),
|
||||
ModeInfo(
|
||||
mode: IntelligentMode.homeLock,
|
||||
label: 'Home Lock',
|
||||
icon: 'home',
|
||||
group: 'Orientation',
|
||||
blurb: 'Pulling back always flies home.',
|
||||
terms: _common,
|
||||
),
|
||||
ModeInfo(
|
||||
mode: IntelligentMode.tripod,
|
||||
label: 'Tripod',
|
||||
icon: 'gauge',
|
||||
group: 'Orientation',
|
||||
blurb: 'Slow, precise control for framing.',
|
||||
terms: _common,
|
||||
),
|
||||
];
|
||||
|
||||
static const List<String> groups = <String>['Map', 'Vision', 'Shots', 'Orientation'];
|
||||
|
||||
static ModeInfo of(IntelligentMode m) => all.firstWhere((ModeInfo i) => i.mode == m);
|
||||
|
||||
/// The modes to list for [p]: every mode when no product is connected (a
|
||||
/// preview, like the rest of the HUD), else the product's own.
|
||||
static List<ModeInfo> forProfile(ProductProfile p) =>
|
||||
<ModeInfo>[for (final ModeInfo i in all) if (!p.connected || p.supportsMode(i.mode)) i];
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'waypoint_mission.dart';
|
||||
|
||||
/// The pilot's saved waypoint routes (GO 4's mission library, plan P7).
|
||||
///
|
||||
/// Kept on this device as JSON in shared preferences, in the
|
||||
/// [WaypointMissionSpec.toJson] format the API Server will store once route
|
||||
/// sync lands. Newest first.
|
||||
class MissionLibrary extends ChangeNotifier {
|
||||
MissionLibrary({SharedPreferences? prefs}) : _prefs = prefs;
|
||||
|
||||
static const String _key = 'missions.waypoint';
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
List<WaypointMissionSpec> _missions = <WaypointMissionSpec>[];
|
||||
bool _loaded = false;
|
||||
|
||||
/// The flight screen can close while [load] still waits for storage.
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void notifyListeners() {
|
||||
if (!_disposed) super.notifyListeners();
|
||||
}
|
||||
|
||||
List<WaypointMissionSpec> get missions => List<WaypointMissionSpec>.unmodifiable(_missions);
|
||||
bool get loaded => _loaded;
|
||||
|
||||
Future<void> load() async {
|
||||
try {
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
final String? raw = _prefs!.getString(_key);
|
||||
_missions = raw == null ? <WaypointMissionSpec>[] : decode(raw);
|
||||
} catch (_) {
|
||||
_missions = <WaypointMissionSpec>[];
|
||||
}
|
||||
_loaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Saves [m] (replacing the one with the same id) and returns it as stored:
|
||||
/// with an id and a fresh [WaypointMissionSpec.updatedAt].
|
||||
Future<WaypointMissionSpec> save(WaypointMissionSpec m, {DateTime? now}) async {
|
||||
final WaypointMissionSpec stored = m.copyWith(
|
||||
id: m.id.isEmpty ? newId(now) : m.id,
|
||||
updatedAt: now ?? DateTime.now(),
|
||||
);
|
||||
_missions = <WaypointMissionSpec>[
|
||||
stored,
|
||||
for (final WaypointMissionSpec o in _missions) if (o.id != stored.id) o,
|
||||
];
|
||||
await _persist();
|
||||
return stored;
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
_missions = <WaypointMissionSpec>[for (final WaypointMissionSpec o in _missions) if (o.id != id) o];
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
notifyListeners();
|
||||
try {
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
await _prefs!.setString(_key, encode(_missions));
|
||||
} catch (_) {
|
||||
// Storage unavailable: the library lives for this session only.
|
||||
}
|
||||
}
|
||||
|
||||
static String newId([DateTime? now]) => 'wp-${(now ?? DateTime.now()).microsecondsSinceEpoch.toRadixString(36)}';
|
||||
|
||||
static String encode(List<WaypointMissionSpec> missions) =>
|
||||
jsonEncode(<Map<String, Object?>>[for (final WaypointMissionSpec m in missions) m.toJson()]);
|
||||
|
||||
/// Unreadable entries are skipped rather than losing the whole library.
|
||||
static List<WaypointMissionSpec> decode(String raw) {
|
||||
final Object? list = jsonDecode(raw);
|
||||
if (list is! List) return <WaypointMissionSpec>[];
|
||||
final List<WaypointMissionSpec> out = <WaypointMissionSpec>[];
|
||||
for (final Object? e in list) {
|
||||
if (e is! Map) continue;
|
||||
try {
|
||||
out.add(WaypointMissionSpec.fromJson(Map<String, dynamic>.from(e)));
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import 'waypoint_mission.dart';
|
||||
|
||||
/// What a tap on the map adds.
|
||||
enum MapTool { waypoint, poi }
|
||||
|
||||
/// The point editor's tabs (GO 4's `gs_waypoint_2_point_editor_view`).
|
||||
enum PointField {
|
||||
altitude('Altitude'),
|
||||
speed('Speed'),
|
||||
heading('Heading'),
|
||||
gimbal('Gimbal'),
|
||||
action('Action'),
|
||||
poi('POI'),
|
||||
radius('Turn radius');
|
||||
|
||||
const PointField(this.label);
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// A numeric field's range and step for the swipe readout and −/+.
|
||||
@immutable
|
||||
class FieldRange {
|
||||
const FieldRange(this.min, this.max, this.step, this.unit);
|
||||
|
||||
final double min;
|
||||
final double max;
|
||||
final double step;
|
||||
final String unit;
|
||||
|
||||
double clamp(double v) => ((v / step).round() * step).clamp(min, max).toDouble();
|
||||
}
|
||||
|
||||
/// The Waypoints editor's state (plan P7): the mission being edited, what is
|
||||
/// selected, the map tool, and an undo history. Pure — the map layers and the
|
||||
/// HUD panels both listen to it, and the tests drive it directly.
|
||||
class WaypointEditor extends ChangeNotifier {
|
||||
WaypointEditor({WaypointMissionSpec mission = const WaypointMissionSpec()}) : _mission = mission;
|
||||
|
||||
WaypointMissionSpec _mission;
|
||||
WaypointMissionSpec get mission => _mission;
|
||||
|
||||
/// The selected waypoint, or null.
|
||||
int? selected;
|
||||
|
||||
/// The selected POI's id, or null. A waypoint and a POI are never both selected.
|
||||
String? selectedPoi;
|
||||
|
||||
MapTool tool = MapTool.waypoint;
|
||||
PointField field = PointField.altitude;
|
||||
|
||||
/// Edited since it was last loaded or saved.
|
||||
bool dirty = false;
|
||||
|
||||
final List<WaypointMissionSpec> _undo = <WaypointMissionSpec>[];
|
||||
static const int _undoDepth = 40;
|
||||
bool get canUndo => _undo.isNotEmpty;
|
||||
|
||||
/// Altitude for the first waypoint of a new route.
|
||||
static const double defaultAltitude = 50;
|
||||
|
||||
/// Ranges of the numeric fields (MSDK 4.18 limits, narrowed to what GO 4 offers).
|
||||
static const FieldRange altitudeRange = FieldRange(-200, WaypointMissionSpec.maxAltitudeSdk, 1, 'm');
|
||||
static const FieldRange speedRange = FieldRange(WaypointMissionSpec.minSpeed, WaypointMissionSpec.maxSpeed, 0.5, 'm/s');
|
||||
static const FieldRange headingRange = FieldRange(-180, 180, 1, '°');
|
||||
static const FieldRange radiusRange = FieldRange(WaypointMissionSpec.minTurnRadius, 200, 0.5, 'm');
|
||||
static const FieldRange hoverRange = FieldRange(0, 32, 1, 's');
|
||||
static const FieldRange poiHeightRange = FieldRange(-200, 500, 1, 'm');
|
||||
|
||||
static FieldRange gimbalRange({double min = -90, double max = 30}) => FieldRange(min, max, 1, '°');
|
||||
|
||||
WaypointSpec? get selectedPoint => selected == null || selected! >= _mission.points.length ? null : _mission.points[selected!];
|
||||
MissionPoi? get selectedPoiSpec => _mission.poi(selectedPoi);
|
||||
|
||||
// ── History ───────────────────────────────────────────────────────────────
|
||||
|
||||
void _set(WaypointMissionSpec next, {bool record = true}) {
|
||||
if (record) {
|
||||
_undo.add(_mission);
|
||||
if (_undo.length > _undoDepth) _undo.removeAt(0);
|
||||
}
|
||||
_mission = next;
|
||||
dirty = true;
|
||||
_fixSelection();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void undo() {
|
||||
if (_undo.isEmpty) return;
|
||||
_mission = _undo.removeLast();
|
||||
dirty = true;
|
||||
_fixSelection();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// A drag moves a marker many times; only its start goes into the history.
|
||||
void beginDrag() {
|
||||
_undo.add(_mission);
|
||||
if (_undo.length > _undoDepth) _undo.removeAt(0);
|
||||
}
|
||||
|
||||
void _fixSelection() {
|
||||
if (selected != null && selected! >= _mission.points.length) selected = _mission.points.isEmpty ? null : _mission.points.length - 1;
|
||||
if (selectedPoi != null && _mission.poi(selectedPoi) == null) selectedPoi = null;
|
||||
}
|
||||
|
||||
// ── Whole mission ─────────────────────────────────────────────────────────
|
||||
|
||||
void load(WaypointMissionSpec m) {
|
||||
_mission = m;
|
||||
_undo.clear();
|
||||
selected = m.points.isEmpty ? null : 0;
|
||||
selectedPoi = null;
|
||||
dirty = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void markSaved(WaypointMissionSpec stored) {
|
||||
_mission = stored;
|
||||
dirty = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
if (_mission.points.isEmpty && _mission.pois.isEmpty) return;
|
||||
_set(_mission.copyWith(points: const <WaypointSpec>[], pois: const <MissionPoi>[]));
|
||||
selected = null;
|
||||
selectedPoi = null;
|
||||
}
|
||||
|
||||
void updateRoute(WaypointMissionSpec Function(WaypointMissionSpec m) change) => _set(change(_mission));
|
||||
|
||||
void setTool(MapTool t) {
|
||||
if (tool == t) return;
|
||||
tool = t;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setField(PointField f) {
|
||||
if (field == f) return;
|
||||
field = f;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Map gestures ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A tap on empty map adds what the tool says.
|
||||
void tapMap(LatLng at) => tool == MapTool.poi ? addPoi(at) : addPoint(at);
|
||||
|
||||
// ── Waypoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Appends a waypoint (or inserts it at [index]); it takes the altitude,
|
||||
/// speed and gimbal of the point before it, as GO 4 does, and is selected.
|
||||
void addPoint(LatLng at, {int? index}) {
|
||||
if (_mission.points.length >= WaypointMissionSpec.maxPoints) return;
|
||||
final List<WaypointSpec> pts = List<WaypointSpec>.of(_mission.points);
|
||||
final int i = (index ?? pts.length).clamp(0, pts.length);
|
||||
final WaypointSpec? like = pts.isEmpty ? null : pts[(i - 1).clamp(0, pts.length - 1)];
|
||||
pts.insert(
|
||||
i,
|
||||
WaypointSpec(
|
||||
at: at,
|
||||
altitude: like?.altitude ?? defaultAltitude,
|
||||
speed: like?.speed,
|
||||
gimbalPitch: like?.gimbalPitch,
|
||||
turnRadius: like?.turnRadius ?? WaypointMissionSpec.minTurnRadius,
|
||||
),
|
||||
);
|
||||
_set(_mission.copyWith(points: pts));
|
||||
selected = i;
|
||||
selectedPoi = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Adds a waypoint halfway along leg [leg] (between points leg and leg+1).
|
||||
void insertMidpoint(int leg) {
|
||||
final List<WaypointSpec> pts = _mission.points;
|
||||
if (leg < 0 || leg + 1 >= pts.length) return;
|
||||
final LatLng a = pts[leg].at, b = pts[leg + 1].at;
|
||||
addPoint(LatLng((a.latitude + b.latitude) / 2, (a.longitude + b.longitude) / 2), index: leg + 1);
|
||||
final WaypointSpec mid = _mission.points[leg + 1];
|
||||
_replace(leg + 1, mid.copyWith(altitude: (pts[leg].altitude + pts[leg + 1].altitude) / 2), record: false);
|
||||
}
|
||||
|
||||
void movePoint(int i, LatLng at, {bool record = false}) => _replace(i, _mission.points[i].copyWith(at: at), record: record);
|
||||
|
||||
void removePoint(int i) {
|
||||
if (i < 0 || i >= _mission.points.length) return;
|
||||
final List<WaypointSpec> pts = List<WaypointSpec>.of(_mission.points)..removeAt(i);
|
||||
_set(_mission.copyWith(points: pts));
|
||||
if (pts.isEmpty) {
|
||||
selected = null;
|
||||
} else if (selected != null && selected! >= i) {
|
||||
selected = (selected! - 1).clamp(0, pts.length - 1);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void select(int? i) {
|
||||
selected = i;
|
||||
if (i != null) selectedPoi = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void selectNext(int delta) {
|
||||
if (_mission.points.isEmpty) return;
|
||||
select(((selected ?? 0) + delta).clamp(0, _mission.points.length - 1));
|
||||
}
|
||||
|
||||
/// [record] false for the steps of a swipe that [beginDrag] already saved.
|
||||
void updatePoint(int i, WaypointSpec Function(WaypointSpec p) change, {bool record = true}) =>
|
||||
_replace(i, change(_mission.points[i]), record: record);
|
||||
|
||||
void _replace(int i, WaypointSpec p, {bool record = true}) {
|
||||
if (i < 0 || i >= _mission.points.length) return;
|
||||
final List<WaypointSpec> pts = List<WaypointSpec>.of(_mission.points);
|
||||
pts[i] = p;
|
||||
_set(_mission.copyWith(points: pts), record: record);
|
||||
}
|
||||
|
||||
/// A per-point heading other than Route only works with per-waypoint
|
||||
/// headings, so choosing one switches the route over.
|
||||
void setHeading(int i, WaypointHeading h, {int? custom}) {
|
||||
final WaypointSpec p = _mission.points[i];
|
||||
final List<WaypointSpec> pts = List<WaypointSpec>.of(_mission.points);
|
||||
pts[i] = p.copyWith(heading: h, customHeading: custom ?? p.customHeading);
|
||||
final bool needsPerPoint = h != WaypointHeading.route && _mission.headingMode != RouteHeadingMode.perPoint;
|
||||
_set(_mission.copyWith(points: pts, headingMode: needsPerPoint ? RouteHeadingMode.perPoint : null));
|
||||
}
|
||||
|
||||
/// Copies the selected waypoint's [f] to every waypoint.
|
||||
void applyToAll(PointField f) {
|
||||
final WaypointSpec? src = selectedPoint;
|
||||
if (src == null) return;
|
||||
final List<WaypointSpec> pts = <WaypointSpec>[
|
||||
for (final WaypointSpec p in _mission.points)
|
||||
switch (f) {
|
||||
PointField.altitude => p.copyWith(altitude: src.altitude),
|
||||
PointField.speed => p.copyWith(speed: src.speed),
|
||||
PointField.heading => p.copyWith(heading: src.heading, customHeading: src.customHeading),
|
||||
PointField.gimbal => p.copyWith(gimbalPitch: src.gimbalPitch, gimbalAimPoi: src.gimbalAimPoi),
|
||||
PointField.action => p.copyWith(action: src.action, hoverSeconds: src.hoverSeconds),
|
||||
PointField.poi => p.copyWith(poiId: src.poiId),
|
||||
PointField.radius => p.copyWith(turnRadius: src.turnRadius),
|
||||
},
|
||||
];
|
||||
_set(_mission.copyWith(points: pts));
|
||||
}
|
||||
|
||||
// ── Points of interest ────────────────────────────────────────────────────
|
||||
|
||||
int _poiSeq = 0;
|
||||
|
||||
void addPoi(LatLng at) {
|
||||
String id;
|
||||
do {
|
||||
id = 'poi${++_poiSeq}';
|
||||
} while (_mission.poi(id) != null);
|
||||
_set(_mission.copyWith(pois: <MissionPoi>[..._mission.pois, MissionPoi(id: id, at: at)]));
|
||||
selectedPoi = id;
|
||||
selected = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void movePoi(String id, LatLng at) => _replacePoi(id, (MissionPoi p) => p.copyWith(at: at), record: false);
|
||||
|
||||
void updatePoi(String id, MissionPoi Function(MissionPoi p) change, {bool record = true}) =>
|
||||
_replacePoi(id, change, record: record);
|
||||
|
||||
void _replacePoi(String id, MissionPoi Function(MissionPoi p) change, {bool record = true}) {
|
||||
_set(
|
||||
_mission.copyWith(pois: <MissionPoi>[for (final MissionPoi p in _mission.pois) p.id == id ? change(p) : p]),
|
||||
record: record,
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes the POI and unlinks every waypoint that faced it.
|
||||
void removePoi(String id) {
|
||||
_set(_mission.copyWith(
|
||||
pois: <MissionPoi>[for (final MissionPoi p in _mission.pois) if (p.id != id) p],
|
||||
points: <WaypointSpec>[
|
||||
for (final WaypointSpec w in _mission.points)
|
||||
w.poiId == id ? w.copyWith(poiId: null, gimbalAimPoi: false, heading: w.heading == WaypointHeading.poi ? WaypointHeading.route : null) : w,
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
void selectPoi(String? id) {
|
||||
selectedPoi = id;
|
||||
if (id != null) selected = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Links waypoint [i] to [poiId] (null unlinks). Linking turns the point to
|
||||
/// face it, as GO 4 does; unlinking turns a POI-facing point back to Route.
|
||||
void linkPoi(int i, String? poiId) {
|
||||
final WaypointSpec p = _mission.points[i];
|
||||
if (poiId == null) {
|
||||
_replace(i, p.copyWith(poiId: null, gimbalAimPoi: false, heading: p.heading == WaypointHeading.poi ? WaypointHeading.route : null));
|
||||
return;
|
||||
}
|
||||
final List<WaypointSpec> pts = List<WaypointSpec>.of(_mission.points);
|
||||
pts[i] = p.copyWith(poiId: poiId, heading: WaypointHeading.poi);
|
||||
final bool needsPerPoint = _mission.headingMode != RouteHeadingMode.perPoint;
|
||||
_set(_mission.copyWith(points: pts, headingMode: needsPerPoint ? RouteHeadingMode.perPoint : null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../hud_geometry.dart';
|
||||
|
||||
/// A Waypoints mission as the pilot edits it (plan P7, GO 4's Waypoints 2.0
|
||||
/// editor), and its translation into what MSDK V4's `WaypointMissionOperator`
|
||||
/// takes.
|
||||
///
|
||||
/// The editor works in GO 4's terms — per-point heading Route / Custom / POI,
|
||||
/// "aim the gimbal at the POI", a camera action, a hover — and [toBridge]
|
||||
/// resolves them into the V1 mission: a heading per waypoint and the turn
|
||||
/// direction to it, a gimbal pitch on every waypoint once any is set, and the
|
||||
/// STAY / photo / record actions. MSDK V1 has one mission-wide point of
|
||||
/// interest, so a per-point POI becomes that point's computed heading.
|
||||
///
|
||||
/// Pure and immutable, so the whole translation and [validate] are unit-tested.
|
||||
|
||||
/// What the camera does on arrival at a waypoint.
|
||||
enum WaypointCameraAction {
|
||||
none('None'),
|
||||
photo('Photo'),
|
||||
startRecord('Start recording'),
|
||||
stopRecord('Stop recording');
|
||||
|
||||
const WaypointCameraAction(this.label);
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// Where a waypoint points the aircraft, when the route heading is [RouteHeadingMode.perPoint].
|
||||
enum WaypointHeading {
|
||||
/// Along the route, towards the next waypoint.
|
||||
route('Route'),
|
||||
custom('Custom'),
|
||||
|
||||
/// At the waypoint's linked POI.
|
||||
poi('POI');
|
||||
|
||||
const WaypointHeading(this.label);
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// The mission-wide heading ("All Waypoint Direction").
|
||||
enum RouteHeadingMode {
|
||||
auto('Route', 'AUTO'),
|
||||
initial('Initial direction', 'USING_INITIAL_DIRECTION'),
|
||||
rc('Manual (RC)', 'CONTROL_BY_REMOTE_CONTROLLER'),
|
||||
perPoint('Per waypoint', 'USING_WAYPOINT_HEADING'),
|
||||
poi('Towards POI', 'TOWARD_POINT_OF_INTEREST');
|
||||
|
||||
const RouteHeadingMode(this.label, this.sdk);
|
||||
final String label;
|
||||
final String sdk;
|
||||
}
|
||||
|
||||
/// "Task Finished".
|
||||
enum RouteFinishAction {
|
||||
hover('Hover', 'NO_ACTION'),
|
||||
goHome('Return to Home', 'GO_HOME'),
|
||||
land('Land', 'AUTO_LAND'),
|
||||
firstWaypoint('Back to first waypoint', 'GO_FIRST_WAYPOINT');
|
||||
|
||||
const RouteFinishAction(this.label, this.sdk);
|
||||
final String label;
|
||||
final String sdk;
|
||||
}
|
||||
|
||||
/// "Route type": straight legs that stop at each waypoint, or curves through them.
|
||||
enum RoutePathMode {
|
||||
straight('Straight', 'NORMAL'),
|
||||
curved('Curved', 'CURVED');
|
||||
|
||||
const RoutePathMode(this.label, this.sdk);
|
||||
final String label;
|
||||
final String sdk;
|
||||
}
|
||||
|
||||
/// How the aircraft gets to the first waypoint.
|
||||
enum RouteGotoFirst {
|
||||
/// Climbs to the waypoint's altitude first if it is higher.
|
||||
safely('Climb first', 'SAFELY'),
|
||||
pointToPoint('Straight line', 'POINT_TO_POINT');
|
||||
|
||||
const RouteGotoFirst(this.label, this.sdk);
|
||||
final String label;
|
||||
final String sdk;
|
||||
}
|
||||
|
||||
/// A point of interest waypoints can face, and aim the gimbal at.
|
||||
@immutable
|
||||
class MissionPoi {
|
||||
const MissionPoi({required this.id, required this.at, this.height = 0});
|
||||
|
||||
final String id;
|
||||
final LatLng at;
|
||||
|
||||
/// Height of the subject above the take-off point, for aiming the gimbal.
|
||||
final double height;
|
||||
|
||||
MissionPoi copyWith({LatLng? at, double? height}) => MissionPoi(id: id, at: at ?? this.at, height: height ?? this.height);
|
||||
|
||||
Map<String, Object?> toJson() => <String, Object?>{'id': id, 'lat': at.latitude, 'lon': at.longitude, 'height': height};
|
||||
|
||||
static MissionPoi fromJson(Map<String, dynamic> j) => MissionPoi(
|
||||
id: j['id'] as String,
|
||||
at: LatLng((j['lat'] as num).toDouble(), (j['lon'] as num).toDouble()),
|
||||
height: (j['height'] as num?)?.toDouble() ?? 0,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is MissionPoi && other.id == id && other.at == at && other.height == height;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(id, at, height);
|
||||
}
|
||||
|
||||
/// One waypoint.
|
||||
@immutable
|
||||
class WaypointSpec {
|
||||
const WaypointSpec({
|
||||
required this.at,
|
||||
this.altitude = 50,
|
||||
this.speed,
|
||||
this.heading = WaypointHeading.route,
|
||||
this.customHeading = 0,
|
||||
this.gimbalPitch,
|
||||
this.gimbalAimPoi = false,
|
||||
this.action = WaypointCameraAction.none,
|
||||
this.hoverSeconds = 0,
|
||||
this.poiId,
|
||||
this.turnRadius = 0.2,
|
||||
});
|
||||
|
||||
final LatLng at;
|
||||
|
||||
/// Metres above the take-off point.
|
||||
final double altitude;
|
||||
|
||||
/// m/s from here to the next waypoint; null flies the route's cruise speed.
|
||||
final double? speed;
|
||||
|
||||
final WaypointHeading heading;
|
||||
|
||||
/// −180…180°, for [WaypointHeading.custom].
|
||||
final int customHeading;
|
||||
|
||||
/// Degrees; null keeps whatever the previous waypoint set.
|
||||
final double? gimbalPitch;
|
||||
|
||||
/// Aim the gimbal at the linked POI instead of [gimbalPitch].
|
||||
final bool gimbalAimPoi;
|
||||
|
||||
final WaypointCameraAction action;
|
||||
|
||||
/// Seconds to hover on arrival, before the camera action.
|
||||
final int hoverSeconds;
|
||||
|
||||
/// The linked [MissionPoi.id].
|
||||
final String? poiId;
|
||||
|
||||
/// Metres; only curved routes use it.
|
||||
final double turnRadius;
|
||||
|
||||
static const Object _keep = Object();
|
||||
|
||||
/// Nullable fields take [_keep] to stay unchanged, so `null` can clear them.
|
||||
WaypointSpec copyWith({
|
||||
LatLng? at,
|
||||
double? altitude,
|
||||
Object? speed = _keep,
|
||||
WaypointHeading? heading,
|
||||
int? customHeading,
|
||||
Object? gimbalPitch = _keep,
|
||||
bool? gimbalAimPoi,
|
||||
WaypointCameraAction? action,
|
||||
int? hoverSeconds,
|
||||
Object? poiId = _keep,
|
||||
double? turnRadius,
|
||||
}) =>
|
||||
WaypointSpec(
|
||||
at: at ?? this.at,
|
||||
altitude: altitude ?? this.altitude,
|
||||
// Any num: an int literal (`?? -30`) arrives here as an int.
|
||||
speed: identical(speed, _keep) ? this.speed : (speed as num?)?.toDouble(),
|
||||
heading: heading ?? this.heading,
|
||||
customHeading: customHeading ?? this.customHeading,
|
||||
gimbalPitch: identical(gimbalPitch, _keep) ? this.gimbalPitch : (gimbalPitch as num?)?.toDouble(),
|
||||
gimbalAimPoi: gimbalAimPoi ?? this.gimbalAimPoi,
|
||||
action: action ?? this.action,
|
||||
hoverSeconds: hoverSeconds ?? this.hoverSeconds,
|
||||
poiId: identical(poiId, _keep) ? this.poiId : poiId as String?,
|
||||
turnRadius: turnRadius ?? this.turnRadius,
|
||||
);
|
||||
|
||||
Map<String, Object?> toJson() => <String, Object?>{
|
||||
'lat': at.latitude,
|
||||
'lon': at.longitude,
|
||||
'altitude': altitude,
|
||||
if (speed != null) 'speed': speed,
|
||||
'heading': heading.name,
|
||||
'customHeading': customHeading,
|
||||
if (gimbalPitch != null) 'gimbalPitch': gimbalPitch,
|
||||
if (gimbalAimPoi) 'gimbalAimPoi': true,
|
||||
'action': action.name,
|
||||
if (hoverSeconds > 0) 'hoverSeconds': hoverSeconds,
|
||||
if (poiId != null) 'poi': poiId,
|
||||
'turnRadius': turnRadius,
|
||||
};
|
||||
|
||||
static WaypointSpec fromJson(Map<String, dynamic> j) => WaypointSpec(
|
||||
at: LatLng((j['lat'] as num).toDouble(), (j['lon'] as num).toDouble()),
|
||||
altitude: (j['altitude'] as num?)?.toDouble() ?? 50,
|
||||
speed: (j['speed'] as num?)?.toDouble(),
|
||||
heading: _byName(WaypointHeading.values, j['heading']) ?? WaypointHeading.route,
|
||||
customHeading: (j['customHeading'] as num?)?.toInt() ?? 0,
|
||||
gimbalPitch: (j['gimbalPitch'] as num?)?.toDouble(),
|
||||
gimbalAimPoi: j['gimbalAimPoi'] == true,
|
||||
action: _byName(WaypointCameraAction.values, j['action']) ?? WaypointCameraAction.none,
|
||||
hoverSeconds: (j['hoverSeconds'] as num?)?.toInt() ?? 0,
|
||||
poiId: j['poi'] as String?,
|
||||
turnRadius: (j['turnRadius'] as num?)?.toDouble() ?? 0.2,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is WaypointSpec && mapEquals(other.toJson(), toJson());
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hashAll(toJson().values);
|
||||
}
|
||||
|
||||
enum IssueLevel { warning, error }
|
||||
|
||||
/// Something wrong with a mission, optionally pinned to a waypoint (0-based).
|
||||
@immutable
|
||||
class MissionIssue {
|
||||
const MissionIssue.error(this.message, {this.point}) : level = IssueLevel.error;
|
||||
const MissionIssue.warning(this.message, {this.point}) : level = IssueLevel.warning;
|
||||
|
||||
final IssueLevel level;
|
||||
final String message;
|
||||
final int? point;
|
||||
|
||||
bool get blocks => level == IssueLevel.error;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => other is MissionIssue && other.level == level && other.message == message && other.point == point;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(level, message, point);
|
||||
|
||||
@override
|
||||
String toString() => '${level.name}: $message';
|
||||
}
|
||||
|
||||
/// What [WaypointMissionSpec.validate] checks the route against: the
|
||||
/// aircraft's own limits, when known.
|
||||
@immutable
|
||||
class MissionLimits {
|
||||
const MissionLimits({this.maxAltitude, this.maxRadius, this.home, this.gimbalMin = -90, this.gimbalMax = 30});
|
||||
|
||||
/// The flight controller's altitude limit, m.
|
||||
final double? maxAltitude;
|
||||
|
||||
/// The distance limit, m from home, when enabled.
|
||||
final double? maxRadius;
|
||||
final LatLng? home;
|
||||
final double gimbalMin;
|
||||
final double gimbalMax;
|
||||
}
|
||||
|
||||
class WaypointMissionSpec {
|
||||
const WaypointMissionSpec({
|
||||
this.id = '',
|
||||
this.name = 'New route',
|
||||
this.points = const <WaypointSpec>[],
|
||||
this.pois = const <MissionPoi>[],
|
||||
this.cruiseSpeed = 8,
|
||||
this.headingMode = RouteHeadingMode.auto,
|
||||
this.finishAction = RouteFinishAction.goHome,
|
||||
this.pathMode = RoutePathMode.straight,
|
||||
this.exitOnSignalLost = true,
|
||||
this.gotoFirst = RouteGotoFirst.safely,
|
||||
this.repeatTimes = 1,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
// ── MSDK 4.18 limits (`Waypoint` / `WaypointMission` constants) ────────────
|
||||
static const int maxPoints = 99;
|
||||
static const int minPoints = 2;
|
||||
static const double minAltitude = -200;
|
||||
|
||||
/// The flight controller's ceiling, whatever the SDK constant says (it is FLT_MAX).
|
||||
static const double maxAltitudeSdk = 500;
|
||||
static const double minSpeed = 1;
|
||||
static const double maxSpeed = 15;
|
||||
static const double minTurnRadius = 0.2;
|
||||
static const double maxTurnRadius = 1000;
|
||||
|
||||
/// Adjacent waypoints closer than this are refused.
|
||||
static const double minLeg = 0.5;
|
||||
|
||||
/// …and further apart than this.
|
||||
static const double maxLeg = 2000;
|
||||
|
||||
/// `STAY`'s parameter is milliseconds up to 32767.
|
||||
static const int maxHoverSeconds = 32;
|
||||
|
||||
/// Below this the pre-start check warns about trees and buildings.
|
||||
static const double lowAltitude = 10;
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final List<WaypointSpec> points;
|
||||
final List<MissionPoi> pois;
|
||||
final double cruiseSpeed;
|
||||
final RouteHeadingMode headingMode;
|
||||
final RouteFinishAction finishAction;
|
||||
final RoutePathMode pathMode;
|
||||
|
||||
/// True: on RC signal loss the mission ends and the aircraft does its
|
||||
/// signal-lost action. False: it finishes the route.
|
||||
final bool exitOnSignalLost;
|
||||
final RouteGotoFirst gotoFirst;
|
||||
final int repeatTimes;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
bool get isEmpty => points.isEmpty;
|
||||
|
||||
MissionPoi? poi(String? id) {
|
||||
if (id == null) return null;
|
||||
for (final MissionPoi p in pois) {
|
||||
if (p.id == id) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
WaypointMissionSpec copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
List<WaypointSpec>? points,
|
||||
List<MissionPoi>? pois,
|
||||
double? cruiseSpeed,
|
||||
RouteHeadingMode? headingMode,
|
||||
RouteFinishAction? finishAction,
|
||||
RoutePathMode? pathMode,
|
||||
bool? exitOnSignalLost,
|
||||
RouteGotoFirst? gotoFirst,
|
||||
int? repeatTimes,
|
||||
DateTime? updatedAt,
|
||||
}) =>
|
||||
WaypointMissionSpec(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
points: points ?? this.points,
|
||||
pois: pois ?? this.pois,
|
||||
cruiseSpeed: cruiseSpeed ?? this.cruiseSpeed,
|
||||
headingMode: headingMode ?? this.headingMode,
|
||||
finishAction: finishAction ?? this.finishAction,
|
||||
pathMode: pathMode ?? this.pathMode,
|
||||
exitOnSignalLost: exitOnSignalLost ?? this.exitOnSignalLost,
|
||||
gotoFirst: gotoFirst ?? this.gotoFirst,
|
||||
repeatTimes: repeatTimes ?? this.repeatTimes,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
|
||||
// ── Geometry and estimates ────────────────────────────────────────────────
|
||||
|
||||
/// 3-D length of each leg, m: leg i runs from point i to point i+1.
|
||||
List<double> get legs => <double>[
|
||||
for (int i = 0; i + 1 < points.length; i++) _dist3(points[i], points[i + 1]),
|
||||
];
|
||||
|
||||
static double _dist2(LatLng a, LatLng b) => HudGeometry.distance(a.latitude, a.longitude, b.latitude, b.longitude);
|
||||
|
||||
static double _dist3(WaypointSpec a, WaypointSpec b) {
|
||||
final double h = _dist2(a.at, b.at), v = b.altitude - a.altitude;
|
||||
return math.sqrt(h * h + v * v);
|
||||
}
|
||||
|
||||
/// The route's length, m, once through.
|
||||
double get length => legs.fold(0.0, (double s, double l) => s + l);
|
||||
|
||||
double speedOf(int i) => (points[i].speed ?? cruiseSpeed).clamp(minSpeed, maxSpeed);
|
||||
|
||||
/// A rough flight time for the route, s: each leg at its speed, the hovers,
|
||||
/// ~2 s per photo, and ~3 s to brake and accelerate at every straight-route stop.
|
||||
int get estimatedSeconds {
|
||||
if (points.length < 2) return 0;
|
||||
double t = 0;
|
||||
final List<double> l = legs;
|
||||
for (int i = 0; i < l.length; i++) {
|
||||
t += l[i] / speedOf(i);
|
||||
}
|
||||
for (final WaypointSpec p in points) {
|
||||
t += p.hoverSeconds;
|
||||
if (p.action == WaypointCameraAction.photo) t += 2;
|
||||
}
|
||||
if (pathMode == RoutePathMode.straight) t += 3.0 * (points.length - 2).clamp(0, maxPoints);
|
||||
return (t * repeatTimes.clamp(1, 255)).round();
|
||||
}
|
||||
|
||||
// ── Resolution into the V1 mission ────────────────────────────────────────
|
||||
|
||||
static int _signed(double bearing) {
|
||||
final int b = bearing.round() % 360;
|
||||
return b > 180 ? b - 360 : b;
|
||||
}
|
||||
|
||||
static double _bearing(LatLng a, LatLng b) => HudGeometry.bearing(a.latitude, a.longitude, b.latitude, b.longitude);
|
||||
|
||||
/// The heading each waypoint flies at, −180…180, when headings are per waypoint.
|
||||
List<int> resolvedHeadings() {
|
||||
final List<int> out = <int>[];
|
||||
for (int i = 0; i < points.length; i++) {
|
||||
final WaypointSpec p = points[i];
|
||||
final MissionPoi? poiAt = poi(p.poiId);
|
||||
switch (p.heading) {
|
||||
case WaypointHeading.custom:
|
||||
out.add(p.customHeading.clamp(-180, 180));
|
||||
case WaypointHeading.poi when poiAt != null:
|
||||
out.add(_signed(_bearing(p.at, poiAt.at)));
|
||||
case WaypointHeading.poi:
|
||||
case WaypointHeading.route:
|
||||
if (points.length < 2) {
|
||||
out.add(0);
|
||||
} else if (i + 1 < points.length) {
|
||||
out.add(_signed(_bearing(p.at, points[i + 1].at)));
|
||||
} else {
|
||||
out.add(_signed(_bearing(points[i - 1].at, p.at)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The gimbal pitch at every waypoint, or null when no waypoint sets one
|
||||
/// (the pilot keeps the gimbal). A waypoint without its own pitch keeps the
|
||||
/// previous one's; leading ones take the first set value.
|
||||
List<double>? resolvedGimbal({double min = -90, double max = 30}) {
|
||||
final List<double?> own = <double?>[
|
||||
for (final WaypointSpec p in points) _ownPitch(p),
|
||||
];
|
||||
final double? first = own.firstWhere((double? v) => v != null, orElse: () => null);
|
||||
if (first == null) return null;
|
||||
double last = first;
|
||||
return <double>[
|
||||
for (final double? v in own) (last = v ?? last).clamp(min, max).toDouble(),
|
||||
];
|
||||
}
|
||||
|
||||
double? _ownPitch(WaypointSpec p) {
|
||||
final MissionPoi? target = poi(p.poiId);
|
||||
if (p.gimbalAimPoi && target != null) {
|
||||
final double d = _dist2(p.at, target.at);
|
||||
final double drop = p.altitude - target.height;
|
||||
return d < 0.5 ? -90 : -math.atan2(drop, d) * 180 / math.pi;
|
||||
}
|
||||
return p.gimbalPitch;
|
||||
}
|
||||
|
||||
/// The `uploadWaypointMission` payload for `MissionBridge.kt`: SDK enum
|
||||
/// names and resolved per-waypoint values.
|
||||
Map<String, Object?> toBridge({double gimbalMin = -90, double gimbalMax = 30}) {
|
||||
final bool perPoint = headingMode == RouteHeadingMode.perPoint;
|
||||
final List<int> headings = resolvedHeadings();
|
||||
final List<double>? pitch = resolvedGimbal(min: gimbalMin, max: gimbalMax);
|
||||
final bool curved = pathMode == RoutePathMode.curved;
|
||||
|
||||
final List<Map<String, Object?>> wps = <Map<String, Object?>>[];
|
||||
int? previous;
|
||||
for (int i = 0; i < points.length; i++) {
|
||||
final WaypointSpec p = points[i];
|
||||
final int h = headings[i];
|
||||
// Turn the short way round from the previous waypoint's heading.
|
||||
String turn = 'CLOCKWISE';
|
||||
if (perPoint && previous != null) {
|
||||
final int delta = ((h - previous) % 360 + 360) % 360;
|
||||
if (delta > 180) turn = 'COUNTER_CLOCKWISE';
|
||||
}
|
||||
previous = h;
|
||||
wps.add(<String, Object?>{
|
||||
'lat': p.at.latitude,
|
||||
'lon': p.at.longitude,
|
||||
'altitude': p.altitude,
|
||||
if (perPoint) 'heading': h,
|
||||
'turnMode': turn,
|
||||
// 0 = the mission's auto flight speed.
|
||||
'speed': p.speed == null ? 0.0 : p.speed!.clamp(minSpeed, maxSpeed),
|
||||
'cornerRadius': curved ? p.turnRadius.clamp(minTurnRadius, maxTurnRadius) : minTurnRadius,
|
||||
if (pitch != null) 'gimbalPitch': pitch[i],
|
||||
'actions': <Map<String, Object?>>[
|
||||
if (p.hoverSeconds > 0) <String, Object?>{'type': 'STAY', 'param': (p.hoverSeconds.clamp(0, maxHoverSeconds) * 1000).clamp(0, 32767)},
|
||||
if (p.action == WaypointCameraAction.photo) const <String, Object?>{'type': 'START_TAKE_PHOTO', 'param': 0},
|
||||
if (p.action == WaypointCameraAction.startRecord) const <String, Object?>{'type': 'START_RECORD', 'param': 0},
|
||||
if (p.action == WaypointCameraAction.stopRecord) const <String, Object?>{'type': 'STOP_RECORD', 'param': 0},
|
||||
],
|
||||
});
|
||||
}
|
||||
final MissionPoi? missionPoi = pois.isEmpty ? null : pois.first;
|
||||
return <String, Object?>{
|
||||
'waypoints': wps,
|
||||
'autoFlightSpeed': cruiseSpeed.clamp(minSpeed, maxSpeed),
|
||||
'maxFlightSpeed': maxSpeed,
|
||||
'finishedAction': finishAction.sdk,
|
||||
'headingMode': headingMode.sdk,
|
||||
'flightPathMode': pathMode.sdk,
|
||||
'gotoFirstWaypointMode': gotoFirst.sdk,
|
||||
'exitMissionOnRCSignalLost': exitOnSignalLost,
|
||||
'gimbalPitchRotation': pitch != null,
|
||||
'repeatTimes': repeatTimes.clamp(1, 255),
|
||||
if (headingMode == RouteHeadingMode.poi && missionPoi != null)
|
||||
'pointOfInterest': <String, Object?>{'lat': missionPoi.at.latitude, 'lon': missionPoi.at.longitude},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Everything that would make the SDK refuse the mission (errors) or that
|
||||
/// the pilot should know before flying it (warnings).
|
||||
List<MissionIssue> validate([MissionLimits limits = const MissionLimits()]) {
|
||||
final List<MissionIssue> out = <MissionIssue>[];
|
||||
if (points.length < minPoints) out.add(const MissionIssue.error('Add at least 2 waypoints.'));
|
||||
if (points.length > maxPoints) out.add(const MissionIssue.error('A route can have at most $maxPoints waypoints.'));
|
||||
|
||||
final double ceiling = math.min(maxAltitudeSdk, limits.maxAltitude ?? maxAltitudeSdk);
|
||||
for (int i = 0; i < points.length; i++) {
|
||||
final WaypointSpec p = points[i];
|
||||
final String n = 'Waypoint ${i + 1}';
|
||||
if (p.altitude > ceiling) {
|
||||
out.add(MissionIssue.error('$n is at ${p.altitude.round()} m, above the ${ceiling.round()} m altitude limit.', point: i));
|
||||
} else if (p.altitude < minAltitude) {
|
||||
out.add(MissionIssue.error('$n is too far below the take-off point.', point: i));
|
||||
} else if (p.altitude < lowAltitude) {
|
||||
out.add(MissionIssue.warning('$n is only ${p.altitude.round()} m above the take-off point.', point: i));
|
||||
}
|
||||
if (limits.maxRadius != null && limits.home != null && _dist2(limits.home!, p.at) > limits.maxRadius!) {
|
||||
out.add(MissionIssue.error('$n is beyond the ${limits.maxRadius!.round()} m distance limit.', point: i));
|
||||
}
|
||||
if (p.heading == WaypointHeading.poi && poi(p.poiId) == null && headingMode == RouteHeadingMode.perPoint) {
|
||||
out.add(MissionIssue.warning('$n faces a POI but none is linked: it flies along the route.', point: i));
|
||||
}
|
||||
}
|
||||
|
||||
final List<double> l = legs;
|
||||
for (int i = 0; i < l.length; i++) {
|
||||
final double flat = _dist2(points[i].at, points[i + 1].at);
|
||||
if (flat < minLeg && (points[i].altitude - points[i + 1].altitude).abs() < minLeg) {
|
||||
out.add(MissionIssue.error('Waypoints ${i + 1} and ${i + 2} are on top of each other.', point: i + 1));
|
||||
} else if (flat > maxLeg) {
|
||||
out.add(MissionIssue.error('Waypoints ${i + 1} and ${i + 2} are more than 2 km apart.', point: i + 1));
|
||||
}
|
||||
}
|
||||
|
||||
if (pathMode == RoutePathMode.curved) {
|
||||
for (int i = 0; i < l.length; i++) {
|
||||
final double sum = points[i].turnRadius + points[i + 1].turnRadius;
|
||||
if (sum >= _dist2(points[i].at, points[i + 1].at)) {
|
||||
out.add(MissionIssue.error('The turn radii of waypoints ${i + 1} and ${i + 2} overlap: make them smaller.', point: i + 1));
|
||||
}
|
||||
}
|
||||
if (points.any((WaypointSpec p) => p.action != WaypointCameraAction.none || p.hoverSeconds > 0)) {
|
||||
out.add(const MissionIssue.warning('A curved route flies through its waypoints without stopping: their actions and hovers are skipped.'));
|
||||
}
|
||||
}
|
||||
|
||||
if (headingMode == RouteHeadingMode.poi && pois.isEmpty) {
|
||||
out.add(const MissionIssue.error('"Towards POI" needs a point of interest: add one on the map.'));
|
||||
}
|
||||
if (cruiseSpeed < minSpeed || cruiseSpeed > maxSpeed) {
|
||||
out.add(const MissionIssue.error('Cruise speed must be between 1 and 15 m/s.'));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Storage (mission library, later the API Server) ───────────────────────
|
||||
|
||||
static const int formatVersion = 1;
|
||||
|
||||
Map<String, Object?> toJson() => <String, Object?>{
|
||||
'version': formatVersion,
|
||||
'id': id,
|
||||
'name': name,
|
||||
'points': <Map<String, Object?>>[for (final WaypointSpec p in points) p.toJson()],
|
||||
'pois': <Map<String, Object?>>[for (final MissionPoi p in pois) p.toJson()],
|
||||
'cruiseSpeed': cruiseSpeed,
|
||||
'headingMode': headingMode.name,
|
||||
'finishAction': finishAction.name,
|
||||
'pathMode': pathMode.name,
|
||||
'exitOnSignalLost': exitOnSignalLost,
|
||||
'gotoFirst': gotoFirst.name,
|
||||
'repeatTimes': repeatTimes,
|
||||
if (updatedAt != null) 'updatedAt': updatedAt!.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
static WaypointMissionSpec fromJson(Map<String, dynamic> j) => WaypointMissionSpec(
|
||||
id: j['id'] as String? ?? '',
|
||||
name: j['name'] as String? ?? 'Route',
|
||||
points: <WaypointSpec>[
|
||||
for (final Object? p in (j['points'] as List<Object?>? ?? const <Object?>[]))
|
||||
if (p is Map) WaypointSpec.fromJson(Map<String, dynamic>.from(p)),
|
||||
],
|
||||
pois: <MissionPoi>[
|
||||
for (final Object? p in (j['pois'] as List<Object?>? ?? const <Object?>[]))
|
||||
if (p is Map) MissionPoi.fromJson(Map<String, dynamic>.from(p)),
|
||||
],
|
||||
cruiseSpeed: (j['cruiseSpeed'] as num?)?.toDouble() ?? 8,
|
||||
headingMode: _byName(RouteHeadingMode.values, j['headingMode']) ?? RouteHeadingMode.auto,
|
||||
finishAction: _byName(RouteFinishAction.values, j['finishAction']) ?? RouteFinishAction.goHome,
|
||||
pathMode: _byName(RoutePathMode.values, j['pathMode']) ?? RoutePathMode.straight,
|
||||
exitOnSignalLost: j['exitOnSignalLost'] as bool? ?? true,
|
||||
gotoFirst: _byName(RouteGotoFirst.values, j['gotoFirst']) ?? RouteGotoFirst.safely,
|
||||
repeatTimes: (j['repeatTimes'] as num?)?.toInt() ?? 1,
|
||||
updatedAt: DateTime.tryParse(j['updatedAt'] as String? ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
T? _byName<T extends Enum>(List<T> values, Object? name) {
|
||||
for (final T v in values) {
|
||||
if (v.name == name) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../dji_service.dart';
|
||||
import '../flight_model.dart';
|
||||
@@ -6,8 +7,10 @@ import 'action_rail_state.dart';
|
||||
import 'calibration.dart';
|
||||
import 'flight_status.dart';
|
||||
import 'hud_geometry.dart';
|
||||
import 'modes/waypoint_mission.dart';
|
||||
import 'state/aircraft_state.dart';
|
||||
import 'state/alert_state.dart';
|
||||
import 'state/mission_state.dart';
|
||||
|
||||
export 'action_rail_state.dart' show FlightCommand;
|
||||
|
||||
@@ -76,6 +79,16 @@ class MaintenanceRefused implements Exception {
|
||||
String toString() => checks.where((GateCheck c) => c.blocks).map((GateCheck c) => c.message).join(' ');
|
||||
}
|
||||
|
||||
/// Thrown by the mission methods of [SafetyGate] when a mission may not start or go on.
|
||||
class MissionRefused implements Exception {
|
||||
const MissionRefused(this.checks);
|
||||
|
||||
final List<GateCheck> checks;
|
||||
|
||||
@override
|
||||
String toString() => checks.where((GateCheck c) => c.blocks).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,
|
||||
@@ -262,6 +275,148 @@ class SafetyGate {
|
||||
await start();
|
||||
}
|
||||
|
||||
// ── Intelligent flight modes (plan P7) ──────────────────────────────────────
|
||||
|
||||
/// Beyond this the pre-start check points out the trip to waypoint 1.
|
||||
static const double farFirstWaypoint = 500;
|
||||
|
||||
/// The aircraft's own limits for [WaypointMissionSpec.validate], read from
|
||||
/// the flight controller (they are not in the telemetry tick).
|
||||
Future<MissionLimits> missionLimits() async {
|
||||
Future<Object?> read(String key) async {
|
||||
if (!model.connected) return null;
|
||||
try {
|
||||
return await 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'),
|
||||
]);
|
||||
final double? lat = _a.homeLatitude, lon = _a.homeLongitude;
|
||||
final bool radiusOn = v[2] == true;
|
||||
return MissionLimits(
|
||||
maxAltitude: (v[0] as num?)?.toDouble(),
|
||||
maxRadius: radiusOn ? (v[1] as num?)?.toDouble() : null,
|
||||
home: lat == null || lon == null || _a.homeLocationSet == false ? null : LatLng(lat, lon),
|
||||
gimbalMin: model.profile.gimbal?.pitch?.min ?? -90,
|
||||
gimbalMax: model.profile.gimbal?.pitch?.max ?? 30,
|
||||
);
|
||||
}
|
||||
|
||||
/// Preconditions for flying [spec]: the aircraft's state, the operator's,
|
||||
/// and the route's own [WaypointMissionSpec.validate] issues.
|
||||
List<GateCheck> checkWaypointStart(WaypointMissionSpec spec, [MissionLimits limits = const MissionLimits()]) {
|
||||
final List<GateCheck> checks = <GateCheck>[];
|
||||
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
|
||||
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
|
||||
|
||||
final WaypointProgress op = model.mission.waypoint;
|
||||
if (op.unsupported) {
|
||||
checks.add(const GateCheck.block('This aircraft cannot fly waypoint missions.'));
|
||||
} else if (op.active) {
|
||||
checks.add(const GateCheck.block('A waypoint mission is already running.'));
|
||||
} else if (op.uploading) {
|
||||
checks.add(const GateCheck.block('A mission is still uploading.'));
|
||||
} else if (op.state == 'DISCONNECTED' || op.state == 'RECOVERING') {
|
||||
checks.add(const GateCheck.block('The mission system is not ready yet. Wait a moment.'));
|
||||
}
|
||||
if (model.mission.tracking) checks.add(const GateCheck.block('Stop ActiveTrack first.'));
|
||||
|
||||
for (final MissionIssue i in spec.validate(limits)) {
|
||||
checks.add(i.blocks ? GateCheck.block(i.message) : GateCheck.warn(i.message));
|
||||
}
|
||||
|
||||
final String mode = _a.flightModeEnum ?? '';
|
||||
if (_a.goingHome == true || mode == 'GO_HOME') checks.add(const GateCheck.block('Cancel Return to Home first.'));
|
||||
if (mode == 'AUTO_LANDING' || mode == 'CONFIRM_LANDING') checks.add(const GateCheck.block('The aircraft is landing.'));
|
||||
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) {
|
||||
checks.add(const GateCheck.block('GPS too weak for a waypoint mission.'));
|
||||
}
|
||||
if (_a.homeLocationSet != true) checks.add(const GateCheck.block('No home point recorded yet.'));
|
||||
|
||||
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%)'}.'));
|
||||
} else if (_a.lowBattery == true || (percent != null && percent <= low)) {
|
||||
checks.add(GateCheck.warn('Battery low${percent == null ? '' : ' ($percent%)'}: the route may not finish.'));
|
||||
}
|
||||
final int? left = _a.goHome?.remainingFlightTime;
|
||||
final int needed = spec.estimatedSeconds + (_a.goHome?.timeNeededToGoHome ?? 0);
|
||||
if (left != null && left > 0 && spec.points.length >= 2 && needed > left) {
|
||||
checks.add(GateCheck.warn('The route takes about ${formatDuration(spec.estimatedSeconds)}; '
|
||||
'the battery has about ${formatDuration(left)} of flight left.'));
|
||||
}
|
||||
|
||||
if (_a.isFlying != true) {
|
||||
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.'));
|
||||
for (final DiagnosticAlert d in model.alerts.all.where((DiagnosticAlert d) => d.severity == AlertSeverity.critical).take(2)) {
|
||||
checks.add(GateCheck.block(d.reason));
|
||||
}
|
||||
checks.add(const GateCheck.warn('The aircraft takes off and flies to waypoint 1 on its own.'));
|
||||
}
|
||||
if (_a.compassHasError == true) checks.add(const GateCheck.block('Compass error. Calibrate the compass or move away from metal.'));
|
||||
|
||||
final double? lat = _a.latitude, lon = _a.longitude;
|
||||
if (spec.points.isNotEmpty && lat != null && lon != null && !(lat == 0 && lon == 0)) {
|
||||
final LatLng first = spec.points.first.at;
|
||||
final double d = HudGeometry.distance(lat, lon, first.latitude, first.longitude);
|
||||
if (d > farFirstWaypoint) checks.add(GateCheck.warn('Waypoint 1 is ${(d / 1000).toStringAsFixed(1)} km from the aircraft.'));
|
||||
}
|
||||
if (!model.profile.hasObstacleSensing && spec.points.any((WaypointSpec p) => p.altitude < lowRthHeight)) {
|
||||
checks.add(const GateCheck.warn('Parts of the route are low and this aircraft cannot see obstacles.'));
|
||||
}
|
||||
final bool shoots = spec.points.any((WaypointSpec p) => p.action != WaypointCameraAction.none);
|
||||
if (shoots && model.camera.storage != null && !model.camera.storage!.usable) {
|
||||
checks.add(GateCheck.warn("${model.camera.storage!.status}: the route's photos and video will not be saved."));
|
||||
}
|
||||
return checks;
|
||||
}
|
||||
|
||||
/// Re-checks [spec], writes [rthHeight] when it changed, uploads the
|
||||
/// mission and starts it. [onUploaded] runs between the two.
|
||||
Future<void> startWaypointMission(
|
||||
WaypointMissionSpec spec, {
|
||||
MissionLimits limits = const MissionLimits(),
|
||||
int? rthHeight,
|
||||
void Function()? onUploaded,
|
||||
}) async {
|
||||
final List<GateCheck> checks = checkWaypointStart(spec, limits);
|
||||
if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks);
|
||||
if (rthHeight != null) {
|
||||
final int h = rthHeight.clamp(minRthHeight, maxRthHeight);
|
||||
if (h != _a.goHomeHeight?.round()) await dji.setGoHomeHeight(h);
|
||||
}
|
||||
await dji.uploadWaypointMission(spec.toBridge(gimbalMin: limits.gimbalMin, gimbalMax: limits.gimbalMax));
|
||||
onUploaded?.call();
|
||||
await dji.startWaypointMission();
|
||||
}
|
||||
|
||||
/// Holds the aircraft where it is; the mission can resume from there.
|
||||
Future<void> pauseWaypointMission() async {
|
||||
if (!model.mission.waypoint.executing) {
|
||||
throw const MissionRefused(<GateCheck>[GateCheck.block('No waypoint mission is flying.')]);
|
||||
}
|
||||
await dji.pauseWaypointMission();
|
||||
}
|
||||
|
||||
Future<void> resumeWaypointMission() async {
|
||||
final List<GateCheck> checks = <GateCheck>[
|
||||
if (!model.mission.waypoint.paused) const GateCheck.block('The mission is not paused.'),
|
||||
if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) const GateCheck.block('GPS too weak to carry on with the route.'),
|
||||
if (_a.goingHome == true) const GateCheck.block('The aircraft is returning to home.'),
|
||||
];
|
||||
if (checks.isNotEmpty) throw MissionRefused(checks);
|
||||
await dji.resumeWaypointMission();
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -2,23 +2,155 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'parse.dart';
|
||||
|
||||
/// The waypoint operator as `MissionBridge.kt` last reported it (plan P7).
|
||||
@immutable
|
||||
class WaypointProgress {
|
||||
const WaypointProgress({
|
||||
this.state = 'UNKNOWN',
|
||||
this.uploaded,
|
||||
this.total,
|
||||
this.target,
|
||||
this.reached = false,
|
||||
this.executeState,
|
||||
});
|
||||
|
||||
factory WaypointProgress.fromMap(Map<String, dynamic> e, WaypointProgress previous) {
|
||||
final Map<String, dynamic>? up = asMap(e['upload']);
|
||||
final Map<String, dynamic>? ex = asMap(e['execution']);
|
||||
return WaypointProgress(
|
||||
state: asString(e['state']) ?? previous.state,
|
||||
uploaded: up == null ? previous.uploaded : asInt(up['index']),
|
||||
total: asInt(ex?['total']) ?? asInt(up?['total']) ?? previous.total,
|
||||
target: ex == null ? previous.target : asInt(ex['target']),
|
||||
reached: ex == null ? previous.reached : asBool(ex['reached']) ?? false,
|
||||
executeState: ex == null ? previous.executeState : asString(ex['executeState']),
|
||||
);
|
||||
}
|
||||
|
||||
/// MSDK `WaypointMissionState`: UNKNOWN, DISCONNECTED, NOT_SUPPORTED,
|
||||
/// RECOVERING, READY_TO_UPLOAD, UPLOADING, READY_TO_EXECUTE, EXECUTING,
|
||||
/// EXECUTION_PAUSED.
|
||||
final String state;
|
||||
|
||||
/// Index of the last waypoint uploaded, and the mission's waypoint count.
|
||||
final int? uploaded;
|
||||
final int? total;
|
||||
|
||||
/// The waypoint being flown to, 0-based.
|
||||
final int? target;
|
||||
final bool reached;
|
||||
|
||||
/// MSDK `WaypointMissionExecuteState` (MOVING, DOING_ACTION, …).
|
||||
final String? executeState;
|
||||
|
||||
bool get uploading => state == 'UPLOADING';
|
||||
bool get readyToExecute => state == 'READY_TO_EXECUTE';
|
||||
bool get executing => state == 'EXECUTING';
|
||||
bool get paused => state == 'EXECUTION_PAUSED';
|
||||
|
||||
/// Flying the mission, paused or not.
|
||||
bool get active => executing || paused;
|
||||
|
||||
/// The operator can take a new mission.
|
||||
bool get available => state == 'READY_TO_UPLOAD' || state == 'READY_TO_EXECUTE';
|
||||
bool get unsupported => state == 'NOT_SUPPORTED';
|
||||
|
||||
/// 0…1 through the upload, or null outside it.
|
||||
double? get uploadFraction {
|
||||
if (!uploading || total == null || total == 0) return null;
|
||||
return (((uploaded ?? -1) + 1) / total!).clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
String get executeLabel => switch (executeState) {
|
||||
'INITIALIZING' => 'Starting',
|
||||
'MOVING' || 'CURVE_MODE_MOVING' => 'Flying',
|
||||
'CURVE_MODE_TURNING' => 'Turning',
|
||||
'BEGIN_ACTION' || 'DOING_ACTION' => 'Action',
|
||||
'FINISHED_ACTION' => 'Action done',
|
||||
'RETURN_TO_FIRST_WAYPOINT' => 'Back to start',
|
||||
'PAUSED' => 'Paused',
|
||||
_ => paused ? 'Paused' : 'Flying',
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is WaypointProgress &&
|
||||
other.state == state &&
|
||||
other.uploaded == uploaded &&
|
||||
other.total == total &&
|
||||
other.target == target &&
|
||||
other.reached == reached &&
|
||||
other.executeState == executeState;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(state, uploaded, total, target, reached, executeState);
|
||||
}
|
||||
|
||||
/// Waypoint / ActiveTrack / QuickShot execution, from the bridge's `mission` events.
|
||||
///
|
||||
/// Waypoint events carry `operator: 'waypoint'` and the operator's own state
|
||||
/// ([waypoint]); the older ActiveTrack / QuickShot events carry a bare `state`.
|
||||
/// [state] is the summary the rail and the gate read.
|
||||
class MissionState extends ChangeNotifier {
|
||||
String state = 'idle'; // idle / ready / uploading / executing / tracking / quickshot …
|
||||
WaypointProgress waypoint = const WaypointProgress();
|
||||
|
||||
/// How the last waypoint mission ended: null while none has, `''` when it
|
||||
/// finished cleanly, else the SDK's error.
|
||||
String? waypointResult;
|
||||
|
||||
/// Bumped on every finish, so listeners can tell a new result from an old one.
|
||||
int waypointFinishes = 0;
|
||||
|
||||
String _legacy = 'idle';
|
||||
String? error;
|
||||
|
||||
/// idle / ready / uploading / executing / tracking / quickshot.
|
||||
String get state {
|
||||
if (waypoint.active) return 'executing';
|
||||
if (_legacy != 'idle') return _legacy;
|
||||
if (waypoint.uploading) return 'uploading';
|
||||
if (waypoint.readyToExecute) return 'ready';
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
set state(String v) {
|
||||
_legacy = v;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool get running => state != 'idle';
|
||||
bool get tracking => state == 'tracking' || state == 'quickshot';
|
||||
|
||||
void apply(Map<String, dynamic> e) {
|
||||
state = asString(e['state']) ?? 'idle';
|
||||
if (asString(e['operator']) == 'waypoint') {
|
||||
_applyWaypoint(e);
|
||||
return;
|
||||
}
|
||||
_legacy = asString(e['state']) ?? 'idle';
|
||||
error = asString(e['error']);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _applyWaypoint(Map<String, dynamic> e) {
|
||||
final WaypointProgress next = WaypointProgress.fromMap(e, waypoint);
|
||||
final String? event = asString(e['event']);
|
||||
final String? err = asString(e['error']);
|
||||
if (event == 'finish') {
|
||||
waypointResult = err ?? '';
|
||||
waypointFinishes++;
|
||||
} else if (event == 'start') {
|
||||
waypointResult = null;
|
||||
}
|
||||
if (err != null) error = err;
|
||||
if (next == waypoint && event == null && err == null) return;
|
||||
waypoint = next;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
state = 'idle';
|
||||
_legacy = 'idle';
|
||||
error = null;
|
||||
waypoint = const WaypointProgress();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -37,13 +37,28 @@ class MinimapController extends ChangeNotifier {
|
||||
/// The map layer: OpenStreetMap tiles (like `map_page.dart`), the flown trail,
|
||||
/// home, pilot and the aircraft. Used both as the small picture-in-picture and,
|
||||
/// after the swap, full-bleed behind the HUD — where it is also interactive.
|
||||
///
|
||||
/// An intelligent mode adds its own [layers] (plan P7: the waypoint route)
|
||||
/// between the trail and the aircraft, and takes map taps through [onTap].
|
||||
class FlyMap extends StatefulWidget {
|
||||
const FlyMap({super.key, required this.model, required this.trail, required this.controller, required this.interactive});
|
||||
const FlyMap({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.trail,
|
||||
required this.controller,
|
||||
required this.interactive,
|
||||
this.layers = const <Widget>[],
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final FlightModel model;
|
||||
final FlightTrail trail;
|
||||
final MinimapController controller;
|
||||
final bool interactive;
|
||||
final List<Widget> layers;
|
||||
|
||||
/// A tap on the map itself (not on a marker), while [interactive].
|
||||
final ValueChanged<LatLng>? onTap;
|
||||
|
||||
@override
|
||||
State<FlyMap> createState() => _FlyMapState();
|
||||
@@ -121,6 +136,7 @@ class _FlyMapState extends State<FlyMap> {
|
||||
onPositionChanged: (MapCamera _, bool hasGesture) {
|
||||
if (hasGesture) widget.controller.setFollow(false);
|
||||
},
|
||||
onTap: widget.interactive && widget.onTap != null ? (TapPosition _, LatLng at) => widget.onTap!(at) : null,
|
||||
),
|
||||
children: <Widget>[
|
||||
TileLayer(
|
||||
@@ -137,6 +153,7 @@ class _FlyMapState extends State<FlyMap> {
|
||||
]);
|
||||
},
|
||||
),
|
||||
...widget.layers,
|
||||
ListenableBuilder(
|
||||
listenable: _a,
|
||||
builder: (BuildContext context, _) => MarkerLayer(markers: <Marker>[
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../../dji_service.dart';
|
||||
import '../../flight_model.dart';
|
||||
import '../../fly/flight_trail.dart';
|
||||
import '../../fly/fly_prefs.dart';
|
||||
import '../../fly/hud_features.dart';
|
||||
import '../../fly/modes/mission_library.dart';
|
||||
import '../../fly/modes/waypoint_editor.dart';
|
||||
import '../../fly/modes/waypoint_mission.dart' show MissionLimits;
|
||||
import '../../fly/product_profile.dart';
|
||||
import '../../fly/safety_gate.dart';
|
||||
import '../../fly/state/mission_state.dart';
|
||||
import '../../theme.dart';
|
||||
import '../album_page.dart';
|
||||
import '../capture_modes_page.dart';
|
||||
@@ -19,6 +25,12 @@ import 'hud_kit.dart';
|
||||
import 'layers/video_layer.dart';
|
||||
import 'left/action_rail.dart';
|
||||
import 'left/gimbal_slider.dart';
|
||||
import 'modes/mode_picker.dart';
|
||||
import 'modes/waypoints/point_editor.dart';
|
||||
import 'modes/waypoints/waypoint_layers.dart';
|
||||
import 'modes/waypoints/waypoint_panels.dart';
|
||||
import 'modes/waypoints/waypoint_sheets.dart';
|
||||
import 'modes/waypoints/waypoint_start.dart';
|
||||
import 'overlays/alert_stack.dart';
|
||||
import 'overlays/camera_touch_layer.dart';
|
||||
import 'overlays/gimbal_drag_layer.dart';
|
||||
@@ -59,12 +71,21 @@ import 'top/status_bar.dart';
|
||||
/// Gimbal (plan P5): the pitch slider with its value bubble, press-and-drag
|
||||
/// on the video as a speed joystick, and the gimbal settings sheet from the
|
||||
/// button under the slider.
|
||||
///
|
||||
/// Intelligent modes (plan P7): the rail's MODES button opens the picker.
|
||||
/// Waypoints turns the map big and editable — tap to add, the point editor
|
||||
/// along the bottom, the tool column on the right — and the run panel at the
|
||||
/// head of the left column follows the upload and the flight, whatever the
|
||||
/// screen shows. The route stays on the map after leaving the mode.
|
||||
class FlyScreen extends StatefulWidget {
|
||||
const FlyScreen({super.key, required this.model, required this.dji});
|
||||
const FlyScreen({super.key, required this.model, required this.dji, this.waypoints});
|
||||
|
||||
final FlightModel model;
|
||||
final DjiService dji;
|
||||
|
||||
/// The Waypoints editor to use; the screen makes (and disposes) its own when null.
|
||||
final WaypointEditor? waypoints;
|
||||
|
||||
@override
|
||||
State<FlyScreen> createState() => _FlyScreenState();
|
||||
}
|
||||
@@ -82,6 +103,19 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
|
||||
bool _mapBig = false;
|
||||
|
||||
/// The intelligent mode whose editor is open, or null.
|
||||
IntelligentMode? _mode;
|
||||
late final WaypointEditor _waypoints = widget.waypoints ?? WaypointEditor();
|
||||
late final MissionLibrary _library = MissionLibrary()..load();
|
||||
MissionLimits _limits = const MissionLimits();
|
||||
int _seenFinishes = 0;
|
||||
|
||||
/// Waypoints is open on the big map and nothing is flying: taps edit.
|
||||
bool get _editing {
|
||||
final WaypointProgress run = _m.mission.waypoint;
|
||||
return _mode == IntelligentMode.waypoint && _mapBig && !run.active && !run.uploading;
|
||||
}
|
||||
|
||||
/// The picture-in-picture box, in HUD canvas pixels from the bottom-left corner.
|
||||
static const Size _pipSize = Size(150, 90);
|
||||
|
||||
@@ -98,8 +132,52 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
_wasConnected = _m.connected;
|
||||
_m.addListener(_onModel);
|
||||
_seenFinishes = _m.mission.waypointFinishes;
|
||||
_m.mission.addListener(_onMission);
|
||||
}
|
||||
|
||||
/// Says how a waypoint mission ended, and leaves edit mode when one starts.
|
||||
void _onMission() {
|
||||
final MissionState ms = _m.mission;
|
||||
if (ms.waypointFinishes != _seenFinishes) {
|
||||
_seenFinishes = ms.waypointFinishes;
|
||||
final String? r = ms.waypointResult;
|
||||
if (mounted && r != null) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(r.isEmpty ? 'Waypoint mission complete' : 'Waypoint mission ended: $r')));
|
||||
}
|
||||
}
|
||||
// Execution events arrive at ~10 Hz; rebuild the screen only when editing turns on or off.
|
||||
final bool editing = _editing;
|
||||
if (mounted && editing != _wasEditing) setState(() => _wasEditing = editing);
|
||||
}
|
||||
|
||||
bool _wasEditing = false;
|
||||
|
||||
Future<void> _openModes(BuildContext context) async {
|
||||
final IntelligentMode? m = await showModePicker(context, profile: _m.profile);
|
||||
if (m == null || !mounted) return;
|
||||
switch (m) {
|
||||
case IntelligentMode.waypoint:
|
||||
setState(() {
|
||||
_mode = m;
|
||||
_mapBig = true;
|
||||
});
|
||||
_minimap.setFollow(_waypoints.mission.points.isEmpty);
|
||||
final MissionLimits limits = await _gate.missionLimits();
|
||||
if (mounted) setState(() => _limits = limits);
|
||||
default:
|
||||
break; // the picker only returns built modes
|
||||
}
|
||||
}
|
||||
|
||||
void _exitMode() => setState(() {
|
||||
_mode = null;
|
||||
_mapBig = false;
|
||||
_waypoints.select(null);
|
||||
});
|
||||
|
||||
/// A new connection gets its own pre-flight review.
|
||||
void _onModel() {
|
||||
if (_m.connected == _wasConnected) return;
|
||||
@@ -116,6 +194,9 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_m.removeListener(_onModel);
|
||||
_m.mission.removeListener(_onMission);
|
||||
if (widget.waypoints == null) _waypoints.dispose();
|
||||
_library.dispose();
|
||||
_trail.dispose();
|
||||
_minimap.dispose();
|
||||
SystemChrome.setPreferredOrientations(<DeviceOrientation>[DeviceOrientation.portraitUp]);
|
||||
@@ -141,6 +222,7 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
final double canvasHeight = area.height / s;
|
||||
final Rect pip = HudCanvas.toScreen(mq, Rect.fromLTWH(0, canvasHeight - _pipSize.height, _pipSize.width, _pipSize.height));
|
||||
final Rect full = Offset.zero & mq.size;
|
||||
_wasEditing = _editing;
|
||||
|
||||
final Widget video = ListenableBuilder(
|
||||
key: const ValueKey<String>('video'),
|
||||
@@ -153,6 +235,10 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
trail: _trail,
|
||||
controller: _minimap,
|
||||
interactive: _mapBig,
|
||||
layers: waypointMapLayers(editor: _waypoints, mission: _m.mission, editable: _editing),
|
||||
onTap: (LatLng at) {
|
||||
if (_editing) _waypoints.tapMap(at);
|
||||
},
|
||||
);
|
||||
|
||||
final Widget body = Stack(fit: StackFit.expand, children: <Widget>[
|
||||
@@ -254,11 +340,12 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 68,
|
||||
top: StatusBar.height + SmartBatteryBar.height + 32,
|
||||
child: Requires(HudFeatures.histogram, child: HistogramView(camera: _m.camera)),
|
||||
),
|
||||
if (!_editing)
|
||||
Positioned(
|
||||
right: 68,
|
||||
top: StatusBar.height + SmartBatteryBar.height + 32,
|
||||
child: Requires(HudFeatures.histogram, child: HistogramView(camera: _m.camera)),
|
||||
),
|
||||
// ── Gimbal ────────────────────────────────────────────────────────────
|
||||
Positioned(
|
||||
left: 54,
|
||||
@@ -311,31 +398,49 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
),
|
||||
),
|
||||
Positioned(left: 0, right: 0, top: StatusBar.height + 3, child: SmartBatteryBar(model: _m)),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: StatusBar.height + SmartBatteryBar.height + 5,
|
||||
child: CameraInfoStrip(
|
||||
camera: _m.camera,
|
||||
onTap: () => showQuickExposure(context, model: _m, dji: _dji, onError: _error),
|
||||
// Exposure means nothing while planning a route on the map.
|
||||
if (!_editing)
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: StatusBar.height + SmartBatteryBar.height + 5,
|
||||
child: CameraInfoStrip(
|
||||
camera: _m.camera,
|
||||
onTap: () => showQuickExposure(context, model: _m, dji: _dji, onError: _error),
|
||||
),
|
||||
),
|
||||
),
|
||||
// ── Bottom: attitude ball + telemetry ─────────────────────────────────
|
||||
// Gives way to the point editor while a waypoint or POI is selected.
|
||||
Positioned(
|
||||
left: _pipSize.width + 10,
|
||||
bottom: 0,
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: <Widget>[
|
||||
AttitudeBall(model: _m, size: 84),
|
||||
const SizedBox(width: 6),
|
||||
TelemetryStrip(aircraft: _m.aircraft),
|
||||
]),
|
||||
child: ListenableBuilder(
|
||||
listenable: _waypoints,
|
||||
builder: (BuildContext context, Widget? child) =>
|
||||
_editing && (_waypoints.selected != null || _waypoints.selectedPoi != null) ? const SizedBox.shrink() : child!,
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: <Widget>[
|
||||
AttitudeBall(model: _m, size: 84),
|
||||
const SizedBox(width: 6),
|
||||
TelemetryStrip(aircraft: _m.aircraft),
|
||||
]),
|
||||
),
|
||||
),
|
||||
// ── Error stack + low-battery banners ─────────────────────────────────
|
||||
// ── Mission run panel / route board, error stack, low-battery banners ─
|
||||
// Bounded above the attitude ball; the stack fits what is left under the banners.
|
||||
Positioned(
|
||||
left: 108,
|
||||
top: StatusBar.height + SmartBatteryBar.height + 8,
|
||||
bottom: 104,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
WaypointRunPanel(model: _m, editor: _waypoints, gate: _gate, flow: _flow, onError: _error),
|
||||
if (_editing)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: WaypointBoard(
|
||||
editor: _waypoints,
|
||||
limits: _limits,
|
||||
onTap: () => showRouteSettings(context, editor: _waypoints, limits: _limits),
|
||||
),
|
||||
),
|
||||
SafetyBanners(gate: _gate, onError: _error),
|
||||
Flexible(child: AlertStack(alerts: _m.alerts, onMore: () => _openStatus(context))),
|
||||
]),
|
||||
@@ -344,17 +449,43 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 60,
|
||||
child: ValueListenableBuilder<GridStyle>(
|
||||
valueListenable: FlyPrefs.grid,
|
||||
builder: (BuildContext context, GridStyle g, _) => ActionRail(
|
||||
model: _m,
|
||||
flow: _flow,
|
||||
grid: g != GridStyle.off,
|
||||
onGrid: () => FlyPrefs.setGrid(g == GridStyle.off ? GridStyle.thirds : GridStyle.off),
|
||||
),
|
||||
child: ActionRail(
|
||||
model: _m,
|
||||
flow: _flow,
|
||||
modeActive: _mode != null,
|
||||
onModes: () => _openModes(context),
|
||||
),
|
||||
),
|
||||
if (_mapBig) Positioned(right: 70, top: 80, child: MinimapControls(controller: _minimap, trail: _trail)),
|
||||
if (_mapBig) Positioned(right: _editing ? 122 : 70, top: 80, child: MinimapControls(controller: _minimap, trail: _trail)),
|
||||
// ── Waypoints editor: tools + point editor ────────────────────────────
|
||||
if (_editing) ...<Widget>[
|
||||
Positioned(
|
||||
right: 70,
|
||||
top: 76,
|
||||
child: WaypointTools(
|
||||
editor: _waypoints,
|
||||
onStart: () => startWaypointFlow(context, gate: _gate, spec: _waypoints.mission, onError: _error),
|
||||
onSettings: () => showRouteSettings(context, editor: _waypoints, limits: _limits),
|
||||
onLibrary: () => showMissionLibrary(context, editor: _waypoints, library: _library),
|
||||
onExit: _exitMode,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: _pipSize.width + 10,
|
||||
right: 124,
|
||||
bottom: 0,
|
||||
child: Builder(
|
||||
builder: (BuildContext context) {
|
||||
final ProductProfile p = ProductProfileScope.of(context);
|
||||
return PointEditorPanel(
|
||||
editor: _waypoints,
|
||||
gimbalMin: p.gimbal?.pitch?.min ?? -90,
|
||||
gimbalMax: p.gimbal?.pitch?.max ?? 30,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
// ── Minimap / video corner box ────────────────────────────────────────
|
||||
Positioned(
|
||||
left: 0,
|
||||
|
||||
@@ -2,22 +2,31 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../flight_model.dart';
|
||||
import '../../../fly/action_rail_state.dart';
|
||||
import '../../../fly/hud_features.dart';
|
||||
import '../../../theme.dart';
|
||||
import '../../pv_icons.dart';
|
||||
import '../flight_command_flow.dart';
|
||||
import '../hud_kit.dart';
|
||||
|
||||
/// 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.
|
||||
/// Land ⇄ Cancel / Confirm landing, RTH ⇄ Cancel RTH while airborne — then
|
||||
/// the Intelligent Flight Modes button (plan P7), which Stop replaces while a
|
||||
/// mode flies the aircraft. Every flight press goes through
|
||||
/// [FlightCommandFlow], i.e. the SafetyGate and slide-to-confirm.
|
||||
///
|
||||
/// The grid toggle that used to sit last lives in the camera menu (Other →
|
||||
/// Grid): a fourth button would reach the minimap on a phone.
|
||||
class ActionRail extends StatelessWidget {
|
||||
const ActionRail({super.key, required this.model, required this.flow, required this.grid, required this.onGrid});
|
||||
const ActionRail({super.key, required this.model, required this.flow, required this.onModes, this.modeActive = false});
|
||||
|
||||
final FlightModel model;
|
||||
final FlightCommandFlow flow;
|
||||
final bool grid;
|
||||
final VoidCallback onGrid;
|
||||
|
||||
/// Opens the mode picker.
|
||||
final VoidCallback onModes;
|
||||
|
||||
/// A mode's editor is open (the IFM button lights up).
|
||||
final bool modeActive;
|
||||
|
||||
static ({String icon, String label, Color color}) _look(FlightCommand c) => switch (c) {
|
||||
FlightCommand.takeOff => (icon: 'takeoff', label: 'TAKE OFF', color: Glass.accent),
|
||||
@@ -53,10 +62,20 @@ class ActionRail extends StatelessWidget {
|
||||
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),
|
||||
// Four buttons would reach the minimap on a phone; Stop outranks the picker.
|
||||
if (rail.showStop)
|
||||
button(FlightCommand.stopMode)
|
||||
else
|
||||
Requires(
|
||||
HudFeatures.modePicker,
|
||||
child: RailButton(
|
||||
key: const ValueKey<String>('rail-modes'),
|
||||
icon: 'master',
|
||||
label: 'MODES',
|
||||
color: modeActive ? Glass.accent : Glass.pillStrong,
|
||||
onTap: onModes,
|
||||
),
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../fly/fly_prefs.dart';
|
||||
import '../../../fly/modes/intelligent_modes.dart';
|
||||
import '../../../fly/product_profile.dart';
|
||||
import '../../../theme.dart';
|
||||
import '../../pv_icons.dart';
|
||||
import '../hud_kit.dart';
|
||||
|
||||
/// The Intelligent Flight Modes picker (plan P7): a grid of the connected
|
||||
/// product's modes, grouped as GO 4 does. Resolves to the chosen mode once
|
||||
/// its first-use terms are accepted, or null.
|
||||
Future<IntelligentMode?> showModePicker(BuildContext context, {required ProductProfile profile}) async {
|
||||
final IntelligentMode? picked = await showGeneralDialog<IntelligentMode>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: 'Intelligent Flight Modes',
|
||||
barrierColor: const Color(0x88050A14),
|
||||
transitionDuration: const Duration(milliseconds: 180),
|
||||
pageBuilder: (BuildContext context, _, __) => Center(child: ModePickerPanel(profile: profile)),
|
||||
transitionBuilder: (BuildContext context, Animation<double> a, _, Widget child) => FadeTransition(
|
||||
opacity: a,
|
||||
child: ScaleTransition(scale: Tween<double>(begin: 0.96, end: 1).animate(a), child: child),
|
||||
),
|
||||
);
|
||||
if (picked == null || !context.mounted) return null;
|
||||
final bool ok = await ensureModeTerms(context, ModeCatalog.of(picked));
|
||||
return ok ? picked : null;
|
||||
}
|
||||
|
||||
/// Shows the mode's terms the first time it is used; true once accepted.
|
||||
Future<bool> ensureModeTerms(BuildContext context, ModeInfo info) async {
|
||||
if (FlyPrefs.acceptedModeTerms.value.contains(info.mode.name)) return true;
|
||||
final bool? agreed = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierColor: const Color(0x99050A14),
|
||||
builder: (BuildContext context) => ModeTermsDialog(info: info),
|
||||
);
|
||||
if (agreed != true) return false;
|
||||
await FlyPrefs.acceptModeTerms(info.mode.name);
|
||||
return true;
|
||||
}
|
||||
|
||||
class ModePickerPanel extends StatelessWidget {
|
||||
const ModePickerPanel({super.key, required this.profile});
|
||||
|
||||
final ProductProfile profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<ModeInfo> modes = ModeCatalog.forProfile(profile);
|
||||
final Size screen = MediaQuery.sizeOf(context);
|
||||
return Material(
|
||||
color: const Color(0xF20B1426),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: 620, maxHeight: screen.height - 32),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 10, 6, 6),
|
||||
child: Row(children: <Widget>[
|
||||
const PVIcon('master', size: 16, color: Glass.ink),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text('Intelligent Flight Modes', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
|
||||
IconButton(
|
||||
key: const ValueKey<String>('mode-picker-close'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const PVIcon('close', size: 18, color: HudText.dim),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const Divider(height: 1, color: Glass.hairline),
|
||||
Flexible(
|
||||
child: modes.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('This aircraft offers no intelligent flight modes through the SDK.',
|
||||
style: HudText.sans.copyWith(color: HudText.dim, fontWeight: FontWeight.w400)),
|
||||
)
|
||||
: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 16),
|
||||
children: <Widget>[
|
||||
for (final String g in ModeCatalog.groups)
|
||||
if (modes.any((ModeInfo m) => m.group == g)) ...<Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6, bottom: 8),
|
||||
child: Text(g.toUpperCase(), style: HudText.label),
|
||||
),
|
||||
Wrap(spacing: 10, runSpacing: 10, children: <Widget>[
|
||||
for (final ModeInfo m in modes)
|
||||
if (m.group == g) _ModeTile(info: m),
|
||||
]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModeTile extends StatelessWidget {
|
||||
const _ModeTile({required this.info});
|
||||
|
||||
final ModeInfo info;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool on = info.built;
|
||||
return GestureDetector(
|
||||
key: ValueKey<String>('mode-${info.mode.name}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: on ? () => Navigator.of(context).pop(info.mode) : null,
|
||||
child: Opacity(
|
||||
opacity: on ? 1 : 0.5,
|
||||
child: Container(
|
||||
width: 136,
|
||||
height: 92,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: on ? const Color(0x263D7BF0) : const Color(0x14FFFFFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: on ? const Color(0x663D7BF0) : Glass.hairline),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
PVIcon(info.icon, size: 18, color: Glass.ink),
|
||||
const Spacer(),
|
||||
if (!on) Text('SOON', style: HudText.label.copyWith(fontSize: 7.5)),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text(info.label, style: HudText.sans.copyWith(fontSize: 12.5, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 3),
|
||||
Expanded(
|
||||
child: Text(info.blurb,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: HudText.sans.copyWith(fontSize: 10, fontWeight: FontWeight.w400, color: HudText.dim)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// GO 4's "Intelligent Flight Mode Terms": Agree / Disagree, once per mode.
|
||||
class ModeTermsDialog extends StatelessWidget {
|
||||
const ModeTermsDialog({super.key, required this.info});
|
||||
|
||||
final ModeInfo info;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: const Color(0xF20B1426),
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 40, vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 12),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
PVIcon(info.icon, size: 18, color: Glass.ink),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text('${info.label}: before you fly', style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
for (final String t in info.terms)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
Container(
|
||||
width: 5,
|
||||
height: 5,
|
||||
margin: const EdgeInsets.only(top: 5),
|
||||
decoration: const BoxDecoration(color: HudText.dim, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(t, style: HudText.sans.copyWith(fontSize: 11.5, fontWeight: FontWeight.w400, height: 1.35))),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.end, children: <Widget>[
|
||||
TextButton(
|
||||
key: const ValueKey<String>('terms-disagree'),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text('Disagree', style: HudText.sans.copyWith(color: HudText.dim)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
key: const ValueKey<String>('terms-agree'),
|
||||
style: FilledButton.styleFrom(backgroundColor: PV.accent),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text('Agree', style: HudText.sans.copyWith(color: Colors.white)),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../fly/modes/waypoint_editor.dart';
|
||||
import '../../../../fly/modes/waypoint_mission.dart';
|
||||
import '../../../../theme.dart';
|
||||
import '../../../pv_icons.dart';
|
||||
import '../../hud_kit.dart';
|
||||
|
||||
/// The bottom point editor (plan P7, GO 4's `gs_waypoint_2_point_editor_view`):
|
||||
/// tabs for Altitude · Speed · Heading · Gimbal · Action · POI · Turn radius,
|
||||
/// numbers set by swiping over a big readout or with −/+, plus prev / next,
|
||||
/// "apply to all" and delete. Shows the POI editor instead when a POI is selected.
|
||||
class PointEditorPanel extends StatelessWidget {
|
||||
const PointEditorPanel({super.key, required this.editor, this.gimbalMin = -90, this.gimbalMax = 30});
|
||||
|
||||
final WaypointEditor editor;
|
||||
final double gimbalMin;
|
||||
final double gimbalMax;
|
||||
|
||||
static const double height = 150;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: editor,
|
||||
builder: (BuildContext context, _) {
|
||||
final Widget? body = editor.selectedPoint != null
|
||||
? _PointBody(editor: editor, gimbalMin: gimbalMin, gimbalMax: gimbalMax)
|
||||
: editor.selectedPoiSpec != null
|
||||
? _PoiBody(editor: editor)
|
||||
: null;
|
||||
if (body == null) return const SizedBox.shrink();
|
||||
return Container(
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xE60B1426),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Glass.hairline),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(10, 6, 10, 8),
|
||||
child: body,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PointBody extends StatelessWidget {
|
||||
const _PointBody({required this.editor, required this.gimbalMin, required this.gimbalMax});
|
||||
|
||||
final WaypointEditor editor;
|
||||
final double gimbalMin;
|
||||
final double gimbalMax;
|
||||
|
||||
int get _i => editor.selected!;
|
||||
WaypointSpec get _p => editor.selectedPoint!;
|
||||
WaypointMissionSpec get _m => editor.mission;
|
||||
|
||||
void _update(WaypointSpec Function(WaypointSpec p) f, {bool record = true}) => editor.updatePoint(_i, f, record: record);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool curved = _m.pathMode == RoutePathMode.curved;
|
||||
final List<PointField> tabs = <PointField>[
|
||||
for (final PointField f in PointField.values)
|
||||
if (f != PointField.radius || curved) f,
|
||||
];
|
||||
final PointField field = tabs.contains(editor.field) ? editor.field : PointField.altitude;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
_IconBtn(key: const ValueKey<String>('wp-prev'), icon: 'chevronLeft', onTap: _i > 0 ? () => editor.selectNext(-1) : null),
|
||||
SizedBox(
|
||||
width: 92,
|
||||
child: Text('WAYPOINT ${_i + 1}/${_m.points.length}', textAlign: TextAlign.center, style: HudText.label.copyWith(color: Glass.ink)),
|
||||
),
|
||||
_IconBtn(
|
||||
key: const ValueKey<String>('wp-next'),
|
||||
icon: 'chevronRight',
|
||||
onTap: _i + 1 < _m.points.length ? () => editor.selectNext(1) : null,
|
||||
),
|
||||
const Spacer(),
|
||||
_TextBtn(key: const ValueKey<String>('wp-apply-all'), label: 'APPLY TO ALL', onTap: () => editor.applyToAll(field)),
|
||||
const SizedBox(width: 6),
|
||||
_IconBtn(key: const ValueKey<String>('wp-delete'), icon: 'close', color: HudColors.danger, onTap: () => editor.removePoint(_i)),
|
||||
_IconBtn(key: const ValueKey<String>('wp-deselect'), icon: 'chevronDown', onTap: () => editor.select(null)),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(children: <Widget>[
|
||||
for (final PointField f in tabs)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: HudChip(key: ValueKey<String>('tab-${f.name}'), label: f.label, selected: f == field, onTap: () => editor.setField(f)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Expanded(child: _fieldBody(field)),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _fieldBody(PointField f) {
|
||||
switch (f) {
|
||||
case PointField.altitude:
|
||||
return SwipeReadout(
|
||||
id: 'altitude',
|
||||
value: _p.altitude,
|
||||
range: WaypointEditor.altitudeRange,
|
||||
caption: 'above take-off',
|
||||
onStart: editor.beginDrag,
|
||||
onChanged: (double v, bool drag) => _update((WaypointSpec p) => p.copyWith(altitude: v), record: !drag),
|
||||
);
|
||||
case PointField.speed:
|
||||
final bool own = _p.speed != null;
|
||||
return Row(children: <Widget>[
|
||||
_choices(<(String, bool, VoidCallback)>[
|
||||
('Cruise ${_m.cruiseSpeed.toStringAsFixed(1)}', !own, () => _update((WaypointSpec p) => p.copyWith(speed: null))),
|
||||
('Custom', own, () => _update((WaypointSpec p) => p.copyWith(speed: p.speed ?? _m.cruiseSpeed))),
|
||||
]),
|
||||
const SizedBox(width: 12),
|
||||
if (own)
|
||||
Expanded(
|
||||
child: SwipeReadout(
|
||||
id: 'speed',
|
||||
value: _p.speed!,
|
||||
range: WaypointEditor.speedRange,
|
||||
digits: 1,
|
||||
caption: 'to the next waypoint',
|
||||
onStart: editor.beginDrag,
|
||||
onChanged: (double v, bool drag) => _update((WaypointSpec p) => p.copyWith(speed: v), record: !drag),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(child: _note('Flies the route\'s cruise speed.')),
|
||||
]);
|
||||
case PointField.heading:
|
||||
final bool perPoint = _m.headingMode == RouteHeadingMode.perPoint;
|
||||
return Row(children: <Widget>[
|
||||
_choices(<(String, bool, VoidCallback)>[
|
||||
for (final WaypointHeading h in WaypointHeading.values)
|
||||
if (h != WaypointHeading.poi || _p.poiId != null)
|
||||
(h.label, perPoint && _p.heading == h, () => editor.setHeading(_i, h)),
|
||||
]),
|
||||
const SizedBox(width: 12),
|
||||
if (perPoint && _p.heading == WaypointHeading.custom)
|
||||
Expanded(
|
||||
child: SwipeReadout(
|
||||
id: 'heading',
|
||||
value: _p.customHeading.toDouble(),
|
||||
range: WaypointEditor.headingRange,
|
||||
caption: 'from north',
|
||||
onStart: editor.beginDrag,
|
||||
onChanged: (double v, bool drag) => _update((WaypointSpec p) => p.copyWith(customHeading: v.round()), record: !drag),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: _note(perPoint
|
||||
? (_p.heading == WaypointHeading.poi ? 'Faces its POI.' : 'Faces along the route.')
|
||||
: 'Route heading: ${_m.headingMode.label}. Pick one here to set it per waypoint.'),
|
||||
),
|
||||
]);
|
||||
case PointField.gimbal:
|
||||
final bool aim = _p.gimbalAimPoi && _p.poiId != null;
|
||||
final bool set = !aim && _p.gimbalPitch != null;
|
||||
return Row(children: <Widget>[
|
||||
_choices(<(String, bool, VoidCallback)>[
|
||||
('Keep', !aim && !set, () => _update((WaypointSpec p) => p.copyWith(gimbalPitch: null, gimbalAimPoi: false))),
|
||||
('Set', set, () => _update((WaypointSpec p) => p.copyWith(gimbalPitch: p.gimbalPitch ?? -30, gimbalAimPoi: false))),
|
||||
if (_p.poiId != null) ('Aim at POI', aim, () => _update((WaypointSpec p) => p.copyWith(gimbalAimPoi: true))),
|
||||
]),
|
||||
const SizedBox(width: 12),
|
||||
if (set)
|
||||
Expanded(
|
||||
child: SwipeReadout(
|
||||
id: 'gimbal',
|
||||
value: _p.gimbalPitch!.clamp(gimbalMin, gimbalMax).toDouble(),
|
||||
range: WaypointEditor.gimbalRange(min: gimbalMin, max: gimbalMax),
|
||||
caption: 'pitch',
|
||||
onStart: editor.beginDrag,
|
||||
onChanged: (double v, bool drag) => _update((WaypointSpec p) => p.copyWith(gimbalPitch: v), record: !drag),
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: _note(aim
|
||||
? 'Tilts to the POI: ${_m.resolvedGimbal(min: gimbalMin, max: gimbalMax)?[_i].round() ?? '—'}°.'
|
||||
: 'Keeps the pitch from the waypoint before, or yours.'),
|
||||
),
|
||||
]);
|
||||
case PointField.action:
|
||||
return Row(children: <Widget>[
|
||||
_choices(<(String, bool, VoidCallback)>[
|
||||
for (final WaypointCameraAction a in WaypointCameraAction.values)
|
||||
(a.label, _p.action == a, () => _update((WaypointSpec p) => p.copyWith(action: a))),
|
||||
]),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: SwipeReadout(
|
||||
id: 'hover',
|
||||
value: _p.hoverSeconds.toDouble(),
|
||||
range: WaypointEditor.hoverRange,
|
||||
caption: 'hover first',
|
||||
onStart: editor.beginDrag,
|
||||
onChanged: (double v, bool drag) => _update((WaypointSpec p) => p.copyWith(hoverSeconds: v.round()), record: !drag),
|
||||
),
|
||||
),
|
||||
]);
|
||||
case PointField.poi:
|
||||
return Row(children: <Widget>[
|
||||
_choices(<(String, bool, VoidCallback)>[
|
||||
('None', _p.poiId == null, () => editor.linkPoi(_i, null)),
|
||||
for (int k = 0; k < _m.pois.length; k++) ('P${k + 1}', _p.poiId == _m.pois[k].id, () => editor.linkPoi(_i, _m.pois[k].id)),
|
||||
]),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _note(_m.pois.isEmpty
|
||||
? 'No POI yet: switch the map tool to POI and tap the map.'
|
||||
: 'A linked waypoint faces its POI and can aim the gimbal at it.'),
|
||||
),
|
||||
]);
|
||||
case PointField.radius:
|
||||
return SwipeReadout(
|
||||
id: 'radius',
|
||||
value: _p.turnRadius,
|
||||
range: WaypointEditor.radiusRange,
|
||||
digits: 1,
|
||||
caption: 'curve through this waypoint',
|
||||
onStart: editor.beginDrag,
|
||||
onChanged: (double v, bool drag) => _update((WaypointSpec p) => p.copyWith(turnRadius: v), record: !drag),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PoiBody extends StatelessWidget {
|
||||
const _PoiBody({required this.editor});
|
||||
|
||||
final WaypointEditor editor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final MissionPoi poi = editor.selectedPoiSpec!;
|
||||
final List<MissionPoi> pois = editor.mission.pois;
|
||||
final int n = pois.indexOf(poi) + 1;
|
||||
final int linked = editor.mission.points.where((WaypointSpec p) => p.poiId == poi.id).length;
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Row(children: <Widget>[
|
||||
Text('POINT OF INTEREST P$n', style: HudText.label.copyWith(color: Glass.ink)),
|
||||
const SizedBox(width: 10),
|
||||
Text('$linked waypoint${linked == 1 ? '' : 's'} linked', style: HudText.label),
|
||||
const Spacer(),
|
||||
_TextBtn(
|
||||
key: const ValueKey<String>('poi-link-all'),
|
||||
label: 'LINK ALL',
|
||||
onTap: editor.mission.points.isEmpty
|
||||
? null
|
||||
: () {
|
||||
for (int i = 0; i < editor.mission.points.length; i++) {
|
||||
editor.linkPoi(i, poi.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_IconBtn(key: const ValueKey<String>('poi-delete'), icon: 'close', color: HudColors.danger, onTap: () => editor.removePoi(poi.id)),
|
||||
_IconBtn(key: const ValueKey<String>('poi-deselect'), icon: 'chevronDown', onTap: () => editor.selectPoi(null)),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Expanded(
|
||||
child: SwipeReadout(
|
||||
id: 'poi-height',
|
||||
value: poi.height,
|
||||
range: WaypointEditor.poiHeightRange,
|
||||
caption: 'subject height, for aiming the gimbal',
|
||||
onStart: editor.beginDrag,
|
||||
onChanged: (double v, bool drag) => editor.updatePoi(poi.id, (MissionPoi p) => p.copyWith(height: v), record: !drag),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _note(String text) => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(text, maxLines: 3, style: HudText.sans.copyWith(fontSize: 10.5, fontWeight: FontWeight.w400, color: HudText.dim)),
|
||||
);
|
||||
|
||||
Widget _choices(List<(String, bool, VoidCallback)> options) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 260),
|
||||
child: Wrap(spacing: 5, runSpacing: 5, children: <Widget>[
|
||||
for (final (String label, bool on, VoidCallback tap) in options)
|
||||
HudChip(key: ValueKey<String>('choice-$label'), label: label, selected: on, onTap: tap),
|
||||
]),
|
||||
);
|
||||
|
||||
/// A big number set by swiping up / down over it (one step per [pxPerStep])
|
||||
/// or with −/+ (GO 4's point editor readout). [onChanged]'s second argument
|
||||
/// is true for swipe steps, which [onStart] has already put in the history.
|
||||
class SwipeReadout extends StatefulWidget {
|
||||
const SwipeReadout({
|
||||
super.key,
|
||||
required this.id,
|
||||
required this.value,
|
||||
required this.range,
|
||||
required this.onChanged,
|
||||
this.onStart,
|
||||
this.caption,
|
||||
this.digits = 0,
|
||||
});
|
||||
|
||||
/// Keys the parts as `readout-<id>`, `readout-<id>-minus`, `readout-<id>-plus`.
|
||||
final String id;
|
||||
final double value;
|
||||
final FieldRange range;
|
||||
final void Function(double value, bool drag) onChanged;
|
||||
final VoidCallback? onStart;
|
||||
final String? caption;
|
||||
final int digits;
|
||||
|
||||
static const double pxPerStep = 8;
|
||||
|
||||
@override
|
||||
State<SwipeReadout> createState() => _SwipeReadoutState();
|
||||
}
|
||||
|
||||
class _SwipeReadoutState extends State<SwipeReadout> {
|
||||
double _acc = 0;
|
||||
double? _dragValue;
|
||||
|
||||
double get _shown => _dragValue ?? widget.value;
|
||||
|
||||
void _step(int dir) {
|
||||
final double next = widget.range.clamp(widget.value + dir * widget.range.step);
|
||||
if (next != widget.value) widget.onChanged(next, false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final FieldRange r = widget.range;
|
||||
Widget btn(String label, int dir) {
|
||||
final bool on = dir > 0 ? widget.value < r.max : widget.value > r.min;
|
||||
return Opacity(
|
||||
opacity: on ? 1 : 0.35,
|
||||
child: GestureDetector(
|
||||
key: ValueKey<String>('readout-${widget.id}-${dir > 0 ? 'plus' : 'minus'}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: on ? () => _step(dir) : null,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(9), border: Border.all(color: Glass.hairline)),
|
||||
alignment: Alignment.center,
|
||||
child: Text(label, style: HudText.value.copyWith(fontSize: 18)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Row(children: <Widget>[
|
||||
btn('−', -1),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
key: ValueKey<String>('readout-${widget.id}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragStart: (_) {
|
||||
_acc = 0;
|
||||
_dragValue = widget.value;
|
||||
widget.onStart?.call();
|
||||
},
|
||||
onVerticalDragUpdate: (DragUpdateDetails d) {
|
||||
_acc -= d.delta.dy;
|
||||
int steps = 0;
|
||||
while (_acc >= SwipeReadout.pxPerStep) {
|
||||
_acc -= SwipeReadout.pxPerStep;
|
||||
steps++;
|
||||
}
|
||||
while (_acc <= -SwipeReadout.pxPerStep) {
|
||||
_acc += SwipeReadout.pxPerStep;
|
||||
steps--;
|
||||
}
|
||||
if (steps == 0) return;
|
||||
final double next = r.clamp(_shown + steps * r.step);
|
||||
if (next == _shown) return;
|
||||
setState(() => _dragValue = next);
|
||||
widget.onChanged(next, true);
|
||||
},
|
||||
onVerticalDragEnd: (_) => setState(() => _dragValue = null),
|
||||
onVerticalDragCancel: () => setState(() => _dragValue = null),
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: <Widget>[
|
||||
Text(_shown.toStringAsFixed(widget.digits), style: HudText.value.copyWith(fontSize: 26)),
|
||||
const SizedBox(width: 3),
|
||||
Text(r.unit, style: HudText.unit.copyWith(fontSize: 11)),
|
||||
]),
|
||||
if (widget.caption != null) Text(widget.caption!, style: HudText.label.copyWith(fontSize: 8)),
|
||||
Text('SWIPE', style: HudText.label.copyWith(fontSize: 7, color: const Color(0x66EAF0FA))),
|
||||
]),
|
||||
),
|
||||
),
|
||||
btn('+', 1),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small selectable chip, for tabs and choices on the HUD.
|
||||
class HudChip extends StatelessWidget {
|
||||
const HudChip({super.key, required this.label, required this.selected, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Glass.accent : Glass.pill,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
border: Border.all(color: selected ? Glass.accent : Glass.hairline),
|
||||
),
|
||||
child: Text(label, style: HudText.sans.copyWith(fontSize: 10.5)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _IconBtn extends StatelessWidget {
|
||||
const _IconBtn({super.key, required this.icon, required this.onTap, this.color = Glass.ink});
|
||||
|
||||
final String icon;
|
||||
final VoidCallback? onTap;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Opacity(
|
||||
opacity: onTap == null ? 0.35 : 1,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: SizedBox(width: 28, height: 26, child: Center(child: PVIcon(icon, size: 15, color: color))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _TextBtn extends StatelessWidget {
|
||||
const _TextBtn({super.key, required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Opacity(
|
||||
opacity: onTap == null ? 0.35 : 1,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
|
||||
decoration: BoxDecoration(borderRadius: BorderRadius.circular(6), border: Border.all(color: Glass.accent)),
|
||||
child: Text(label, style: HudText.label.copyWith(color: Glass.ink, fontSize: 8.5)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../../../../fly/modes/waypoint_editor.dart';
|
||||
import '../../../../fly/modes/waypoint_mission.dart';
|
||||
import '../../../../fly/state/mission_state.dart';
|
||||
import '../../../../theme.dart';
|
||||
import '../../hud_kit.dart';
|
||||
|
||||
/// The Waypoints mode's map layers (plan P7), as children of `FlyMap`: the
|
||||
/// route, POI links, leg "+" handles, POIs and the numbered waypoints.
|
||||
///
|
||||
/// While editing ([editable]), a tap selects a marker, a long press then drag
|
||||
/// moves it, and a leg's "+" inserts a waypoint halfway. While the mission
|
||||
/// flies, legs already flown dim, the target waypoint is amber, reached ones
|
||||
/// green.
|
||||
List<Widget> waypointMapLayers({required WaypointEditor editor, required MissionState mission, required bool editable}) {
|
||||
final Listenable both = Listenable.merge(<Listenable>[editor, mission]);
|
||||
return <Widget>[
|
||||
ListenableBuilder(
|
||||
key: const ValueKey<String>('wp-lines'),
|
||||
listenable: both,
|
||||
builder: (BuildContext context, _) => PolylineLayer<Object>(polylines: _lines(editor.mission, mission.waypoint)),
|
||||
),
|
||||
if (editable)
|
||||
ListenableBuilder(
|
||||
key: const ValueKey<String>('wp-handles'),
|
||||
listenable: editor,
|
||||
builder: (BuildContext context, _) => MarkerLayer(markers: _handles(editor)),
|
||||
),
|
||||
ListenableBuilder(
|
||||
key: const ValueKey<String>('wp-markers'),
|
||||
listenable: both,
|
||||
builder: (BuildContext context, _) => MarkerLayer(markers: <Marker>[
|
||||
..._pois(editor, editable),
|
||||
..._points(editor, mission.waypoint, editable),
|
||||
]),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const Color _route = Color(0xFF5B93F5);
|
||||
const Color _flown = Color(0x99EAF0FA);
|
||||
const Color _poiColor = Color(0xFFB98CF5);
|
||||
|
||||
List<Polyline<Object>> _lines(WaypointMissionSpec m, WaypointProgress run) {
|
||||
final List<Polyline<Object>> out = <Polyline<Object>>[];
|
||||
final List<WaypointSpec> pts = m.points;
|
||||
// POI links under the route.
|
||||
for (final WaypointSpec p in pts) {
|
||||
final MissionPoi? poi = m.poi(p.poiId);
|
||||
if (poi == null) continue;
|
||||
out.add(Polyline<Object>(
|
||||
points: <LatLng>[p.at, poi.at],
|
||||
strokeWidth: 1.2,
|
||||
color: const Color(0x99B98CF5),
|
||||
pattern: StrokePattern.dashed(segments: const <double>[5, 4]),
|
||||
));
|
||||
}
|
||||
if (pts.length < 2) return out;
|
||||
final int? target = run.active ? run.target : null;
|
||||
for (int i = 0; i + 1 < pts.length; i++) {
|
||||
final bool done = target != null && i + 1 < target;
|
||||
final bool current = target != null && i + 1 == target;
|
||||
out.add(Polyline<Object>(
|
||||
points: <LatLng>[pts[i].at, pts[i + 1].at],
|
||||
strokeWidth: current ? 4 : 3,
|
||||
color: done ? _flown : (current ? HudColors.caution : _route),
|
||||
borderStrokeWidth: 1,
|
||||
borderColor: const Color(0x99050A14),
|
||||
));
|
||||
}
|
||||
if (m.finishAction == RouteFinishAction.firstWaypoint) {
|
||||
out.add(Polyline<Object>(
|
||||
points: <LatLng>[pts.last.at, pts.first.at],
|
||||
strokeWidth: 2,
|
||||
color: const Color(0x995B93F5),
|
||||
pattern: StrokePattern.dashed(segments: const <double>[8, 6]),
|
||||
));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
List<Marker> _handles(WaypointEditor e) {
|
||||
final List<WaypointSpec> pts = e.mission.points;
|
||||
if (pts.length >= WaypointMissionSpec.maxPoints) return const <Marker>[];
|
||||
return <Marker>[
|
||||
for (int i = 0; i + 1 < pts.length; i++)
|
||||
Marker(
|
||||
key: ValueKey<String>('wp-insert-$i'),
|
||||
point: LatLng((pts[i].at.latitude + pts[i + 1].at.latitude) / 2, (pts[i].at.longitude + pts[i + 1].at.longitude) / 2),
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => e.insertMidpoint(i),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xCC0B1426),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: _route, width: 1.2),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text('+', style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, height: 1, color: Glass.ink)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Marker> _pois(WaypointEditor e, bool editable) {
|
||||
return <Marker>[
|
||||
for (int i = 0; i < e.mission.pois.length; i++)
|
||||
() {
|
||||
final MissionPoi p = e.mission.pois[i];
|
||||
final bool sel = e.selectedPoi == p.id;
|
||||
return Marker(
|
||||
key: ValueKey<String>('poi-${p.id}'),
|
||||
point: p.at,
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: _Draggable(
|
||||
enabled: editable,
|
||||
point: p.at,
|
||||
onTap: () => e.selectPoi(p.id),
|
||||
onStart: e.beginDrag,
|
||||
onMove: (LatLng at) => e.movePoi(p.id, at),
|
||||
child: Center(
|
||||
child: Transform.rotate(
|
||||
angle: math.pi / 4,
|
||||
child: Container(
|
||||
width: sel ? 22 : 18,
|
||||
height: sel ? 22 : 18,
|
||||
decoration: BoxDecoration(
|
||||
color: _poiColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(color: sel ? Colors.white : const Color(0xFF0A1120), width: sel ? 2 : 1.2),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Transform.rotate(
|
||||
angle: -math.pi / 4,
|
||||
child: Text('P${i + 1}',
|
||||
style:
|
||||
const TextStyle(fontFamily: PV.fontMono, fontSize: 8.5, fontWeight: FontWeight.w700, color: Color(0xFF0A1120))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}(),
|
||||
];
|
||||
}
|
||||
|
||||
List<Marker> _points(WaypointEditor e, WaypointProgress run, bool editable) {
|
||||
final WaypointMissionSpec m = e.mission;
|
||||
final bool perPoint = m.headingMode == RouteHeadingMode.perPoint;
|
||||
final List<int> headings = perPoint ? m.resolvedHeadings() : const <int>[];
|
||||
final int? target = run.active ? run.target : null;
|
||||
return <Marker>[
|
||||
for (int i = 0; i < m.points.length; i++)
|
||||
() {
|
||||
final WaypointSpec p = m.points[i];
|
||||
final bool sel = editable && e.selected == i;
|
||||
final Color fill = target == null
|
||||
? (sel ? PV.accent : const Color(0xFF2B62CC))
|
||||
: (i < target || (i == target && run.reached) ? HudColors.ready : (i == target ? HudColors.caution : const Color(0xFF2B62CC)));
|
||||
return Marker(
|
||||
key: ValueKey<String>('wp-$i'),
|
||||
point: p.at,
|
||||
width: 44,
|
||||
height: 62,
|
||||
child: _Draggable(
|
||||
enabled: editable,
|
||||
point: p.at,
|
||||
onTap: () => e.select(i),
|
||||
onStart: e.beginDrag,
|
||||
onMove: (LatLng at) => e.movePoint(i, at),
|
||||
// The ring sits on the point; the altitude tag hangs below it.
|
||||
child: Stack(alignment: Alignment.center, children: <Widget>[
|
||||
SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: Stack(alignment: Alignment.center, children: <Widget>[
|
||||
if (perPoint)
|
||||
Transform.rotate(
|
||||
angle: headings[i] * math.pi / 180,
|
||||
child: const CustomPaint(size: Size(34, 34), painter: _HeadingTick()),
|
||||
),
|
||||
Container(
|
||||
width: sel ? 26 : 22,
|
||||
height: sel ? 26 : 22,
|
||||
decoration: BoxDecoration(
|
||||
color: fill,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: sel ? Colors.white : const Color(0xCCEAF0FA), width: sel ? 2.2 : 1.2),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text('${i + 1}',
|
||||
style: const TextStyle(fontFamily: PV.fontMono, fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(color: const Color(0xB3050A14), borderRadius: BorderRadius.circular(3)),
|
||||
child: Text('${p.altitude.round()}m', style: HudText.label.copyWith(fontSize: 8, color: Glass.ink)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}(),
|
||||
];
|
||||
}
|
||||
|
||||
/// The small triangle on a waypoint's ring that shows where it points.
|
||||
class _HeadingTick extends CustomPainter {
|
||||
const _HeadingTick();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final double c = size.width / 2;
|
||||
final Path p = Path()
|
||||
..moveTo(c, 0)
|
||||
..lineTo(c + 5, 7)
|
||||
..lineTo(c - 5, 7)
|
||||
..close();
|
||||
canvas.drawPath(p, Paint()..color = Colors.white);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
/// Tap to select; long press, then drag, to move. A long press wins the
|
||||
/// gesture arena before the map's pan does, so a plain drag still pans.
|
||||
class _Draggable extends StatefulWidget {
|
||||
const _Draggable({
|
||||
required this.enabled,
|
||||
required this.point,
|
||||
required this.onTap,
|
||||
required this.onStart,
|
||||
required this.onMove,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final LatLng point;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onStart;
|
||||
final ValueChanged<LatLng> onMove;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_Draggable> createState() => _DraggableState();
|
||||
}
|
||||
|
||||
class _DraggableState extends State<_Draggable> {
|
||||
Offset? _startScreen;
|
||||
Offset? _startGlobal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.enabled) return IgnorePointer(child: widget.child);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onTap,
|
||||
onLongPressStart: (LongPressStartDetails d) {
|
||||
final MapCamera cam = MapCamera.of(context);
|
||||
_startScreen = cam.latLngToScreenPoint(widget.point).toOffset();
|
||||
_startGlobal = d.globalPosition;
|
||||
widget.onTap();
|
||||
widget.onStart();
|
||||
},
|
||||
onLongPressMoveUpdate: (LongPressMoveUpdateDetails d) {
|
||||
if (_startScreen == null || _startGlobal == null) return;
|
||||
final MapCamera cam = MapCamera.of(context);
|
||||
final Offset at = _startScreen! + (d.globalPosition - _startGlobal!);
|
||||
widget.onMove(cam.pointToLatLng(math.Point<double>(at.dx, at.dy)));
|
||||
},
|
||||
onLongPressEnd: (_) {
|
||||
_startScreen = null;
|
||||
_startGlobal = null;
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
|
||||
import '../../../../flight_model.dart';
|
||||
import '../../../../fly/hud_geometry.dart';
|
||||
import '../../../../fly/modes/waypoint_editor.dart';
|
||||
import '../../../../fly/modes/waypoint_mission.dart';
|
||||
import '../../../../fly/safety_gate.dart';
|
||||
import '../../../../fly/state/mission_state.dart';
|
||||
import '../../../../theme.dart';
|
||||
import '../../../pv_icons.dart';
|
||||
import '../../flight_command_flow.dart';
|
||||
import '../../hud_kit.dart';
|
||||
|
||||
/// The route summary across the top of the map while editing (GO 4's top
|
||||
/// board): name, waypoints, length, time, cruise speed, and the issue count.
|
||||
class WaypointBoard extends StatelessWidget {
|
||||
const WaypointBoard({super.key, required this.editor, required this.limits, required this.onTap});
|
||||
|
||||
final WaypointEditor editor;
|
||||
final MissionLimits limits;
|
||||
|
||||
/// Opens the route settings (where the issues are listed).
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: editor,
|
||||
builder: (BuildContext context, _) {
|
||||
final WaypointMissionSpec m = editor.mission;
|
||||
final List<MissionIssue> issues = m.points.isEmpty ? const <MissionIssue>[] : m.validate(limits);
|
||||
final int errors = issues.where((MissionIssue i) => i.blocks).length;
|
||||
final int warnings = issues.length - errors;
|
||||
final String hint = switch ((m.points.length, editor.tool)) {
|
||||
(_, MapTool.poi) => 'Tap the map to add a point of interest',
|
||||
(0, _) => 'Tap the map to add waypoints',
|
||||
(1, _) => 'Tap the map to add the next waypoint',
|
||||
_ => '',
|
||||
};
|
||||
return GlassPill(
|
||||
key: const ValueKey<String>('wp-board'),
|
||||
height: 30,
|
||||
color: Glass.pillStrong,
|
||||
onTap: onTap,
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
const PVIcon('route', size: 14, color: Glass.ink),
|
||||
const SizedBox(width: 6),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 120),
|
||||
child: Text(m.name, overflow: TextOverflow.ellipsis, style: HudText.sans.copyWith(fontSize: 11.5)),
|
||||
),
|
||||
if (editor.dirty) Text(' •', style: HudText.sans.copyWith(color: HudColors.caution)),
|
||||
_sep(),
|
||||
_stat('${m.points.length}', 'WP'),
|
||||
_sep(),
|
||||
_stat(m.length >= 1000 ? (m.length / 1000).toStringAsFixed(2) : m.length.round().toString(), m.length >= 1000 ? 'km' : 'm'),
|
||||
_sep(),
|
||||
_stat('≈${formatDuration(m.estimatedSeconds)}', ''),
|
||||
_sep(),
|
||||
_stat(m.cruiseSpeed.toStringAsFixed(1), 'm/s'),
|
||||
if (errors > 0 || warnings > 0) ...<Widget>[
|
||||
const SizedBox(width: 8),
|
||||
_badge(errors > 0 ? '$errors' : '$warnings', errors > 0 ? HudColors.danger : HudColors.caution),
|
||||
],
|
||||
if (hint.isNotEmpty) ...<Widget>[
|
||||
_sep(),
|
||||
Text(hint, style: HudText.label.copyWith(color: Glass.ink)),
|
||||
],
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _sep() => Container(width: 1, height: 14, margin: const EdgeInsets.symmetric(horizontal: 8), color: Glass.hairline);
|
||||
|
||||
static Widget _stat(String v, String unit) => Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: <Widget>[
|
||||
Text(v, style: HudText.mono.copyWith(fontWeight: FontWeight.w700)),
|
||||
if (unit.isNotEmpty) ...<Widget>[const SizedBox(width: 2), Text(unit, style: HudText.unit)],
|
||||
]);
|
||||
|
||||
static Widget _badge(String n, Color c) => Container(
|
||||
key: const ValueKey<String>('wp-issues'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(6)),
|
||||
child: Text(n, style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
);
|
||||
}
|
||||
|
||||
/// The Waypoints tool column on the right of the map: Start, the map tool
|
||||
/// (waypoint / POI), route settings, library, undo, exit.
|
||||
class WaypointTools extends StatelessWidget {
|
||||
const WaypointTools({
|
||||
super.key,
|
||||
required this.editor,
|
||||
required this.onStart,
|
||||
required this.onSettings,
|
||||
required this.onLibrary,
|
||||
required this.onExit,
|
||||
});
|
||||
|
||||
final WaypointEditor editor;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback onSettings;
|
||||
final VoidCallback onLibrary;
|
||||
final VoidCallback onExit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: editor,
|
||||
builder: (BuildContext context, _) => Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
_ToolButton(
|
||||
id: 'start',
|
||||
icon: 'play2',
|
||||
label: 'START',
|
||||
color: editor.mission.points.length >= WaypointMissionSpec.minPoints ? Glass.accent : Glass.pillStrong,
|
||||
onTap: editor.mission.points.length >= WaypointMissionSpec.minPoints ? onStart : null,
|
||||
),
|
||||
_ToolButton(
|
||||
id: 'tool',
|
||||
icon: editor.tool == MapTool.poi ? 'circle' : 'pin',
|
||||
label: editor.tool == MapTool.poi ? 'POI' : 'WAYPT',
|
||||
active: editor.tool == MapTool.poi,
|
||||
onTap: () => editor.setTool(editor.tool == MapTool.poi ? MapTool.waypoint : MapTool.poi),
|
||||
),
|
||||
_ToolButton(id: 'settings', icon: 'settings', label: 'ROUTE', onTap: onSettings),
|
||||
_ToolButton(id: 'library', icon: 'album', label: 'LIBRARY', onTap: onLibrary),
|
||||
_ToolButton(id: 'undo', icon: 'chevronLeft', label: 'UNDO', onTap: editor.canUndo ? editor.undo : null),
|
||||
_ToolButton(id: 'exit', icon: 'close', label: 'EXIT', onTap: onExit, last: true),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolButton extends StatelessWidget {
|
||||
const _ToolButton({
|
||||
required this.id,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.color,
|
||||
this.active = false,
|
||||
this.last = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final Color? color;
|
||||
final bool active;
|
||||
final bool last;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: EdgeInsets.only(bottom: last ? 0 : 5),
|
||||
child: Opacity(
|
||||
opacity: onTap == null ? 0.4 : 1,
|
||||
child: GestureDetector(
|
||||
key: ValueKey<String>('wp-tool-$id'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: color ?? (active ? Glass.accent : Glass.pillStrong),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Glass.hairline),
|
||||
),
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
|
||||
PVIcon(icon, size: 14, color: Glass.ink),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: HudText.label.copyWith(color: Glass.ink, fontSize: 7)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The mission in flight (and its upload): waypoint n / N, what it is doing,
|
||||
/// distance left, and Pause / Resume and Stop. Shown whenever the waypoint
|
||||
/// operator is uploading or flying, whatever the screen shows.
|
||||
class WaypointRunPanel extends StatelessWidget {
|
||||
const WaypointRunPanel({
|
||||
super.key,
|
||||
required this.model,
|
||||
required this.editor,
|
||||
required this.gate,
|
||||
required this.flow,
|
||||
required this.onError,
|
||||
});
|
||||
|
||||
final FlightModel model;
|
||||
final WaypointEditor editor;
|
||||
final SafetyGate gate;
|
||||
final FlightCommandFlow flow;
|
||||
final void Function(String label, Object error) onError;
|
||||
|
||||
/// Metres left: aircraft → target waypoint, then the remaining legs. Null
|
||||
/// when the route on screen is not the one flying.
|
||||
static double? remaining(WaypointMissionSpec m, WaypointProgress run, LatLng? aircraft) {
|
||||
final int? t = run.target;
|
||||
if (t == null || run.total != m.points.length || t >= m.points.length) return null;
|
||||
double d = 0;
|
||||
if (aircraft != null) {
|
||||
final LatLng p = m.points[t].at;
|
||||
d += HudGeometry.distance(aircraft.latitude, aircraft.longitude, p.latitude, p.longitude);
|
||||
}
|
||||
final List<double> legs = m.legs;
|
||||
for (int i = t; i < legs.length; i++) {
|
||||
d += legs[i];
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge(<Listenable>[model.mission, model.aircraft]),
|
||||
builder: (BuildContext context, _) {
|
||||
final WaypointProgress run = model.mission.waypoint;
|
||||
if (!run.uploading && !run.active) return const SizedBox.shrink();
|
||||
final Widget content;
|
||||
if (run.uploading) {
|
||||
final double f = run.uploadFraction ?? 0;
|
||||
content = Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
const PVIcon('download', size: 13, color: Glass.ink),
|
||||
const SizedBox(width: 6),
|
||||
Text('UPLOADING ${(run.uploaded ?? -1) + 1}/${run.total ?? '—'}', style: HudText.label.copyWith(color: Glass.ink)),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 90,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(value: f, minHeight: 4, color: Glass.accent, backgroundColor: HudColors.off),
|
||||
),
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
final double? lat = model.aircraft.latitude, lon = model.aircraft.longitude;
|
||||
final double? left = remaining(editor.mission, run, lat == null || lon == null ? null : LatLng(lat, lon));
|
||||
content = Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
const PVIcon('route', size: 13, color: Glass.ink),
|
||||
const SizedBox(width: 6),
|
||||
Text('WAYPOINT ${(run.target ?? 0) + 1}/${run.total ?? '—'}', style: HudText.label.copyWith(color: Glass.ink)),
|
||||
const SizedBox(width: 8),
|
||||
Text(run.executeLabel.toUpperCase(),
|
||||
style: HudText.label.copyWith(color: run.paused ? HudColors.caution : HudColors.ready, fontWeight: FontWeight.w700)),
|
||||
if (left != null) ...<Widget>[
|
||||
const SizedBox(width: 8),
|
||||
Text(left >= 1000 ? '${(left / 1000).toStringAsFixed(2)} km left' : '${left.round()} m left', style: HudText.label),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
_RunButton(
|
||||
id: run.paused ? 'resume' : 'pause',
|
||||
icon: run.paused ? 'play2' : 'pause',
|
||||
label: run.paused ? 'RESUME' : 'PAUSE',
|
||||
onTap: () => _guard(run.paused ? 'Resume' : 'Pause', run.paused ? gate.resumeWaypointMission : gate.pauseWaypointMission),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_RunButton(
|
||||
id: 'stop',
|
||||
icon: 'stop',
|
||||
label: 'STOP',
|
||||
color: HudColors.danger,
|
||||
onTap: () => flow.request(context, FlightCommand.stopMode),
|
||||
),
|
||||
]);
|
||||
}
|
||||
return Container(
|
||||
key: const ValueKey<String>('wp-run'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(10), border: Border.all(color: Glass.hairline)),
|
||||
child: content,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _guard(String label, Future<void> Function() send) async {
|
||||
try {
|
||||
await send();
|
||||
} catch (e) {
|
||||
onError(label, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RunButton extends StatelessWidget {
|
||||
const _RunButton({required this.id, required this.icon, required this.label, required this.onTap, this.color = Glass.accent});
|
||||
|
||||
final String id;
|
||||
final String icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
key: ValueKey<String>('wp-run-$id'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 26,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(7)),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
PVIcon(icon, size: 12, color: Colors.white, fill: icon == 'play2'),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../fly/hud_geometry.dart';
|
||||
import '../../../../fly/modes/mission_library.dart';
|
||||
import '../../../../fly/modes/waypoint_editor.dart';
|
||||
import '../../../../fly/modes/waypoint_mission.dart';
|
||||
import '../../../../theme.dart';
|
||||
import '../../../pv_icons.dart';
|
||||
import '../../hud_kit.dart';
|
||||
import '../../panels/camera_menu/camera_rows.dart';
|
||||
|
||||
/// Opens a right-side glass sheet over the map, like the camera and gimbal menus.
|
||||
Future<void> _showSheet(BuildContext context, {required String title, required String icon, required Widget body}) {
|
||||
return showGeneralDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: title,
|
||||
barrierColor: const Color(0x55050A14),
|
||||
transitionDuration: const Duration(milliseconds: 200),
|
||||
pageBuilder: (BuildContext context, _, __) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Material(
|
||||
color: const Color(0xF20B1426),
|
||||
child: SizedBox(
|
||||
width: 380,
|
||||
height: MediaQuery.sizeOf(context).height,
|
||||
child: SafeArea(
|
||||
left: false,
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: <Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 4, 6),
|
||||
child: Row(children: <Widget>[
|
||||
PVIcon(icon, size: 16, color: Glass.ink),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(title, style: HudText.sans.copyWith(fontSize: 14, fontWeight: FontWeight.w700))),
|
||||
IconButton(
|
||||
key: const ValueKey<String>('sheet-close'),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const PVIcon('close', size: 18, color: HudText.dim),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const Divider(height: 1, color: Glass.hairline),
|
||||
Expanded(child: body),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Route settings ─────────────────────────────────────────────────────────
|
||||
|
||||
/// GO 4's Route Settings: route type, cruise speed, heading, finish and
|
||||
/// signal-lost actions, how to reach waypoint 1, repeats — plus the route's
|
||||
/// issues and Clear.
|
||||
Future<void> showRouteSettings(BuildContext context, {required WaypointEditor editor, required MissionLimits limits}) =>
|
||||
_showSheet(context, title: 'Route settings', icon: 'route', body: RouteSettingsList(editor: editor, limits: limits));
|
||||
|
||||
class RouteSettingsList extends StatelessWidget {
|
||||
const RouteSettingsList({super.key, required this.editor, required this.limits});
|
||||
|
||||
final WaypointEditor editor;
|
||||
final MissionLimits limits;
|
||||
|
||||
void _set(WaypointMissionSpec Function(WaypointMissionSpec m) f) => editor.updateRoute(f);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: editor,
|
||||
builder: (BuildContext context, _) {
|
||||
final WaypointMissionSpec m = editor.mission;
|
||||
final List<MissionIssue> issues = m.validate(limits);
|
||||
return ListView(padding: const EdgeInsets.only(bottom: 16), children: <Widget>[
|
||||
ValueTile(
|
||||
label: 'Name',
|
||||
value: m.name,
|
||||
onTap: () async {
|
||||
final String? name = await showRenameDialog(context, m.name);
|
||||
if (name != null) _set((WaypointMissionSpec m) => m.copyWith(name: name));
|
||||
},
|
||||
),
|
||||
ChoiceTile(
|
||||
label: 'Route type',
|
||||
hint: 'Curved routes fly through waypoints without stopping.',
|
||||
options: <(String, String)>[for (final RoutePathMode v in RoutePathMode.values) (v.name, v.label)],
|
||||
selected: m.pathMode.name,
|
||||
onSelect: (String v) => _set((WaypointMissionSpec m) => m.copyWith(pathMode: RoutePathMode.values.byName(v))),
|
||||
),
|
||||
StepperTile(
|
||||
id: 'cruise',
|
||||
label: 'Cruise speed',
|
||||
shown: '${m.cruiseSpeed.toStringAsFixed(1)} m/s',
|
||||
canDown: m.cruiseSpeed > WaypointMissionSpec.minSpeed,
|
||||
canUp: m.cruiseSpeed < WaypointMissionSpec.maxSpeed,
|
||||
onStep: (int d) => _set((WaypointMissionSpec m) => m.copyWith(cruiseSpeed: WaypointEditor.speedRange.clamp(m.cruiseSpeed + d * 0.5))),
|
||||
),
|
||||
ChoiceTile(
|
||||
label: 'Heading',
|
||||
hint: m.headingMode == RouteHeadingMode.perPoint ? 'Set on each waypoint\'s Heading tab.' : null,
|
||||
options: <(String, String)>[
|
||||
for (final RouteHeadingMode v in RouteHeadingMode.values)
|
||||
if (v != RouteHeadingMode.poi || m.pois.isNotEmpty) (v.name, v.label),
|
||||
],
|
||||
selected: m.headingMode.name,
|
||||
onSelect: (String v) => _set((WaypointMissionSpec m) => m.copyWith(headingMode: RouteHeadingMode.values.byName(v))),
|
||||
),
|
||||
ChoiceTile(
|
||||
label: 'When finished',
|
||||
options: <(String, String)>[for (final RouteFinishAction v in RouteFinishAction.values) (v.name, v.label)],
|
||||
selected: m.finishAction.name,
|
||||
onSelect: (String v) => _set((WaypointMissionSpec m) => m.copyWith(finishAction: RouteFinishAction.values.byName(v))),
|
||||
),
|
||||
ChoiceTile(
|
||||
label: 'RC signal lost',
|
||||
hint: m.exitOnSignalLost ? 'The mission ends and the aircraft does its signal-lost action (Settings → Safety).' : 'The aircraft finishes the route on its own.',
|
||||
options: const <(String, String)>[('exit', 'End mission'), ('continue', 'Finish the route')],
|
||||
selected: m.exitOnSignalLost ? 'exit' : 'continue',
|
||||
onSelect: (String v) => _set((WaypointMissionSpec m) => m.copyWith(exitOnSignalLost: v == 'exit')),
|
||||
),
|
||||
ChoiceTile(
|
||||
label: 'To waypoint 1',
|
||||
options: <(String, String)>[for (final RouteGotoFirst v in RouteGotoFirst.values) (v.name, v.label)],
|
||||
selected: m.gotoFirst.name,
|
||||
onSelect: (String v) => _set((WaypointMissionSpec m) => m.copyWith(gotoFirst: RouteGotoFirst.values.byName(v))),
|
||||
),
|
||||
StepperTile(
|
||||
id: 'repeat',
|
||||
label: 'Fly the route',
|
||||
shown: m.repeatTimes == 1 ? 'once' : '${m.repeatTimes}×',
|
||||
canDown: m.repeatTimes > 1,
|
||||
canUp: m.repeatTimes < 10,
|
||||
onStep: (int d) => _set((WaypointMissionSpec m) => m.copyWith(repeatTimes: (m.repeatTimes + d).clamp(1, 10))),
|
||||
),
|
||||
ValueTile(label: 'Length', value: '${m.length.round()} m · ≈${formatDuration(m.estimatedSeconds)}'),
|
||||
if (issues.isNotEmpty) ...<Widget>[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||
child: Text('CHECKS', style: HudText.label),
|
||||
),
|
||||
for (final MissionIssue i in issues) IssueRow(issue: i, onTap: i.point == null ? null : () => editor.select(i.point)),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
ButtonTile(
|
||||
id: 'clear-route',
|
||||
label: 'Clear the route',
|
||||
button: 'Clear',
|
||||
danger: true,
|
||||
onTap: m.points.isEmpty && m.pois.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
if (await confirmHud(context, title: 'Clear the route?', body: 'Every waypoint and POI is removed. Undo brings them back.', action: 'Clear')) {
|
||||
editor.clear();
|
||||
}
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class IssueRow extends StatelessWidget {
|
||||
const IssueRow({super.key, required this.issue, this.onTap});
|
||||
|
||||
final MissionIssue issue;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
PVIcon('alert', size: 13, color: issue.blocks ? HudColors.danger : HudColors.caution),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(issue.message, style: HudText.sans.copyWith(fontSize: 11, fontWeight: FontWeight.w400, height: 1.3))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mission library ────────────────────────────────────────────────────────
|
||||
|
||||
/// The saved routes: save the current one, start a new one, load or delete.
|
||||
Future<void> showMissionLibrary(BuildContext context, {required WaypointEditor editor, required MissionLibrary library}) =>
|
||||
_showSheet(context, title: 'Mission library', icon: 'album', body: MissionLibraryList(editor: editor, library: library));
|
||||
|
||||
class MissionLibraryList extends StatelessWidget {
|
||||
const MissionLibraryList({super.key, required this.editor, required this.library});
|
||||
|
||||
final WaypointEditor editor;
|
||||
final MissionLibrary library;
|
||||
|
||||
Future<void> _save(BuildContext context) async {
|
||||
WaypointMissionSpec m = editor.mission;
|
||||
if (m.id.isEmpty) {
|
||||
final String? name = await showRenameDialog(context, m.name, title: 'Save route as');
|
||||
if (name == null) return;
|
||||
m = m.copyWith(name: name);
|
||||
}
|
||||
editor.markSaved(await library.save(m));
|
||||
}
|
||||
|
||||
Future<bool> _discardOk(BuildContext context) async {
|
||||
if (!editor.dirty || editor.mission.points.isEmpty) return true;
|
||||
return confirmHud(context, title: 'Discard changes?', body: 'The route on the map has unsaved changes.', action: 'Discard');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge(<Listenable>[editor, library]),
|
||||
builder: (BuildContext context, _) {
|
||||
final WaypointMissionSpec current = editor.mission;
|
||||
return ListView(padding: const EdgeInsets.only(bottom: 16), children: <Widget>[
|
||||
ButtonTile(
|
||||
id: 'save-route',
|
||||
label: current.id.isEmpty ? 'This route is not saved' : (editor.dirty ? 'Unsaved changes' : 'Saved'),
|
||||
button: 'Save',
|
||||
onTap: current.points.isEmpty || (!editor.dirty && current.id.isNotEmpty) ? null : () => _save(context),
|
||||
),
|
||||
ButtonTile(
|
||||
id: 'new-route',
|
||||
label: 'Start a new route',
|
||||
button: 'New',
|
||||
onTap: current.points.isEmpty && current.id.isEmpty
|
||||
? null
|
||||
: () async {
|
||||
if (await _discardOk(context)) editor.load(const WaypointMissionSpec());
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||
child: Text('SAVED ON THIS DEVICE', style: HudText.label),
|
||||
),
|
||||
if (library.missions.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text('No saved routes yet.', style: HudText.sans.copyWith(color: HudText.dim, fontWeight: FontWeight.w400)),
|
||||
),
|
||||
for (final WaypointMissionSpec m in library.missions)
|
||||
_MissionRow(
|
||||
mission: m,
|
||||
current: m.id == current.id,
|
||||
onLoad: () async {
|
||||
if (!await _discardOk(context)) return;
|
||||
editor.load(m);
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
onDelete: () async {
|
||||
if (await confirmHud(context, title: 'Delete "${m.name}"?', body: 'The saved route is removed from this device.', action: 'Delete')) {
|
||||
await library.delete(m.id);
|
||||
}
|
||||
},
|
||||
),
|
||||
]);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MissionRow extends StatelessWidget {
|
||||
const _MissionRow({required this.mission, required this.current, required this.onLoad, required this.onDelete});
|
||||
|
||||
final WaypointMissionSpec mission;
|
||||
final bool current;
|
||||
final VoidCallback onLoad;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DateTime? at = mission.updatedAt?.toLocal();
|
||||
final String when = at == null
|
||||
? ''
|
||||
: '${at.year}-${at.month.toString().padLeft(2, '0')}-${at.day.toString().padLeft(2, '0')} '
|
||||
'${at.hour.toString().padLeft(2, '0')}:${at.minute.toString().padLeft(2, '0')}';
|
||||
return GestureDetector(
|
||||
key: ValueKey<String>('mission-${mission.id}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onLoad,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 6, 8),
|
||||
color: current ? const Color(0x1A3D7BF0) : null,
|
||||
child: Row(children: <Widget>[
|
||||
const PVIcon('route', size: 15, color: Glass.ink),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
Text(mission.name, style: HudText.sans),
|
||||
const SizedBox(height: 2),
|
||||
Text('${mission.points.length} WP · ${mission.length.round()} m · $when', style: HudText.label),
|
||||
]),
|
||||
),
|
||||
IconButton(
|
||||
key: ValueKey<String>('mission-delete-${mission.id}'),
|
||||
onPressed: onDelete,
|
||||
icon: const PVIcon('close', size: 15, color: HudText.dim),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Small dialogs ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Asks for a route name. The dialog's own State owns the text controller:
|
||||
/// disposing it right after `showDialog` returns would crash the exit animation.
|
||||
Future<String?> showRenameDialog(BuildContext context, String initial, {String title = 'Route name'}) =>
|
||||
showDialog<String>(context: context, builder: (BuildContext context) => _RenameDialog(initial: initial, title: title));
|
||||
|
||||
class _RenameDialog extends StatefulWidget {
|
||||
const _RenameDialog({required this.initial, required this.title});
|
||||
|
||||
final String initial;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<_RenameDialog> createState() => _RenameDialogState();
|
||||
}
|
||||
|
||||
class _RenameDialogState extends State<_RenameDialog> {
|
||||
late final TextEditingController _c = TextEditingController(text: widget.initial);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _done() {
|
||||
final String v = _c.text.trim();
|
||||
Navigator.of(context).pop(v.isEmpty ? null : v);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AlertDialog(
|
||||
backgroundColor: const Color(0xF20B1426),
|
||||
title: Text(widget.title, style: HudText.sans.copyWith(fontSize: 14)),
|
||||
content: TextField(
|
||||
key: const ValueKey<String>('rename-field'),
|
||||
controller: _c,
|
||||
autofocus: true,
|
||||
maxLength: 40,
|
||||
style: HudText.sans.copyWith(fontWeight: FontWeight.w400),
|
||||
onSubmitted: (_) => _done(),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Cancel', style: HudText.sans.copyWith(color: HudText.dim))),
|
||||
FilledButton(
|
||||
key: const ValueKey<String>('rename-ok'),
|
||||
style: FilledButton.styleFrom(backgroundColor: PV.accent),
|
||||
onPressed: _done,
|
||||
child: Text('OK', style: HudText.sans.copyWith(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// A yes / no question in the HUD's style; true on [action].
|
||||
Future<bool> confirmHud(BuildContext context, {required String title, required String body, required String action}) async {
|
||||
final bool? ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
backgroundColor: const Color(0xF20B1426),
|
||||
title: Text(title, style: HudText.sans.copyWith(fontSize: 14)),
|
||||
content: Text(body, style: HudText.sans.copyWith(fontWeight: FontWeight.w400, height: 1.3)),
|
||||
actions: <Widget>[
|
||||
TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text('Cancel', style: HudText.sans.copyWith(color: HudText.dim))),
|
||||
FilledButton(
|
||||
key: const ValueKey<String>('confirm-ok'),
|
||||
style: FilledButton.styleFrom(backgroundColor: HudColors.danger),
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(action, style: HudText.sans.copyWith(color: Colors.white)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return ok == true;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../fly/hud_geometry.dart';
|
||||
import '../../../../fly/modes/waypoint_mission.dart';
|
||||
import '../../../../fly/safety_gate.dart';
|
||||
import '../../dialogs/slide_to_confirm.dart';
|
||||
|
||||
/// Starts a waypoint mission the GO 4 way (plan P7): the pre-start check —
|
||||
/// the [SafetyGate]'s mission checks against the aircraft's limits, the RTH
|
||||
/// altitude, the route summary — then slide to start. The gate re-checks,
|
||||
/// uploads (the run panel shows the progress) and starts.
|
||||
Future<void> startWaypointFlow(
|
||||
BuildContext context, {
|
||||
required SafetyGate gate,
|
||||
required WaypointMissionSpec spec,
|
||||
required void Function(String label, Object error) onError,
|
||||
}) async {
|
||||
final MissionLimits limits = await gate.missionLimits();
|
||||
if (!context.mounted) return;
|
||||
final List<GateCheck> checks = gate.checkWaypointStart(spec, limits);
|
||||
final double top = spec.points.fold(0.0, (double h, WaypointSpec p) => math.max(h, p.altitude));
|
||||
final int rth = (gate.model.aircraft.goHomeHeight ?? 60).round().clamp(SafetyGate.minRthHeight, SafetyGate.maxRthHeight);
|
||||
final String length = spec.length >= 1000 ? '${(spec.length / 1000).toStringAsFixed(2)} km' : '${spec.length.round()} m';
|
||||
|
||||
final SlideResult? r = await showSlideToConfirm(
|
||||
context,
|
||||
SlideConfirmSpec(
|
||||
icon: 'route',
|
||||
title: 'Start "${spec.name}"',
|
||||
description: '${spec.points.length} waypoints · $length · about ${formatDuration(spec.estimatedSeconds)}'
|
||||
'${spec.repeatTimes > 1 ? ' · ${spec.repeatTimes}×' : ''}. '
|
||||
'Highest waypoint ${top.round()} m. When finished: ${spec.finishAction.label.toLowerCase()}. '
|
||||
'Pause or Stop at any time, or press the remote\'s pause button.',
|
||||
sliderLabel: 'Slide to start',
|
||||
checks: checks,
|
||||
number: SlideNumber(label: 'RTH altitude', initial: rth, min: SafetyGate.minRthHeight, max: SafetyGate.maxRthHeight),
|
||||
),
|
||||
);
|
||||
if (r == null) return;
|
||||
try {
|
||||
await gate.startWaypointMission(spec, limits: limits, rthHeight: r.number);
|
||||
} catch (e) {
|
||||
onError('Waypoint mission', e);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../dji_service.dart';
|
||||
import '../flight_model.dart';
|
||||
import '../fly/modes/waypoint_mission.dart';
|
||||
import '../theme.dart';
|
||||
import 'pv_icons.dart';
|
||||
|
||||
@@ -58,13 +59,14 @@ class _MapPageState extends State<MapPage> {
|
||||
_snack('Drop at least 2 waypoints first');
|
||||
return;
|
||||
}
|
||||
final List<Map<String, dynamic>> points = _waypoints
|
||||
.map((LatLng p) => <String, dynamic>{'lat': p.latitude, 'lon': p.longitude, 'altitude': _altitude})
|
||||
.toList();
|
||||
final WaypointMissionSpec mission = WaypointMissionSpec(
|
||||
points: <WaypointSpec>[for (final LatLng p in _waypoints) WaypointSpec(at: p, altitude: _altitude)],
|
||||
cruiseSpeed: _speed,
|
||||
);
|
||||
try {
|
||||
await _dji.uploadWaypointMission(points, speed: _speed, finishAction: 'GO_HOME');
|
||||
await _dji.uploadWaypointMission(mission.toBridge());
|
||||
await _dji.startWaypointMission();
|
||||
if (mounted) _snack('Route running — ${points.length} waypoints');
|
||||
if (mounted) _snack('Route running — ${_waypoints.length} waypoints');
|
||||
} catch (e) {
|
||||
if (mounted) _snack('Route: ${e is PlatformException ? (e.message ?? e.code) : e}');
|
||||
}
|
||||
|
||||
@@ -33,6 +33,23 @@ class FakeDjiService extends DjiService {
|
||||
Future<void> setHomeLocation(double latitude, double longitude) => _record('setHomeLocation');
|
||||
@override
|
||||
Future<void> stopWaypointMission() => _record('stopWaypointMission');
|
||||
|
||||
// ── Missions (plan P7) ─────────────────────────────────────────────────────
|
||||
/// The last `uploadWaypointMission` payload.
|
||||
Map<String, Object?>? uploaded;
|
||||
|
||||
@override
|
||||
Future<void> uploadWaypointMission(Map<String, Object?> mission) {
|
||||
uploaded = mission;
|
||||
return _record('uploadWaypointMission(${(mission['waypoints'] as List<Object?>).length})');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> startWaypointMission() => _record('startWaypointMission');
|
||||
@override
|
||||
Future<void> pauseWaypointMission() => _record('pauseWaypointMission');
|
||||
@override
|
||||
Future<void> resumeWaypointMission() => _record('resumeWaypointMission');
|
||||
@override
|
||||
Future<void> stopActiveTrack() => _record('stopActiveTrack');
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:latlong2/latlong.dart' show LatLng;
|
||||
import 'package:pilotvault_fly/flight_model.dart';
|
||||
import 'package:pilotvault_fly/fly/action_rail_state.dart';
|
||||
import 'package:pilotvault_fly/fly/fly_prefs.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/intelligent_modes.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/mission_library.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/waypoint_editor.dart';
|
||||
import 'package:pilotvault_fly/fly/modes/waypoint_mission.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:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'fake_dji.dart';
|
||||
import 'profile_fixtures.dart';
|
||||
import 'telemetry_fixtures.dart';
|
||||
|
||||
const LatLng base = LatLng(55.6761, 12.5683);
|
||||
|
||||
/// [dLat] / [dLon] in metres from [base] (near enough at this latitude).
|
||||
LatLng at(double north, double east) => LatLng(base.latitude + north / 111195, base.longitude + east / 62790);
|
||||
|
||||
WaypointMissionSpec route(List<LatLng> pts, {double altitude = 50}) =>
|
||||
WaypointMissionSpec(points: <WaypointSpec>[for (final LatLng p in pts) WaypointSpec(at: p, altitude: altitude)]);
|
||||
|
||||
Map<String, dynamic> waypointEvent(String state, {Map<String, Object?>? upload, Map<String, Object?>? execution, String? event, String? error}) =>
|
||||
<String, dynamic>{
|
||||
'type': 'mission',
|
||||
'operator': 'waypoint',
|
||||
'state': state,
|
||||
if (upload != null) 'upload': upload,
|
||||
if (execution != null) 'execution': execution,
|
||||
if (event != null) 'event': event,
|
||||
if (error != null) 'error': error,
|
||||
};
|
||||
|
||||
FlightModel modelWith(Map<String, dynamic> tick, {String operator = 'READY_TO_UPLOAD'}) {
|
||||
final FlightModel m = FlightModel()
|
||||
..connected = true
|
||||
..profile = ProductProfile.fromBridge(phantom4);
|
||||
final TelemetryReducer r = TelemetryReducer(m);
|
||||
r.apply(tick);
|
||||
r.apply(waypointEvent(operator));
|
||||
return m;
|
||||
}
|
||||
|
||||
Map<String, dynamic> grounded() => telemetryTick()
|
||||
..['isFlying'] = false
|
||||
..['areMotorsOn'] = false
|
||||
..['flightTimeSeconds'] = 0;
|
||||
|
||||
void main() {
|
||||
group('WaypointMissionSpec.toBridge', () {
|
||||
test('maps the route settings to SDK enums and keeps per-point defaults', () {
|
||||
final WaypointMissionSpec m = route(<LatLng>[base, at(0, 100)]).copyWith(
|
||||
cruiseSpeed: 6,
|
||||
finishAction: RouteFinishAction.land,
|
||||
pathMode: RoutePathMode.straight,
|
||||
exitOnSignalLost: false,
|
||||
gotoFirst: RouteGotoFirst.pointToPoint,
|
||||
repeatTimes: 2,
|
||||
);
|
||||
final Map<String, Object?> b = m.toBridge();
|
||||
expect(b['autoFlightSpeed'], 6);
|
||||
expect(b['maxFlightSpeed'], WaypointMissionSpec.maxSpeed);
|
||||
expect(b['finishedAction'], 'AUTO_LAND');
|
||||
expect(b['headingMode'], 'AUTO');
|
||||
expect(b['flightPathMode'], 'NORMAL');
|
||||
expect(b['gotoFirstWaypointMode'], 'POINT_TO_POINT');
|
||||
expect(b['exitMissionOnRCSignalLost'], false);
|
||||
expect(b['gimbalPitchRotation'], false);
|
||||
expect(b['repeatTimes'], 2);
|
||||
final Map<String, Object?> w = (b['waypoints'] as List<Object?>).first as Map<String, Object?>;
|
||||
expect(w['speed'], 0.0, reason: '0 = the mission auto speed');
|
||||
expect(w['cornerRadius'], WaypointMissionSpec.minTurnRadius);
|
||||
expect(w.containsKey('heading'), isFalse, reason: 'only sent with per-waypoint headings');
|
||||
expect(w.containsKey('gimbalPitch'), isFalse);
|
||||
expect(w['actions'], isEmpty);
|
||||
});
|
||||
|
||||
test('a hover then the camera action, in that order', () {
|
||||
final WaypointMissionSpec m = WaypointMissionSpec(points: <WaypointSpec>[
|
||||
WaypointSpec(at: base, hoverSeconds: 3, action: WaypointCameraAction.photo),
|
||||
WaypointSpec(at: at(0, 50), action: WaypointCameraAction.startRecord),
|
||||
WaypointSpec(at: at(0, 100), hoverSeconds: 99, action: WaypointCameraAction.stopRecord),
|
||||
]);
|
||||
final List<Object?> wps = m.toBridge()['waypoints'] as List<Object?>;
|
||||
List<Object?> actions(int i) => (wps[i] as Map<String, Object?>)['actions'] as List<Object?>;
|
||||
expect(actions(0), <Object?>[
|
||||
<String, Object?>{'type': 'STAY', 'param': 3000},
|
||||
<String, Object?>{'type': 'START_TAKE_PHOTO', 'param': 0},
|
||||
]);
|
||||
expect(actions(1), <Object?>[<String, Object?>{'type': 'START_RECORD', 'param': 0}]);
|
||||
expect((actions(2).first as Map<String, Object?>)['param'], 32000, reason: 'STAY is capped at 32 s');
|
||||
});
|
||||
|
||||
test('curved routes send the turn radius', () {
|
||||
final WaypointMissionSpec m = WaypointMissionSpec(
|
||||
pathMode: RoutePathMode.curved,
|
||||
points: <WaypointSpec>[WaypointSpec(at: base, turnRadius: 5), WaypointSpec(at: at(0, 100), turnRadius: 0.01)],
|
||||
);
|
||||
final List<Object?> wps = m.toBridge()['waypoints'] as List<Object?>;
|
||||
expect((wps[0] as Map<String, Object?>)['cornerRadius'], 5);
|
||||
expect((wps[1] as Map<String, Object?>)['cornerRadius'], WaypointMissionSpec.minTurnRadius);
|
||||
});
|
||||
|
||||
test('Towards POI sends the first POI as the mission point of interest', () {
|
||||
final WaypointMissionSpec m = route(<LatLng>[base, at(0, 100)]).copyWith(
|
||||
headingMode: RouteHeadingMode.poi,
|
||||
pois: <MissionPoi>[MissionPoi(id: 'a', at: at(50, 50))],
|
||||
);
|
||||
final Map<String, Object?> b = m.toBridge();
|
||||
expect(b['headingMode'], 'TOWARD_POINT_OF_INTEREST');
|
||||
expect((b['pointOfInterest'] as Map<String, Object?>)['lat'], closeTo(at(50, 50).latitude, 1e-9));
|
||||
});
|
||||
});
|
||||
|
||||
group('heading resolution', () {
|
||||
test('route headings point at the next waypoint; the last keeps the final leg', () {
|
||||
final WaypointMissionSpec m = route(<LatLng>[base, at(0, 100), at(100, 100)]);
|
||||
final List<int> h = m.resolvedHeadings();
|
||||
expect(h[0], closeTo(90, 1));
|
||||
expect(h[1], closeTo(0, 1));
|
||||
expect(h[2], closeTo(0, 1));
|
||||
});
|
||||
|
||||
test('custom and POI headings, signed −180…180', () {
|
||||
final WaypointMissionSpec m = WaypointMissionSpec(
|
||||
headingMode: RouteHeadingMode.perPoint,
|
||||
pois: <MissionPoi>[MissionPoi(id: 'p', at: at(0, -100))],
|
||||
points: <WaypointSpec>[
|
||||
WaypointSpec(at: base, heading: WaypointHeading.custom, customHeading: 200),
|
||||
WaypointSpec(at: at(0, 100), heading: WaypointHeading.poi, poiId: 'p'),
|
||||
WaypointSpec(at: at(0, 200), heading: WaypointHeading.poi), // no POI linked → route
|
||||
],
|
||||
);
|
||||
final List<int> h = m.resolvedHeadings();
|
||||
expect(h[0], 180, reason: 'clamped');
|
||||
expect(h[1], closeTo(-90, 1), reason: 'west, towards the POI');
|
||||
expect(h[2], closeTo(90, 1));
|
||||
});
|
||||
|
||||
test('per-waypoint headings turn the short way round', () {
|
||||
final WaypointMissionSpec m = WaypointMissionSpec(
|
||||
headingMode: RouteHeadingMode.perPoint,
|
||||
points: <WaypointSpec>[
|
||||
WaypointSpec(at: base, heading: WaypointHeading.custom, customHeading: 170),
|
||||
WaypointSpec(at: at(0, 50), heading: WaypointHeading.custom, customHeading: -170),
|
||||
WaypointSpec(at: at(0, 100), heading: WaypointHeading.custom, customHeading: 170),
|
||||
],
|
||||
);
|
||||
final List<Object?> wps = m.toBridge()['waypoints'] as List<Object?>;
|
||||
String turn(int i) => (wps[i] as Map<String, Object?>)['turnMode']! as String;
|
||||
expect((wps[0] as Map<String, Object?>)['heading'], 170);
|
||||
expect(turn(1), 'CLOCKWISE', reason: '170 → −170 is 20° clockwise');
|
||||
expect(turn(2), 'COUNTER_CLOCKWISE');
|
||||
});
|
||||
});
|
||||
|
||||
group('gimbal resolution', () {
|
||||
test('no pitch anywhere leaves the gimbal to the pilot', () {
|
||||
expect(route(<LatLng>[base, at(0, 100)]).resolvedGimbal(), isNull);
|
||||
});
|
||||
|
||||
test('a pitch carries forward, and leading waypoints take the first one', () {
|
||||
final WaypointMissionSpec m = WaypointMissionSpec(points: <WaypointSpec>[
|
||||
WaypointSpec(at: base),
|
||||
WaypointSpec(at: at(0, 50), gimbalPitch: -30),
|
||||
WaypointSpec(at: at(0, 100)),
|
||||
WaypointSpec(at: at(0, 150), gimbalPitch: -120),
|
||||
]);
|
||||
expect(m.resolvedGimbal(), <double>[-30, -30, -30, -90]);
|
||||
expect(m.toBridge()['gimbalPitchRotation'], isTrue);
|
||||
});
|
||||
|
||||
test('aim at POI tilts down by the drop over the distance', () {
|
||||
final WaypointMissionSpec m = WaypointMissionSpec(
|
||||
pois: <MissionPoi>[MissionPoi(id: 'p', at: at(50, 0))],
|
||||
points: <WaypointSpec>[
|
||||
WaypointSpec(at: base, altitude: 50, poiId: 'p', gimbalAimPoi: true),
|
||||
WaypointSpec(at: at(0, 100)),
|
||||
],
|
||||
);
|
||||
expect(m.resolvedGimbal()![0], closeTo(-45, 0.5));
|
||||
});
|
||||
});
|
||||
|
||||
group('validate', () {
|
||||
List<String> errors(WaypointMissionSpec m, [MissionLimits l = const MissionLimits()]) =>
|
||||
<String>[for (final MissionIssue i in m.validate(l)) if (i.blocks) i.message];
|
||||
List<String> warnings(WaypointMissionSpec m) => <String>[for (final MissionIssue i in m.validate()) if (!i.blocks) i.message];
|
||||
|
||||
test('a valid route has no errors', () {
|
||||
expect(errors(route(<LatLng>[base, at(0, 100), at(100, 100)])), isEmpty);
|
||||
});
|
||||
|
||||
test('needs two waypoints', () {
|
||||
expect(errors(route(<LatLng>[base])), contains('Add at least 2 waypoints.'));
|
||||
});
|
||||
|
||||
test('altitude above the aircraft limit, and low waypoints warn', () {
|
||||
final WaypointMissionSpec m = WaypointMissionSpec(points: <WaypointSpec>[
|
||||
WaypointSpec(at: base, altitude: 150),
|
||||
WaypointSpec(at: at(0, 100), altitude: 5),
|
||||
]);
|
||||
expect(errors(m, const MissionLimits(maxAltitude: 120)).single, contains('above the 120 m altitude limit'));
|
||||
expect(warnings(m).single, contains('only 5 m above'));
|
||||
});
|
||||
|
||||
test('waypoints on top of each other, or more than 2 km apart', () {
|
||||
expect(errors(route(<LatLng>[base, base])).single, contains('on top of each other'));
|
||||
expect(errors(route(<LatLng>[base, at(0, 2500)])).single, contains('more than 2 km apart'));
|
||||
});
|
||||
|
||||
test('beyond the distance limit from home', () {
|
||||
final List<String> e = errors(route(<LatLng>[base, at(0, 600)]), MissionLimits(maxRadius: 500, home: base));
|
||||
expect(e.single, contains('Waypoint 2 is beyond the 500 m distance limit'));
|
||||
});
|
||||
|
||||
test('curved: overlapping radii are refused, skipped actions warned about', () {
|
||||
final WaypointMissionSpec m = WaypointMissionSpec(pathMode: RoutePathMode.curved, points: <WaypointSpec>[
|
||||
WaypointSpec(at: base, turnRadius: 30, action: WaypointCameraAction.photo),
|
||||
WaypointSpec(at: at(0, 50), turnRadius: 30),
|
||||
]);
|
||||
expect(errors(m).single, contains('turn radii'));
|
||||
expect(warnings(m).single, contains('skipped'));
|
||||
});
|
||||
|
||||
test('Towards POI without a POI', () {
|
||||
expect(errors(route(<LatLng>[base, at(0, 100)]).copyWith(headingMode: RouteHeadingMode.poi)).single, contains('needs a point of interest'));
|
||||
});
|
||||
});
|
||||
|
||||
test('length and time estimate', () {
|
||||
final WaypointMissionSpec m = route(<LatLng>[base, at(0, 100), at(100, 100)]).copyWith(cruiseSpeed: 10);
|
||||
expect(m.length, closeTo(200, 2));
|
||||
// 200 m at 10 m/s + one 3 s stop at the middle waypoint.
|
||||
expect(m.estimatedSeconds, closeTo(23, 1));
|
||||
});
|
||||
|
||||
test('JSON round trip keeps everything', () {
|
||||
final WaypointMissionSpec m = WaypointMissionSpec(
|
||||
id: 'wp-1',
|
||||
name: 'Bridge survey',
|
||||
cruiseSpeed: 7.5,
|
||||
headingMode: RouteHeadingMode.perPoint,
|
||||
finishAction: RouteFinishAction.firstWaypoint,
|
||||
pathMode: RoutePathMode.curved,
|
||||
exitOnSignalLost: false,
|
||||
gotoFirst: RouteGotoFirst.pointToPoint,
|
||||
repeatTimes: 3,
|
||||
updatedAt: DateTime.utc(2026, 10, 20, 9),
|
||||
pois: <MissionPoi>[MissionPoi(id: 'p', at: at(10, 10), height: 12)],
|
||||
points: <WaypointSpec>[
|
||||
WaypointSpec(at: base, altitude: 40, speed: 4, heading: WaypointHeading.custom, customHeading: -45, gimbalPitch: -20, turnRadius: 3),
|
||||
WaypointSpec(at: at(0, 100), poiId: 'p', heading: WaypointHeading.poi, gimbalAimPoi: true, action: WaypointCameraAction.photo, hoverSeconds: 4),
|
||||
],
|
||||
);
|
||||
final WaypointMissionSpec back = WaypointMissionSpec.fromJson(m.toJson());
|
||||
expect(back.toJson(), m.toJson());
|
||||
});
|
||||
|
||||
group('MissionLibrary', () {
|
||||
setUp(() => SharedPreferences.setMockInitialValues(<String, Object>{}));
|
||||
|
||||
test('save gives an id, newest first, survives a reload; delete removes', () async {
|
||||
final MissionLibrary lib = MissionLibrary();
|
||||
await lib.load();
|
||||
final WaypointMissionSpec a = await lib.save(route(<LatLng>[base, at(0, 100)]).copyWith(name: 'A'), now: DateTime(2026, 1, 1));
|
||||
final WaypointMissionSpec b = await lib.save(route(<LatLng>[base, at(0, 50)]).copyWith(name: 'B'), now: DateTime(2026, 1, 2));
|
||||
expect(a.id, isNotEmpty);
|
||||
expect(lib.missions.map((WaypointMissionSpec m) => m.name), <String>['B', 'A']);
|
||||
await lib.save(a.copyWith(name: 'A2'));
|
||||
expect(lib.missions.map((WaypointMissionSpec m) => m.name), <String>['A2', 'B'], reason: 'same id replaces');
|
||||
|
||||
final MissionLibrary again = MissionLibrary();
|
||||
await again.load();
|
||||
expect(again.missions.map((WaypointMissionSpec m) => m.name), <String>['A2', 'B']);
|
||||
await again.delete(b.id);
|
||||
expect(again.missions.single.name, 'A2');
|
||||
});
|
||||
|
||||
test('a corrupt entry is skipped, not the whole library', () {
|
||||
final List<WaypointMissionSpec> out = MissionLibrary.decode('[{"name":"ok","points":[]}, {"points": 7}, 3]');
|
||||
expect(out.map((WaypointMissionSpec m) => m.name), <String>['ok']);
|
||||
});
|
||||
});
|
||||
|
||||
group('WaypointEditor', () {
|
||||
test('a new waypoint copies the one before, and is selected', () {
|
||||
final WaypointEditor e = WaypointEditor()..addPoint(base);
|
||||
e.updatePoint(0, (WaypointSpec p) => p.copyWith(altitude: 80, speed: 5, gimbalPitch: -40));
|
||||
e.addPoint(at(0, 100));
|
||||
expect(e.selected, 1);
|
||||
expect(e.mission.points[1].altitude, 80);
|
||||
expect(e.mission.points[1].speed, 5);
|
||||
expect(e.mission.points[1].gimbalPitch, -40);
|
||||
});
|
||||
|
||||
test('a tap adds what the tool says', () {
|
||||
final WaypointEditor e = WaypointEditor()..tapMap(base);
|
||||
e.setTool(MapTool.poi);
|
||||
e.tapMap(at(10, 10));
|
||||
expect(e.mission.points, hasLength(1));
|
||||
expect(e.mission.pois, hasLength(1));
|
||||
expect(e.selectedPoi, e.mission.pois.single.id);
|
||||
expect(e.selected, isNull);
|
||||
});
|
||||
|
||||
test('insert a midpoint, remove, undo', () {
|
||||
final WaypointEditor e = WaypointEditor()
|
||||
..addPoint(base)
|
||||
..addPoint(at(0, 100));
|
||||
e.updatePoint(1, (WaypointSpec p) => p.copyWith(altitude: 100));
|
||||
e.insertMidpoint(0);
|
||||
expect(e.mission.points, hasLength(3));
|
||||
expect(e.mission.points[1].at.longitude, closeTo(at(0, 50).longitude, 1e-9));
|
||||
expect(e.mission.points[1].altitude, 75);
|
||||
expect(e.selected, 1);
|
||||
|
||||
e.removePoint(1);
|
||||
expect(e.mission.points, hasLength(2));
|
||||
e.undo();
|
||||
expect(e.mission.points, hasLength(3));
|
||||
e.undo();
|
||||
expect(e.mission.points, hasLength(2), reason: 'the midpoint insert is one step');
|
||||
});
|
||||
|
||||
test('a drag is one undo step', () {
|
||||
final WaypointEditor e = WaypointEditor()
|
||||
..addPoint(base)
|
||||
..addPoint(at(0, 100));
|
||||
e.beginDrag();
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
e.movePoint(1, at(i * 10, 100));
|
||||
}
|
||||
e.undo();
|
||||
expect(e.mission.points[1].at, at(0, 100));
|
||||
});
|
||||
|
||||
test('a per-point heading switches the route to per-waypoint headings', () {
|
||||
final WaypointEditor e = WaypointEditor()
|
||||
..addPoint(base)
|
||||
..addPoint(at(0, 100));
|
||||
expect(e.mission.headingMode, RouteHeadingMode.auto);
|
||||
e.setHeading(0, WaypointHeading.custom, custom: 45);
|
||||
expect(e.mission.headingMode, RouteHeadingMode.perPoint);
|
||||
expect(e.mission.points[0].customHeading, 45);
|
||||
});
|
||||
|
||||
test('linking a POI faces it; removing the POI unlinks every waypoint', () {
|
||||
final WaypointEditor e = WaypointEditor()
|
||||
..addPoint(base)
|
||||
..addPoint(at(0, 100))
|
||||
..addPoi(at(50, 50));
|
||||
final String id = e.mission.pois.single.id;
|
||||
e.linkPoi(0, id);
|
||||
e.linkPoi(1, id);
|
||||
e.updatePoint(1, (WaypointSpec p) => p.copyWith(gimbalAimPoi: true));
|
||||
expect(e.mission.points[0].heading, WaypointHeading.poi);
|
||||
expect(e.mission.headingMode, RouteHeadingMode.perPoint);
|
||||
e.removePoi(id);
|
||||
expect(e.mission.points.every((WaypointSpec p) => p.poiId == null && !p.gimbalAimPoi && p.heading == WaypointHeading.route), isTrue);
|
||||
});
|
||||
|
||||
test('apply to all copies the selected waypoint\'s field', () {
|
||||
final WaypointEditor e = WaypointEditor()
|
||||
..addPoint(base)
|
||||
..addPoint(at(0, 100))
|
||||
..addPoint(at(0, 200));
|
||||
e.select(1);
|
||||
e.updatePoint(1, (WaypointSpec p) => p.copyWith(action: WaypointCameraAction.photo, hoverSeconds: 2, altitude: 70));
|
||||
e.applyToAll(PointField.action);
|
||||
expect(e.mission.points.every((WaypointSpec p) => p.action == WaypointCameraAction.photo && p.hoverSeconds == 2), isTrue);
|
||||
expect(e.mission.points[0].altitude, 50, reason: 'only the one field');
|
||||
});
|
||||
|
||||
test('loading resets history and dirtiness', () {
|
||||
final WaypointEditor e = WaypointEditor()..addPoint(base);
|
||||
expect(e.dirty, isTrue);
|
||||
e.load(route(<LatLng>[base, at(0, 100)]));
|
||||
expect(e.dirty, isFalse);
|
||||
expect(e.canUndo, isFalse);
|
||||
expect(e.selected, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('MissionState (waypoint operator)', () {
|
||||
test('upload progress, execution, pause, finish', () {
|
||||
final MissionState s = MissionState()
|
||||
..apply(waypointEvent('UPLOADING', upload: <String, Object?>{'index': 2, 'total': 6, 'summary': true}));
|
||||
expect(s.state, 'uploading');
|
||||
expect(s.waypoint.uploadFraction, closeTo(0.5, 1e-9));
|
||||
|
||||
s.apply(waypointEvent('READY_TO_EXECUTE'));
|
||||
expect(s.state, 'ready');
|
||||
s.apply(waypointEvent('EXECUTING', event: 'start'));
|
||||
s.apply(waypointEvent('EXECUTING', execution: <String, Object?>{'target': 3, 'reached': false, 'executeState': 'MOVING', 'total': 6}));
|
||||
expect(s.state, 'executing');
|
||||
expect(s.waypoint.target, 3);
|
||||
expect(s.waypoint.executeLabel, 'Flying');
|
||||
|
||||
s.apply(waypointEvent('EXECUTION_PAUSED'));
|
||||
expect(s.state, 'executing', reason: 'paused still needs Stop');
|
||||
expect(s.waypoint.paused, isTrue);
|
||||
expect(s.waypoint.target, 3, reason: 'progress kept across state-only events');
|
||||
|
||||
s.apply(waypointEvent('READY_TO_UPLOAD', event: 'finish'));
|
||||
expect(s.state, 'idle');
|
||||
expect(s.waypointResult, '');
|
||||
expect(s.waypointFinishes, 1);
|
||||
s.apply(waypointEvent('READY_TO_UPLOAD', event: 'finish', error: 'Mission interrupted'));
|
||||
expect(s.waypointResult, 'Mission interrupted');
|
||||
expect(s.waypointFinishes, 2);
|
||||
});
|
||||
|
||||
test('ActiveTrack keeps its legacy state alongside the operator', () {
|
||||
final MissionState s = MissionState()
|
||||
..apply(<String, dynamic>{'type': 'mission', 'state': 'tracking'})
|
||||
..apply(waypointEvent('READY_TO_UPLOAD'));
|
||||
expect(s.tracking, isTrue);
|
||||
});
|
||||
|
||||
test('a paused mission still offers Stop on the rail', () {
|
||||
final MissionState s = MissionState()..apply(waypointEvent('EXECUTION_PAUSED'));
|
||||
final AircraftState a = AircraftState()..apply(telemetryTick());
|
||||
expect(RailState.of(connected: true, a: a, mission: s).mode, ActiveMode.waypoint);
|
||||
});
|
||||
});
|
||||
|
||||
group('SafetyGate (waypoints)', () {
|
||||
final WaypointMissionSpec ok = route(<LatLng>[at(5, 5), at(0, 100), at(100, 100)]);
|
||||
List<String> blockers(FlightModel m, [WaypointMissionSpec? spec]) =>
|
||||
<String>[for (final GateCheck c in SafetyGate(model: m, dji: FakeDjiService()).checkWaypointStart(spec ?? ok)) if (c.blocks) c.message];
|
||||
List<String> warnings(FlightModel m) =>
|
||||
<String>[for (final GateCheck c in SafetyGate(model: m, dji: FakeDjiService()).checkWaypointStart(ok)) if (!c.blocks) c.message];
|
||||
|
||||
test('airborne, good GPS and battery: nothing blocks', () {
|
||||
expect(blockers(modelWith(telemetryTick())), isEmpty);
|
||||
});
|
||||
|
||||
test('disconnected, unsupported or busy operator', () {
|
||||
expect(blockers(FlightModel()), <String>['No aircraft connected.']);
|
||||
expect(blockers(modelWith(telemetryTick(), operator: 'NOT_SUPPORTED')), contains('This aircraft cannot fly waypoint missions.'));
|
||||
expect(blockers(modelWith(telemetryTick(), operator: 'EXECUTING')), contains('A waypoint mission is already running.'));
|
||||
expect(blockers(modelWith(telemetryTick(), operator: 'UPLOADING')), contains('A mission is still uploading.'));
|
||||
});
|
||||
|
||||
test('route errors block', () {
|
||||
expect(blockers(modelWith(telemetryTick()), route(<LatLng>[base])), contains('Add at least 2 waypoints.'));
|
||||
});
|
||||
|
||||
test('weak GPS, no home, going home, critical battery', () {
|
||||
expect(blockers(modelWith(telemetryTick()..['gpsSignalLevel'] = 1)), contains('GPS too weak for a waypoint mission.'));
|
||||
expect(blockers(modelWith(telemetryTick()..['isHomeLocationSet'] = false)), contains('No home point recorded yet.'));
|
||||
expect(blockers(modelWith(telemetryTick()..['isGoingHome'] = true)), contains('Cancel Return to Home first.'));
|
||||
expect(blockers(modelWith(telemetryTick(battery: 10))).any((String b) => b.startsWith('Battery critically low')), isTrue);
|
||||
});
|
||||
|
||||
test('on the ground it warns that the aircraft takes off by itself', () {
|
||||
expect(warnings(modelWith(grounded())), contains('The aircraft takes off and flies to waypoint 1 on its own.'));
|
||||
expect(warnings(modelWith(telemetryTick())), isNot(contains('The aircraft takes off and flies to waypoint 1 on its own.')));
|
||||
});
|
||||
|
||||
test('a route longer than the battery warns', () {
|
||||
final FlightModel m = modelWith(telemetryTick());
|
||||
final WaypointMissionSpec long = route(<LatLng>[base, at(0, 1900), at(1900, 1900), at(1900, 0)]).copyWith(cruiseSpeed: 2);
|
||||
final List<GateCheck> c = SafetyGate(model: m, dji: FakeDjiService()).checkWaypointStart(long);
|
||||
expect(c.any((GateCheck g) => !g.blocks && g.message.contains('of flight left')), isTrue);
|
||||
});
|
||||
|
||||
test('start writes the RTH altitude, uploads, then starts', () async {
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
final SafetyGate gate = SafetyGate(model: modelWith(telemetryTick()), dji: dji);
|
||||
int uploadedAt = -1;
|
||||
await gate.startWaypointMission(ok, rthHeight: 80, onUploaded: () => uploadedAt = dji.calls.length);
|
||||
expect(dji.calls, <String>['setGoHomeHeight(80)', 'uploadWaypointMission(3)', 'startWaypointMission']);
|
||||
expect(uploadedAt, 2);
|
||||
expect((dji.uploaded!['waypoints'] as List<Object?>).length, 3);
|
||||
});
|
||||
|
||||
test('start refuses a blocked mission without touching the aircraft', () async {
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
final SafetyGate gate = SafetyGate(model: modelWith(telemetryTick()..['gpsSignalLevel'] = 0), dji: dji);
|
||||
await expectLater(gate.startWaypointMission(ok), throwsA(isA<MissionRefused>()));
|
||||
expect(dji.calls, isEmpty);
|
||||
});
|
||||
|
||||
test('pause only while flying the mission, resume only while paused', () async {
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
final FlightModel m = modelWith(telemetryTick(), operator: 'EXECUTING');
|
||||
final SafetyGate gate = SafetyGate(model: m, dji: dji);
|
||||
await expectLater(gate.resumeWaypointMission(), throwsA(isA<MissionRefused>()));
|
||||
await gate.pauseWaypointMission();
|
||||
m.mission.apply(waypointEvent('EXECUTION_PAUSED'));
|
||||
await expectLater(gate.pauseWaypointMission(), throwsA(isA<MissionRefused>()));
|
||||
await gate.resumeWaypointMission();
|
||||
expect(dji.calls, <String>['pauseWaypointMission', 'resumeWaypointMission']);
|
||||
});
|
||||
|
||||
test('Stop on the rail stops a paused mission', () async {
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
final FlightModel m = modelWith(telemetryTick(), operator: 'EXECUTION_PAUSED');
|
||||
await SafetyGate(model: m, dji: dji).run(FlightCommand.stopMode);
|
||||
expect(dji.calls, <String>['stopWaypointMission']);
|
||||
});
|
||||
|
||||
test('missionLimits reads the flight controller and the gimbal range', () async {
|
||||
final MissionLimits l = await SafetyGate(model: modelWith(telemetryTick()), dji: FakeDjiService()).missionLimits();
|
||||
expect(l.maxAltitude, 120);
|
||||
expect(l.maxRadius, 500);
|
||||
expect(l.home, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
test('the catalogue lists only the product\'s modes, all of them when disconnected', () {
|
||||
final ProductProfile p4 = ProductProfile.fromBridge(phantom4);
|
||||
final Set<IntelligentMode> listed = <IntelligentMode>{for (final ModeInfo i in ModeCatalog.forProfile(p4)) i.mode};
|
||||
expect(listed, p4.modes);
|
||||
expect(ModeCatalog.forProfile(ProductProfile.disconnected), hasLength(ModeCatalog.all.length));
|
||||
expect(ModeCatalog.of(IntelligentMode.waypoint).built, isTrue);
|
||||
});
|
||||
|
||||
group('FlyScreen (waypoints)', () {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
FlyPrefs.acceptedModeTerms.value = <String>{};
|
||||
});
|
||||
|
||||
Future<FakeDjiService> pump(WidgetTester tester, FlightModel m, {WaypointEditor? editor}) async {
|
||||
tester.view.physicalSize = const Size(844, 390);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final FakeDjiService dji = FakeDjiService();
|
||||
await tester.pumpWidget(MaterialApp(home: FlyScreen(model: m, dji: dji, waypoints: editor)));
|
||||
await tester.pump();
|
||||
return dji;
|
||||
}
|
||||
|
||||
Future<void> openWaypoints(WidgetTester tester) async {
|
||||
await tester.tap(find.byKey(const ValueKey<String>('rail-modes')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey<String>('mode-waypoint')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
testWidgets('MODES → Waypoints asks the terms once, then opens the editor on the map', (WidgetTester tester) async {
|
||||
final FlightModel m = modelWith(grounded());
|
||||
await pump(tester, m);
|
||||
expect(find.byKey(const ValueKey<String>('rail-modes')), findsOneWidget);
|
||||
|
||||
await openWaypoints(tester);
|
||||
expect(find.byKey(const ValueKey<String>('terms-agree')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey<String>('terms-disagree')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byKey(const ValueKey<String>('wp-tool-start')), findsNothing, reason: 'disagreeing stays out of the mode');
|
||||
|
||||
await openWaypoints(tester);
|
||||
await tester.tap(find.byKey(const ValueKey<String>('terms-agree')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byKey(const ValueKey<String>('wp-tool-start')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey<String>('wp-board')), findsOneWidget);
|
||||
expect(FlyPrefs.acceptedModeTerms.value, contains('waypoint'));
|
||||
|
||||
// Second time round: straight in.
|
||||
await tester.tap(find.byKey(const ValueKey<String>('wp-tool-exit')));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey<String>('wp-tool-start')), findsNothing);
|
||||
await openWaypoints(tester);
|
||||
expect(find.byKey(const ValueKey<String>('terms-agree')), findsNothing);
|
||||
expect(find.byKey(const ValueKey<String>('wp-tool-start')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the point editor sets the selected waypoint and replaces the attitude ball', (WidgetTester tester) async {
|
||||
FlyPrefs.acceptedModeTerms.value = <String>{'waypoint'};
|
||||
final FlightModel m = modelWith(grounded());
|
||||
final WaypointEditor e = WaypointEditor();
|
||||
await pump(tester, m, editor: e);
|
||||
await openWaypoints(tester);
|
||||
|
||||
// Taps on the map tiles are hard to aim in a test; drive the editor's own entry point.
|
||||
e.addPoint(at(5, 5));
|
||||
e.addPoint(at(0, 100));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const ValueKey<String>('readout-altitude')), findsOneWidget);
|
||||
expect(find.text('WAYPOINT 2/2'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey<String>('readout-altitude-plus')));
|
||||
await tester.pump();
|
||||
expect(e.mission.points[1].altitude, 51);
|
||||
|
||||
// Swipe up 5 steps.
|
||||
await tester.drag(find.byKey(const ValueKey<String>('readout-altitude')), const Offset(0, -44));
|
||||
await tester.pump();
|
||||
expect(e.mission.points[1].altitude, greaterThanOrEqualTo(55));
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey<String>('tab-action')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey<String>('choice-Photo')));
|
||||
await tester.pump();
|
||||
expect(e.mission.points[1].action, WaypointCameraAction.photo);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey<String>('wp-deselect')));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey<String>('readout-altitude')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Start: pre-start check, slide, upload, start', (WidgetTester tester) async {
|
||||
FlyPrefs.acceptedModeTerms.value = <String>{'waypoint'};
|
||||
final FlightModel m = modelWith(telemetryTick());
|
||||
final WaypointEditor e = WaypointEditor();
|
||||
final FakeDjiService dji = await pump(tester, m, editor: e);
|
||||
await openWaypoints(tester);
|
||||
e.addPoint(at(5, 5));
|
||||
e.addPoint(at(0, 100));
|
||||
e.select(null);
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey<String>('wp-tool-start')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.textContaining('Start "New route"'), findsOneWidget);
|
||||
expect(find.text('SLIDE TO START'), findsOneWidget);
|
||||
|
||||
await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dji.calls, containsAllInOrder(<String>['uploadWaypointMission(2)', 'startWaypointMission']));
|
||||
|
||||
// The operator reports the flight: the editor's tools give way to the run panel.
|
||||
TelemetryReducer(m).apply(waypointEvent('EXECUTING', execution: <String, Object?>{'target': 0, 'reached': false, 'executeState': 'MOVING', 'total': 2}));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey<String>('wp-run')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey<String>('wp-tool-start')), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey<String>('wp-run-pause')));
|
||||
await tester.pump();
|
||||
expect(dji.calls.last, 'pauseWaypointMission');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user