From db6b46140cf17532c1f1e508a8c0013cc6d7c5c3 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:21:35 +0200 Subject: [PATCH] Add center reticle control for dropping waypoints - ReticleButton: fixed-at-screen-center "+" circle (HTML prototype: #centerCluster/#reticleBtn), tapping it drops a waypoint at the current map center. - CurrentMissionNotifier (Riverpod): holds the waypoint list for the mission being edited. Reactive single-value state per doc 4.16, so Riverpod rather than a new Bloc/Cubit. - MissionMap now renders each waypoint as a CircleLayer marker (white border, dark fill, matching the prototype's L.circleMarker styling). - PlanScreen owns the MapController needed to read the camera center on tap, and wires the reticle to the provider. Default altitude/speed/catch-radius (60m / 15 m/s / 60m) are hardcoded to the T1 Ranger default profile values from the prototype for now - TODO once drone profile settings (doc 3.5) exist. Verified with flutter analyze, flutter test (incl. a new test covering the add-waypoint path), and a real run on the Pixel_10a emulator: panned the map and dropped two waypoints, both markers stayed correctly georeferenced. Co-Authored-By: Claude Sonnet 5 --- .../providers/current_mission_provider.dart | 21 ++++++ app/lib/ui/screens/plan/plan_screen.dart | 67 ++++++++++++++++++- app/lib/ui/widgets/mission_map.dart | 6 +- app/lib/ui/widgets/reticle_button.dart | 28 ++++++++ app/test/widget_test.dart | 29 ++++++++ 5 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 app/lib/ui/providers/current_mission_provider.dart create mode 100644 app/lib/ui/widgets/reticle_button.dart diff --git a/app/lib/ui/providers/current_mission_provider.dart b/app/lib/ui/providers/current_mission_provider.dart new file mode 100644 index 0000000..00d599b --- /dev/null +++ b/app/lib/ui/providers/current_mission_provider.dart @@ -0,0 +1,21 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain/waypoint/flat_waypoint_list.dart'; + +/// Haelt die Wegpunktliste der aktuell in Bearbeitung befindlichen Mission +/// (Architektur-Doku 3.2/3.6). Reaktiver Einzelwert im Sinne von 4.16 - +/// daher Riverpod statt Bloc; die App-Mode State Machine bleibt in +/// AppModeCubit. +class CurrentMissionNotifier extends Notifier> { + @override + List build() => []; + + void addWaypoint(Waypoint waypoint) { + state = [...state, waypoint]; + } +} + +final currentMissionProvider = + NotifierProvider>( + CurrentMissionNotifier.new, +); diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index a712ae2..36d769a 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -1,14 +1,77 @@ import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:latlong2/latlong.dart'; +import '../../../domain/waypoint/flat_waypoint_list.dart'; +import '../../providers/current_mission_provider.dart'; import '../../widgets/mission_map.dart'; +import '../../widgets/reticle_button.dart'; /// 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. -class PlanScreen extends StatelessWidget { +class PlanScreen extends ConsumerStatefulWidget { const PlanScreen({super.key}); @override - Widget build(BuildContext context) => const MissionMap(); + ConsumerState createState() => _PlanScreenState(); +} + +class _PlanScreenState extends ConsumerState { + final _mapController = MapController(); + + // TODO: aus dem aktiven Drohnen-Profil (Doku 3.5) uebernehmen, sobald die + // Profilverwaltung existiert. Werte entsprechen dem Default-Profil + // T1 Ranger im HTML-Demonstrator (curAlt=60, cruiseSpeed=15, catchRadius=60). + static const _defaultAltitudeM = 60.0; + static const _defaultSpeedMs = 15.0; + static const _defaultCatchRadiusM = 60.0; + + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } + + void _addWaypointAtMapCenter() { + final center = _mapController.camera.center; + ref.read(currentMissionProvider.notifier).addWaypoint( + Waypoint( + lat: center.latitude, + lon: center.longitude, + altitudeM: _defaultAltitudeM, + speedMs: _defaultSpeedMs, + catchRadiusM: _defaultCatchRadiusM, + ), + ); + } + + @override + Widget build(BuildContext context) { + final waypoints = ref.watch(currentMissionProvider); + + return Stack( + children: [ + MissionMap( + mapController: _mapController, + 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, + ), + ], + ), + Align( + alignment: Alignment.center, + child: ReticleButton(onTap: _addWaypointAtMapCenter), + ), + ], + ); + } } diff --git a/app/lib/ui/widgets/mission_map.dart b/app/lib/ui/widgets/mission_map.dart index 654f8ec..026825e 100644 --- a/app/lib/ui/widgets/mission_map.dart +++ b/app/lib/ui/widgets/mission_map.dart @@ -6,10 +6,13 @@ 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}); + const MissionMap({super.key, this.mapController, this.waypointMarkers = const []}); final MapController? mapController; + /// Kreis-Marker fuer die Wegpunkte der aktuellen Mission (Doku 3.7). + final List waypointMarkers; + // 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); @@ -31,6 +34,7 @@ class MissionMap extends StatelessWidget { userAgentPackageName: 'de.dmc.dmc_app', maxZoom: 19, ), + CircleLayer(circles: waypointMarkers), SimpleAttributionWidget( alignment: Alignment.bottomLeft, source: const Text('OpenStreetMap contributors'), diff --git a/app/lib/ui/widgets/reticle_button.dart b/app/lib/ui/widgets/reticle_button.dart new file mode 100644 index 0000000..7a86649 --- /dev/null +++ b/app/lib/ui/widgets/reticle_button.dart @@ -0,0 +1,28 @@ +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. +class ReticleButton extends StatelessWidget { + const ReticleButton({super.key, required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 55, + height: 55, + decoration: BoxDecoration( + shape: BoxShape.circle, + 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), + ), + ); + } +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index a73de51..48ab2b6 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -6,6 +6,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:dmc_app/app_mode/app_mode_cubit.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/reticle_button.dart'; Widget _wrap(Widget child) => ProviderScope( child: BlocProvider( @@ -43,4 +45,31 @@ void main() { // Weiterhin im Plan-Modus, da das Gate nicht erfuellt war. expect(find.byType(FlutterMap), findsOneWidget); }); + + testWidgets('Reticle-Button fuegt einen Wegpunkt am Kartenzentrum hinzu', + (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(); + + expect(container.read(currentMissionProvider), isEmpty); + + await tester.tap(find.byType(ReticleButton)); + await tester.pump(); + + final waypoints = container.read(currentMissionProvider); + expect(waypoints, hasLength(1)); + expect(waypoints.single.lat, closeTo(52.3676, 0.0001)); + expect(waypoints.single.lon, closeTo(4.9041, 0.0001)); + }); }