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:
tajniak81andClaude Opus 5.5 committed 2026-10-20 16:22:53 +02:00
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))
}
+13 -11
View File
@@ -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,
+11
View File
@@ -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;
+4 -1
View File
@@ -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];
}
+101
View File
@@ -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;
}
}
+309
View File
@@ -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));
}
}
+624
View File
@@ -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;
}
+155
View File
@@ -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;
+135 -3
View File
@@ -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();
}
}
+18 -1
View File
@@ -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>[
+159 -28
View File
@@ -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,
+29 -10
View File
@@ -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,
),
),
]);
},
);
+214
View File
@@ -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);
}
}
+7 -5
View File
@@ -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}');
}
+17
View File
@@ -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
+649
View File
@@ -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');
});
});
}