Files
dmc/app/lib/ui/widgets/halo_menu.dart
T
Constantin LeueandClaude Sonnet 5 6dcb765b80 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>
2026-07-27 22:56:06 +02:00

159 lines
4.4 KiB
Dart

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