diff --git a/app/lib/ui/providers/current_mission_provider.dart b/app/lib/ui/providers/current_mission_provider.dart index 00d599b..418bcd3 100644 --- a/app/lib/ui/providers/current_mission_provider.dart +++ b/app/lib/ui/providers/current_mission_provider.dart @@ -13,6 +13,37 @@ class CurrentMissionNotifier extends Notifier> { void addWaypoint(Waypoint waypoint) { state = [...state, waypoint]; } + + /// Aktualisiert die Position eines Wegpunkts (Drag-to-Reposition im + /// Editier-Modus: Kartenverschieben bewegt den Zielpunkt live mit). + void moveWaypoint(int index, double lat, double lon) { + state = [ + for (var i = 0; i < state.length; i++) + if (i == index) state[i].copyWith(lat: lat, lon: lon) else state[i], + ]; + } + + /// Schaltet die Execute-Aktion eines Wegpunkts um (Halo-Radialmenue: + /// erneutes Antippen derselben Aktion setzt sie zurueck auf "none"). + void toggleAction(int index, WaypointAction action) { + state = [ + for (var i = 0; i < state.length; i++) + if (i == index) + state[i].copyWith( + action: + state[i].action == action ? WaypointAction.none : action, + ) + else + state[i], + ]; + } + + void removeAt(int index) { + state = [ + for (var i = 0; i < state.length; i++) + if (i != index) state[i], + ]; + } } final currentMissionProvider = diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index 36d769a..fee9fda 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -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 { final _mapController = MapController(); + StreamSubscription? _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 { 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? 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, + ); + } } diff --git a/app/lib/ui/widgets/halo_menu.dart b/app/lib/ui/widgets/halo_menu.dart new file mode 100644 index 0000000..de4f4e2 --- /dev/null +++ b/app/lib/ui/widgets/halo_menu.dart @@ -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 { + 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; +} diff --git a/app/lib/ui/widgets/mission_map.dart b/app/lib/ui/widgets/mission_map.dart index 026825e..99b449d 100644 --- a/app/lib/ui/widgets/mission_map.dart +++ b/app/lib/ui/widgets/mission_map.dart @@ -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 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? routeLine; + + /// Gestrichelte Vorschaulinie vom letzten Wegpunkt zur aktuellen + /// Kartenmitte, waehrend noch kein neuer Punkt bestaetigt wurde. + final List? 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, diff --git a/app/lib/ui/widgets/reticle_button.dart b/app/lib/ui/widgets/reticle_button.dart index 7a86649..25088e5 100644 --- a/app/lib/ui/widgets/reticle_button.dart +++ b/app/lib/ui/widgets/reticle_button.dart @@ -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), ), ); } diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 48ab2b6..624700e 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -5,8 +5,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:dmc_app/app_mode/app_mode_cubit.dart'; +import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; import 'package:dmc_app/ui/app_shell.dart'; import 'package:dmc_app/ui/providers/current_mission_provider.dart'; +import 'package:dmc_app/ui/widgets/halo_menu.dart'; import 'package:dmc_app/ui/widgets/reticle_button.dart'; Widget _wrap(Widget child) => ProviderScope( @@ -72,4 +74,92 @@ void main() { expect(waypoints.single.lat, closeTo(52.3676, 0.0001)); expect(waypoints.single.lon, closeTo(4.9041, 0.0001)); }); + + testWidgets( + 'Zweiter Tap auf den frisch gesetzten Wegpunkt oeffnet das Halo-Menue', + (tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider( + create: (_) => AppModeCubit(), + child: const MaterialApp(home: AppShell()), + ), + ), + ); + await tester.pump(); + + // 1. Tap: idle -> snapped (legt Wegpunkt an). + await tester.tap(find.byType(ReticleButton)); + await tester.pump(); + expect(find.byType(HaloMenu), findsNothing); + + // 2. Tap: snapped -> editing. + await tester.tap(find.byType(ReticleButton)); + await tester.pump(); + expect(find.byType(HaloMenu), findsOneWidget); + }); + + testWidgets('Halo-Remove entfernt den Wegpunkt und schliesst das Menue', + (tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider( + create: (_) => AppModeCubit(), + child: const MaterialApp(home: AppShell()), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byType(ReticleButton)); // idle -> snapped + await tester.pump(); + await tester.tap(find.byType(ReticleButton)); // snapped -> editing + await tester.pump(); + + // Die Wedge-Bounding-Box-Mitte liegt ausserhalb der Keilform (Donut- + // Segment) - stattdessen exakt auf das (nicht hit-testbare) Icon tippen, + // das per Konstruktion innerhalb des Keils sitzt. + await tester.tapAt(tester.getCenter(find.byIcon(Icons.remove))); + await tester.pump(); + + expect(container.read(currentMissionProvider), isEmpty); + expect(find.byType(HaloMenu), findsNothing); + }); + + testWidgets('Halo-Loiter setzt die Execute-Aktion und schliesst das Menue', + (tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider( + create: (_) => AppModeCubit(), + child: const MaterialApp(home: AppShell()), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.byType(ReticleButton)); // idle -> snapped + await tester.pump(); + await tester.tap(find.byType(ReticleButton)); // snapped -> editing + await tester.pump(); + + await tester.tapAt(tester.getCenter(find.byIcon(Icons.loop))); + await tester.pump(); + + final waypoints = container.read(currentMissionProvider); + expect(waypoints.single.action, WaypointAction.loiter); + expect(find.byType(HaloMenu), findsNothing); + }); }