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>
310 lines
12 KiB
Dart
310 lines
12 KiB
Dart
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));
|
||
}
|
||
}
|