Add waypoint route line, snap-to-edit, and halo action menu

Full reticle interaction state machine (idle/snapped/editing), matching
the HTML prototype's mode variable:

- idle: reticle shows "+", tap drops a new waypoint at the map center.
- snapped: after any pan ends, if the map center lands within the
  reticle's on-screen radius (converted to meters at the current
  zoom/latitude) of an existing waypoint, the view auto-recenters onto it
  and the reticle switches to a pencil icon. Tap to enter editing.
- editing: reticle shows a checkmark; panning the map now live-updates the
  target waypoint's position (drag-to-reposition) instead of moving the
  camera "away" from it, and the HaloMenu radial menu appears.

HaloMenu: three ring-wedge hit regions (custom ClipPath/CustomClipper,
angles/radii taken from the prototype's buildRingWedgePath) for
Loiter/Landing/Remove, with Material icons standing in for the
demonstrator's hand-drawn SVGs. Loiter/Landing toggle the waypoint's
action and drop back to snapped; Remove deletes it and returns to idle.

MissionMap now draws a straight white polyline connecting all waypoints
in order (the physically-grounded straight-line + fillet-arc geometry
from doc 3.7/4.6 is still open - this is a straight-line placeholder) plus
a dashed rubber-band preview from the last waypoint to the live map center
while idle.

Note on porting from Leaflet: flutter_map's MapController.move() emits
MapEventMove, not MapEventMoveEnd, so (unlike the prototype's panTo()) the
auto-recenter-on-snap doesn't re-trigger snap detection - no "autoPanning"
guard flag was needed.

Verified with flutter analyze, flutter test (three new tests: halo menu
open/close, remove, loiter toggle - the wedge tap tests had to target the
icon's exact center via tapAt(), since a wedge's bounding-box center falls
outside its actual donut-segment hit area), and a full manual pass on the
Pixel_10a emulator: idle -> add -> snap (auto-recenter confirmed) -> edit
-> drag-reposition (confirmed via re-snap after further panning showing
the moved position, not the original one) -> halo remove -> back to idle,
plus the connecting line rendering correctly between two waypoints set
far apart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-07-27 22:56:06 +02:00
co-authored by Claude Sonnet 5
parent db6b46140c
commit 6dcb765b80
6 changed files with 509 additions and 29 deletions
+34 -2
View File
@@ -6,13 +6,31 @@ import 'package:url_launcher/url_launcher.dart';
/// Vollbild-OSM-Karte, entspricht dem Leaflet-`#map` im HTML-Demonstrator:
/// randlos, ohne Zoom-Buttons (Pinch-to-Zoom/Pan reicht), Standard-OSM-Tiles.
class MissionMap extends StatelessWidget {
const MissionMap({super.key, this.mapController, this.waypointMarkers = const []});
const MissionMap({
super.key,
this.mapController,
this.waypointMarkers = const [],
this.routeLine,
this.rubberBandLine,
this.onMapReady,
});
final MapController? mapController;
/// Kreis-Marker fuer die Wegpunkte der aktuellen Mission (Doku 3.7).
final List<CircleMarker> waypointMarkers;
/// Verbindungslinie zwischen den Wegpunkten. Bewusst eine gerade Strecke -
/// die physikalisch fundierte Geraden+Kreisbogen-Geometrie (Doku 3.7/4.6)
/// ist ein separater, noch offener Schritt.
final List<LatLng>? routeLine;
/// Gestrichelte Vorschaulinie vom letzten Wegpunkt zur aktuellen
/// Kartenmitte, waehrend noch kein neuer Punkt bestaetigt wurde.
final List<LatLng>? rubberBandLine;
final VoidCallback? onMapReady;
// Default-Zentrum/Zoom uebernommen aus dem HTML-Demonstrator
// (Drone Mission Control.html: L.map(...).setView([52.3676, 4.9041], 16)).
static const _initialCenter = LatLng(52.3676, 4.9041);
@@ -22,11 +40,12 @@ class MissionMap extends StatelessWidget {
Widget build(BuildContext context) {
return FlutterMap(
mapController: mapController,
options: const MapOptions(
options: MapOptions(
initialCenter: _initialCenter,
initialZoom: _initialZoom,
minZoom: 3,
maxZoom: 19,
onMapReady: onMapReady,
),
children: [
TileLayer(
@@ -34,6 +53,19 @@ class MissionMap extends StatelessWidget {
userAgentPackageName: 'de.dmc.dmc_app',
maxZoom: 19,
),
PolylineLayer(
polylines: [
if (routeLine != null && routeLine!.length > 1)
Polyline(points: routeLine!, strokeWidth: 3, color: Colors.white),
if (rubberBandLine != null)
Polyline(
points: rubberBandLine!,
strokeWidth: 2,
color: Colors.white.withValues(alpha: 0.55),
pattern: StrokePattern.dashed(segments: const [6.0, 6.0]),
),
],
),
CircleLayer(circles: waypointMarkers),
SimpleAttributionWidget(
alignment: Alignment.bottomLeft,