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:
co-authored by
Claude Sonnet 5
parent
db6b46140c
commit
6dcb765b80
@@ -0,0 +1,158 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Radialmenue um den Reticle-Button im Editier-Modus (HTML-Demonstrator:
|
||||
/// #haloSvg mit den drei Ring-Segmenten Loiter/Landing/Remove).
|
||||
enum HaloAction { loiter, landing, remove }
|
||||
|
||||
class HaloMenu extends StatelessWidget {
|
||||
const HaloMenu({
|
||||
super.key,
|
||||
required this.loiterActive,
|
||||
required this.landingActive,
|
||||
required this.onAction,
|
||||
});
|
||||
|
||||
final bool loiterActive;
|
||||
final bool landingActive;
|
||||
final void Function(HaloAction action) onAction;
|
||||
|
||||
// Radien/Groesse 1:1 aus dem Demonstrator uebernommen
|
||||
// (HALO_SCALE=1.2, HALO_RIN=27, HALO_ROUT=60, viewBox 192x192).
|
||||
static const _size = 192.0;
|
||||
static const _innerRadius = 27 * 1.2;
|
||||
static const _outerRadius = 60 * 1.2;
|
||||
static const _midRadius = (_innerRadius + _outerRadius) / 2;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: _size,
|
||||
height: _size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
_wedge(
|
||||
key: const ValueKey('halo-loiter'),
|
||||
startAngleDeg: -60,
|
||||
endAngleDeg: 60,
|
||||
active: loiterActive,
|
||||
onTap: () => onAction(HaloAction.loiter),
|
||||
),
|
||||
_wedge(
|
||||
key: const ValueKey('halo-landing'),
|
||||
startAngleDeg: 60,
|
||||
endAngleDeg: 180,
|
||||
active: landingActive,
|
||||
onTap: () => onAction(HaloAction.landing),
|
||||
),
|
||||
_wedge(
|
||||
key: const ValueKey('halo-remove'),
|
||||
startAngleDeg: 180,
|
||||
endAngleDeg: 300,
|
||||
active: false,
|
||||
onTap: () => onAction(HaloAction.remove),
|
||||
),
|
||||
_wedgeIcon(angleDeg: 0, icon: Icons.loop),
|
||||
_wedgeIcon(angleDeg: 120, icon: Icons.flight_land),
|
||||
_wedgeIcon(angleDeg: 240, icon: Icons.remove),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wedge({
|
||||
Key? key,
|
||||
required double startAngleDeg,
|
||||
required double endAngleDeg,
|
||||
required bool active,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return ClipPath(
|
||||
key: key,
|
||||
clipper: _WedgeClipper(
|
||||
startAngleDeg: startAngleDeg,
|
||||
endAngleDeg: endAngleDeg,
|
||||
innerRadius: _innerRadius,
|
||||
outerRadius: _outerRadius,
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
color: active
|
||||
? const Color(0xFF4D9DFF).withValues(alpha: 0.55)
|
||||
: Colors.black.withValues(alpha: 0.62),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _wedgeIcon({required double angleDeg, required IconData icon}) {
|
||||
final point = _polarPoint(angleDeg, _midRadius);
|
||||
return Positioned(
|
||||
left: _size / 2 + point.dx - 12,
|
||||
top: _size / 2 + point.dy - 12,
|
||||
child: IgnorePointer(
|
||||
child: Icon(icon, color: Colors.white, size: 22),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Offset _polarPoint(double angleDeg, double radius) {
|
||||
final rad = angleDeg * math.pi / 180;
|
||||
return Offset(radius * math.sin(rad), -radius * math.cos(rad));
|
||||
}
|
||||
}
|
||||
|
||||
class _WedgeClipper extends CustomClipper<Path> {
|
||||
const _WedgeClipper({
|
||||
required this.startAngleDeg,
|
||||
required this.endAngleDeg,
|
||||
required this.innerRadius,
|
||||
required this.outerRadius,
|
||||
});
|
||||
|
||||
final double startAngleDeg;
|
||||
final double endAngleDeg;
|
||||
final double innerRadius;
|
||||
final double outerRadius;
|
||||
|
||||
static const _steps = 18;
|
||||
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
|
||||
Offset pointAt(double angleDeg, double radius) {
|
||||
final rad = angleDeg * math.pi / 180;
|
||||
return Offset(
|
||||
center.dx + radius * math.sin(rad),
|
||||
center.dy - radius * math.cos(rad),
|
||||
);
|
||||
}
|
||||
|
||||
final path = Path();
|
||||
final first = pointAt(startAngleDeg, outerRadius);
|
||||
path.moveTo(first.dx, first.dy);
|
||||
for (var i = 1; i <= _steps; i++) {
|
||||
final a = startAngleDeg + (endAngleDeg - startAngleDeg) * i / _steps;
|
||||
final p = pointAt(a, outerRadius);
|
||||
path.lineTo(p.dx, p.dy);
|
||||
}
|
||||
for (var i = _steps; i >= 0; i--) {
|
||||
final a = startAngleDeg + (endAngleDeg - startAngleDeg) * i / _steps;
|
||||
final p = pointAt(a, innerRadius);
|
||||
path.lineTo(p.dx, p.dy);
|
||||
}
|
||||
path.close();
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(covariant _WedgeClipper oldClipper) =>
|
||||
oldClipper.startAngleDeg != startAngleDeg ||
|
||||
oldClipper.endAngleDeg != endAngleDeg ||
|
||||
oldClipper.innerRadius != innerRadius ||
|
||||
oldClipper.outerRadius != outerRadius;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Zentrales Steuerelement zum Setzen von Wegpunkten (HTML-Demonstrator:
|
||||
/// #centerCluster/#reticleBtn), fixiert in der Bildschirmmitte. Die Karte
|
||||
/// wird darunter verschoben; ein Tap setzt einen Wegpunkt an der aktuellen
|
||||
/// Kartenmitte.
|
||||
/// Zentrales Steuerelement zum Setzen/Editieren von Wegpunkten
|
||||
/// (HTML-Demonstrator: #centerCluster/#reticleBtn), fixiert in der
|
||||
/// Bildschirmmitte. Die Karte wird darunter verschoben. Icon wechselt je
|
||||
/// nach Modus: "+" (idle, neuer Wegpunkt), Stift (snapped, zum Bearbeiten
|
||||
/// antippen), Haken (editing, bestaetigen).
|
||||
class ReticleButton extends StatelessWidget {
|
||||
const ReticleButton({super.key, required this.onTap});
|
||||
const ReticleButton({super.key, required this.icon, required this.onTap});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
@@ -21,7 +23,7 @@ class ReticleButton extends StatelessWidget {
|
||||
color: Colors.black.withValues(alpha: 0.68),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
child: const Icon(Icons.add, color: Colors.white, size: 26),
|
||||
child: Icon(icon, color: Colors.white, size: 26),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user