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 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-07-27 22:21:35 +02:00
co-authored by Claude Sonnet 5
parent fc7d014fda
commit db6b46140c
5 changed files with 148 additions and 3 deletions
@@ -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<List<Waypoint>> {
@override
List<Waypoint> build() => [];
void addWaypoint(Waypoint waypoint) {
state = [...state, waypoint];
}
}
final currentMissionProvider =
NotifierProvider<CurrentMissionNotifier, List<Waypoint>>(
CurrentMissionNotifier.new,
);
+65 -2
View File
@@ -1,14 +1,77 @@
import 'package:flutter/material.dart'; 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/mission_map.dart';
import '../../widgets/reticle_button.dart';
/// Missionsplanung: Karte + Wegpunktliste (Architektur-Doku 3.6/3.7). /// Missionsplanung: Karte + Wegpunktliste (Architektur-Doku 3.6/3.7).
/// Kein eigenes Scaffold - die Kopfleiste (Plan/Fly-Umschalter) und der /// Kein eigenes Scaffold - die Kopfleiste (Plan/Fly-Umschalter) und der
/// gemeinsame Scaffold-Rahmen liegen im AppShell darueber. Wegpunktliste, /// gemeinsame Scaffold-Rahmen liegen im AppShell darueber. Wegpunktliste,
/// Draeder und Bottom-Bar folgen als naechster Schritt. /// Draeder und Bottom-Bar folgen als naechster Schritt.
class PlanScreen extends StatelessWidget { class PlanScreen extends ConsumerStatefulWidget {
const PlanScreen({super.key}); const PlanScreen({super.key});
@override @override
Widget build(BuildContext context) => const MissionMap(); ConsumerState<PlanScreen> createState() => _PlanScreenState();
}
class _PlanScreenState extends ConsumerState<PlanScreen> {
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),
),
],
);
}
} }
+5 -1
View File
@@ -6,10 +6,13 @@ import 'package:url_launcher/url_launcher.dart';
/// Vollbild-OSM-Karte, entspricht dem Leaflet-`#map` im HTML-Demonstrator: /// Vollbild-OSM-Karte, entspricht dem Leaflet-`#map` im HTML-Demonstrator:
/// randlos, ohne Zoom-Buttons (Pinch-to-Zoom/Pan reicht), Standard-OSM-Tiles. /// randlos, ohne Zoom-Buttons (Pinch-to-Zoom/Pan reicht), Standard-OSM-Tiles.
class MissionMap extends StatelessWidget { class MissionMap extends StatelessWidget {
const MissionMap({super.key, this.mapController}); const MissionMap({super.key, this.mapController, this.waypointMarkers = const []});
final MapController? mapController; final MapController? mapController;
/// Kreis-Marker fuer die Wegpunkte der aktuellen Mission (Doku 3.7).
final List<CircleMarker> waypointMarkers;
// Default-Zentrum/Zoom uebernommen aus dem HTML-Demonstrator // Default-Zentrum/Zoom uebernommen aus dem HTML-Demonstrator
// (Drone Mission Control.html: L.map(...).setView([52.3676, 4.9041], 16)). // (Drone Mission Control.html: L.map(...).setView([52.3676, 4.9041], 16)).
static const _initialCenter = LatLng(52.3676, 4.9041); static const _initialCenter = LatLng(52.3676, 4.9041);
@@ -31,6 +34,7 @@ class MissionMap extends StatelessWidget {
userAgentPackageName: 'de.dmc.dmc_app', userAgentPackageName: 'de.dmc.dmc_app',
maxZoom: 19, maxZoom: 19,
), ),
CircleLayer(circles: waypointMarkers),
SimpleAttributionWidget( SimpleAttributionWidget(
alignment: Alignment.bottomLeft, alignment: Alignment.bottomLeft,
source: const Text('OpenStreetMap contributors'), source: const Text('OpenStreetMap contributors'),
+28
View File
@@ -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),
),
);
}
}
+29
View File
@@ -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/app_mode/app_mode_cubit.dart';
import 'package:dmc_app/ui/app_shell.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( Widget _wrap(Widget child) => ProviderScope(
child: BlocProvider( child: BlocProvider(
@@ -43,4 +45,31 @@ void main() {
// Weiterhin im Plan-Modus, da das Gate nicht erfuellt war. // Weiterhin im Plan-Modus, da das Gate nicht erfuellt war.
expect(find.byType(FlutterMap), findsOneWidget); 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));
});
} }