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 <noreply@anthropic.com>
This commit is contained in:
1 parent
43a66df8df
commit
2c908cf17e
13 files changed
+1519
-12
No files matched your search
@@ -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<String, Any?>? =
|
||||
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<String, Any?>("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<String, Any?>("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<Boolean>("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<Number>("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<Number>("speed") ?: 5).toFloat()
|
||||
mission.isHorizontalObstacleAvoidanceEnabled = call.argument<Boolean>("avoidance") ?: true
|
||||
mission.target = PointF(
|
||||
(call.argument<Number>("x") ?: 0.5).toFloat(),
|
||||
(call.argument<Number>("y") ?: 0.5).toFloat(),
|
||||
)
|
||||
op.startMission(mission, ctx.completion(result))
|
||||
}
|
||||
|
||||
private inline fun <reified T : Enum<T>> enumArg(call: MethodCall, name: String, fallback: T): T {
|
||||
val raw = call.argument<String>(name) ?: return fallback
|
||||
return try {
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -298,6 +298,33 @@ class DjiService {
|
||||
});
|
||||
Future<void> stopActiveTrack() => _methods.invokeMethod<void>('stopActiveTrack');
|
||||
|
||||
/// Answers the operator's WAITING_FOR_CONFIRMATION: yes follows the subject, no drops it.
|
||||
Future<void> acceptActiveTrackConfirmation() => _methods.invokeMethod<void>('acceptActiveTrackConfirmation');
|
||||
Future<void> rejectActiveTrackConfirmation() => _methods.invokeMethod<void>('rejectActiveTrackConfirmation');
|
||||
|
||||
/// mode: TRACE | PROFILE | SPOTLIGHT | SPOTLIGHT_PRO, while tracking.
|
||||
Future<void> switchActiveTrackMode(String mode) =>
|
||||
_methods.invokeMethod<void>('switchActiveTrackMode', <String, dynamic>{'mode': mode});
|
||||
|
||||
/// Lets Trace fly backwards ahead of a subject walking towards the aircraft.
|
||||
Future<void> setActiveTrackRetreat(bool on) =>
|
||||
_methods.invokeMethod<void>('setActiveTrackRetreat', <String, dynamic>{'on': on});
|
||||
|
||||
// ── TapFly ──────────────────────────────────────────────────────────────────
|
||||
/// Flies towards the point [x], [y] (0..1 across the live view).
|
||||
/// mode: FORWARD | BACKWARD | FREE; speed in m/s.
|
||||
Future<void> startTapFly(double x, double y, {String mode = 'FORWARD', double speed = 5, bool avoidance = true}) =>
|
||||
_methods.invokeMethod<void>('startTapFly', <String, dynamic>{
|
||||
'x': x,
|
||||
'y': y,
|
||||
'mode': mode,
|
||||
'speed': speed,
|
||||
'avoidance': avoidance,
|
||||
});
|
||||
Future<void> stopTapFly() => _methods.invokeMethod<void>('stopTapFly');
|
||||
Future<void> setTapFlySpeed(double speed) => _methods.invokeMethod<void>('setTapFlySpeed', <String, dynamic>{'speed': speed});
|
||||
Future<void> resetTapFlyHeading() => _methods.invokeMethod<void>('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.
|
||||
|
||||
@@ -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<String> _waypointModes = <String>{'GPS_WAYPOINT'};
|
||||
static const Set<String> _trackModes = <String>{'ACTIVE_TRACK', 'ACTIVE_TRACK_SPOTLIGHT'};
|
||||
static const Set<String> _otherModes = <String>{'GPS_HOT_POINT', 'GPS_FOLLOW_ME', 'TAP_FLY', 'DRAW', 'TERRAIN_FOLLOW'};
|
||||
static const Set<String> _otherModes = <String>{'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;
|
||||
}
|
||||
|
||||
@@ -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: <String>[
|
||||
..._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: <String>[
|
||||
..._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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<TapFlyDirection> tapFlyDirections() {
|
||||
final VisionSensors v = model.profile.vision;
|
||||
final bool rear = v == VisionSensors.frontBack || v == VisionSensors.omni;
|
||||
return <TapFlyDirection>[
|
||||
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<GateCheck> 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<GateCheck> checkActiveTrack(TrackMode mode) {
|
||||
final List<GateCheck> checks = <GateCheck>[];
|
||||
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
|
||||
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
|
||||
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<void> startActiveTrack(Rect rect, TrackMode mode) async {
|
||||
final List<GateCheck> 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<void> confirmActiveTrack({required bool yes}) async {
|
||||
if (!model.mission.activeTrack.waitingForConfirmation && !model.mission.activeTrack.cannotConfirm) return;
|
||||
await (yes ? dji.acceptActiveTrackConfirmation() : dji.rejectActiveTrackConfirmation());
|
||||
}
|
||||
|
||||
Future<void> switchActiveTrackMode(TrackMode mode) async {
|
||||
if (!model.mission.activeTrack.active) {
|
||||
throw const MissionRefused(<GateCheck>[GateCheck.block('Not tracking a subject.')]);
|
||||
}
|
||||
await dji.switchActiveTrackMode(mode.sdk);
|
||||
}
|
||||
|
||||
/// Preconditions for flying towards a tapped point in [direction].
|
||||
List<GateCheck> checkTapFly(TapFlyDirection direction) {
|
||||
final List<GateCheck> checks = <GateCheck>[];
|
||||
if (!model.connected) return checks..add(const GateCheck.block('No aircraft connected.'));
|
||||
if (_a.updatedAt == null) return checks..add(const GateCheck.block('Waiting for the flight controller to report.'));
|
||||
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<void> startTapFly(Offset target, TapFlyDirection direction, {double speed = 5}) async {
|
||||
final List<GateCheck> 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<void> 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()
|
||||
|
||||
@@ -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<String, dynamic> e, ActiveTrackProgress previous) {
|
||||
final Map<String, dynamic>? 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<String, dynamic>? 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<String, dynamic>? 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<String> _tracking = <String>{
|
||||
'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<String, dynamic> e, TapFlyProgress previous) {
|
||||
final Map<String, dynamic>? 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<String> _flying = <String>{'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<String, dynamic> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<FlyScreen> {
|
||||
/// 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<FlyScreen> {
|
||||
_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<FlyScreen> {
|
||||
_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<void> _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<void> _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<FlyScreen> {
|
||||
_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<FlyScreen> {
|
||||
if (!_mapBig) ...<Widget>[
|
||||
Positioned.fromRect(key: const ValueKey<String>('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 ...<Widget>[
|
||||
// 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<FlyScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: pip, child: map),
|
||||
] else ...<Widget>[
|
||||
Positioned.fromRect(key: const ValueKey<String>('map-slot'), rect: full, child: map),
|
||||
@@ -432,6 +512,19 @@ class _FlyScreenState extends State<FlyScreen> {
|
||||
bottom: 104,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[
|
||||
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),
|
||||
|
||||
@@ -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<Rect> onGo;
|
||||
final ValueChanged<bool> onConfirm;
|
||||
|
||||
@override
|
||||
State<ActiveTrackLayer> createState() => _ActiveTrackLayerState();
|
||||
}
|
||||
|
||||
class _ActiveTrackLayerState extends State<ActiveTrackLayer> {
|
||||
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(<Listenable>[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<String>('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: <Widget>[
|
||||
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<Widget> _pending(Rect r, Size view) => <Widget>[
|
||||
Positioned.fromRect(rect: r, child: const _Box(color: PV.accent)),
|
||||
_beside(r, view, Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
_RoundButton(
|
||||
key: const ValueKey<String>('track-go'),
|
||||
label: 'GO',
|
||||
color: PV.accent,
|
||||
onTap: () => widget.onGo(widget.session.pendingBox!),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_RoundButton(
|
||||
key: const ValueKey<String>('track-discard'),
|
||||
icon: 'close',
|
||||
color: Glass.pillStrong,
|
||||
onTap: () => widget.session.setPendingBox(null),
|
||||
),
|
||||
])),
|
||||
];
|
||||
|
||||
List<Widget> _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 <Widget>[
|
||||
Positioned.fromRect(rect: r, child: _Box(key: const ValueKey<String>('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: <Widget>[
|
||||
if (t.waitingForConfirmation)
|
||||
_RoundButton(key: const ValueKey<String>('track-confirm'), icon: 'check', color: HudColors.ready, onTap: () => widget.onConfirm(true)),
|
||||
const SizedBox(width: 6),
|
||||
_RoundButton(key: const ValueKey<String>('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<Offset> onGo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (BuildContext context, BoxConstraints c) {
|
||||
final Size view = c.biggest;
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge(<Listenable>[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<String>('tapfly-layer'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapUp: (TapUpDetails d) => session.setPendingPoint(VisionGeometry.normalise(d.localPosition, view)),
|
||||
child: Stack(children: <Widget>[
|
||||
const SizedBox.expand(),
|
||||
if (heading != null) _marker(VisionGeometry.pointToScreen(heading, view), HudColors.ready, key: 'tapfly-heading'),
|
||||
if (pending != null) ...<Widget>[
|
||||
_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: <Widget>[
|
||||
_RoundButton(key: const ValueKey<String>('tapfly-go'), label: 'GO', color: PV.accent, onTap: () => onGo(pending)),
|
||||
const SizedBox(width: 6),
|
||||
_RoundButton(
|
||||
key: const ValueKey<String>('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<String>(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>[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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<void> startVisionMode(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String icon,
|
||||
required String description,
|
||||
required List<GateCheck> checks,
|
||||
required Future<void> 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<void> _guard(String label, Future<void> Function() f) async {
|
||||
try {
|
||||
await f();
|
||||
} catch (e) {
|
||||
onError(label, e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: Listenable.merge(<Listenable>[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<Widget> chips = tapFly
|
||||
? <Widget>[
|
||||
for (final TapFlyDirection d in gate.tapFlyDirections())
|
||||
HudChip(
|
||||
key: ValueKey<String>('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),
|
||||
),
|
||||
]
|
||||
: <Widget>[
|
||||
for (final TrackMode m in TrackMode.values)
|
||||
HudChip(
|
||||
key: ValueKey<String>('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<String>(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: <Widget>[
|
||||
Row(mainAxisSize: MainAxisSize.min, children: <Widget>[
|
||||
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<String>('vision-stop'),
|
||||
icon: 'stop',
|
||||
label: 'STOP',
|
||||
color: HudColors.danger,
|
||||
onTap: () => flow.request(context, FlightCommand.stopMode),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_PanelButton(key: const ValueKey<String>('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: <Widget>[
|
||||
...chips,
|
||||
if (tapFly) ...<Widget>[
|
||||
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<int> onStep;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget b(String l, int d, bool on) => Opacity(
|
||||
opacity: on ? 1 : 0.35,
|
||||
child: GestureDetector(
|
||||
key: ValueKey<String>('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: <Widget>[
|
||||
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: <Widget>[
|
||||
PVIcon(icon, size: 11, color: Colors.white),
|
||||
const SizedBox(width: 3),
|
||||
Text(label, style: HudText.label.copyWith(color: Colors.white, fontWeight: FontWeight.w700)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,24 @@ class FakeDjiService extends DjiService {
|
||||
Future<void> pauseWaypointMission() => _record('pauseWaypointMission');
|
||||
@override
|
||||
Future<void> resumeWaypointMission() => _record('resumeWaypointMission');
|
||||
|
||||
// ── ActiveTrack / TapFly (plan P7b) ────────────────────────────────────────
|
||||
@override
|
||||
Future<void> 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<void> acceptActiveTrackConfirmation() => _record('acceptActiveTrackConfirmation');
|
||||
@override
|
||||
Future<void> rejectActiveTrackConfirmation() => _record('rejectActiveTrackConfirmation');
|
||||
@override
|
||||
Future<void> switchActiveTrackMode(String mode) => _record('switchActiveTrackMode($mode)');
|
||||
@override
|
||||
Future<void> 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<void> stopTapFly() => _record('stopTapFly');
|
||||
@override
|
||||
Future<void> setTapFlySpeed(double speed) => _record('setTapFlySpeed(${speed.toStringAsFixed(0)})');
|
||||
@override
|
||||
Future<void> stopActiveTrack() => _record('stopActiveTrack');
|
||||
@override
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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<String, dynamic> trackEvent(String state, {Map<String, Object?>? tracking, String? error}) => <String, dynamic>{
|
||||
'type': 'mission',
|
||||
'operator': 'activeTrack',
|
||||
'state': state,
|
||||
if (tracking != null) 'tracking': tracking,
|
||||
if (error != null) 'error': error,
|
||||
};
|
||||
|
||||
Map<String, dynamic> tapFlyEvent(String state, {Map<String, Object?>? execution}) => <String, dynamic>{
|
||||
'type': 'mission',
|
||||
'operator': 'tapFly',
|
||||
'state': state,
|
||||
if (execution != null) 'execution': execution,
|
||||
};
|
||||
|
||||
const Map<String, Object?> box = <String, Object?>{'l': 0.4, 't': 0.3, 'r': 0.6, 'b': 0.7};
|
||||
|
||||
FlightModel modelWith(Map<String, dynamic> tick, {Map<String, dynamic>? 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<String, dynamic> 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: <String, Object?>{
|
||||
'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: <String, Object?>{'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: <String, Object?>{'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: <String, Object?>{'rect': <String, Object?>{'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(<String, dynamic>{'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: <String, Object?>{'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<String> blockers(List<GateCheck> c) => <String>[for (final GateCheck g in c) if (g.blocks) g.message];
|
||||
List<String> warnings(List<GateCheck> c) => <String>[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)), <String>['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(<String, dynamic>{'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>[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, <String>['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<MissionRefused>()));
|
||||
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, <String>['acceptActiveTrackConfirmation', 'rejectActiveTrackConfirmation', 'stopTapFly']);
|
||||
});
|
||||
});
|
||||
|
||||
group('FlyScreen (vision modes)', () {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
FlyPrefs.acceptedModeTerms.value = <String>{'activeTrack', 'tapFly'};
|
||||
});
|
||||
|
||||
Future<FakeDjiService> 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<void> open(WidgetTester tester, String mode) async {
|
||||
await tester.tap(find.byKey(const ValueKey<String>('rail-modes')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(ValueKey<String>('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<String>('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<String>('track-go')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey<String>('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: <String, Object?>{'targetState': 'WAITING_FOR_CONFIRMATION', 'rect': box}));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey<String>('track-box')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey<String>('track-confirm')));
|
||||
await tester.pump();
|
||||
expect(dji.calls.last, 'acceptActiveTrackConfirmation');
|
||||
|
||||
TelemetryReducer(m).apply(trackEvent('AIRCRAFT_FOLLOWING', tracking: <String, Object?>{'targetState': 'TRACKING_WITH_HIGH_CONFIDENCE', 'rect': box}));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey<String>('track-spotlight')));
|
||||
await tester.pump();
|
||||
expect(dji.calls.last, 'switchActiveTrackMode(SPOTLIGHT)');
|
||||
expect(find.byKey(const ValueKey<String>('rail-stopMode')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey<String>('vision-exit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dji.calls.last, 'stopActiveTrack', reason: 'Exit stops a running mode');
|
||||
expect(find.byKey(const ValueKey<String>('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<String>('track-profile')));
|
||||
await tester.pump();
|
||||
await tester.tapAt(const Offset(420, 200));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey<String>('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<String>('tapfly-panel')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey<String>('tapfly-backward')), findsNothing, reason: 'Phantom 4 has front sensors only');
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey<String>('tapfly-speed-plus')));
|
||||
await tester.pump();
|
||||
await tester.tapAt(const Offset(422, 195));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey<String>('tapfly-target')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey<String>('tapfly-go')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dji.calls.single, 'startTapFly(0.50, 0.50, FORWARD, 6, true)');
|
||||
|
||||
TelemetryReducer(m).apply(tapFlyEvent('EXECUTING', execution: <String, Object?>{'imageX': 0.5, 'imageY': 0.5, 'bypass': 'NONE', 'speed': 6.0}));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey<String>('tapfly-heading')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey<String>('tapfly-speed-minus')));
|
||||
await tester.pump();
|
||||
expect(dji.calls.last, 'setTapFlySpeed(5)');
|
||||
await tester.tap(find.byKey(const ValueKey<String>('vision-stop')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dji.calls.last, 'stopTapFly');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user