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:
co-authored by
Claude Sonnet 5
parent
fc7d014fda
commit
db6b46140c
@@ -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,
|
||||||
|
);
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user