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>
299 lines
11 KiB
Dart
299 lines
11 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:latlong2/latlong.dart' show LatLng;
|
|
|
|
import '../../../../fly/modes/waypoint_editor.dart';
|
|
import '../../../../fly/modes/waypoint_mission.dart';
|
|
import '../../../../fly/state/mission_state.dart';
|
|
import '../../../../theme.dart';
|
|
import '../../hud_kit.dart';
|
|
|
|
/// The Waypoints mode's map layers (plan P7), as children of `FlyMap`: the
|
|
/// route, POI links, leg "+" handles, POIs and the numbered waypoints.
|
|
///
|
|
/// While editing ([editable]), a tap selects a marker, a long press then drag
|
|
/// moves it, and a leg's "+" inserts a waypoint halfway. While the mission
|
|
/// flies, legs already flown dim, the target waypoint is amber, reached ones
|
|
/// green.
|
|
List<Widget> waypointMapLayers({required WaypointEditor editor, required MissionState mission, required bool editable}) {
|
|
final Listenable both = Listenable.merge(<Listenable>[editor, mission]);
|
|
return <Widget>[
|
|
ListenableBuilder(
|
|
key: const ValueKey<String>('wp-lines'),
|
|
listenable: both,
|
|
builder: (BuildContext context, _) => PolylineLayer<Object>(polylines: _lines(editor.mission, mission.waypoint)),
|
|
),
|
|
if (editable)
|
|
ListenableBuilder(
|
|
key: const ValueKey<String>('wp-handles'),
|
|
listenable: editor,
|
|
builder: (BuildContext context, _) => MarkerLayer(markers: _handles(editor)),
|
|
),
|
|
ListenableBuilder(
|
|
key: const ValueKey<String>('wp-markers'),
|
|
listenable: both,
|
|
builder: (BuildContext context, _) => MarkerLayer(markers: <Marker>[
|
|
..._pois(editor, editable),
|
|
..._points(editor, mission.waypoint, editable),
|
|
]),
|
|
),
|
|
];
|
|
}
|
|
|
|
const Color _route = Color(0xFF5B93F5);
|
|
const Color _flown = Color(0x99EAF0FA);
|
|
const Color _poiColor = Color(0xFFB98CF5);
|
|
|
|
List<Polyline<Object>> _lines(WaypointMissionSpec m, WaypointProgress run) {
|
|
final List<Polyline<Object>> out = <Polyline<Object>>[];
|
|
final List<WaypointSpec> pts = m.points;
|
|
// POI links under the route.
|
|
for (final WaypointSpec p in pts) {
|
|
final MissionPoi? poi = m.poi(p.poiId);
|
|
if (poi == null) continue;
|
|
out.add(Polyline<Object>(
|
|
points: <LatLng>[p.at, poi.at],
|
|
strokeWidth: 1.2,
|
|
color: const Color(0x99B98CF5),
|
|
pattern: StrokePattern.dashed(segments: const <double>[5, 4]),
|
|
));
|
|
}
|
|
if (pts.length < 2) return out;
|
|
final int? target = run.active ? run.target : null;
|
|
for (int i = 0; i + 1 < pts.length; i++) {
|
|
final bool done = target != null && i + 1 < target;
|
|
final bool current = target != null && i + 1 == target;
|
|
out.add(Polyline<Object>(
|
|
points: <LatLng>[pts[i].at, pts[i + 1].at],
|
|
strokeWidth: current ? 4 : 3,
|
|
color: done ? _flown : (current ? HudColors.caution : _route),
|
|
borderStrokeWidth: 1,
|
|
borderColor: const Color(0x99050A14),
|
|
));
|
|
}
|
|
if (m.finishAction == RouteFinishAction.firstWaypoint) {
|
|
out.add(Polyline<Object>(
|
|
points: <LatLng>[pts.last.at, pts.first.at],
|
|
strokeWidth: 2,
|
|
color: const Color(0x995B93F5),
|
|
pattern: StrokePattern.dashed(segments: const <double>[8, 6]),
|
|
));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
List<Marker> _handles(WaypointEditor e) {
|
|
final List<WaypointSpec> pts = e.mission.points;
|
|
if (pts.length >= WaypointMissionSpec.maxPoints) return const <Marker>[];
|
|
return <Marker>[
|
|
for (int i = 0; i + 1 < pts.length; i++)
|
|
Marker(
|
|
key: ValueKey<String>('wp-insert-$i'),
|
|
point: LatLng((pts[i].at.latitude + pts[i + 1].at.latitude) / 2, (pts[i].at.longitude + pts[i + 1].at.longitude) / 2),
|
|
width: 22,
|
|
height: 22,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => e.insertMidpoint(i),
|
|
child: Center(
|
|
child: Container(
|
|
width: 16,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xCC0B1426),
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: _route, width: 1.2),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: const Text('+', style: TextStyle(fontFamily: PV.fontMono, fontSize: 11, height: 1, color: Glass.ink)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
List<Marker> _pois(WaypointEditor e, bool editable) {
|
|
return <Marker>[
|
|
for (int i = 0; i < e.mission.pois.length; i++)
|
|
() {
|
|
final MissionPoi p = e.mission.pois[i];
|
|
final bool sel = e.selectedPoi == p.id;
|
|
return Marker(
|
|
key: ValueKey<String>('poi-${p.id}'),
|
|
point: p.at,
|
|
width: 34,
|
|
height: 34,
|
|
child: _Draggable(
|
|
enabled: editable,
|
|
point: p.at,
|
|
onTap: () => e.selectPoi(p.id),
|
|
onStart: e.beginDrag,
|
|
onMove: (LatLng at) => e.movePoi(p.id, at),
|
|
child: Center(
|
|
child: Transform.rotate(
|
|
angle: math.pi / 4,
|
|
child: Container(
|
|
width: sel ? 22 : 18,
|
|
height: sel ? 22 : 18,
|
|
decoration: BoxDecoration(
|
|
color: _poiColor,
|
|
borderRadius: BorderRadius.circular(3),
|
|
border: Border.all(color: sel ? Colors.white : const Color(0xFF0A1120), width: sel ? 2 : 1.2),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Transform.rotate(
|
|
angle: -math.pi / 4,
|
|
child: Text('P${i + 1}',
|
|
style:
|
|
const TextStyle(fontFamily: PV.fontMono, fontSize: 8.5, fontWeight: FontWeight.w700, color: Color(0xFF0A1120))),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}(),
|
|
];
|
|
}
|
|
|
|
List<Marker> _points(WaypointEditor e, WaypointProgress run, bool editable) {
|
|
final WaypointMissionSpec m = e.mission;
|
|
final bool perPoint = m.headingMode == RouteHeadingMode.perPoint;
|
|
final List<int> headings = perPoint ? m.resolvedHeadings() : const <int>[];
|
|
final int? target = run.active ? run.target : null;
|
|
return <Marker>[
|
|
for (int i = 0; i < m.points.length; i++)
|
|
() {
|
|
final WaypointSpec p = m.points[i];
|
|
final bool sel = editable && e.selected == i;
|
|
final Color fill = target == null
|
|
? (sel ? PV.accent : const Color(0xFF2B62CC))
|
|
: (i < target || (i == target && run.reached) ? HudColors.ready : (i == target ? HudColors.caution : const Color(0xFF2B62CC)));
|
|
return Marker(
|
|
key: ValueKey<String>('wp-$i'),
|
|
point: p.at,
|
|
width: 44,
|
|
height: 62,
|
|
child: _Draggable(
|
|
enabled: editable,
|
|
point: p.at,
|
|
onTap: () => e.select(i),
|
|
onStart: e.beginDrag,
|
|
onMove: (LatLng at) => e.movePoint(i, at),
|
|
// The ring sits on the point; the altitude tag hangs below it.
|
|
child: Stack(alignment: Alignment.center, children: <Widget>[
|
|
SizedBox(
|
|
width: 34,
|
|
height: 34,
|
|
child: Stack(alignment: Alignment.center, children: <Widget>[
|
|
if (perPoint)
|
|
Transform.rotate(
|
|
angle: headings[i] * math.pi / 180,
|
|
child: const CustomPaint(size: Size(34, 34), painter: _HeadingTick()),
|
|
),
|
|
Container(
|
|
width: sel ? 26 : 22,
|
|
height: sel ? 26 : 22,
|
|
decoration: BoxDecoration(
|
|
color: fill,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: sel ? Colors.white : const Color(0xCCEAF0FA), width: sel ? 2.2 : 1.2),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text('${i + 1}',
|
|
style: const TextStyle(fontFamily: PV.fontMono, fontSize: 11, fontWeight: FontWeight.w700, color: Colors.white)),
|
|
),
|
|
]),
|
|
),
|
|
Positioned(
|
|
bottom: 0,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 3),
|
|
decoration: BoxDecoration(color: const Color(0xB3050A14), borderRadius: BorderRadius.circular(3)),
|
|
child: Text('${p.altitude.round()}m', style: HudText.label.copyWith(fontSize: 8, color: Glass.ink)),
|
|
),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}(),
|
|
];
|
|
}
|
|
|
|
/// The small triangle on a waypoint's ring that shows where it points.
|
|
class _HeadingTick extends CustomPainter {
|
|
const _HeadingTick();
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final double c = size.width / 2;
|
|
final Path p = Path()
|
|
..moveTo(c, 0)
|
|
..lineTo(c + 5, 7)
|
|
..lineTo(c - 5, 7)
|
|
..close();
|
|
canvas.drawPath(p, Paint()..color = Colors.white);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
}
|
|
|
|
/// Tap to select; long press, then drag, to move. A long press wins the
|
|
/// gesture arena before the map's pan does, so a plain drag still pans.
|
|
class _Draggable extends StatefulWidget {
|
|
const _Draggable({
|
|
required this.enabled,
|
|
required this.point,
|
|
required this.onTap,
|
|
required this.onStart,
|
|
required this.onMove,
|
|
required this.child,
|
|
});
|
|
|
|
final bool enabled;
|
|
final LatLng point;
|
|
final VoidCallback onTap;
|
|
final VoidCallback onStart;
|
|
final ValueChanged<LatLng> onMove;
|
|
final Widget child;
|
|
|
|
@override
|
|
State<_Draggable> createState() => _DraggableState();
|
|
}
|
|
|
|
class _DraggableState extends State<_Draggable> {
|
|
Offset? _startScreen;
|
|
Offset? _startGlobal;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!widget.enabled) return IgnorePointer(child: widget.child);
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: widget.onTap,
|
|
onLongPressStart: (LongPressStartDetails d) {
|
|
final MapCamera cam = MapCamera.of(context);
|
|
_startScreen = cam.latLngToScreenPoint(widget.point).toOffset();
|
|
_startGlobal = d.globalPosition;
|
|
widget.onTap();
|
|
widget.onStart();
|
|
},
|
|
onLongPressMoveUpdate: (LongPressMoveUpdateDetails d) {
|
|
if (_startScreen == null || _startGlobal == null) return;
|
|
final MapCamera cam = MapCamera.of(context);
|
|
final Offset at = _startScreen! + (d.globalPosition - _startGlobal!);
|
|
widget.onMove(cam.pointToLatLng(math.Point<double>(at.dx, at.dy)));
|
|
},
|
|
onLongPressEnd: (_) {
|
|
_startScreen = null;
|
|
_startGlobal = null;
|
|
},
|
|
child: widget.child,
|
|
);
|
|
}
|
|
}
|