diff --git a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MissionBridge.kt b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MissionBridge.kt index c8f73c1..676e28f 100644 --- a/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MissionBridge.kt +++ b/Fly App/android/app/src/main/kotlin/com/pilotvault/flyapp/MissionBridge.kt @@ -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? = null, + execution: Map? = null, + event: String? = null, + error: DJIError? = null, + ) { + val m = linkedMapOf("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("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 > enumArg(call: MethodCall, name: String, fallback: T): T { + val raw = call.argument(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>>("points") ?: emptyList() - if (raw.size < 2) { + val raw = call.argument>>("waypoints") ?: emptyList() + if (raw.size < WaypointMission.MIN_WAYPOINT_COUNT) { ctx.fail(result, "A route needs at least 2 waypoints") return } - val speed = (call.argument("speed") ?: 8.0).toFloat() - val finish = try { - WaypointMissionFinishedAction.valueOf(call.argument("finishAction") ?: "NO_ACTION") - } catch (_: IllegalArgumentException) { - WaypointMissionFinishedAction.NO_ACTION + + val waypoints = ArrayList(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> ?: 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("autoFlightSpeed") ?: 8).toFloat()) + .maxFlightSpeed((call.argument("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("exitMissionOnRCSignalLost") ?: true) + .setGimbalPitchRotationEnabled(call.argument("gimbalPitchRotation") ?: false) + .repeatTimes(call.argument("repeatTimes")?.toInt() ?: 1) + .waypointList(waypoints) .waypointCount(waypoints.size) - .build() + call.argument>("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)) } diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index adccdca..9bd2ef8 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -263,22 +263,24 @@ class DjiService { Future resetGimbalControlParameters() => _methods.invokeMethod('resetGimbalControlParameters'); // ── Missions ──────────────────────────────────────────────────────────────── - /// points: [{lat, lon, altitude}], finishAction: NO_ACTION|GO_HOME|AUTO_LAND|GO_FIRST_WAYPOINT - Future uploadWaypointMission( - List> points, { - double speed = 8, - String finishAction = 'NO_ACTION', - }) => - _methods.invokeMethod('uploadWaypointMission', { - '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 uploadWaypointMission(Map mission) => + _methods.invokeMethod('uploadWaypointMission', mission); + Future cancelWaypointUpload() => _methods.invokeMethod('cancelWaypointUpload'); + + /// The operator's `WaypointMissionState` name; also re-published as an event. + Future getWaypointMissionState() => _methods.invokeMethod('getWaypointMissionState'); Future startWaypointMission() => _methods.invokeMethod('startWaypointMission'); Future stopWaypointMission() => _methods.invokeMethod('stopWaypointMission'); Future pauseWaypointMission() => _methods.invokeMethod('pauseWaypointMission'); Future resumeWaypointMission() => _methods.invokeMethod('resumeWaypointMission'); + /// Changes the cruise speed of the mission in flight (m/s, −15…15). + Future setWaypointAutoFlightSpeed(double speed) => + _methods.invokeMethod('setWaypointAutoFlightSpeed', {'speed': speed}); + /// Rect is normalized 0..1 in the live view. mode: TRACE|PROFILE|SPOTLIGHT|QUICK_SHOT Future startActiveTrack( double x, diff --git a/Fly App/lib/fly/fly_prefs.dart b/Fly App/lib/fly/fly_prefs.dart index 2bead54..560fbac 100644 --- a/Fly App/lib/fly/fly_prefs.dart +++ b/Fly App/lib/fly/fly_prefs.dart @@ -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 showRoute = ValueNotifier(true); + /// Intelligent flight modes whose first-use terms the pilot accepted (plan P7), by name. + static final ValueNotifier> acceptedModeTerms = ValueNotifier>({}); + static SharedPreferences? _prefs; static Future 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 []).toSet(); } catch (_) { // Storage unavailable: run on the defaults. } @@ -92,6 +97,12 @@ abstract final class FlyPrefs { await _prefs?.setBool(_routeKey, on); } + static Future acceptModeTerms(String mode) async { + if (acceptedModeTerms.value.contains(mode)) return; + acceptedModeTerms.value = {...acceptedModeTerms.value, mode}; + await _prefs?.setStringList(_termsKey, acceptedModeTerms.value.toList()..sort()); + } + static T? _byName(List values, String? name) { for (final T v in values) { if (v.name == name) return v; diff --git a/Fly App/lib/fly/hud_features.dart b/Fly App/lib/fly/hud_features.dart index f99e450..2200744 100644 --- a/Fly App/lib/fly/hud_features.dart +++ b/Fly App/lib/fly/hud_features.dart @@ -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 modes = [ 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, ]; diff --git a/Fly App/lib/fly/modes/intelligent_modes.dart b/Fly App/lib/fly/modes/intelligent_modes.dart new file mode 100644 index 0000000..29fcc77 --- /dev/null +++ b/Fly App/lib/fly/modes/intelligent_modes.dart @@ -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 terms; + + /// This app can fly the mode. + final bool built; +} + +abstract final class ModeCatalog { + static const List _common = [ + '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 all = [ + ModeInfo( + mode: IntelligentMode.waypoint, + label: 'Waypoints', + icon: 'route', + group: 'Map', + blurb: 'Plan a route on the map and fly it.', + built: true, + terms: [ + ..._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 groups = ['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 forProfile(ProductProfile p) => + [for (final ModeInfo i in all) if (!p.connected || p.supportsMode(i.mode)) i]; +} diff --git a/Fly App/lib/fly/modes/mission_library.dart b/Fly App/lib/fly/modes/mission_library.dart new file mode 100644 index 0000000..c37493f --- /dev/null +++ b/Fly App/lib/fly/modes/mission_library.dart @@ -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 _missions = []; + 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 get missions => List.unmodifiable(_missions); + bool get loaded => _loaded; + + Future load() async { + try { + _prefs ??= await SharedPreferences.getInstance(); + final String? raw = _prefs!.getString(_key); + _missions = raw == null ? [] : decode(raw); + } catch (_) { + _missions = []; + } + _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 save(WaypointMissionSpec m, {DateTime? now}) async { + final WaypointMissionSpec stored = m.copyWith( + id: m.id.isEmpty ? newId(now) : m.id, + updatedAt: now ?? DateTime.now(), + ); + _missions = [ + stored, + for (final WaypointMissionSpec o in _missions) if (o.id != stored.id) o, + ]; + await _persist(); + return stored; + } + + Future delete(String id) async { + _missions = [for (final WaypointMissionSpec o in _missions) if (o.id != id) o]; + await _persist(); + } + + Future _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 missions) => + jsonEncode(>[for (final WaypointMissionSpec m in missions) m.toJson()]); + + /// Unreadable entries are skipped rather than losing the whole library. + static List decode(String raw) { + final Object? list = jsonDecode(raw); + if (list is! List) return []; + final List out = []; + for (final Object? e in list) { + if (e is! Map) continue; + try { + out.add(WaypointMissionSpec.fromJson(Map.from(e))); + } catch (_) { + continue; + } + } + return out; + } +} diff --git a/Fly App/lib/fly/modes/waypoint_editor.dart b/Fly App/lib/fly/modes/waypoint_editor.dart new file mode 100644 index 0000000..79542b3 --- /dev/null +++ b/Fly App/lib/fly/modes/waypoint_editor.dart @@ -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 _undo = []; + 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 [], pois: const [])); + 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 pts = List.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 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 pts = List.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 pts = List.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 pts = List.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 pts = [ + 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: [..._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: [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: [for (final MissionPoi p in _mission.pois) if (p.id != id) p], + points: [ + 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 pts = List.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)); + } +} diff --git a/Fly App/lib/fly/modes/waypoint_mission.dart b/Fly App/lib/fly/modes/waypoint_mission.dart new file mode 100644 index 0000000..566d73a --- /dev/null +++ b/Fly App/lib/fly/modes/waypoint_mission.dart @@ -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 toJson() => {'id': id, 'lat': at.latitude, 'lon': at.longitude, 'height': height}; + + static MissionPoi fromJson(Map 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 toJson() => { + '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 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 [], + this.pois = const [], + 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 points; + final List 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? points, + List? 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 get legs => [ + 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 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 resolvedHeadings() { + final List out = []; + 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? resolvedGimbal({double min = -90, double max = 30}) { + final List own = [ + 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 [ + 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 toBridge({double gimbalMin = -90, double gimbalMax = 30}) { + final bool perPoint = headingMode == RouteHeadingMode.perPoint; + final List headings = resolvedHeadings(); + final List? pitch = resolvedGimbal(min: gimbalMin, max: gimbalMax); + final bool curved = pathMode == RoutePathMode.curved; + + final List> wps = >[]; + 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({ + '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': >[ + if (p.hoverSeconds > 0) {'type': 'STAY', 'param': (p.hoverSeconds.clamp(0, maxHoverSeconds) * 1000).clamp(0, 32767)}, + if (p.action == WaypointCameraAction.photo) const {'type': 'START_TAKE_PHOTO', 'param': 0}, + if (p.action == WaypointCameraAction.startRecord) const {'type': 'START_RECORD', 'param': 0}, + if (p.action == WaypointCameraAction.stopRecord) const {'type': 'STOP_RECORD', 'param': 0}, + ], + }); + } + final MissionPoi? missionPoi = pois.isEmpty ? null : pois.first; + return { + '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': {'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 validate([MissionLimits limits = const MissionLimits()]) { + final List out = []; + 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 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 toJson() => { + 'version': formatVersion, + 'id': id, + 'name': name, + 'points': >[for (final WaypointSpec p in points) p.toJson()], + 'pois': >[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 j) => WaypointMissionSpec( + id: j['id'] as String? ?? '', + name: j['name'] as String? ?? 'Route', + points: [ + for (final Object? p in (j['points'] as List? ?? const [])) + if (p is Map) WaypointSpec.fromJson(Map.from(p)), + ], + pois: [ + for (final Object? p in (j['pois'] as List? ?? const [])) + if (p is Map) MissionPoi.fromJson(Map.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(List values, Object? name) { + for (final T v in values) { + if (v.name == name) return v; + } + return null; +} diff --git a/Fly App/lib/fly/safety_gate.dart b/Fly App/lib/fly/safety_gate.dart index b869c2a..b031848 100644 --- a/Fly App/lib/fly/safety_gate.dart +++ b/Fly App/lib/fly/safety_gate.dart @@ -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 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() async { + Future read(String key) async { + if (!model.connected) return null; + try { + return await dji.getKey(DjiKey.flightController(key)); + } catch (_) { + return null; + } + } + + final List v = await Future.wait(>[ + read('MAX_FLIGHT_HEIGHT'), + read('MAX_FLIGHT_RADIUS'), + read('MAX_FLIGHT_RADIUS_ENABLED'), + ]); + 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 checkWaypointStart(WaypointMissionSpec spec, [MissionLimits limits = const MissionLimits()]) { + final List checks = []; + 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 startWaypointMission( + WaypointMissionSpec spec, { + MissionLimits limits = const MissionLimits(), + int? rthHeight, + void Function()? onUploaded, + }) async { + final List 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 pauseWaypointMission() async { + if (!model.mission.waypoint.executing) { + throw const MissionRefused([GateCheck.block('No waypoint mission is flying.')]); + } + await dji.pauseWaypointMission(); + } + + Future resumeWaypointMission() async { + final List checks = [ + 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; diff --git a/Fly App/lib/fly/state/mission_state.dart b/Fly App/lib/fly/state/mission_state.dart index 49ebd62..0a0af75 100644 --- a/Fly App/lib/fly/state/mission_state.dart +++ b/Fly App/lib/fly/state/mission_state.dart @@ -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 e, WaypointProgress previous) { + final Map? up = asMap(e['upload']); + final Map? 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 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 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(); } } diff --git a/Fly App/lib/ui/fly/bottom/minimap.dart b/Fly App/lib/ui/fly/bottom/minimap.dart index f7bb1e2..66d0bb5 100644 --- a/Fly App/lib/ui/fly/bottom/minimap.dart +++ b/Fly App/lib/ui/fly/bottom/minimap.dart @@ -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 [], + this.onTap, + }); final FlightModel model; final FlightTrail trail; final MinimapController controller; final bool interactive; + final List layers; + + /// A tap on the map itself (not on a marker), while [interactive]. + final ValueChanged? onTap; @override State createState() => _FlyMapState(); @@ -121,6 +136,7 @@ class _FlyMapState extends State { onPositionChanged: (MapCamera _, bool hasGesture) { if (hasGesture) widget.controller.setFollow(false); }, + onTap: widget.interactive && widget.onTap != null ? (TapPosition _, LatLng at) => widget.onTap!(at) : null, ), children: [ TileLayer( @@ -137,6 +153,7 @@ class _FlyMapState extends State { ]); }, ), + ...widget.layers, ListenableBuilder( listenable: _a, builder: (BuildContext context, _) => MarkerLayer(markers: [ diff --git a/Fly App/lib/ui/fly/fly_screen.dart b/Fly App/lib/ui/fly/fly_screen.dart index 5ea6747..f568531 100644 --- a/Fly App/lib/ui/fly/fly_screen.dart +++ b/Fly App/lib/ui/fly/fly_screen.dart @@ -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 createState() => _FlyScreenState(); } @@ -82,6 +103,19 @@ class _FlyScreenState extends State { 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 { 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 _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 { @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.portraitUp]); @@ -141,6 +222,7 @@ class _FlyScreenState extends State { 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('video'), @@ -153,6 +235,10 @@ class _FlyScreenState extends State { 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: [ @@ -254,11 +340,12 @@ class _FlyScreenState extends State { ), ), ), - 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 { ), ), 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: [ - 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: [ + 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: [ + 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 { Positioned( left: 0, top: 60, - child: ValueListenableBuilder( - 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) ...[ + 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, diff --git a/Fly App/lib/ui/fly/left/action_rail.dart b/Fly App/lib/ui/fly/left/action_rail.dart index db5be68..b89f62d 100644 --- a/Fly App/lib/ui/fly/left/action_rail.dart +++ b/Fly App/lib/ui/fly/left/action_rail.dart @@ -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: [ 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('rail-modes'), + icon: 'master', + label: 'MODES', + color: modeActive ? Glass.accent : Glass.pillStrong, + onTap: onModes, + ), + ), ]); }, ); diff --git a/Fly App/lib/ui/fly/modes/mode_picker.dart b/Fly App/lib/ui/fly/modes/mode_picker.dart new file mode 100644 index 0000000..362345f --- /dev/null +++ b/Fly App/lib/ui/fly/modes/mode_picker.dart @@ -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 showModePicker(BuildContext context, {required ProductProfile profile}) async { + final IntelligentMode? picked = await showGeneralDialog( + 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 a, _, Widget child) => FadeTransition( + opacity: a, + child: ScaleTransition(scale: Tween(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 ensureModeTerms(BuildContext context, ModeInfo info) async { + if (FlyPrefs.acceptedModeTerms.value.contains(info.mode.name)) return true; + final bool? agreed = await showDialog( + 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 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: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 10, 6, 6), + child: Row(children: [ + 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('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: [ + for (final String g in ModeCatalog.groups) + if (modes.any((ModeInfo m) => m.group == g)) ...[ + Padding( + padding: const EdgeInsets.only(top: 6, bottom: 8), + child: Text(g.toUpperCase(), style: HudText.label), + ), + Wrap(spacing: 10, runSpacing: 10, children: [ + 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('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: [ + Row(children: [ + 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: [ + Row(children: [ + 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: [ + for (final String t in info.terms) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + 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: [ + TextButton( + key: const ValueKey('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('terms-agree'), + style: FilledButton.styleFrom(backgroundColor: PV.accent), + onPressed: () => Navigator.of(context).pop(true), + child: Text('Agree', style: HudText.sans.copyWith(color: Colors.white)), + ), + ]), + ]), + ), + ), + ); + } +} diff --git a/Fly App/lib/ui/fly/modes/waypoints/point_editor.dart b/Fly App/lib/ui/fly/modes/waypoints/point_editor.dart new file mode 100644 index 0000000..57d5d9f --- /dev/null +++ b/Fly App/lib/ui/fly/modes/waypoints/point_editor.dart @@ -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 tabs = [ + 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: [ + Row(children: [ + _IconBtn(key: const ValueKey('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('wp-next'), + icon: 'chevronRight', + onTap: _i + 1 < _m.points.length ? () => editor.selectNext(1) : null, + ), + const Spacer(), + _TextBtn(key: const ValueKey('wp-apply-all'), label: 'APPLY TO ALL', onTap: () => editor.applyToAll(field)), + const SizedBox(width: 6), + _IconBtn(key: const ValueKey('wp-delete'), icon: 'close', color: HudColors.danger, onTap: () => editor.removePoint(_i)), + _IconBtn(key: const ValueKey('wp-deselect'), icon: 'chevronDown', onTap: () => editor.select(null)), + ]), + const SizedBox(height: 4), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: [ + for (final PointField f in tabs) + Padding( + padding: const EdgeInsets.only(right: 4), + child: HudChip(key: ValueKey('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: [ + _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: [ + _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: [ + _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: [ + _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: [ + _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 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: [ + Row(children: [ + 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('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('poi-delete'), icon: 'close', color: HudColors.danger, onTap: () => editor.removePoi(poi.id)), + _IconBtn(key: const ValueKey('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: [ + for (final (String label, bool on, VoidCallback tap) in options) + HudChip(key: ValueKey('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-`, `readout--minus`, `readout--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 createState() => _SwipeReadoutState(); +} + +class _SwipeReadoutState extends State { + 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('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: [ + btn('−', -1), + Expanded( + child: GestureDetector( + key: ValueKey('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: [ + Row(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ + 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)), + ), + ), + ); +} diff --git a/Fly App/lib/ui/fly/modes/waypoints/waypoint_layers.dart b/Fly App/lib/ui/fly/modes/waypoints/waypoint_layers.dart new file mode 100644 index 0000000..d0a747a --- /dev/null +++ b/Fly App/lib/ui/fly/modes/waypoints/waypoint_layers.dart @@ -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 waypointMapLayers({required WaypointEditor editor, required MissionState mission, required bool editable}) { + final Listenable both = Listenable.merge([editor, mission]); + return [ + ListenableBuilder( + key: const ValueKey('wp-lines'), + listenable: both, + builder: (BuildContext context, _) => PolylineLayer(polylines: _lines(editor.mission, mission.waypoint)), + ), + if (editable) + ListenableBuilder( + key: const ValueKey('wp-handles'), + listenable: editor, + builder: (BuildContext context, _) => MarkerLayer(markers: _handles(editor)), + ), + ListenableBuilder( + key: const ValueKey('wp-markers'), + listenable: both, + builder: (BuildContext context, _) => MarkerLayer(markers: [ + ..._pois(editor, editable), + ..._points(editor, mission.waypoint, editable), + ]), + ), + ]; +} + +const Color _route = Color(0xFF5B93F5); +const Color _flown = Color(0x99EAF0FA); +const Color _poiColor = Color(0xFFB98CF5); + +List> _lines(WaypointMissionSpec m, WaypointProgress run) { + final List> out = >[]; + final List 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( + points: [p.at, poi.at], + strokeWidth: 1.2, + color: const Color(0x99B98CF5), + pattern: StrokePattern.dashed(segments: const [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( + points: [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( + points: [pts.last.at, pts.first.at], + strokeWidth: 2, + color: const Color(0x995B93F5), + pattern: StrokePattern.dashed(segments: const [8, 6]), + )); + } + return out; +} + +List _handles(WaypointEditor e) { + final List pts = e.mission.points; + if (pts.length >= WaypointMissionSpec.maxPoints) return const []; + return [ + for (int i = 0; i + 1 < pts.length; i++) + Marker( + key: ValueKey('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 _pois(WaypointEditor e, bool editable) { + return [ + 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('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 _points(WaypointEditor e, WaypointProgress run, bool editable) { + final WaypointMissionSpec m = e.mission; + final bool perPoint = m.headingMode == RouteHeadingMode.perPoint; + final List headings = perPoint ? m.resolvedHeadings() : const []; + final int? target = run.active ? run.target : null; + return [ + 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('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: [ + SizedBox( + width: 34, + height: 34, + child: Stack(alignment: Alignment.center, children: [ + 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 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(at.dx, at.dy))); + }, + onLongPressEnd: (_) { + _startScreen = null; + _startGlobal = null; + }, + child: widget.child, + ); + } +} diff --git a/Fly App/lib/ui/fly/modes/waypoints/waypoint_panels.dart b/Fly App/lib/ui/fly/modes/waypoints/waypoint_panels.dart new file mode 100644 index 0000000..bcec5ec --- /dev/null +++ b/Fly App/lib/ui/fly/modes/waypoints/waypoint_panels.dart @@ -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 issues = m.points.isEmpty ? const [] : 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('wp-board'), + height: 30, + color: Glass.pillStrong, + onTap: onTap, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + 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) ...[ + const SizedBox(width: 8), + _badge(errors > 0 ? '$errors' : '$warnings', errors > 0 ? HudColors.danger : HudColors.caution), + ], + if (hint.isNotEmpty) ...[ + _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: [ + Text(v, style: HudText.mono.copyWith(fontWeight: FontWeight.w700)), + if (unit.isNotEmpty) ...[const SizedBox(width: 2), Text(unit, style: HudText.unit)], + ]); + + static Widget _badge(String n, Color c) => Container( + key: const ValueKey('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: [ + _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('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: [ + 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 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([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: [ + 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: [ + 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) ...[ + 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('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 _guard(String label, Future 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('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: [ + 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)), + ]), + ), + ); +} diff --git a/Fly App/lib/ui/fly/modes/waypoints/waypoint_sheets.dart b/Fly App/lib/ui/fly/modes/waypoints/waypoint_sheets.dart new file mode 100644 index 0000000..d215021 --- /dev/null +++ b/Fly App/lib/ui/fly/modes/waypoints/waypoint_sheets.dart @@ -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 _showSheet(BuildContext context, {required String title, required String icon, required Widget body}) { + return showGeneralDialog( + 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: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 4, 6), + child: Row(children: [ + 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('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 a, _, Widget child) => SlideTransition( + position: Tween(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 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 issues = m.validate(limits); + return ListView(padding: const EdgeInsets.only(bottom: 16), children: [ + 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) ...[ + 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: [ + 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 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 _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 _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([editor, library]), + builder: (BuildContext context, _) { + final WaypointMissionSpec current = editor.mission; + return ListView(padding: const EdgeInsets.only(bottom: 16), children: [ + 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('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: [ + const PVIcon('route', size: 15, color: Glass.ink), + const SizedBox(width: 10), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + 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('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 showRenameDialog(BuildContext context, String initial, {String title = 'Route name'}) => + showDialog(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('rename-field'), + controller: _c, + autofocus: true, + maxLength: 40, + style: HudText.sans.copyWith(fontWeight: FontWeight.w400), + onSubmitted: (_) => _done(), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(), child: Text('Cancel', style: HudText.sans.copyWith(color: HudText.dim))), + FilledButton( + key: const ValueKey('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 confirmHud(BuildContext context, {required String title, required String body, required String action}) async { + final bool? ok = await showDialog( + 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: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text('Cancel', style: HudText.sans.copyWith(color: HudText.dim))), + FilledButton( + key: const ValueKey('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; +} diff --git a/Fly App/lib/ui/fly/modes/waypoints/waypoint_start.dart b/Fly App/lib/ui/fly/modes/waypoints/waypoint_start.dart new file mode 100644 index 0000000..299c99e --- /dev/null +++ b/Fly App/lib/ui/fly/modes/waypoints/waypoint_start.dart @@ -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 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 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); + } +} diff --git a/Fly App/lib/ui/map_page.dart b/Fly App/lib/ui/map_page.dart index b3ecb18..8b14238 100644 --- a/Fly App/lib/ui/map_page.dart +++ b/Fly App/lib/ui/map_page.dart @@ -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 { _snack('Drop at least 2 waypoints first'); return; } - final List> points = _waypoints - .map((LatLng p) => {'lat': p.latitude, 'lon': p.longitude, 'altitude': _altitude}) - .toList(); + final WaypointMissionSpec mission = WaypointMissionSpec( + points: [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}'); } diff --git a/Fly App/test/fly/fake_dji.dart b/Fly App/test/fly/fake_dji.dart index 9b3840a..7c7beb6 100644 --- a/Fly App/test/fly/fake_dji.dart +++ b/Fly App/test/fly/fake_dji.dart @@ -33,6 +33,23 @@ class FakeDjiService extends DjiService { Future setHomeLocation(double latitude, double longitude) => _record('setHomeLocation'); @override Future stopWaypointMission() => _record('stopWaypointMission'); + + // ── Missions (plan P7) ───────────────────────────────────────────────────── + /// The last `uploadWaypointMission` payload. + Map? uploaded; + + @override + Future uploadWaypointMission(Map mission) { + uploaded = mission; + return _record('uploadWaypointMission(${(mission['waypoints'] as List).length})'); + } + + @override + Future startWaypointMission() => _record('startWaypointMission'); + @override + Future pauseWaypointMission() => _record('pauseWaypointMission'); + @override + Future resumeWaypointMission() => _record('resumeWaypointMission'); @override Future stopActiveTrack() => _record('stopActiveTrack'); @override diff --git a/Fly App/test/fly/waypoint_test.dart b/Fly App/test/fly/waypoint_test.dart new file mode 100644 index 0000000..30bb72b --- /dev/null +++ b/Fly App/test/fly/waypoint_test.dart @@ -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 pts, {double altitude = 50}) => + WaypointMissionSpec(points: [for (final LatLng p in pts) WaypointSpec(at: p, altitude: altitude)]); + +Map waypointEvent(String state, {Map? upload, Map? execution, String? event, String? error}) => + { + '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 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 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([base, at(0, 100)]).copyWith( + cruiseSpeed: 6, + finishAction: RouteFinishAction.land, + pathMode: RoutePathMode.straight, + exitOnSignalLost: false, + gotoFirst: RouteGotoFirst.pointToPoint, + repeatTimes: 2, + ); + final Map 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 w = (b['waypoints'] as List).first as Map; + 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(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 wps = m.toBridge()['waypoints'] as List; + List actions(int i) => (wps[i] as Map)['actions'] as List; + expect(actions(0), [ + {'type': 'STAY', 'param': 3000}, + {'type': 'START_TAKE_PHOTO', 'param': 0}, + ]); + expect(actions(1), [{'type': 'START_RECORD', 'param': 0}]); + expect((actions(2).first as Map)['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(at: base, turnRadius: 5), WaypointSpec(at: at(0, 100), turnRadius: 0.01)], + ); + final List wps = m.toBridge()['waypoints'] as List; + expect((wps[0] as Map)['cornerRadius'], 5); + expect((wps[1] as Map)['cornerRadius'], WaypointMissionSpec.minTurnRadius); + }); + + test('Towards POI sends the first POI as the mission point of interest', () { + final WaypointMissionSpec m = route([base, at(0, 100)]).copyWith( + headingMode: RouteHeadingMode.poi, + pois: [MissionPoi(id: 'a', at: at(50, 50))], + ); + final Map b = m.toBridge(); + expect(b['headingMode'], 'TOWARD_POINT_OF_INTEREST'); + expect((b['pointOfInterest'] as Map)['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([base, at(0, 100), at(100, 100)]); + final List 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(id: 'p', at: at(0, -100))], + points: [ + 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 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(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 wps = m.toBridge()['waypoints'] as List; + String turn(int i) => (wps[i] as Map)['turnMode']! as String; + expect((wps[0] as Map)['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([base, at(0, 100)]).resolvedGimbal(), isNull); + }); + + test('a pitch carries forward, and leading waypoints take the first one', () { + final WaypointMissionSpec m = WaypointMissionSpec(points: [ + 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(), [-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(id: 'p', at: at(50, 0))], + points: [ + 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 errors(WaypointMissionSpec m, [MissionLimits l = const MissionLimits()]) => + [for (final MissionIssue i in m.validate(l)) if (i.blocks) i.message]; + List warnings(WaypointMissionSpec m) => [for (final MissionIssue i in m.validate()) if (!i.blocks) i.message]; + + test('a valid route has no errors', () { + expect(errors(route([base, at(0, 100), at(100, 100)])), isEmpty); + }); + + test('needs two waypoints', () { + expect(errors(route([base])), contains('Add at least 2 waypoints.')); + }); + + test('altitude above the aircraft limit, and low waypoints warn', () { + final WaypointMissionSpec m = WaypointMissionSpec(points: [ + 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([base, base])).single, contains('on top of each other')); + expect(errors(route([base, at(0, 2500)])).single, contains('more than 2 km apart')); + }); + + test('beyond the distance limit from home', () { + final List e = errors(route([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(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([base, at(0, 100)]).copyWith(headingMode: RouteHeadingMode.poi)).single, contains('needs a point of interest')); + }); + }); + + test('length and time estimate', () { + final WaypointMissionSpec m = route([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(id: 'p', at: at(10, 10), height: 12)], + points: [ + 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({})); + + 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([base, at(0, 100)]).copyWith(name: 'A'), now: DateTime(2026, 1, 1)); + final WaypointMissionSpec b = await lib.save(route([base, at(0, 50)]).copyWith(name: 'B'), now: DateTime(2026, 1, 2)); + expect(a.id, isNotEmpty); + expect(lib.missions.map((WaypointMissionSpec m) => m.name), ['B', 'A']); + await lib.save(a.copyWith(name: 'A2')); + expect(lib.missions.map((WaypointMissionSpec m) => m.name), ['A2', 'B'], reason: 'same id replaces'); + + final MissionLibrary again = MissionLibrary(); + await again.load(); + expect(again.missions.map((WaypointMissionSpec m) => m.name), ['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 out = MissionLibrary.decode('[{"name":"ok","points":[]}, {"points": 7}, 3]'); + expect(out.map((WaypointMissionSpec m) => m.name), ['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([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: {'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: {'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({'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([at(5, 5), at(0, 100), at(100, 100)]); + List blockers(FlightModel m, [WaypointMissionSpec? spec]) => + [for (final GateCheck c in SafetyGate(model: m, dji: FakeDjiService()).checkWaypointStart(spec ?? ok)) if (c.blocks) c.message]; + List warnings(FlightModel m) => + [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()), ['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([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([base, at(0, 1900), at(1900, 1900), at(1900, 0)]).copyWith(cruiseSpeed: 2); + final List 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, ['setGoHomeHeight(80)', 'uploadWaypointMission(3)', 'startWaypointMission']); + expect(uploadedAt, 2); + expect((dji.uploaded!['waypoints'] as List).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())); + 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())); + await gate.pauseWaypointMission(); + m.mission.apply(waypointEvent('EXECUTION_PAUSED')); + await expectLater(gate.pauseWaypointMission(), throwsA(isA())); + await gate.resumeWaypointMission(); + expect(dji.calls, ['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, ['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 listed = {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({}); + FlyPrefs.acceptedModeTerms.value = {}; + }); + + Future 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 openWaypoints(WidgetTester tester) async { + await tester.tap(find.byKey(const ValueKey('rail-modes'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byKey(const ValueKey('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('rail-modes')), findsOneWidget); + + await openWaypoints(tester); + expect(find.byKey(const ValueKey('terms-agree')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('terms-disagree'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byKey(const ValueKey('wp-tool-start')), findsNothing, reason: 'disagreeing stays out of the mode'); + + await openWaypoints(tester); + await tester.tap(find.byKey(const ValueKey('terms-agree'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byKey(const ValueKey('wp-tool-start')), findsOneWidget); + expect(find.byKey(const ValueKey('wp-board')), findsOneWidget); + expect(FlyPrefs.acceptedModeTerms.value, contains('waypoint')); + + // Second time round: straight in. + await tester.tap(find.byKey(const ValueKey('wp-tool-exit'))); + await tester.pump(); + expect(find.byKey(const ValueKey('wp-tool-start')), findsNothing); + await openWaypoints(tester); + expect(find.byKey(const ValueKey('terms-agree')), findsNothing); + expect(find.byKey(const ValueKey('wp-tool-start')), findsOneWidget); + }); + + testWidgets('the point editor sets the selected waypoint and replaces the attitude ball', (WidgetTester tester) async { + FlyPrefs.acceptedModeTerms.value = {'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('readout-altitude')), findsOneWidget); + expect(find.text('WAYPOINT 2/2'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('readout-altitude-plus'))); + await tester.pump(); + expect(e.mission.points[1].altitude, 51); + + // Swipe up 5 steps. + await tester.drag(find.byKey(const ValueKey('readout-altitude')), const Offset(0, -44)); + await tester.pump(); + expect(e.mission.points[1].altitude, greaterThanOrEqualTo(55)); + + await tester.tap(find.byKey(const ValueKey('tab-action'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('choice-Photo'))); + await tester.pump(); + expect(e.mission.points[1].action, WaypointCameraAction.photo); + + await tester.tap(find.byKey(const ValueKey('wp-deselect'))); + await tester.pump(); + expect(find.byKey(const ValueKey('readout-altitude')), findsNothing); + }); + + testWidgets('Start: pre-start check, slide, upload, start', (WidgetTester tester) async { + FlyPrefs.acceptedModeTerms.value = {'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('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(['uploadWaypointMission(2)', 'startWaypointMission'])); + + // The operator reports the flight: the editor's tools give way to the run panel. + TelemetryReducer(m).apply(waypointEvent('EXECUTING', execution: {'target': 0, 'reached': false, 'executeState': 'MOVING', 'total': 2})); + await tester.pump(); + expect(find.byKey(const ValueKey('wp-run')), findsOneWidget); + expect(find.byKey(const ValueKey('wp-tool-start')), findsNothing); + await tester.tap(find.byKey(const ValueKey('wp-run-pause'))); + await tester.pump(); + expect(dji.calls.last, 'pauseWaypointMission'); + }); + }); +}