From 2c908cf17e3f46183a1f6378057f0a6036f40d16 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:11:09 +0200 Subject: [PATCH] Give the Fly App GO 4-style ActiveTrack and TapFly P7b of the GO 4-parity plan. Both modes open from the MODES picker on the live video, where they take over the touches the camera and gimbal used. ActiveTrack: drag a box round the subject (or tap it), then GO. The operator's own box follows on the video, coloured by confidence, with confirm / reject when it asks and the reason when it cannot follow. Trace, Profile and Spotlight switch live from the mode panel. TapFly: tap where to fly, then GO; the aircraft's heading point and any obstacle bypass show while it flies, and the speed changes live. Forward only on aircraft without rear sensors; Backward and Free otherwise. Both go through new SafetyGate checks (flying, P mode, above 2 m, nothing else in control, battery), start on GO when the check is clean and ask for the slide when it warns. Stop on the rail and Exit stop them. MissionBridge now listens to the ActiveTrack and TapFly operators. Not yet run on a simulator or aircraft. Co-Authored-By: Claude Opus 5.5 --- .../com/pilotvault/flyapp/MissionBridge.kt | 116 ++++++- Fly App/lib/dji_service.dart | 27 ++ Fly App/lib/fly/action_rail_state.dart | 7 +- Fly App/lib/fly/modes/intelligent_modes.dart | 13 +- Fly App/lib/fly/modes/vision_modes.dart | 123 +++++++ Fly App/lib/fly/safety_gate.dart | 130 ++++++++ Fly App/lib/fly/state/mission_state.dart | 188 ++++++++++- Fly App/lib/ui/fly/fly_screen.dart | 93 ++++++ .../ui/fly/modes/vision/vision_layers.dart | 278 ++++++++++++++++ .../lib/ui/fly/modes/vision/vision_panel.dart | 222 +++++++++++++ Fly App/test/fly/fake_dji.dart | 18 + Fly App/test/fly/safety_test.dart | 6 +- Fly App/test/fly/vision_modes_test.dart | 310 ++++++++++++++++++ 13 files changed, 1519 insertions(+), 12 deletions(-) create mode 100644 Fly App/lib/fly/modes/vision_modes.dart create mode 100644 Fly App/lib/ui/fly/modes/vision/vision_layers.dart create mode 100644 Fly App/lib/ui/fly/modes/vision/vision_panel.dart create mode 100644 Fly App/test/fly/vision_modes_test.dart 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 676e28f..fe2b72d 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,10 +1,14 @@ package com.pilotvault.flyapp +import android.graphics.PointF import android.graphics.RectF import dji.common.error.DJIError import dji.common.mission.activetrack.ActiveTrackMission +import dji.common.mission.activetrack.ActiveTrackMissionEvent import dji.common.mission.activetrack.ActiveTrackMode import dji.common.mission.activetrack.QuickShotMode +import dji.common.mission.tapfly.TapFlyMission +import dji.common.mission.tapfly.TapFlyMode import dji.common.mission.waypoint.Waypoint import dji.common.mission.waypoint.WaypointAction import dji.common.mission.waypoint.WaypointActionType @@ -19,7 +23,11 @@ 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.ActiveTrackMissionOperatorListener import dji.sdk.mission.activetrack.ActiveTrackOperator +import dji.sdk.mission.tapfly.TapFlyMissionEvent +import dji.sdk.mission.tapfly.TapFlyMissionOperator +import dji.sdk.mission.tapfly.TapFlyMissionOperatorListener import dji.sdk.mission.waypoint.WaypointMissionOperator import dji.sdk.mission.waypoint.WaypointMissionOperatorListener import dji.sdk.sdkmanager.DJISDKManager @@ -44,7 +52,18 @@ import io.flutter.plugin.common.MethodChannel * `operator: 'waypoint'`: `state` (WaypointMissionState), `upload` * {index, total, summary}, `execution` {target, reached, executeState, * total}, `event` (`start` / `finish`) and `error`. - * - ActiveTrack subject tracking → [ActiveTrackOperator]. + * - ActiveTrack subject tracking → [ActiveTrackOperator]. Its listener goes + * out as `mission` events with `operator: 'activeTrack'`: `state` + * (ActiveTrackState), `tracking` {targetState, targetType, rect: {l, t, r, b} + * normalised to the live view, index, percent, reason}, `error`. Methods: + * `startActiveTrack {x, y, w, h, mode}` (checked with `canStartTracking` + * first), `acceptActiveTrackConfirmation`, `rejectActiveTrackConfirmation`, + * `switchActiveTrackMode {mode}`, `setActiveTrackRetreat {on}`, `stopActiveTrack`. + * - TapFly → [TapFlyMissionOperator]: `mission` events with `operator: + * 'tapFly'`: `state` (TapFlyMissionState), `execution` {imageX, imageY, + * bypass, relativeHeading, speed}, `error`. Methods: `startTapFly {x, y, + * mode, speed, avoidance}` (x / y normalised), `stopTapFly`, + * `setTapFlySpeed {speed}`, `resetTapFlyHeading`. * - DJI-Fly QuickShots (Dronie/Rocket/Circle/Helix/Boomerang/Asteroid) → an * ActiveTrack mission in [ActiveTrackMode.QUICK_SHOT] with the matching * [QuickShotMode]. Unsupported modes on a given aircraft fail cleanly. @@ -57,7 +76,49 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge { private fun trackOp(): ActiveTrackOperator? = DJISDKManager.getInstance().missionControl?.activeTrackOperator + private fun tapFlyOp(): TapFlyMissionOperator? = + DJISDKManager.getInstance().missionControl?.tapFlyMissionOperator + private var listenedOp: WaypointMissionOperator? = null + private var listenedTrack: ActiveTrackOperator? = null + private var listenedTapFly: TapFlyMissionOperator? = null + + private fun rect(r: RectF?): Map? = + r?.let { mapOf("l" to it.left.toDouble(), "t" to it.top.toDouble(), "r" to it.right.toDouble(), "b" to it.bottom.toDouble()) } + + private val trackListener = ActiveTrackMissionOperatorListener { event: ActiveTrackMissionEvent -> + val m = linkedMapOf("type" to "mission", "operator" to "activeTrack") + event.currentState?.let { m["state"] = it.name } + event.trackingState?.let { t -> + m["tracking"] = mapOf( + "targetState" to t.state?.name, + "targetType" to t.type?.name, + "rect" to rect(t.targetRect), + "index" to t.targetIndex, + "percent" to t.completionPercentage, + "reason" to t.reason?.name, + ) + } + event.error?.let { m["error"] = it.description } + ctx.emit(m) + } + + private val tapFlyListener = TapFlyMissionOperatorListener { event: TapFlyMissionEvent? -> + if (event == null) return@TapFlyMissionOperatorListener + val m = linkedMapOf("type" to "mission", "operator" to "tapFly") + event.currentState?.let { m["state"] = it.name } + event.executionState?.let { x -> + m["execution"] = mapOf( + "imageX" to x.imageLocation?.x?.toDouble(), + "imageY" to x.imageLocation?.y?.toDouble(), + "bypass" to x.bypassDirection?.name, + "relativeHeading" to x.relativeHeading.toDouble(), + "speed" to x.speed.toDouble(), + ) + } + event.error?.let { m["error"] = it.description } + ctx.emit(m) + } private val waypointListener = object : WaypointMissionOperatorListener { override fun onDownloadUpdate(event: WaypointMissionDownloadEvent) {} @@ -113,6 +174,21 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge { } override fun bind(product: BaseProduct?) { + trackOp()?.let { op -> + if (listenedTrack !== op) { + listenedTrack?.removeListener(trackListener) + op.addListener(trackListener) + listenedTrack = op + } + ctx.emit(mapOf("type" to "mission", "operator" to "activeTrack", "state" to op.currentState?.name)) + } + tapFlyOp()?.let { op -> + if (listenedTapFly !== op) { + listenedTapFly?.removeListener(tapFlyListener) + op.addListener(tapFlyListener) + listenedTapFly = op + } + } val op = waypointOp() ?: return if (listenedOp !== op) { listenedOp?.removeListener(waypointListener) @@ -141,7 +217,21 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge { } "startActiveTrack" -> startActiveTrack(call, result) "stopActiveTrack" -> track(result) { it.stopTracking(ctx.completion(result)) } + "acceptActiveTrackConfirmation" -> track(result) { it.acceptConfirmation(ctx.completion(result)) } + "rejectActiveTrackConfirmation" -> track(result) { it.rejectConfirmation(ctx.completion(result)) } + "switchActiveTrackMode" -> track(result) { + it.switchMode(enumArg(call, "mode", ActiveTrackMode.TRACE), ctx.completion(result)) + } + "setActiveTrackRetreat" -> track(result) { + it.setRetreatEnabled(call.argument("on") ?: false, ctx.completion(result)) + } "startQuickShot" -> startQuickShot(call, result) + "startTapFly" -> startTapFly(call, result) + "stopTapFly" -> tapFly(result) { it.stopMission(ctx.completion(result)) } + "setTapFlySpeed" -> tapFly(result) { + it.setAutoFlightSpeed((call.argument("speed") ?: 5).toFloat(), ctx.completion(result)) + } + "resetTapFlyHeading" -> tapFly(result) { it.resetHeading(ctx.completion(result)) } else -> return false } return true @@ -157,6 +247,24 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge { block(op) } + private inline fun tapFly(result: MethodChannel.Result, block: (TapFlyMissionOperator) -> Unit) { + val op = tapFlyOp() ?: run { ctx.fail(result, "TapFly unavailable"); return } + block(op) + } + + private fun startTapFly(call: MethodCall, result: MethodChannel.Result) { + val op = tapFlyOp() ?: run { ctx.fail(result, "TapFly unavailable"); return } + val mission = TapFlyMission() + mission.tapFlyMode = enumArg(call, "mode", TapFlyMode.FORWARD) + mission.speed = (call.argument("speed") ?: 5).toFloat() + mission.isHorizontalObstacleAvoidanceEnabled = call.argument("avoidance") ?: true + mission.target = PointF( + (call.argument("x") ?: 0.5).toFloat(), + (call.argument("y") ?: 0.5).toFloat(), + ) + op.startMission(mission, ctx.completion(result)) + } + private inline fun > enumArg(call: MethodCall, name: String, fallback: T): T { val raw = call.argument(name) ?: return fallback return try { @@ -244,7 +352,11 @@ class MissionBridge(private val ctx: BridgeCtx) : SubBridge { ActiveTrackMode.TRACE } val mission = ActiveTrackMission(RectF(x, y, x + w, y + h), mode) - ctx.emit(mapOf("type" to "mission", "state" to "tracking")) + op.canStartTracking(mission)?.let { + ctx.fail(result, it.description) + return + } + // The operator's listener reports the tracking from here on. op.startTracking(mission, ctx.completion(result)) } diff --git a/Fly App/lib/dji_service.dart b/Fly App/lib/dji_service.dart index 9bd2ef8..b5679b5 100644 --- a/Fly App/lib/dji_service.dart +++ b/Fly App/lib/dji_service.dart @@ -298,6 +298,33 @@ class DjiService { }); Future stopActiveTrack() => _methods.invokeMethod('stopActiveTrack'); + /// Answers the operator's WAITING_FOR_CONFIRMATION: yes follows the subject, no drops it. + Future acceptActiveTrackConfirmation() => _methods.invokeMethod('acceptActiveTrackConfirmation'); + Future rejectActiveTrackConfirmation() => _methods.invokeMethod('rejectActiveTrackConfirmation'); + + /// mode: TRACE | PROFILE | SPOTLIGHT | SPOTLIGHT_PRO, while tracking. + Future switchActiveTrackMode(String mode) => + _methods.invokeMethod('switchActiveTrackMode', {'mode': mode}); + + /// Lets Trace fly backwards ahead of a subject walking towards the aircraft. + Future setActiveTrackRetreat(bool on) => + _methods.invokeMethod('setActiveTrackRetreat', {'on': on}); + + // ── TapFly ────────────────────────────────────────────────────────────────── + /// Flies towards the point [x], [y] (0..1 across the live view). + /// mode: FORWARD | BACKWARD | FREE; speed in m/s. + Future startTapFly(double x, double y, {String mode = 'FORWARD', double speed = 5, bool avoidance = true}) => + _methods.invokeMethod('startTapFly', { + 'x': x, + 'y': y, + 'mode': mode, + 'speed': speed, + 'avoidance': avoidance, + }); + Future stopTapFly() => _methods.invokeMethod('stopTapFly'); + Future setTapFlySpeed(double speed) => _methods.invokeMethod('setTapFlySpeed', {'speed': speed}); + Future resetTapFlyHeading() => _methods.invokeMethod('resetTapFlyHeading'); + /// A DJI-Fly QuickShot by name (Dronie/Rocket/Circle/Helix/Boomerang/Asteroid). /// The native side maps each to the closest real operator or returns a typed /// "unsupported on this aircraft" error. diff --git a/Fly App/lib/fly/action_rail_state.dart b/Fly App/lib/fly/action_rail_state.dart index bd0c673..54234ec 100644 --- a/Fly App/lib/fly/action_rail_state.dart +++ b/Fly App/lib/fly/action_rail_state.dart @@ -7,7 +7,7 @@ enum FlightCommand { takeOff, land, cancelLanding, confirmLanding, goHome, cance /// What is running when the rail offers [FlightCommand.stopMode], i.e. which /// operator "Stop" has to address. -enum ActiveMode { none, waypoint, activeTrack, other } +enum ActiveMode { none, waypoint, activeTrack, tapFly, other } /// The left rail's slots for the current flight state (plan P3, GO 4's /// `fpv_leftmenu`): one primary button that walks Take-off → Land → Cancel @@ -36,7 +36,7 @@ class RailState { /// Flight-controller modes in which an intelligent mode is flying the aircraft. static const Set _waypointModes = {'GPS_WAYPOINT'}; static const Set _trackModes = {'ACTIVE_TRACK', 'ACTIVE_TRACK_SPOTLIGHT'}; - static const Set _otherModes = {'GPS_HOT_POINT', 'GPS_FOLLOW_ME', 'TAP_FLY', 'DRAW', 'TERRAIN_FOLLOW'}; + static const Set _otherModes = {'GPS_HOT_POINT', 'GPS_FOLLOW_ME', 'DRAW', 'TERRAIN_FOLLOW'}; static RailState of({required bool connected, required AircraftState a, MissionState? mission}) { final bool reported = connected && a.updatedAt != null; @@ -72,10 +72,13 @@ class RailState { case 'tracking': case 'quickshot': return ActiveMode.activeTrack; + case 'tapfly': + return ActiveMode.tapFly; } final String mode = a.flightModeEnum ?? ''; if (_waypointModes.contains(mode)) return ActiveMode.waypoint; if (_trackModes.contains(mode)) return ActiveMode.activeTrack; + if (mode == 'TAP_FLY') return ActiveMode.tapFly; if (_otherModes.contains(mode)) return ActiveMode.other; return ActiveMode.none; } diff --git a/Fly App/lib/fly/modes/intelligent_modes.dart b/Fly App/lib/fly/modes/intelligent_modes.dart index 29fcc77..38eb813 100644 --- a/Fly App/lib/fly/modes/intelligent_modes.dart +++ b/Fly App/lib/fly/modes/intelligent_modes.dart @@ -87,7 +87,12 @@ abstract final class ModeCatalog { icon: 'crosshair', group: 'Vision', blurb: 'Draw a box around a subject to follow it.', - terms: _common, + built: true, + terms: [ + ..._common, + 'ActiveTrack follows what the camera sees. It can lose the subject behind trees or in crowds, and it does not see obstacles to the sides or behind on every aircraft.', + 'Do not use it to follow cars or boats at speed, or over water and roads you cannot see the end of.', + ], ), ModeInfo( mode: IntelligentMode.tapFly, @@ -95,7 +100,11 @@ abstract final class ModeCatalog { icon: 'chevronsRight', group: 'Vision', blurb: 'Tap the video to fly that way.', - terms: _common, + built: true, + terms: [ + ..._common, + 'TapFly keeps flying towards the point until you stop it. Obstacle avoidance only works in the direction the sensors face, and not at all in Free.', + ], ), ModeInfo( mode: IntelligentMode.quickShots, diff --git a/Fly App/lib/fly/modes/vision_modes.dart b/Fly App/lib/fly/modes/vision_modes.dart new file mode 100644 index 0000000..feed09f --- /dev/null +++ b/Fly App/lib/fly/modes/vision_modes.dart @@ -0,0 +1,123 @@ +import 'dart:ui' show Offset, Rect, Size; + +import 'package:flutter/foundation.dart'; + +/// What the pilot has chosen in an ActiveTrack or TapFly session, before and +/// while the operator runs: shared by the video overlay and the mode panel. +class VisionSession extends ChangeNotifier { + TrackMode trackMode = TrackMode.trace; + TapFlyDirection direction = TapFlyDirection.forward; + double speed = 5; + + /// A box drawn (or tapped) on the video, 0..1, waiting for GO. + Rect? pendingBox; + + /// A TapFly target, 0..1, waiting for GO. + Offset? pendingPoint; + + void setTrackMode(TrackMode m) { + trackMode = m; + notifyListeners(); + } + + void setDirection(TapFlyDirection d) { + direction = d; + notifyListeners(); + } + + void setSpeed(double v) { + speed = v; + notifyListeners(); + } + + void setPendingBox(Rect? r) { + pendingBox = r; + notifyListeners(); + } + + void setPendingPoint(Offset? p) { + pendingPoint = p; + notifyListeners(); + } + + void clearPending() { + if (pendingBox == null && pendingPoint == null) return; + pendingBox = null; + pendingPoint = null; + notifyListeners(); + } +} + +/// ActiveTrack's follow styles (plan P7b, GO 4's Trace / Profile / Spotlight). +enum TrackMode { + /// Follows behind (or ahead of) the subject. + trace('Trace', 'TRACE'), + + /// Flies alongside it. + profile('Profile', 'PROFILE'), + + /// Stays put and keeps the camera on it; the pilot flies. + spotlight('Spotlight', 'SPOTLIGHT'); + + const TrackMode(this.label, this.sdk); + final String label; + final String sdk; + + static TrackMode? fromSdk(String? s) { + for (final TrackMode m in values) { + if (m.sdk == s) return m; + } + return null; + } +} + +/// TapFly's directions (GO 4's Forward / Backward / Free). +enum TapFlyDirection { + forward('Forward', 'FORWARD'), + backward('Backward', 'BACKWARD'), + + /// Flies towards the point while the pilot turns the aircraft freely. + free('Free', 'FREE'); + + const TapFlyDirection(this.label, this.sdk); + final String label; + final String sdk; +} + +/// The live-view geometry the vision modes share: screen points to the +/// operators' 0..1 coordinates. The feed is assumed to fill [view] (the same +/// assumption as tap-to-focus; letterboxing is ignored). +abstract final class VisionGeometry { + /// A drawn box smaller than this (px, either side) is taken as a tap. + static const double minDrag = 24; + + /// The box a tap makes, as a fraction of the view: a person-sized window. + static const Size tapBox = Size(0.12, 0.22); + + /// The 0..1 box between two corners of a drag, clipped to the view; null + /// when it is too small to be a deliberate box. + static Rect? dragBox(Offset a, Offset b, Size view) { + if ((a.dx - b.dx).abs() < minDrag || (a.dy - b.dy).abs() < minDrag) return null; + final Rect r = Rect.fromPoints(a, b); + return Rect.fromLTRB( + (r.left / view.width).clamp(0.0, 1.0), + (r.top / view.height).clamp(0.0, 1.0), + (r.right / view.width).clamp(0.0, 1.0), + (r.bottom / view.height).clamp(0.0, 1.0), + ); + } + + /// A [tapBox] centred on a tap, kept inside the view. + static Rect tapBoxAt(Offset p, Size view) { + final double cx = (p.dx / view.width).clamp(tapBox.width / 2, 1 - tapBox.width / 2); + final double cy = (p.dy / view.height).clamp(tapBox.height / 2, 1 - tapBox.height / 2); + return Rect.fromCenter(center: Offset(cx, cy), width: tapBox.width, height: tapBox.height); + } + + static Offset normalise(Offset p, Size view) => + Offset((p.dx / view.width).clamp(0.0, 1.0), (p.dy / view.height).clamp(0.0, 1.0)); + + static Rect toScreen(Rect r, Size view) => Rect.fromLTRB(r.left * view.width, r.top * view.height, r.right * view.width, r.bottom * view.height); + + static Offset pointToScreen(Offset p, Size view) => Offset(p.dx * view.width, p.dy * view.height); +} diff --git a/Fly App/lib/fly/safety_gate.dart b/Fly App/lib/fly/safety_gate.dart index b031848..1e64350 100644 --- a/Fly App/lib/fly/safety_gate.dart +++ b/Fly App/lib/fly/safety_gate.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show Offset, Rect; + import 'package:flutter/foundation.dart'; import 'package:latlong2/latlong.dart' show LatLng; @@ -7,7 +9,9 @@ import 'action_rail_state.dart'; import 'calibration.dart'; import 'flight_status.dart'; import 'hud_geometry.dart'; +import 'modes/vision_modes.dart'; import 'modes/waypoint_mission.dart'; +import 'product_profile.dart'; import 'state/aircraft_state.dart'; import 'state/alert_state.dart'; import 'state/mission_state.dart'; @@ -150,6 +154,7 @@ class SafetyGate { checks.add(const GateCheck.block('This mode cannot be stopped from the app yet. Press the pause button on the remote.')); case ActiveMode.waypoint: case ActiveMode.activeTrack: + case ActiveMode.tapFly: break; } } @@ -417,6 +422,129 @@ class SafetyGate { await dji.resumeWaypointMission(); } + // ── Vision modes: ActiveTrack and TapFly (plan P7b) ───────────────────────── + + /// Below this height (m above take-off) the vision modes refuse to start: + /// the aircraft needs room to climb over and see the subject. + static const double visionMinAltitude = 2; + + /// TapFly speed range the operator accepts, m/s. + static const double tapFlyMinSpeed = 1; + static const double tapFlyMaxSpeed = 10; + + /// TapFly directions this product can fly: Backward needs rear sensors, Free + /// the newer flight controllers that ship with them. + List tapFlyDirections() { + final VisionSensors v = model.profile.vision; + final bool rear = v == VisionSensors.frontBack || v == VisionSensors.omni; + return [ + TapFlyDirection.forward, + if (rear || !model.profile.connected) TapFlyDirection.backward, + if (rear || !model.profile.connected) TapFlyDirection.free, + ]; + } + + /// What both vision modes need: flying in P mode with room, nothing else in charge. + void _visionCommon(List checks) { + if (_a.isFlying != true) { + checks.add(const GateCheck.block('Take off first.')); + return; + } + 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 (model.mission.waypoint.active) checks.add(const GateCheck.block('Stop the waypoint mission first.')); + if (mode == 'ATTI' || mode == 'GPS_SPORT' || mode == 'MANUAL') { + checks.add(const GateCheck.block('Switch the remote to P mode first.')); + } + final double? h = _a.altitude; + if (h != null && h < visionMinAltitude) checks.add(const GateCheck.block('Climb above 2 m first.')); + 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%)'}.')); + } + if ((_a.gpsSignalLevel ?? 0) < FlightStatus.minGpsLevel) { + checks.add(const GateCheck.warn('Weak GPS: the aircraft may drift while it follows.')); + } + } + + /// Preconditions for following a subject in [mode]. + List checkActiveTrack(TrackMode mode) { + 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.')); + if (model.mission.activeTrack.unsupported) return checks..add(const GateCheck.block('This aircraft cannot run ActiveTrack.')); + _visionCommon(checks); + if (model.mission.tapFly.active) checks.add(const GateCheck.block('Stop TapFly first.')); + if (!model.profile.hasObstacleSensing && mode != TrackMode.spotlight) { + checks.add(const GateCheck.warn('This aircraft cannot see obstacles: keep the path behind the subject clear.')); + } else if (mode == TrackMode.profile) { + checks.add(const GateCheck.warn('Profile flies sideways, where the aircraft has no obstacle sensors.')); + } + return checks; + } + + /// Re-checks and starts following the subject in [rect] (0..1 of the live view). + Future startActiveTrack(Rect rect, TrackMode mode) async { + final List checks = checkActiveTrack(mode); + if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks); + final Rect r = Rect.fromLTRB(rect.left.clamp(0, 1), rect.top.clamp(0, 1), rect.right.clamp(0, 1), rect.bottom.clamp(0, 1)); + await dji.startActiveTrack(r.left, r.top, r.width, r.height, mode: mode.sdk); + } + + /// Answers the operator's "is this the subject?". + Future confirmActiveTrack({required bool yes}) async { + if (!model.mission.activeTrack.waitingForConfirmation && !model.mission.activeTrack.cannotConfirm) return; + await (yes ? dji.acceptActiveTrackConfirmation() : dji.rejectActiveTrackConfirmation()); + } + + Future switchActiveTrackMode(TrackMode mode) async { + if (!model.mission.activeTrack.active) { + throw const MissionRefused([GateCheck.block('Not tracking a subject.')]); + } + await dji.switchActiveTrackMode(mode.sdk); + } + + /// Preconditions for flying towards a tapped point in [direction]. + List checkTapFly(TapFlyDirection direction) { + 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.')); + if (model.mission.tapFly.unsupported) return checks..add(const GateCheck.block('This aircraft cannot run TapFly.')); + _visionCommon(checks); + if (model.mission.activeTrack.active) checks.add(const GateCheck.block('Stop ActiveTrack first.')); + if (!tapFlyDirections().contains(direction)) checks.add(GateCheck.block('${direction.label} is not available on this aircraft.')); + if (direction == TapFlyDirection.free) { + checks.add(const GateCheck.warn('Free does not avoid obstacles sideways: watch the video.')); + } else if (!model.profile.hasObstacleSensing) { + checks.add(const GateCheck.warn('This aircraft cannot see obstacles: it flies straight into whatever is in the way.')); + } + return checks; + } + + /// Re-checks and flies towards [target] (0..1 of the live view). + Future startTapFly(Offset target, TapFlyDirection direction, {double speed = 5}) async { + final List checks = checkTapFly(direction); + if (checks.any((GateCheck c) => c.blocks)) throw MissionRefused(checks); + await dji.startTapFly( + target.dx.clamp(0.0, 1.0), + target.dy.clamp(0.0, 1.0), + mode: direction.sdk, + speed: speed.clamp(tapFlyMinSpeed, tapFlyMaxSpeed), + avoidance: direction != TapFlyDirection.free, + ); + } + + /// Changes the TapFly speed in flight. + Future setTapFlySpeed(double speed) async { + if (!model.mission.tapFly.active) return; + await dji.setTapFlySpeed(speed.clamp(tapFlyMinSpeed, tapFlyMaxSpeed)); + } + /// 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; @@ -469,6 +597,8 @@ class SafetyGate { await dji.stopWaypointMission(); case ActiveMode.activeTrack: await dji.stopActiveTrack(); + case ActiveMode.tapFly: + await dji.stopTapFly(); case ActiveMode.none: case ActiveMode.other: break; // refused by check() diff --git a/Fly App/lib/fly/state/mission_state.dart b/Fly App/lib/fly/state/mission_state.dart index 0a0af75..03d8a63 100644 --- a/Fly App/lib/fly/state/mission_state.dart +++ b/Fly App/lib/fly/state/mission_state.dart @@ -1,3 +1,5 @@ +import 'dart:ui' show Offset, Rect; + import 'package:flutter/foundation.dart'; import 'parse.dart'; @@ -86,6 +88,159 @@ class WaypointProgress { int get hashCode => Object.hash(state, uploaded, total, target, reached, executeState); } +/// The ActiveTrack operator as last reported (plan P7b). +@immutable +class ActiveTrackProgress { + const ActiveTrackProgress({this.state = 'UNKNOWN', this.targetState, this.targetType, this.rect, this.reason}); + + factory ActiveTrackProgress.fromMap(Map e, ActiveTrackProgress previous) { + final Map? t = asMap(e['tracking']); + final String state = asString(e['state']) ?? previous.state; + // A tracking-state-less event keeps the last target only while still tracking. + final bool keep = t == null && _tracking.contains(state); + final Map? r = asMap(t?['rect']); + return ActiveTrackProgress( + state: state, + targetState: t == null ? (keep ? previous.targetState : null) : asString(t['targetState']), + targetType: t == null ? (keep ? previous.targetType : null) : asString(t['targetType']), + rect: t == null ? (keep ? previous.rect : null) : _rect(r), + reason: t == null ? (keep ? previous.reason : null) : asString(t['reason']), + ); + } + + static Rect? _rect(Map? r) { + if (r == null) return null; + final double? l = finite(r['l']), t = finite(r['t']), rr = finite(r['r']), b = finite(r['b']); + if (l == null || t == null || rr == null || b == null || rr <= l || b <= t) return null; + return Rect.fromLTRB(l, t, rr, b); + } + + static const Set _tracking = { + 'AIRCRAFT_FOLLOWING', + 'ONLY_CAMERA_FOLLOWING', + 'WAITING_FOR_CONFIRMATION', + 'CANNOT_CONFIRM', + 'FINDING_TRACKED_TARGET', + 'PERFORMING_QUICK_SHOT', + }; + + /// MSDK `ActiveTrackState`. + final String state; + + /// `ActiveTrackTargetState`: TRACKING_WITH_HIGH_CONFIDENCE, …_LOW_…, WAITING_FOR_CONFIRMATION, CANNOT_CONFIRM. + final String? targetState; + + /// HUMAN, CAR, VAN, BIKE, BOAT, … + final String? targetType; + + /// The subject's box, 0..1 across the live view. + final Rect? rect; + + /// `ActiveTrackCannotConfirmReason`, when the aircraft cannot follow. + final String? reason; + + /// A subject is selected and the operator is working on it. + bool get active => _tracking.contains(state); + bool get following => state == 'AIRCRAFT_FOLLOWING'; + bool get waitingForConfirmation => state == 'WAITING_FOR_CONFIRMATION'; + bool get cannotConfirm => state == 'CANNOT_CONFIRM' || targetState == 'CANNOT_CONFIRM'; + bool get unsupported => state == 'NOT_SUPPORT'; + bool get lowConfidence => targetState == 'TRACKING_WITH_LOW_CONFIDENCE'; + + String get stateLabel => switch (state) { + 'AIRCRAFT_FOLLOWING' => 'Following', + 'ONLY_CAMERA_FOLLOWING' => 'Camera only', + 'WAITING_FOR_CONFIRMATION' => 'Confirm subject', + 'CANNOT_CONFIRM' => 'Cannot follow', + 'FINDING_TRACKED_TARGET' => 'Subject lost', + 'PERFORMING_QUICK_SHOT' => 'QuickShot', + 'CANNOT_START' => 'Cannot start', + 'NOT_SUPPORT' => 'Not supported', + _ => 'Draw a box', + }; + + String? get reasonLabel => switch (reason) { + 'UNSTABLE_TARGET' => 'Subject moving erratically', + 'TARGET_TOO_HIGH' => 'Subject too high', + 'BLOCKED_BY_OBSTACLE' => 'Obstacle in the way', + 'GIMBAL_ATTITUDE_ERROR' => 'Gimbal angle', + 'TARGET_TOO_FAR' => 'Subject too far', + 'TARGET_TOO_CLOSE' => 'Subject too close', + 'AIRCRAFT_TOO_HIGH' => 'Aircraft too high', + 'AIRCRAFT_TOO_LOW' => 'Aircraft too low', + 'OBSTACLE_SENSOR_ERROR' => 'Obstacle sensor error', + _ => null, + }; + + @override + bool operator ==(Object other) => + other is ActiveTrackProgress && + other.state == state && + other.targetState == targetState && + other.targetType == targetType && + other.rect == rect && + other.reason == reason; + + @override + int get hashCode => Object.hash(state, targetState, targetType, rect, reason); +} + +/// The TapFly operator as last reported (plan P7b). +@immutable +class TapFlyProgress { + const TapFlyProgress({this.state = 'UNKNOWN', this.image, this.bypass, this.speed, this.relativeHeading}); + + factory TapFlyProgress.fromMap(Map e, TapFlyProgress previous) { + final Map? x = asMap(e['execution']); + final String state = asString(e['state']) ?? previous.state; + final double? ix = finite(x?['imageX']), iy = finite(x?['imageY']); + final bool keep = x == null && _flying.contains(state); + return TapFlyProgress( + state: state, + image: x == null ? (keep ? previous.image : null) : (ix == null || iy == null ? null : Offset(ix, iy)), + bypass: x == null ? (keep ? previous.bypass : null) : asString(x['bypass']), + speed: x == null ? (keep ? previous.speed : null) : finite(x['speed']), + relativeHeading: x == null ? (keep ? previous.relativeHeading : null) : finite(x['relativeHeading']), + ); + } + + static const Set _flying = {'EXECUTION_STARTING', 'EXECUTING', 'EXECUTION_PAUSED', 'EXECUTION_RESETTING'}; + + /// MSDK `TapFlyMissionState`. + final String state; + + /// Where the aircraft is heading on the live view, 0..1. + final Offset? image; + + /// NONE / LEFT / RIGHT / OVER while going round an obstacle. + final String? bypass; + final double? speed; + final double? relativeHeading; + + bool get active => _flying.contains(state); + bool get paused => state == 'EXECUTION_PAUSED'; + bool get unsupported => state == 'NOT_SUPPORT'; + + String? get bypassLabel => switch (bypass) { + 'LEFT' => 'Going round an obstacle: left', + 'RIGHT' => 'Going round an obstacle: right', + 'OVER' => 'Climbing over an obstacle', + _ => null, + }; + + @override + bool operator ==(Object other) => + other is TapFlyProgress && + other.state == state && + other.image == image && + other.bypass == bypass && + other.speed == speed && + other.relativeHeading == relativeHeading; + + @override + int get hashCode => Object.hash(state, image, bypass, speed, relativeHeading); +} + /// Waypoint / ActiveTrack / QuickShot execution, from the bridge's `mission` events. /// /// Waypoint events carry `operator: 'waypoint'` and the operator's own state @@ -93,6 +248,8 @@ class WaypointProgress { /// [state] is the summary the rail and the gate read. class MissionState extends ChangeNotifier { WaypointProgress waypoint = const WaypointProgress(); + ActiveTrackProgress activeTrack = const ActiveTrackProgress(); + TapFlyProgress tapFly = const TapFlyProgress(); /// How the last waypoint mission ended: null while none has, `''` when it /// finished cleanly, else the SDK's error. @@ -104,9 +261,11 @@ class MissionState extends ChangeNotifier { String _legacy = 'idle'; String? error; - /// idle / ready / uploading / executing / tracking / quickshot. + /// idle / ready / uploading / executing / tracking / quickshot / tapfly. String get state { if (waypoint.active) return 'executing'; + if (activeTrack.active) return activeTrack.state == 'PERFORMING_QUICK_SHOT' ? 'quickshot' : 'tracking'; + if (tapFly.active) return 'tapfly'; if (_legacy != 'idle') return _legacy; if (waypoint.uploading) return 'uploading'; if (waypoint.readyToExecute) return 'ready'; @@ -122,9 +281,28 @@ class MissionState extends ChangeNotifier { bool get tracking => state == 'tracking' || state == 'quickshot'; void apply(Map e) { - if (asString(e['operator']) == 'waypoint') { - _applyWaypoint(e); - return; + switch (asString(e['operator'])) { + case 'waypoint': + _applyWaypoint(e); + return; + case 'activeTrack': + final ActiveTrackProgress next = ActiveTrackProgress.fromMap(e, activeTrack); + final String? err = asString(e['error']); + if (next == activeTrack && err == null) return; + activeTrack = next; + if (err != null) error = err; + // The operator now speaks for itself; drop the bridge's old guess. + if (_legacy == 'tracking' || _legacy == 'quickshot') _legacy = 'idle'; + notifyListeners(); + return; + case 'tapFly': + final TapFlyProgress next = TapFlyProgress.fromMap(e, tapFly); + final String? err = asString(e['error']); + if (next == tapFly && err == null) return; + tapFly = next; + if (err != null) error = err; + notifyListeners(); + return; } _legacy = asString(e['state']) ?? 'idle'; error = asString(e['error']); @@ -151,6 +329,8 @@ class MissionState extends ChangeNotifier { _legacy = 'idle'; error = null; waypoint = const WaypointProgress(); + activeTrack = const ActiveTrackProgress(); + tapFly = const TapFlyProgress(); notifyListeners(); } } diff --git a/Fly App/lib/ui/fly/fly_screen.dart b/Fly App/lib/ui/fly/fly_screen.dart index f568531..eaecc5b 100644 --- a/Fly App/lib/ui/fly/fly_screen.dart +++ b/Fly App/lib/ui/fly/fly_screen.dart @@ -8,6 +8,7 @@ import '../../fly/flight_trail.dart'; import '../../fly/fly_prefs.dart'; import '../../fly/hud_features.dart'; import '../../fly/modes/mission_library.dart'; +import '../../fly/modes/vision_modes.dart'; import '../../fly/modes/waypoint_editor.dart'; import '../../fly/modes/waypoint_mission.dart' show MissionLimits; import '../../fly/product_profile.dart'; @@ -26,6 +27,8 @@ import 'layers/video_layer.dart'; import 'left/action_rail.dart'; import 'left/gimbal_slider.dart'; import 'modes/mode_picker.dart'; +import 'modes/vision/vision_layers.dart'; +import 'modes/vision/vision_panel.dart'; import 'modes/waypoints/point_editor.dart'; import 'modes/waypoints/waypoint_layers.dart'; import 'modes/waypoints/waypoint_panels.dart'; @@ -106,6 +109,7 @@ class _FlyScreenState extends State { /// The intelligent mode whose editor is open, or null. IntelligentMode? _mode; late final WaypointEditor _waypoints = widget.waypoints ?? WaypointEditor(); + final VisionSession _vision = VisionSession(); late final MissionLibrary _library = MissionLibrary()..load(); MissionLimits _limits = const MissionLimits(); int _seenFinishes = 0; @@ -167,6 +171,14 @@ class _FlyScreenState extends State { _minimap.setFollow(_waypoints.mission.points.isEmpty); final MissionLimits limits = await _gate.missionLimits(); if (mounted) setState(() => _limits = limits); + case IntelligentMode.activeTrack: + case IntelligentMode.tapFly: + // Vision modes work on the live video. + _vision.clearPending(); + setState(() { + _mode = m; + _mapBig = false; + }); default: break; // the picker only returns built modes } @@ -178,6 +190,57 @@ class _FlyScreenState extends State { _waypoints.select(null); }); + bool get _visionMode => _mode == IntelligentMode.activeTrack || _mode == IntelligentMode.tapFly; + + /// Leaving a vision mode while it flies stops it first (GO 4's Exit). + Future _exitVision(BuildContext context) async { + final MissionState ms = _m.mission; + final bool running = _mode == IntelligentMode.tapFly ? ms.tapFly.active : ms.activeTrack.active; + if (running) await _flow.request(context, FlightCommand.stopMode); + _vision.clearPending(); + if (mounted) setState(() => _mode = null); + } + + void _goTrack(BuildContext context, Rect box) { + final TrackMode mode = _vision.trackMode; + startVisionMode( + context, + title: 'ActiveTrack · ${mode.label}', + icon: 'crosshair', + description: 'The aircraft follows the boxed subject. Stop, the remote\'s pause button, or the sticks take back control.', + checks: _gate.checkActiveTrack(mode), + start: () async { + await _gate.startActiveTrack(box, mode); + _vision.setPendingBox(null); + }, + onError: _error, + ); + } + + void _goTapFly(BuildContext context, Offset target) { + final TapFlyDirection d = _vision.direction; + startVisionMode( + context, + title: 'TapFly · ${d.label}', + icon: 'chevronsRight', + description: 'The aircraft flies towards the point at ${_vision.speed.toStringAsFixed(0)} m/s until you stop it.', + checks: _gate.checkTapFly(d), + start: () async { + await _gate.startTapFly(target, d, speed: _vision.speed); + _vision.setPendingPoint(null); + }, + onError: _error, + ); + } + + Future _confirmTrack(bool yes) async { + try { + await _gate.confirmActiveTrack(yes: yes); + } catch (e) { + _error('ActiveTrack', e); + } + } + /// A new connection gets its own pre-flight review. void _onModel() { if (_m.connected == _wasConnected) return; @@ -196,6 +259,7 @@ class _FlyScreenState extends State { _m.removeListener(_onModel); _m.mission.removeListener(_onMission); if (widget.waypoints == null) _waypoints.dispose(); + _vision.dispose(); _library.dispose(); _trail.dispose(); _minimap.dispose(); @@ -247,6 +311,21 @@ class _FlyScreenState extends State { if (!_mapBig) ...[ Positioned.fromRect(key: const ValueKey('video-slot'), rect: full, child: video), const Positioned.fill(child: VisualOverlays()), + // A vision mode owns every touch on the video: a box or a point. + if (_mode == IntelligentMode.activeTrack) + Positioned.fill( + child: ActiveTrackLayer( + mission: _m.mission, + session: _vision, + onGo: (Rect box) => _goTrack(context, box), + onConfirm: _confirmTrack, + ), + ) + else if (_mode == IntelligentMode.tapFly) + Positioned.fill( + child: TapFlyLayer(mission: _m.mission, session: _vision, onGo: (Offset p) => _goTapFly(context, p)), + ) + else ...[ // Under the camera touch layer, in the same gesture arena: drags aim // the gimbal, taps and long presses still reach the camera. Positioned.fill( @@ -274,6 +353,7 @@ class _FlyScreenState extends State { ), ), ), + ], Positioned.fromRect(key: const ValueKey('map-slot'), rect: pip, child: map), ] else ...[ Positioned.fromRect(key: const ValueKey('map-slot'), rect: full, child: map), @@ -432,6 +512,19 @@ class _FlyScreenState extends State { bottom: 104, child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ WaypointRunPanel(model: _m, editor: _waypoints, gate: _gate, flow: _flow, onError: _error), + if (_visionMode) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: VisionModePanel( + tapFly: _mode == IntelligentMode.tapFly, + model: _m, + session: _vision, + gate: _gate, + flow: _flow, + onExit: () => _exitVision(context), + onError: _error, + ), + ), if (_editing) Padding( padding: const EdgeInsets.only(bottom: 6), diff --git a/Fly App/lib/ui/fly/modes/vision/vision_layers.dart b/Fly App/lib/ui/fly/modes/vision/vision_layers.dart new file mode 100644 index 0000000..8414257 --- /dev/null +++ b/Fly App/lib/ui/fly/modes/vision/vision_layers.dart @@ -0,0 +1,278 @@ +import 'dart:math' as math; + +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart'; + +import '../../../../fly/modes/vision_modes.dart'; +import '../../../../fly/state/mission_state.dart'; +import '../../../../theme.dart'; +import '../../../pv_icons.dart'; +import '../../hud_kit.dart'; + +/// ActiveTrack on the live view (plan P7b, GO 4's `fpv_track_*`): drag a box +/// round the subject, or tap it for a person-sized box, then GO. While the +/// operator tracks, its own box is drawn — green when sure, amber when not, +/// blue with ✓ / ✕ when it asks "this subject?", red with the reason when it +/// cannot follow. +/// +/// Full-screen, in place of the camera and gimbal touch layers while the +/// mode is open: every touch on the video means "this subject". +class ActiveTrackLayer extends StatefulWidget { + const ActiveTrackLayer({super.key, required this.mission, required this.session, required this.onGo, required this.onConfirm}); + + final MissionState mission; + final VisionSession session; + final ValueChanged onGo; + final ValueChanged onConfirm; + + @override + State createState() => _ActiveTrackLayerState(); +} + +class _ActiveTrackLayerState extends State { + Offset? _from; + Offset? _to; + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) { + final Size view = c.biggest; + return ListenableBuilder( + listenable: Listenable.merge([widget.mission, widget.session]), + builder: (BuildContext context, _) { + final ActiveTrackProgress t = widget.mission.activeTrack; + final bool tracking = t.active; + final Rect? pending = tracking ? null : widget.session.pendingBox; + final Rect? drawing = _from != null && _to != null ? Rect.fromPoints(_from!, _to!) : null; + return GestureDetector( + key: const ValueKey('track-layer'), + behavior: HitTestBehavior.opaque, + dragStartBehavior: DragStartBehavior.down, + onTapUp: tracking ? null : (TapUpDetails d) => widget.session.setPendingBox(VisionGeometry.tapBoxAt(d.localPosition, view)), + onPanStart: tracking + ? null + : (DragStartDetails d) => setState(() { + _from = d.localPosition; + _to = d.localPosition; + }), + onPanUpdate: tracking ? null : (DragUpdateDetails d) => setState(() => _to = d.localPosition), + onPanEnd: tracking + ? null + : (_) { + final Rect? box = _from == null || _to == null ? null : VisionGeometry.dragBox(_from!, _to!, view); + setState(() => _from = _to = null); + if (box != null) widget.session.setPendingBox(box); + }, + child: Stack(children: [ + const SizedBox.expand(), + if (drawing != null) Positioned.fromRect(rect: drawing, child: const _Box(color: Glass.ink, dashed: true)), + if (pending != null) ..._pending(VisionGeometry.toScreen(pending, view), view), + if (tracking && t.rect != null) ..._tracked(t, VisionGeometry.toScreen(t.rect!, view), view), + ]), + ); + }, + ); + }); + } + + List _pending(Rect r, Size view) => [ + Positioned.fromRect(rect: r, child: const _Box(color: PV.accent)), + _beside(r, view, Row(mainAxisSize: MainAxisSize.min, children: [ + _RoundButton( + key: const ValueKey('track-go'), + label: 'GO', + color: PV.accent, + onTap: () => widget.onGo(widget.session.pendingBox!), + ), + const SizedBox(width: 6), + _RoundButton( + key: const ValueKey('track-discard'), + icon: 'close', + color: Glass.pillStrong, + onTap: () => widget.session.setPendingBox(null), + ), + ])), + ]; + + List _tracked(ActiveTrackProgress t, Rect r, Size view) { + final Color c = t.cannotConfirm + ? HudColors.danger + : t.waitingForConfirmation + ? PV.accent + : (t.lowConfidence || t.state == 'FINDING_TRACKED_TARGET') + ? HudColors.caution + : HudColors.ready; + final String? caption = t.cannotConfirm ? (t.reasonLabel ?? 'Cannot follow') : (t.targetType == null || t.targetType == 'UNKNOWN' ? null : _type(t.targetType!)); + return [ + Positioned.fromRect(rect: r, child: _Box(key: const ValueKey('track-box'), color: c)), + if (caption != null) + Positioned( + left: r.left, + top: math.max(0, r.top - 18), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), + decoration: BoxDecoration(color: c, borderRadius: BorderRadius.circular(4)), + child: Text(caption, style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)), + ), + ), + if (t.waitingForConfirmation || t.cannotConfirm) + _beside(r, view, Row(mainAxisSize: MainAxisSize.min, children: [ + if (t.waitingForConfirmation) + _RoundButton(key: const ValueKey('track-confirm'), icon: 'check', color: HudColors.ready, onTap: () => widget.onConfirm(true)), + const SizedBox(width: 6), + _RoundButton(key: const ValueKey('track-reject'), icon: 'close', color: HudColors.danger, onTap: () => widget.onConfirm(false)), + ])), + ]; + } + + static String _type(String t) => switch (t) { + 'HUMAN' => 'Person', + 'CAR' => 'Car', + 'VAN' => 'Van', + 'BIKE' => 'Bike', + 'BOAT' => 'Boat', + 'HOT_POINT' => 'Heat spot', + _ => t, + }; +} + +/// Places [child] just right of [r], or left of it near the right edge. +Widget _beside(Rect r, Size view, Widget child) { + const double w = 100; + final bool right = r.right + 8 + w < view.width; + return Positioned( + left: right ? r.right + 8 : null, + right: right ? null : view.width - r.left + 8, + top: (r.center.dy - 20).clamp(0, view.height - 40), + child: child, + ); +} + +/// TapFly on the live view (plan P7b, GO 4's `fpv_tapfly_*`): tap where to +/// fly, then GO. While flying, the aircraft's own heading point is drawn and +/// a new tap re-aims it. +class TapFlyLayer extends StatelessWidget { + const TapFlyLayer({super.key, required this.mission, required this.session, required this.onGo}); + + final MissionState mission; + final VisionSession session; + final ValueChanged onGo; + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) { + final Size view = c.biggest; + return ListenableBuilder( + listenable: Listenable.merge([mission, session]), + builder: (BuildContext context, _) { + final TapFlyProgress f = mission.tapFly; + final Offset? pending = session.pendingPoint; + final Offset? heading = f.active ? f.image : null; + return GestureDetector( + key: const ValueKey('tapfly-layer'), + behavior: HitTestBehavior.opaque, + onTapUp: (TapUpDetails d) => session.setPendingPoint(VisionGeometry.normalise(d.localPosition, view)), + child: Stack(children: [ + const SizedBox.expand(), + if (heading != null) _marker(VisionGeometry.pointToScreen(heading, view), HudColors.ready, key: 'tapfly-heading'), + if (pending != null) ...[ + _marker(VisionGeometry.pointToScreen(pending, view), PV.accent, key: 'tapfly-target'), + _beside( + Rect.fromCenter(center: VisionGeometry.pointToScreen(pending, view), width: 40, height: 40), + view, + Row(mainAxisSize: MainAxisSize.min, children: [ + _RoundButton(key: const ValueKey('tapfly-go'), label: 'GO', color: PV.accent, onTap: () => onGo(pending)), + const SizedBox(width: 6), + _RoundButton( + key: const ValueKey('tapfly-discard'), + icon: 'close', + color: Glass.pillStrong, + onTap: () => session.setPendingPoint(null), + ), + ]), + ), + ], + ]), + ); + }, + ); + }); + } + + static Widget _marker(Offset p, Color c, {required String key}) => Positioned( + left: p.dx - 20, + top: p.dy - 20, + child: IgnorePointer( + child: SizedBox( + key: ValueKey(key), + width: 40, + height: 40, + child: CustomPaint(painter: _TargetPainter(c)), + ), + ), + ); +} + +class _TargetPainter extends CustomPainter { + const _TargetPainter(this.color); + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final Offset c = size.center(Offset.zero); + final Paint p = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawCircle(c, size.width / 2 - 4, p); + canvas.drawCircle(c, 3, Paint()..color = color); + for (final Offset d in const [Offset(1, 0), Offset(-1, 0), Offset(0, 1), Offset(0, -1)]) { + canvas.drawLine(c + d * (size.width / 2 - 8), c + d * (size.width / 2), p); + } + } + + @override + bool shouldRepaint(covariant _TargetPainter old) => old.color != color; +} + +class _Box extends StatelessWidget { + const _Box({super.key, required this.color, this.dashed = false}); + + final Color color; + final bool dashed; + + @override + Widget build(BuildContext context) => IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + color: color.withValues(alpha: dashed ? 0.06 : 0.12), + border: Border.all(color: color, width: dashed ? 1.2 : 2), + borderRadius: BorderRadius.circular(4), + ), + ), + ); +} + +class _RoundButton extends StatelessWidget { + const _RoundButton({super.key, required this.color, required this.onTap, this.label, this.icon}); + + final Color color; + final VoidCallback onTap; + final String? label; + final String? icon; + + @override + Widget build(BuildContext context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all(color: const Color(0x66FFFFFF))), + alignment: Alignment.center, + child: label != null + ? Text(label!, style: HudText.value.copyWith(fontSize: 13, color: Colors.white)) + : PVIcon(icon!, size: 16, color: Colors.white), + ), + ); +} diff --git a/Fly App/lib/ui/fly/modes/vision/vision_panel.dart b/Fly App/lib/ui/fly/modes/vision/vision_panel.dart new file mode 100644 index 0000000..e58ab96 --- /dev/null +++ b/Fly App/lib/ui/fly/modes/vision/vision_panel.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; + +import '../../../../flight_model.dart'; +import '../../../../fly/modes/vision_modes.dart'; +import '../../../../fly/safety_gate.dart'; +import '../../../../fly/state/mission_state.dart'; +import '../../../../theme.dart'; +import '../../../pv_icons.dart'; +import '../../dialogs/slide_to_confirm.dart'; +import '../../flight_command_flow.dart'; +import '../../hud_kit.dart'; +import '../waypoints/point_editor.dart' show HudChip; + +/// Starts a vision mode the GO 4 way: GO on the video is the pilot's +/// confirmation, so a clean check starts at once; warnings get the +/// slide-to-confirm dialog, and blockers its reasons with no slider. +Future startVisionMode( + BuildContext context, { + required String title, + required String icon, + required String description, + required List checks, + required Future Function() start, + required void Function(String label, Object error) onError, +}) async { + if (checks.isNotEmpty) { + final SlideResult? r = await showSlideToConfirm( + context, + SlideConfirmSpec(icon: icon, title: title, description: description, sliderLabel: 'Slide to start', checks: checks), + ); + if (r == null) return; + } + try { + await start(); + } catch (e) { + onError(title, e); + } +} + +/// The ActiveTrack / TapFly panel at the head of the left column: what the +/// operator is doing, the follow style or direction, TapFly's speed, Stop +/// while it flies, and Exit. +class VisionModePanel extends StatelessWidget { + const VisionModePanel({ + super.key, + required this.tapFly, + required this.model, + required this.session, + required this.gate, + required this.flow, + required this.onExit, + required this.onError, + }); + + /// TapFly's panel, or ActiveTrack's. + final bool tapFly; + final FlightModel model; + final VisionSession session; + final SafetyGate gate; + final FlightCommandFlow flow; + final VoidCallback onExit; + final void Function(String label, Object error) onError; + + Future _guard(String label, Future Function() f) async { + try { + await f(); + } catch (e) { + onError(label, e); + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([model.mission, session]), + builder: (BuildContext context, _) { + final MissionState ms = model.mission; + final bool active = tapFly ? ms.tapFly.active : ms.activeTrack.active; + final String title = tapFly ? 'TAPFLY' : 'ACTIVETRACK'; + final String status; + final Color statusColor; + String? note; + if (tapFly) { + final TapFlyProgress f = ms.tapFly; + status = f.active ? (f.paused ? 'PAUSED' : 'FLYING') : (session.pendingPoint != null ? 'TAP GO' : 'TAP WHERE TO FLY'); + statusColor = f.active ? (f.paused ? HudColors.caution : HudColors.ready) : HudText.dim; + note = f.bypassLabel; + } else { + final ActiveTrackProgress t = ms.activeTrack; + status = t.active ? t.stateLabel.toUpperCase() : (session.pendingBox != null ? 'TAP GO' : 'DRAW A BOX ROUND THE SUBJECT'); + statusColor = t.cannotConfirm ? HudColors.danger : (t.active ? (t.following ? HudColors.ready : HudColors.caution) : HudText.dim); + note = t.cannotConfirm ? t.reasonLabel : null; + } + + final List chips = tapFly + ? [ + for (final TapFlyDirection d in gate.tapFlyDirections()) + HudChip( + key: ValueKey('tapfly-${d.name}'), + label: d.label, + selected: session.direction == d, + // The direction is fixed once flying; tap a new point to change course. + onTap: active ? null : () => session.setDirection(d), + ), + ] + : [ + for (final TrackMode m in TrackMode.values) + HudChip( + key: ValueKey('track-${m.name}'), + label: m.label, + selected: session.trackMode == m, + onTap: () { + session.setTrackMode(m); + if (active) _guard('Switch ActiveTrack mode', () => gate.switchActiveTrackMode(m)); + }, + ), + ]; + + return Container( + key: ValueKey(tapFly ? 'tapfly-panel' : 'track-panel'), + constraints: const BoxConstraints(maxWidth: 390), + padding: const EdgeInsets.fromLTRB(10, 6, 6, 6), + decoration: BoxDecoration(color: Glass.pillStrong, borderRadius: BorderRadius.circular(10), border: Border.all(color: Glass.hairline)), + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(mainAxisSize: MainAxisSize.min, children: [ + PVIcon(tapFly ? 'chevronsRight' : 'crosshair', size: 13, color: Glass.ink), + const SizedBox(width: 6), + Text(title, style: HudText.label.copyWith(color: Glass.ink, fontWeight: FontWeight.w700)), + const SizedBox(width: 8), + Flexible( + child: Text(status, overflow: TextOverflow.ellipsis, style: HudText.label.copyWith(color: statusColor, fontWeight: FontWeight.w700)), + ), + const SizedBox(width: 8), + if (active) + _PanelButton( + key: const ValueKey('vision-stop'), + icon: 'stop', + label: 'STOP', + color: HudColors.danger, + onTap: () => flow.request(context, FlightCommand.stopMode), + ), + const SizedBox(width: 4), + _PanelButton(key: const ValueKey('vision-exit'), icon: 'close', label: 'EXIT', color: Glass.pill, onTap: onExit), + ]), + if (note != null) Padding(padding: const EdgeInsets.only(top: 3), child: Text(note, style: HudText.label.copyWith(color: HudColors.caution))), + const SizedBox(height: 5), + Wrap(spacing: 4, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ + ...chips, + if (tapFly) ...[ + const SizedBox(width: 6), + _SpeedStepper( + speed: session.speed, + onStep: (int d) { + final double v = (session.speed + d).clamp(SafetyGate.tapFlyMinSpeed, SafetyGate.tapFlyMaxSpeed); + session.setSpeed(v); + if (active) _guard('TapFly speed', () => gate.setTapFlySpeed(v)); + }, + ), + ], + ]), + ]), + ); + }, + ); + } +} + +class _SpeedStepper extends StatelessWidget { + const _SpeedStepper({required this.speed, required this.onStep}); + + final double speed; + final ValueChanged onStep; + + @override + Widget build(BuildContext context) { + Widget b(String l, int d, bool on) => Opacity( + opacity: on ? 1 : 0.35, + child: GestureDetector( + key: ValueKey('tapfly-speed-${d > 0 ? 'plus' : 'minus'}'), + behavior: HitTestBehavior.opaque, + onTap: on ? () => onStep(d) : null, + child: Container( + width: 24, + height: 22, + decoration: BoxDecoration(color: Glass.pill, borderRadius: BorderRadius.circular(6), border: Border.all(color: Glass.hairline)), + alignment: Alignment.center, + child: Text(l, style: HudText.mono.copyWith(fontWeight: FontWeight.w700)), + ), + ), + ); + return Row(mainAxisSize: MainAxisSize.min, children: [ + b('−', -1, speed > SafetyGate.tapFlyMinSpeed), + SizedBox(width: 54, child: Text('${speed.toStringAsFixed(0)} m/s', textAlign: TextAlign.center, style: HudText.mono)), + b('+', 1, speed < SafetyGate.tapFlyMaxSpeed), + ]); + } +} + +class _PanelButton extends StatelessWidget { + const _PanelButton({super.key, required this.icon, required this.label, required this.color, required this.onTap}); + + final String icon; + final String label; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + height: 24, + padding: const EdgeInsets.symmetric(horizontal: 7), + decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(7), border: Border.all(color: Glass.hairline)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + PVIcon(icon, size: 11, color: Colors.white), + const SizedBox(width: 3), + Text(label, style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)), + ]), + ), + ); +} diff --git a/Fly App/test/fly/fake_dji.dart b/Fly App/test/fly/fake_dji.dart index 7c7beb6..0a647db 100644 --- a/Fly App/test/fly/fake_dji.dart +++ b/Fly App/test/fly/fake_dji.dart @@ -50,6 +50,24 @@ class FakeDjiService extends DjiService { Future pauseWaypointMission() => _record('pauseWaypointMission'); @override Future resumeWaypointMission() => _record('resumeWaypointMission'); + + // ── ActiveTrack / TapFly (plan P7b) ──────────────────────────────────────── + @override + Future startActiveTrack(double x, double y, double w, double h, {String mode = 'TRACE'}) => + _record('startActiveTrack(${x.toStringAsFixed(2)}, ${y.toStringAsFixed(2)}, ${w.toStringAsFixed(2)}, ${h.toStringAsFixed(2)}, $mode)'); + @override + Future acceptActiveTrackConfirmation() => _record('acceptActiveTrackConfirmation'); + @override + Future rejectActiveTrackConfirmation() => _record('rejectActiveTrackConfirmation'); + @override + Future switchActiveTrackMode(String mode) => _record('switchActiveTrackMode($mode)'); + @override + Future startTapFly(double x, double y, {String mode = 'FORWARD', double speed = 5, bool avoidance = true}) => + _record('startTapFly(${x.toStringAsFixed(2)}, ${y.toStringAsFixed(2)}, $mode, ${speed.toStringAsFixed(0)}, $avoidance)'); + @override + Future stopTapFly() => _record('stopTapFly'); + @override + Future setTapFlySpeed(double speed) => _record('setTapFlySpeed(${speed.toStringAsFixed(0)})'); @override Future stopActiveTrack() => _record('stopActiveTrack'); @override diff --git a/Fly App/test/fly/safety_test.dart b/Fly App/test/fly/safety_test.dart index a5509f6..b5e0286 100644 --- a/Fly App/test/fly/safety_test.dart +++ b/Fly App/test/fly/safety_test.dart @@ -70,7 +70,8 @@ void main() { expect(rail(telemetryTick(), mission: 'tracking').mode, ActiveMode.activeTrack); expect(rail(telemetryTick(), mission: 'executing').mode, ActiveMode.waypoint); expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_WAYPOINT').mode, ActiveMode.waypoint); - expect(rail(telemetryTick()..['flightModeEnum'] = 'TAP_FLY').mode, ActiveMode.other); + expect(rail(telemetryTick()..['flightModeEnum'] = 'TAP_FLY').mode, ActiveMode.tapFly); + expect(rail(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT').mode, ActiveMode.other); expect(rail(telemetryTick(), mission: 'uploading').showStop, isFalse); expect(rail(grounded(), mission: 'tracking').showStop, isFalse, reason: 'nothing to stop on the ground'); }); @@ -145,7 +146,8 @@ void main() { expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'AUTO_LANDING'), FlightCommand.cancelLanding), isEmpty); expect(blockers(modelWith(telemetryTick()..['isLandingConfirmationNeeded'] = true), FlightCommand.confirmLanding), isEmpty); expect(blockers(modelWith(telemetryTick()..['isGoingHome'] = true), FlightCommand.cancelGoHome), isEmpty); - expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'TAP_FLY'), FlightCommand.stopMode).single, contains('pause button')); + expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'GPS_HOT_POINT'), FlightCommand.stopMode).single, contains('pause button')); + expect(blockers(modelWith(telemetryTick()..['flightModeEnum'] = 'TAP_FLY'), FlightCommand.stopMode), isEmpty); }); test('the RTH target is offered once the pilot is more than 30 m from home', () { diff --git a/Fly App/test/fly/vision_modes_test.dart b/Fly App/test/fly/vision_modes_test.dart new file mode 100644 index 0000000..31d2117 --- /dev/null +++ b/Fly App/test/fly/vision_modes_test.dart @@ -0,0 +1,310 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +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/vision_modes.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'; + +Map trackEvent(String state, {Map? tracking, String? error}) => { + 'type': 'mission', + 'operator': 'activeTrack', + 'state': state, + if (tracking != null) 'tracking': tracking, + if (error != null) 'error': error, + }; + +Map tapFlyEvent(String state, {Map? execution}) => { + 'type': 'mission', + 'operator': 'tapFly', + 'state': state, + if (execution != null) 'execution': execution, + }; + +const Map box = {'l': 0.4, 't': 0.3, 'r': 0.6, 'b': 0.7}; + +FlightModel modelWith(Map tick, {Map? profile}) { + final FlightModel m = FlightModel() + ..connected = true + ..profile = ProductProfile.fromBridge(profile ?? phantom4); + TelemetryReducer(m) + ..apply(tick) + ..apply(trackEvent('IDLE')) + ..apply(tapFlyEvent('IDLE')); + return m; +} + +Map grounded() => telemetryTick() + ..['isFlying'] = false + ..['areMotorsOn'] = false; + +void main() { + group('VisionGeometry', () { + const Size view = Size(800, 400); + + test('a drag becomes a 0..1 box whichever way it was drawn, clipped to the view', () { + final Rect r = VisionGeometry.dragBox(const Offset(600, 300), const Offset(200, -50), view)!; + expect(r, const Rect.fromLTRB(0.25, 0, 0.75, 0.75)); + }); + + test('a small drag is no box', () { + expect(VisionGeometry.dragBox(const Offset(100, 100), const Offset(110, 200), view), isNull); + }); + + test('a tap makes a person-sized box, kept inside the view', () { + final Rect mid = VisionGeometry.tapBoxAt(const Offset(400, 200), view); + expect(mid.center, const Offset(0.5, 0.5)); + expect(mid.width, closeTo(VisionGeometry.tapBox.width, 1e-9)); + expect(mid.height, closeTo(VisionGeometry.tapBox.height, 1e-9)); + final Rect corner = VisionGeometry.tapBoxAt(Offset.zero, view); + expect(corner.left, 0); + expect(corner.top, closeTo(0, 1e-9)); + }); + }); + + group('MissionState (vision operators)', () { + test('ActiveTrack: selecting, confirming, following, losing, done', () { + final MissionState s = MissionState()..apply(trackEvent('WAITING_FOR_CONFIRMATION', tracking: { + 'targetState': 'WAITING_FOR_CONFIRMATION', + 'targetType': 'HUMAN', + 'rect': box, + })); + expect(s.activeTrack.waitingForConfirmation, isTrue); + expect(s.activeTrack.rect, const Rect.fromLTRB(0.4, 0.3, 0.6, 0.7)); + expect(s.state, 'tracking'); + expect(s.tracking, isTrue); + + s.apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: {'targetState': 'TRACKING_WITH_LOW_CONFIDENCE', 'rect': box})); + expect(s.activeTrack.following, isTrue); + expect(s.activeTrack.lowConfidence, isTrue); + + s.apply(trackEvent('FINDING_TRACKED_TARGET')); + expect(s.activeTrack.rect, isNotNull, reason: 'a state-only event keeps the box while tracking'); + + s.apply(trackEvent('CANNOT_CONFIRM', tracking: {'targetState': 'CANNOT_CONFIRM', 'reason': 'TARGET_TOO_CLOSE', 'rect': box})); + expect(s.activeTrack.cannotConfirm, isTrue); + expect(s.activeTrack.reasonLabel, 'Subject too close'); + + s.apply(trackEvent('IDLE')); + expect(s.activeTrack.active, isFalse); + expect(s.activeTrack.rect, isNull); + expect(s.state, 'idle'); + }); + + test('a malformed rect is ignored', () { + final MissionState s = MissionState() + ..apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: {'rect': {'l': 0.6, 't': 0.3, 'r': 0.4, 'b': 0.7}})); + expect(s.activeTrack.rect, isNull); + }); + + test('the operator replaces the old QuickShot guess', () { + final MissionState s = MissionState()..apply({'type': 'mission', 'state': 'quickshot'}); + expect(s.tracking, isTrue); + s.apply(trackEvent('IDLE')); + expect(s.tracking, isFalse); + }); + + test('TapFly: flying with an image point and a bypass, then idle', () { + final MissionState s = MissionState() + ..apply(tapFlyEvent('EXECUTING', execution: {'imageX': 0.3, 'imageY': 0.4, 'bypass': 'LEFT', 'speed': 4.0})); + expect(s.tapFly.active, isTrue); + expect(s.tapFly.image, const Offset(0.3, 0.4)); + expect(s.tapFly.bypassLabel, contains('left')); + expect(s.state, 'tapfly'); + final AircraftState a = AircraftState()..apply(telemetryTick()); + expect(RailState.of(connected: true, a: a, mission: s).mode, ActiveMode.tapFly); + s.apply(tapFlyEvent('IDLE')); + expect(s.tapFly.active, isFalse); + expect(s.tapFly.image, isNull); + }); + }); + + group('SafetyGate (vision modes)', () { + List blockers(List c) => [for (final GateCheck g in c) if (g.blocks) g.message]; + List warnings(List c) => [for (final GateCheck g in c) if (!g.blocks) g.message]; + SafetyGate gate(FlightModel m, [FakeDjiService? dji]) => SafetyGate(model: m, dji: dji ?? FakeDjiService()); + + test('airborne in P mode with a good battery: ActiveTrack and TapFly may start', () { + final FlightModel m = modelWith(telemetryTick()); + expect(blockers(gate(m).checkActiveTrack(TrackMode.trace)), isEmpty); + expect(blockers(gate(m).checkTapFly(TapFlyDirection.forward)), isEmpty); + }); + + test('on the ground, low, in Sport, going home or during a waypoint mission they refuse', () { + expect(blockers(gate(modelWith(grounded())).checkActiveTrack(TrackMode.trace)), ['Take off first.']); + expect(blockers(gate(modelWith(telemetryTick(altitude: 1))).checkTapFly(TapFlyDirection.forward)), contains('Climb above 2 m first.')); + expect(blockers(gate(modelWith(telemetryTick()..['flightModeEnum'] = 'GPS_SPORT')).checkTapFly(TapFlyDirection.forward)), + contains('Switch the remote to P mode first.')); + expect(blockers(gate(modelWith(telemetryTick()..['isGoingHome'] = true)).checkActiveTrack(TrackMode.trace)), contains('Cancel Return to Home first.')); + final FlightModel wp = modelWith(telemetryTick()); + wp.mission.apply({'type': 'mission', 'operator': 'waypoint', 'state': 'EXECUTING'}); + expect(blockers(gate(wp).checkActiveTrack(TrackMode.trace)), contains('Stop the waypoint mission first.')); + }); + + test('one vision mode at a time', () { + final FlightModel m = modelWith(telemetryTick()); + m.mission.apply(tapFlyEvent('EXECUTING')); + expect(blockers(gate(m).checkActiveTrack(TrackMode.trace)), contains('Stop TapFly first.')); + m.mission.apply(tapFlyEvent('IDLE')); + m.mission.apply(trackEvent('AIRCRAFT_FOLLOWING')); + expect(blockers(gate(m).checkTapFly(TapFlyDirection.forward)), contains('Stop ActiveTrack first.')); + }); + + test('TapFly Backward and Free need rear sensors', () { + final FlightModel p4 = modelWith(telemetryTick()); // Phantom 4: front only + expect(gate(p4).tapFlyDirections(), [TapFlyDirection.forward]); + expect(blockers(gate(p4).checkTapFly(TapFlyDirection.backward)).single, contains('not available')); + }); + + test('Free warns that it does not avoid obstacles', () { + final FlightModel m = FlightModel()..connected = true; + TelemetryReducer(m).apply(telemetryTick()); + expect(warnings(gate(m).checkTapFly(TapFlyDirection.free)).single, contains('Free does not avoid')); + }); + + test('Profile warns about its blind side', () { + expect(warnings(gate(modelWith(telemetryTick())).checkActiveTrack(TrackMode.profile)).single, contains('sideways')); + }); + + test('start sends the clipped box / point, and refuses without touching the aircraft', () async { + final FakeDjiService dji = FakeDjiService(); + await gate(modelWith(telemetryTick()), dji).startActiveTrack(const Rect.fromLTRB(0.2, -0.1, 0.4, 0.5), TrackMode.spotlight); + await gate(modelWith(telemetryTick()), dji).startTapFly(const Offset(0.7, 0.4), TapFlyDirection.forward, speed: 30); + expect(dji.calls, ['startActiveTrack(0.20, 0.00, 0.20, 0.50, SPOTLIGHT)', 'startTapFly(0.70, 0.40, FORWARD, 10, true)']); + + final FakeDjiService none = FakeDjiService(); + await expectLater(gate(modelWith(grounded()), none).startTapFly(const Offset(0.5, 0.5), TapFlyDirection.forward), throwsA(isA())); + expect(none.calls, isEmpty); + }); + + test('confirm only answers a question that was asked; Stop reaches TapFly', () async { + final FakeDjiService dji = FakeDjiService(); + final FlightModel m = modelWith(telemetryTick()); + await gate(m, dji).confirmActiveTrack(yes: true); + expect(dji.calls, isEmpty); + m.mission.apply(trackEvent('WAITING_FOR_CONFIRMATION')); + await gate(m, dji).confirmActiveTrack(yes: true); + await gate(m, dji).confirmActiveTrack(yes: false); + m.mission.apply(trackEvent('IDLE')); + m.mission.apply(tapFlyEvent('EXECUTING')); + await gate(m, dji).run(FlightCommand.stopMode); + expect(dji.calls, ['acceptActiveTrackConfirmation', 'rejectActiveTrackConfirmation', 'stopTapFly']); + }); + }); + + group('FlyScreen (vision modes)', () { + setUp(() { + SharedPreferences.setMockInitialValues({}); + FlyPrefs.acceptedModeTerms.value = {'activeTrack', 'tapFly'}; + }); + + Future pump(WidgetTester tester, FlightModel m) 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))); + await tester.pump(); + return dji; + } + + Future open(WidgetTester tester, String mode) 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(ValueKey('mode-$mode'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + + testWidgets('ActiveTrack: draw a box, GO starts it, the tracked box and Stop follow', (WidgetTester tester) async { + final FlightModel m = modelWith(telemetryTick()); + final FakeDjiService dji = await pump(tester, m); + await open(tester, 'activeTrack'); + expect(find.byKey(const ValueKey('track-panel')), findsOneWidget); + + // Draw in the empty middle of the view, clear of the HUD. + await tester.dragFrom(const Offset(380, 160), const Offset(80, 120)); + await tester.pump(); + expect(find.byKey(const ValueKey('track-go')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('track-go'))); + await tester.pumpAndSettle(); + expect(dji.calls.single, startsWith('startActiveTrack(')); + expect(dji.calls.single, endsWith('TRACE)')); + + TelemetryReducer(m).apply(trackEvent('WAITING_FOR_CONFIRMATION', tracking: {'targetState': 'WAITING_FOR_CONFIRMATION', 'rect': box})); + await tester.pump(); + expect(find.byKey(const ValueKey('track-box')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('track-confirm'))); + await tester.pump(); + expect(dji.calls.last, 'acceptActiveTrackConfirmation'); + + TelemetryReducer(m).apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: {'targetState': 'TRACKING_WITH_HIGH_CONFIDENCE', 'rect': box})); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('track-spotlight'))); + await tester.pump(); + expect(dji.calls.last, 'switchActiveTrackMode(SPOTLIGHT)'); + expect(find.byKey(const ValueKey('rail-stopMode')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('vision-exit'))); + await tester.pumpAndSettle(); + expect(dji.calls.last, 'stopActiveTrack', reason: 'Exit stops a running mode'); + expect(find.byKey(const ValueKey('track-panel')), findsNothing); + }); + + testWidgets('a vision mode that would warn asks for the slide first', (WidgetTester tester) async { + final FlightModel m = modelWith(telemetryTick()); + final FakeDjiService dji = await pump(tester, m); + await open(tester, 'activeTrack'); + await tester.tap(find.byKey(const ValueKey('track-profile'))); + await tester.pump(); + await tester.tapAt(const Offset(420, 200)); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('track-go'))); + await tester.pumpAndSettle(); + expect(dji.calls, isEmpty); + expect(find.byKey(SlideToConfirmTrack.thumbKey), findsOneWidget); + await tester.drag(find.byKey(SlideToConfirmTrack.thumbKey), const Offset(600, 0)); + await tester.pumpAndSettle(); + expect(dji.calls.single, endsWith('PROFILE)')); + }); + + testWidgets('TapFly: tap a point, set the speed, GO, Stop', (WidgetTester tester) async { + final FlightModel m = modelWith(telemetryTick()); + final FakeDjiService dji = await pump(tester, m); + await open(tester, 'tapFly'); + expect(find.byKey(const ValueKey('tapfly-panel')), findsOneWidget); + expect(find.byKey(const ValueKey('tapfly-backward')), findsNothing, reason: 'Phantom 4 has front sensors only'); + + await tester.tap(find.byKey(const ValueKey('tapfly-speed-plus'))); + await tester.pump(); + await tester.tapAt(const Offset(422, 195)); + await tester.pump(); + expect(find.byKey(const ValueKey('tapfly-target')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('tapfly-go'))); + await tester.pumpAndSettle(); + expect(dji.calls.single, 'startTapFly(0.50, 0.50, FORWARD, 6, true)'); + + TelemetryReducer(m).apply(tapFlyEvent('EXECUTING', execution: {'imageX': 0.5, 'imageY': 0.5, 'bypass': 'NONE', 'speed': 6.0})); + await tester.pump(); + expect(find.byKey(const ValueKey('tapfly-heading')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('tapfly-speed-minus'))); + await tester.pump(); + expect(dji.calls.last, 'setTapFlySpeed(5)'); + await tester.tap(find.byKey(const ValueKey('vision-stop'))); + await tester.pumpAndSettle(); + expect(dji.calls.last, 'stopTapFly'); + }); + }); +}