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
@@ -1,3 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -5,13 +8,20 @@ import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../../domain/waypoint/flat_waypoint_list.dart';
|
||||
import '../../providers/current_mission_provider.dart';
|
||||
import '../../widgets/halo_menu.dart';
|
||||
import '../../widgets/mission_map.dart';
|
||||
import '../../widgets/reticle_button.dart';
|
||||
|
||||
/// Reticle-Zustand (HTML-Demonstrator: Variable `mode`).
|
||||
/// idle: kein Wegpunkt in der Naehe, Tap legt einen neuen an.
|
||||
/// snapped: Kartenmitte ist auf einen bestehenden Wegpunkt eingerastet.
|
||||
/// editing: Wegpunkt wird bearbeitet - Kartenverschieben repositioniert ihn
|
||||
/// live, das Halo-Menue (Loiter/Landing/Entfernen) ist sichtbar.
|
||||
enum _ReticleMode { idle, snapped, editing }
|
||||
|
||||
/// Missionsplanung: Karte + Wegpunktliste (Architektur-Doku 3.6/3.7).
|
||||
/// Kein eigenes Scaffold - die Kopfleiste (Plan/Fly-Umschalter) und der
|
||||
/// gemeinsame Scaffold-Rahmen liegen im AppShell darueber. Wegpunktliste,
|
||||
/// Draeder und Bottom-Bar folgen als naechster Schritt.
|
||||
/// gemeinsame Scaffold-Rahmen liegen im AppShell darueber.
|
||||
class PlanScreen extends ConsumerStatefulWidget {
|
||||
const PlanScreen({super.key});
|
||||
|
||||
@@ -21,6 +31,11 @@ class PlanScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
final _mapController = MapController();
|
||||
StreamSubscription<MapEvent>? _mapEventSub;
|
||||
|
||||
_ReticleMode _mode = _ReticleMode.idle;
|
||||
int _targetIndex = -1;
|
||||
bool _mapReady = false;
|
||||
|
||||
// TODO: aus dem aktiven Drohnen-Profil (Doku 3.5) uebernehmen, sobald die
|
||||
// Profilverwaltung existiert. Werte entsprechen dem Default-Profil
|
||||
@@ -29,49 +44,201 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
static const _defaultSpeedMs = 15.0;
|
||||
static const _defaultCatchRadiusM = 60.0;
|
||||
|
||||
// Radius des Reticle-Buttons (siehe ReticleButton: 55px Durchmesser).
|
||||
static const _reticleRadiusPx = 55 / 2;
|
||||
|
||||
static const _distance = Distance(roundResult: false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_mapEventSub = _mapController.mapEventStream.listen(_onMapEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mapEventSub?.cancel();
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _addWaypointAtMapCenter() {
|
||||
void _onMapEvent(MapEvent event) {
|
||||
if (event is MapEventMoveEnd) {
|
||||
_handleMoveEnd();
|
||||
} else if (event is MapEventMove) {
|
||||
if (_mode == _ReticleMode.editing && _targetIndex >= 0) {
|
||||
final waypoints = ref.read(currentMissionProvider);
|
||||
if (_targetIndex < waypoints.length) {
|
||||
final center = _mapController.camera.center;
|
||||
ref.read(currentMissionProvider.notifier).moveWaypoint(
|
||||
_targetIndex,
|
||||
center.latitude,
|
||||
center.longitude,
|
||||
);
|
||||
}
|
||||
} else if (_mode == _ReticleMode.idle) {
|
||||
// Nur fuer die gestrichelte Vorschaulinie neu zeichnen.
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metergenauer Fangradius des Reticle bei aktuellem Zoom/Breitengrad
|
||||
/// (HTML-Demonstrator: currentSnapMeters()).
|
||||
double _currentSnapMeters() {
|
||||
final camera = _mapController.camera;
|
||||
final metersPerPixel = 156543.03392 *
|
||||
math.cos(camera.center.latitude * math.pi / 180) /
|
||||
math.pow(2, camera.zoom);
|
||||
return _reticleRadiusPx * metersPerPixel;
|
||||
}
|
||||
|
||||
void _handleMoveEnd() {
|
||||
if (_mode == _ReticleMode.editing) return;
|
||||
|
||||
final waypoints = ref.read(currentMissionProvider);
|
||||
final center = _mapController.camera.center;
|
||||
ref.read(currentMissionProvider.notifier).addWaypoint(
|
||||
Waypoint(
|
||||
lat: center.latitude,
|
||||
lon: center.longitude,
|
||||
altitudeM: _defaultAltitudeM,
|
||||
speedMs: _defaultSpeedMs,
|
||||
catchRadiusM: _defaultCatchRadiusM,
|
||||
),
|
||||
);
|
||||
final snapMeters = _currentSnapMeters();
|
||||
|
||||
var bestIndex = -1;
|
||||
var bestDist = snapMeters;
|
||||
for (var i = 0; i < waypoints.length; i++) {
|
||||
final d = _distance(center, LatLng(waypoints[i].lat, waypoints[i].lon));
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
if (bestIndex >= 0) {
|
||||
_mode = _ReticleMode.snapped;
|
||||
_targetIndex = bestIndex;
|
||||
final target = waypoints[bestIndex];
|
||||
final targetLatLng = LatLng(target.lat, target.lon);
|
||||
if (_distance(center, targetLatLng) > 0.5) {
|
||||
// flutter_map's MapController.move() emits MapEventMove, nicht
|
||||
// MoveEnd - anders als Leaflets panTo() droht hier also keine
|
||||
// Rekursion in _handleMoveEnd, ein Guard ist nicht noetig.
|
||||
_mapController.move(targetLatLng, _mapController.camera.zoom);
|
||||
}
|
||||
} else {
|
||||
_mode = _ReticleMode.idle;
|
||||
_targetIndex = -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onReticleTap() {
|
||||
switch (_mode) {
|
||||
case _ReticleMode.idle:
|
||||
final center = _mapController.camera.center;
|
||||
final waypoints = ref.read(currentMissionProvider);
|
||||
ref.read(currentMissionProvider.notifier).addWaypoint(
|
||||
Waypoint(
|
||||
lat: center.latitude,
|
||||
lon: center.longitude,
|
||||
altitudeM: _defaultAltitudeM,
|
||||
speedMs: _defaultSpeedMs,
|
||||
catchRadiusM: _defaultCatchRadiusM,
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_targetIndex = waypoints.length;
|
||||
_mode = _ReticleMode.snapped;
|
||||
});
|
||||
case _ReticleMode.snapped:
|
||||
setState(() => _mode = _ReticleMode.editing);
|
||||
case _ReticleMode.editing:
|
||||
setState(() => _mode = _ReticleMode.snapped);
|
||||
}
|
||||
}
|
||||
|
||||
void _onHaloAction(HaloAction action) {
|
||||
if (_mode != _ReticleMode.editing || _targetIndex < 0) return;
|
||||
final notifier = ref.read(currentMissionProvider.notifier);
|
||||
|
||||
switch (action) {
|
||||
case HaloAction.loiter:
|
||||
notifier.toggleAction(_targetIndex, WaypointAction.loiter);
|
||||
setState(() => _mode = _ReticleMode.snapped);
|
||||
case HaloAction.landing:
|
||||
notifier.toggleAction(_targetIndex, WaypointAction.landing);
|
||||
setState(() => _mode = _ReticleMode.snapped);
|
||||
case HaloAction.remove:
|
||||
notifier.removeAt(_targetIndex);
|
||||
setState(() {
|
||||
_mode = _ReticleMode.idle;
|
||||
_targetIndex = -1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final waypoints = ref.watch(currentMissionProvider);
|
||||
|
||||
final routePoints = [
|
||||
for (final wp in waypoints) LatLng(wp.lat, wp.lon),
|
||||
];
|
||||
|
||||
List<LatLng>? rubberBand;
|
||||
if (_mapReady && _mode == _ReticleMode.idle && waypoints.isNotEmpty) {
|
||||
final last = waypoints.last;
|
||||
rubberBand = [
|
||||
LatLng(last.lat, last.lon),
|
||||
_mapController.camera.center,
|
||||
];
|
||||
}
|
||||
|
||||
final targetWaypoint =
|
||||
(_targetIndex >= 0 && _targetIndex < waypoints.length)
|
||||
? waypoints[_targetIndex]
|
||||
: null;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
MissionMap(
|
||||
mapController: _mapController,
|
||||
onMapReady: () => setState(() => _mapReady = true),
|
||||
routeLine: routePoints,
|
||||
rubberBandLine: rubberBand,
|
||||
waypointMarkers: [
|
||||
for (final wp in waypoints)
|
||||
CircleMarker(
|
||||
point: LatLng(wp.lat, wp.lon),
|
||||
radius: 6,
|
||||
color: const Color(0xFF2B2B28),
|
||||
borderStrokeWidth: 1.5,
|
||||
borderColor: Colors.white,
|
||||
),
|
||||
for (var i = 0; i < waypoints.length; i++)
|
||||
_markerFor(waypoints[i], isTarget: i == _targetIndex),
|
||||
],
|
||||
),
|
||||
if (_mode == _ReticleMode.editing && targetWaypoint != null)
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: HaloMenu(
|
||||
loiterActive: targetWaypoint.action == WaypointAction.loiter,
|
||||
landingActive: targetWaypoint.action == WaypointAction.landing,
|
||||
onAction: _onHaloAction,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: ReticleButton(onTap: _addWaypointAtMapCenter),
|
||||
child: ReticleButton(icon: _reticleIcon, onTap: _onReticleTap),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
IconData get _reticleIcon => switch (_mode) {
|
||||
_ReticleMode.idle => Icons.add,
|
||||
_ReticleMode.snapped => Icons.edit,
|
||||
_ReticleMode.editing => Icons.check,
|
||||
};
|
||||
|
||||
CircleMarker _markerFor(Waypoint wp, {required bool isTarget}) {
|
||||
final highlighted = isTarget && _mode != _ReticleMode.idle;
|
||||
return CircleMarker(
|
||||
point: LatLng(wp.lat, wp.lon),
|
||||
radius: highlighted ? 9 : 6,
|
||||
color: const Color(0xFF2B2B28),
|
||||
borderStrokeWidth: highlighted ? 3 : 1.5,
|
||||
borderColor: Colors.white,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user