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 _undo = []; 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 [], pois: const [])); 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 pts = List.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 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 pts = List.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 pts = List.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 pts = List.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 pts = [ 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: [..._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: [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: [for (final MissionPoi p in _mission.pois) if (p.id != id) p], points: [ 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 pts = List.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)); } }