Files
PilotVault/Fly App/lib/ui/fly/modes/waypoints/waypoint_layers.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

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,
);
}
}