Files
PilotVault/Fly App/lib/fly/modes/waypoint_editor.dart
T
tajniak81andClaude Opus 5.5 43a66df8df Give the Fly App GO 4-style intelligent modes and a Waypoints editor
P7a of the GO 4-parity plan. The rail's GRID button becomes MODES, which
opens a picker of the product's intelligent flight modes with first-use
terms; Waypoints is the first one built.

Waypoints opens on the big map: tap to add waypoints or POIs, long-press
to drag, "+" to insert on a leg, and a point editor for altitude, speed,
heading (route / custom / POI), gimbal (set or aim at POI), camera action
and hover, and turn radius, with a swipe-to-adjust readout. Route settings,
a local mission library and undo sit in a tool column. Start runs the
SafetyGate's pre-start check in the slide-to-confirm dialog, then uploads
and starts; a run panel shows upload and flight progress with Pause,
Resume and Stop.

MissionBridge now listens to the waypoint operator and builds the full V1
mission (per-waypoint headings, turn direction, gimbal pitch, actions).
Not yet run on a simulator or aircraft.

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

310 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart' show LatLng;
import 'waypoint_mission.dart';
/// What a tap on the map adds.
enum MapTool { waypoint, poi }
/// The point editor's tabs (GO 4's `gs_waypoint_2_point_editor_view`).
enum PointField {
altitude('Altitude'),
speed('Speed'),
heading('Heading'),
gimbal('Gimbal'),
action('Action'),
poi('POI'),
radius('Turn radius');
const PointField(this.label);
final String label;
}
/// A numeric field's range and step for the swipe readout and −/+.
@immutable
class FieldRange {
const FieldRange(this.min, this.max, this.step, this.unit);
final double min;
final double max;
final double step;
final String unit;
double clamp(double v) => ((v / step).round() * step).clamp(min, max).toDouble();
}
/// The Waypoints editor's state (plan P7): the mission being edited, what is
/// selected, the map tool, and an undo history. Pure — the map layers and the
/// HUD panels both listen to it, and the tests drive it directly.
class WaypointEditor extends ChangeNotifier {
WaypointEditor({WaypointMissionSpec mission = const WaypointMissionSpec()}) : _mission = mission;
WaypointMissionSpec _mission;
WaypointMissionSpec get mission => _mission;
/// The selected waypoint, or null.
int? selected;
/// The selected POI's id, or null. A waypoint and a POI are never both selected.
String? selectedPoi;
MapTool tool = MapTool.waypoint;
PointField field = PointField.altitude;
/// Edited since it was last loaded or saved.
bool dirty = false;
final List<WaypointMissionSpec> _undo = <WaypointMissionSpec>[];
static const int _undoDepth = 40;
bool get canUndo => _undo.isNotEmpty;
/// Altitude for the first waypoint of a new route.
static const double defaultAltitude = 50;
/// Ranges of the numeric fields (MSDK 4.18 limits, narrowed to what GO 4 offers).
static const FieldRange altitudeRange = FieldRange(-200, WaypointMissionSpec.maxAltitudeSdk, 1, 'm');
static const FieldRange speedRange = FieldRange(WaypointMissionSpec.minSpeed, WaypointMissionSpec.maxSpeed, 0.5, 'm/s');
static const FieldRange headingRange = FieldRange(-180, 180, 1, '°');
static const FieldRange radiusRange = FieldRange(WaypointMissionSpec.minTurnRadius, 200, 0.5, 'm');
static const FieldRange hoverRange = FieldRange(0, 32, 1, 's');
static const FieldRange poiHeightRange = FieldRange(-200, 500, 1, 'm');
static FieldRange gimbalRange({double min = -90, double max = 30}) => FieldRange(min, max, 1, '°');
WaypointSpec? get selectedPoint => selected == null || selected! >= _mission.points.length ? null : _mission.points[selected!];
MissionPoi? get selectedPoiSpec => _mission.poi(selectedPoi);
// ── History ───────────────────────────────────────────────────────────────
void _set(WaypointMissionSpec next, {bool record = true}) {
if (record) {
_undo.add(_mission);
if (_undo.length > _undoDepth) _undo.removeAt(0);
}
_mission = next;
dirty = true;
_fixSelection();
notifyListeners();
}
void undo() {
if (_undo.isEmpty) return;
_mission = _undo.removeLast();
dirty = true;
_fixSelection();
notifyListeners();
}
/// A drag moves a marker many times; only its start goes into the history.
void beginDrag() {
_undo.add(_mission);
if (_undo.length > _undoDepth) _undo.removeAt(0);
}
void _fixSelection() {
if (selected != null && selected! >= _mission.points.length) selected = _mission.points.isEmpty ? null : _mission.points.length - 1;
if (selectedPoi != null && _mission.poi(selectedPoi) == null) selectedPoi = null;
}
// ── Whole mission ─────────────────────────────────────────────────────────
void load(WaypointMissionSpec m) {
_mission = m;
_undo.clear();
selected = m.points.isEmpty ? null : 0;
selectedPoi = null;
dirty = false;
notifyListeners();
}
void markSaved(WaypointMissionSpec stored) {
_mission = stored;
dirty = false;
notifyListeners();
}
void clear() {
if (_mission.points.isEmpty && _mission.pois.isEmpty) return;
_set(_mission.copyWith(points: const <WaypointSpec>[], pois: const <MissionPoi>[]));
selected = null;
selectedPoi = null;
}
void updateRoute(WaypointMissionSpec Function(WaypointMissionSpec m) change) => _set(change(_mission));
void setTool(MapTool t) {
if (tool == t) return;
tool = t;
notifyListeners();
}
void setField(PointField f) {
if (field == f) return;
field = f;
notifyListeners();
}
// ── Map gestures ──────────────────────────────────────────────────────────
/// A tap on empty map adds what the tool says.
void tapMap(LatLng at) => tool == MapTool.poi ? addPoi(at) : addPoint(at);
// ── Waypoints ─────────────────────────────────────────────────────────────
/// Appends a waypoint (or inserts it at [index]); it takes the altitude,
/// speed and gimbal of the point before it, as GO 4 does, and is selected.
void addPoint(LatLng at, {int? index}) {
if (_mission.points.length >= WaypointMissionSpec.maxPoints) return;
final List<WaypointSpec> pts = List<WaypointSpec>.of(_mission.points);
final int i = (index ?? pts.length).clamp(0, pts.length);
final WaypointSpec? like = pts.isEmpty ? null : pts[(i - 1).clamp(0, pts.length - 1)];
pts.insert(
i,
WaypointSpec(
at: at,
altitude: like?.altitude ?? defaultAltitude,
speed: like?.speed,
gimbalPitch: like?.gimbalPitch,
turnRadius: like?.turnRadius ?? WaypointMissionSpec.minTurnRadius,
),
);
_set(_mission.copyWith(points: pts));
selected = i;
selectedPoi = null;
notifyListeners();
}
/// Adds a waypoint halfway along leg [leg] (between points leg and leg+1).
void insertMidpoint(int leg) {
final List<WaypointSpec> pts = _mission.points;
if (leg < 0 || leg + 1 >= pts.length) return;
final LatLng a = pts[leg].at, b = pts[leg + 1].at;
addPoint(LatLng((a.latitude + b.latitude) / 2, (a.longitude + b.longitude) / 2), index: leg + 1);
final WaypointSpec mid = _mission.points[leg + 1];
_replace(leg + 1, mid.copyWith(altitude: (pts[leg].altitude + pts[leg + 1].altitude) / 2), record: false);
}
void movePoint(int i, LatLng at, {bool record = false}) => _replace(i, _mission.points[i].copyWith(at: at), record: record);
void removePoint(int i) {
if (i < 0 || i >= _mission.points.length) return;
final List<WaypointSpec> pts = List<WaypointSpec>.of(_mission.points)..removeAt(i);
_set(_mission.copyWith(points: pts));
if (pts.isEmpty) {
selected = null;
} else if (selected != null && selected! >= i) {
selected = (selected! - 1).clamp(0, pts.length - 1);
}
notifyListeners();
}
void select(int? i) {
selected = i;
if (i != null) selectedPoi = null;
notifyListeners();
}
void selectNext(int delta) {
if (_mission.points.isEmpty) return;
select(((selected ?? 0) + delta).clamp(0, _mission.points.length - 1));
}
/// [record] false for the steps of a swipe that [beginDrag] already saved.
void updatePoint(int i, WaypointSpec Function(WaypointSpec p) change, {bool record = true}) =>
_replace(i, change(_mission.points[i]), record: record);
void _replace(int i, WaypointSpec p, {bool record = true}) {
if (i < 0 || i >= _mission.points.length) return;
final List<WaypointSpec> pts = List<WaypointSpec>.of(_mission.points);
pts[i] = p;
_set(_mission.copyWith(points: pts), record: record);
}
/// A per-point heading other than Route only works with per-waypoint
/// headings, so choosing one switches the route over.
void setHeading(int i, WaypointHeading h, {int? custom}) {
final WaypointSpec p = _mission.points[i];
final List<WaypointSpec> pts = List<WaypointSpec>.of(_mission.points);
pts[i] = p.copyWith(heading: h, customHeading: custom ?? p.customHeading);
final bool needsPerPoint = h != WaypointHeading.route && _mission.headingMode != RouteHeadingMode.perPoint;
_set(_mission.copyWith(points: pts, headingMode: needsPerPoint ? RouteHeadingMode.perPoint : null));
}
/// Copies the selected waypoint's [f] to every waypoint.
void applyToAll(PointField f) {
final WaypointSpec? src = selectedPoint;
if (src == null) return;
final List<WaypointSpec> pts = <WaypointSpec>[
for (final WaypointSpec p in _mission.points)
switch (f) {
PointField.altitude => p.copyWith(altitude: src.altitude),
PointField.speed => p.copyWith(speed: src.speed),
PointField.heading => p.copyWith(heading: src.heading, customHeading: src.customHeading),
PointField.gimbal => p.copyWith(gimbalPitch: src.gimbalPitch, gimbalAimPoi: src.gimbalAimPoi),
PointField.action => p.copyWith(action: src.action, hoverSeconds: src.hoverSeconds),
PointField.poi => p.copyWith(poiId: src.poiId),
PointField.radius => p.copyWith(turnRadius: src.turnRadius),
},
];
_set(_mission.copyWith(points: pts));
}
// ── Points of interest ────────────────────────────────────────────────────
int _poiSeq = 0;
void addPoi(LatLng at) {
String id;
do {
id = 'poi${++_poiSeq}';
} while (_mission.poi(id) != null);
_set(_mission.copyWith(pois: <MissionPoi>[..._mission.pois, MissionPoi(id: id, at: at)]));
selectedPoi = id;
selected = null;
notifyListeners();
}
void movePoi(String id, LatLng at) => _replacePoi(id, (MissionPoi p) => p.copyWith(at: at), record: false);
void updatePoi(String id, MissionPoi Function(MissionPoi p) change, {bool record = true}) =>
_replacePoi(id, change, record: record);
void _replacePoi(String id, MissionPoi Function(MissionPoi p) change, {bool record = true}) {
_set(
_mission.copyWith(pois: <MissionPoi>[for (final MissionPoi p in _mission.pois) p.id == id ? change(p) : p]),
record: record,
);
}
/// Removes the POI and unlinks every waypoint that faced it.
void removePoi(String id) {
_set(_mission.copyWith(
pois: <MissionPoi>[for (final MissionPoi p in _mission.pois) if (p.id != id) p],
points: <WaypointSpec>[
for (final WaypointSpec w in _mission.points)
w.poiId == id ? w.copyWith(poiId: null, gimbalAimPoi: false, heading: w.heading == WaypointHeading.poi ? WaypointHeading.route : null) : w,
],
));
}
void selectPoi(String? id) {
selectedPoi = id;
if (id != null) selected = null;
notifyListeners();
}
/// Links waypoint [i] to [poiId] (null unlinks). Linking turns the point to
/// face it, as GO 4 does; unlinking turns a POI-facing point back to Route.
void linkPoi(int i, String? poiId) {
final WaypointSpec p = _mission.points[i];
if (poiId == null) {
_replace(i, p.copyWith(poiId: null, gimbalAimPoi: false, heading: p.heading == WaypointHeading.poi ? WaypointHeading.route : null));
return;
}
final List<WaypointSpec> pts = List<WaypointSpec>.of(_mission.points);
pts[i] = p.copyWith(poiId: poiId, heading: WaypointHeading.poi);
final bool needsPerPoint = _mission.headingMode != RouteHeadingMode.perPoint;
_set(_mission.copyWith(points: pts, headingMode: needsPerPoint ? RouteHeadingMode.perPoint : null));
}
}