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'); + }); + }); +}