Files
PilotVault/Fly App/lib/fly/modes/vision_modes.dart
T
tajniak81andClaude Opus 5.5 636da1ac2c Give the Fly App every remaining GO 4 flight mode and the missing settings
Intelligent modes: Point of Interest (orbit on the map, live radius and
speed), Follow Me (follows the phone), QuickShots on a boxed subject,
Course Lock / Home Lock, Tripod and Cinematic, a Free hyperlapse, and
Waypoints 2.0 for V2-only aircraft. ActiveTrack gains Spotlight Pro and
Circle. Every mode has a first-use tutorial and stops from the rail.
Waypoints can resume an interrupted mission, change cruise speed in
flight and check terrain clearance (Open-Meteo, on tap).

Settings: units, C1/C2 actions, dual-operator master/slave, HDMI/SDI
output, a link spectrum chart, Wi-Fi name and password, aircraft name,
video cache, battery warning history and pairing, non-smart cell
voltages. Calibration: compass interference meter, per-IMU calibration,
lens infinity focus and a first-manual-focus prompt. Also an RTK chip,
gimbal movement presets and motor tuning, Precision Takeoff gated on the
flight controller, and the precise GEO database's server version.

Built and unit/widget-tested only; nothing has flown yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:06:16 +02:00

204 lines
6.3 KiB
Dart

import 'dart:ui' show Offset, Rect, Size;
import 'package:flutter/foundation.dart';
import 'orbit_modes.dart' show QuickShot;
/// 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;
/// ActiveTrack Circle's orbit, m/s; negative goes anticlockwise.
double circleSpeed = 3;
/// The QuickShot the next box flies.
QuickShot quickShot = QuickShot.dronie;
void setCircleSpeed(double v) {
circleSpeed = v;
notifyListeners();
}
void setQuickShot(QuickShot s) {
quickShot = s;
notifyListeners();
}
/// A box drawn (or tapped) on the video, 0..1, waiting for GO.
Rect? pendingBox;
/// When [pendingBox] is a subject the aircraft found itself: its index.
int? pendingSubject;
/// A TapFly target, 0..1, waiting for GO.
Offset? pendingPoint;
/// Trace may fly backwards ahead of a subject coming towards it; null until read.
bool? retreat;
void setRetreat(bool? on) {
retreat = on;
notifyListeners();
}
/// Picks an auto-sensed subject: its box with its index.
void pickSubject(int index, Rect box) {
pendingBox = box;
pendingSubject = index;
notifyListeners();
}
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;
pendingSubject = null;
notifyListeners();
}
void setPendingPoint(Offset? p) {
pendingPoint = p;
notifyListeners();
}
void clearPending() {
if (pendingBox == null && pendingPoint == null) return;
pendingBox = null;
pendingSubject = 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'),
/// Spotlight that also turns the aircraft (the gimbal alone cannot keep
/// up): Mavic 2-class aircraft.
spotlightPro('Spotlight Pro', 'SPOTLIGHT_PRO'),
/// Orbits the subject at [VisionSession.circleSpeed].
circle('Circle', 'CIRCLE');
const TrackMode(this.label, this.sdk);
final String label;
final String sdk;
/// The follow styles GO 4 offers: Spotlight Pro and Circle came with the
/// omni-sensing aircraft (Mavic 2); everything else has the first three.
static List<TrackMode> offered({required bool omni}) =>
<TrackMode>[trace, profile, spotlight, if (omni) spotlightPro, if (omni) circle];
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 touch layers share: screen points to the
/// operators' 0..1 picture coordinates.
///
/// The native view letterboxes the feed to its own aspect ratio, so the
/// picture is [contentRect] — centred in the view, with black bars on two
/// sides. Every mapping goes through that rect; a touch on a bar is no touch.
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 picture: a person-sized window.
static const Size tapBox = Size(0.12, 0.22);
/// Where the picture of size [video] sits inside [view] (aspect fit,
/// centred); the whole view while the feed size is unknown.
static Rect contentRect(Size view, Size? video) {
if (video == null || video.isEmpty || view.isEmpty) return Offset.zero & view;
final double aspect = video.width / video.height;
final double w, h;
if (view.width / view.height > aspect) {
h = view.height;
w = h * aspect;
} else {
w = view.width;
h = w / aspect;
}
return Rect.fromLTWH((view.width - w) / 2, (view.height - h) / 2, w, h);
}
/// The 0..1 box between two corners of a drag, clipped to the picture; null
/// when it is too small to be a deliberate box, or misses the picture.
static Rect? dragBox(Offset a, Offset b, Rect content) {
if ((a.dx - b.dx).abs() < minDrag || (a.dy - b.dy).abs() < minDrag) return null;
final Rect r = Rect.fromPoints(a, b).intersect(content);
if (r.width < minDrag || r.height < minDrag) return null;
return Rect.fromLTRB(
(r.left - content.left) / content.width,
(r.top - content.top) / content.height,
(r.right - content.left) / content.width,
(r.bottom - content.top) / content.height,
);
}
/// A [tapBox] centred on a tap, kept inside the picture; null on a bar.
static Rect? tapBoxAt(Offset p, Rect content) {
final Offset? n = normalise(p, content);
if (n == null) return null;
final double cx = n.dx.clamp(tapBox.width / 2, 1 - tapBox.width / 2);
final double cy = n.dy.clamp(tapBox.height / 2, 1 - tapBox.height / 2);
return Rect.fromCenter(center: Offset(cx, cy), width: tapBox.width, height: tapBox.height);
}
/// [p] as 0..1 of the picture; null when it falls on a bar.
static Offset? normalise(Offset p, Rect content) {
if (!content.contains(p)) return null;
return Offset((p.dx - content.left) / content.width, (p.dy - content.top) / content.height);
}
static Rect toScreen(Rect r, Rect content) => Rect.fromLTRB(
content.left + r.left * content.width,
content.top + r.top * content.height,
content.left + r.right * content.width,
content.top + r.bottom * content.height,
);
static Offset pointToScreen(Offset p, Rect content) => Offset(content.left + p.dx * content.width, content.top + p.dy * content.height);
}