From 13da935f9980108d1ddf830e4611bc370cd23b0f Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:37:22 +0200 Subject: [PATCH] Merge search/home/fit controls into the Plan/Fly header pill Suche, Home- und Fit-Button sassen bisher in einer eigenen Leiste unter der Plan/Fly-Kopfleiste - ein reiner UI-Kompromiss, weil die Karte (inkl. MapController) in PlanScreen lebte und von der aeusseren Kopfleiste aus nicht erreichbar war. Der HTML-Demonstrator zeigt Suche/Home/Fit dagegen als Mittelteil derselben Leiste wie die Modus-Buttons (#searchWrap/#homeBtn/#fitBtn in #topBarMiddle) - das war also keine Stilfrage, sondern eine Datenfluss-Frage. Loesung: MapController aus einem PlanScreen-privaten Feld in einen app-lebenslangen mapControllerProvider (Riverpod) gehoben. Damit kann die neue MapSearchControls (ersetzt TopNavBar) direkt in TopModeBar eingebettet werden und lebt architektonisch dort, wo sie hingehoert: bei der Kartensteuerung, nicht als Kind von PlanScreen. Als Nebeneffekt - tatsaechlich der wichtigere Punkt - bleibt die Kamera-Position/Zoom jetzt auch beim Wechsel Plan -> Fly -> Plan erhalten. Verifiziert per flutter_map-Quellcode (FlutterMap haengt einen extern uebergebenen Controller beim Neu-Mounten nicht an und disposed ihn nicht) sowie per neuem Widget-Test, der den Cubit direkt durch das Ready-to-Fly-Gate schickt (der Fly-Modus ist ueber die UI aktuell nicht erreichbar, da Upload/Verify noch nicht implementiert ist) und die Kamera vor/nach dem Wechsel vergleicht. Zusaetzlich: _onMapEvent unterscheidet jetzt per MapEventMove.source, ob eine Kartenbewegung programmatisch (mapController, z.B. durch Suche/ Home/Fit) oder per Geste ausgeloest wurde, und triggert den Snap-to-Edit-Handler entsprechend nur bei Gesten bzw. sofort bei programmatischen Moves - das ersetzt den bisherigen Ansatz, an jeder Aufrufstelle manuell _handleMoveEnd() zu rufen, der nicht mehr skaliert sobald diese Aufrufstellen (wie jetzt) in einem anderen Widget liegen. Verifiziert: flutter analyze (0 issues), flutter test (22/22, inkl. neuem Kamera-Persistenz-Test), manuell auf Pixel_10a-Emulator (Release- Build) - Suche/Home/Fit erscheinen fusioniert in der gruenen Pille, Kartenverschiebung bleibt nach Wechsel in den (durch das Gate weiterhin blockierten) Fly-Modus sichtbar unveraendert. Co-Authored-By: Claude Sonnet 5 --- .../ui/providers/map_controller_provider.dart | 22 +++ app/lib/ui/screens/plan/plan_screen.dart | 105 ++--------- app/lib/ui/widgets/map_search_controls.dart | 165 ++++++++++++++++++ app/lib/ui/widgets/top_mode_bar.dart | 17 +- app/lib/ui/widgets/top_nav_bar.dart | 110 ------------ app/test/widget_test.dart | 51 +++++- 6 files changed, 267 insertions(+), 203 deletions(-) create mode 100644 app/lib/ui/providers/map_controller_provider.dart create mode 100644 app/lib/ui/widgets/map_search_controls.dart delete mode 100644 app/lib/ui/widgets/top_nav_bar.dart diff --git a/app/lib/ui/providers/map_controller_provider.dart b/app/lib/ui/providers/map_controller_provider.dart new file mode 100644 index 0000000..b7b47c5 --- /dev/null +++ b/app/lib/ui/providers/map_controller_provider.dart @@ -0,0 +1,22 @@ +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Ein einziger, app-lebenslanger MapController statt einer pro +/// PlanScreen-Mount neu erzeugten Instanz. +/// +/// Grund: Die Kopfleiste (AppShell/TopModeBar) liegt eine Ebene ueber +/// PlanScreen und braucht fuer Suche/Home/Fit denselben Controller wie die +/// Karte - ein PlanScreen-privates Feld waere von dort nicht erreichbar. +/// Als Nebeneffekt (und in Wahrheit der wichtigere Punkt) bleibt die +/// Kamera-Position/Zoom jetzt auch ueber einen Wechsel nach Fly und +/// zurueck erhalten: flutter_map tastet einen extern uebergebenen +/// MapController beim erneuten Mounten nicht an (siehe +/// FlutterMap._setMapController()), anders als der bisherige Zustand, wo +/// PlanScreens eigener Controller beim Verlassen des Plan-Modus komplett +/// verworfen wurde. Entspricht damit auch besser dem HTML-Demonstrator, +/// der nur eine einzige Leaflet-Instanz fuer die gesamte App kennt. +final mapControllerProvider = Provider((ref) { + final controller = MapController(); + ref.onDispose(controller.dispose); + return controller; +}); diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index 891bcf5..6f12fd7 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -1,23 +1,21 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart'; import '../../../domain/mission/default_drone_profile.dart'; import '../../../domain/mission/route_geometry.dart'; import '../../../domain/waypoint/flat_waypoint_list.dart'; import '../../providers/current_mission_provider.dart'; +import '../../providers/map_controller_provider.dart'; import '../../widgets/bottom_stats_bar.dart'; import '../../widgets/halo_menu.dart'; import '../../widgets/mini_altitude_profile.dart'; import '../../widgets/mission_map.dart'; import '../../widgets/reticle_button.dart'; -import '../../widgets/top_nav_bar.dart'; import '../../widgets/value_wheel.dart'; import '../../widgets/waypoint_list_panel.dart'; @@ -39,7 +37,10 @@ class PlanScreen extends ConsumerStatefulWidget { } class _PlanScreenState extends ConsumerState { - final _mapController = MapController(); + // Geteilter, app-lebenslanger Controller statt einer PlanScreen-eigenen + // Instanz (siehe mapControllerProvider) - PlanScreen besitzt ihn nicht + // mehr und darf ihn daher auch nicht disposen. + late final MapController _mapController; StreamSubscription? _mapEventSub; _ReticleMode _mode = _ReticleMode.idle; @@ -64,13 +65,13 @@ class _PlanScreenState extends ConsumerState { @override void initState() { super.initState(); + _mapController = ref.read(mapControllerProvider); _mapEventSub = _mapController.mapEventStream.listen(_onMapEvent); } @override void dispose() { _mapEventSub?.cancel(); - _mapController.dispose(); super.dispose(); } @@ -78,7 +79,14 @@ class _PlanScreenState extends ConsumerState { if (event is MapEventMoveEnd) { _handleMoveEnd(); } else if (event is MapEventMove) { - if (_mode == _ReticleMode.editing && _targetIndex >= 0) { + if (event.source == MapEventSource.mapController) { + // Programmatischer Sprung (Suche/Home/Fit in der Kopfleiste, + // Zeile-antippen in der Wegpunktliste) - im Gegensatz zu einer + // Gesten-Verschiebung ist die Position hier sofort final, es + // kommt aber nie ein MoveEnd fuer diesen Fall. Direkt reagieren + // statt darauf zu warten. + if (_mode != _ReticleMode.editing) _handleMoveEnd(); + } else if (_mode == _ReticleMode.editing && _targetIndex >= 0) { final waypoints = ref.read(currentMissionProvider); if (_targetIndex < waypoints.length) { final center = _mapController.camera.center; @@ -245,78 +253,14 @@ class _PlanScreenState extends ConsumerState { }); } - /// Zeile antippen zentriert die Karte auf diesen Wegpunkt (Doku 3.6). - /// Danach wird die Snap-Erkennung einmal manuell angestossen, da - /// flutter_maps MapController.move() - anders als Leaflets panTo() - kein - /// MoveEnd-Event feuert. + /// Zeile antippen zentriert die Karte auf diesen Wegpunkt (Doku 3.6). Die + /// Snap-Erkennung reagiert automatisch ueber _onMapEvent (source == + /// mapController), ein manueller Folgeaufruf ist nicht noetig. void _onCenterOnWaypoint(int index) { final waypoints = ref.read(currentMissionProvider); if (index < 0 || index >= waypoints.length) return; final wp = waypoints[index]; _mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom); - _handleMoveEnd(); - } - - /// Ortssuche per Nominatim-Geocoding (HTML-Demonstrator: doSearch()). - /// Zentriert die Karte auf den ersten Treffer. - Future _onSearch(String query) async { - final q = query.trim(); - if (q.isEmpty) return; - final messenger = ScaffoldMessenger.of(context); - try { - final uri = Uri.https('nominatim.openstreetmap.org', '/search', { - 'format': 'json', - 'addressdetails': '1', - 'accept-language': 'en', - 'limit': '1', - 'q': q, - }); - final response = await http - .get(uri, headers: {'User-Agent': 'dmc_app (Drone Mission Control)'}); - final results = jsonDecode(response.body) as List; - if (results.isEmpty) { - messenger.showSnackBar(const SnackBar(content: Text('Location not found'))); - return; - } - final first = results.first as Map; - final lat = double.parse(first['lat'] as String); - final lon = double.parse(first['lon'] as String); - _mapController.move(LatLng(lat, lon), 15); - _handleMoveEnd(); - } catch (_) { - messenger.showSnackBar(const SnackBar(content: Text('Search failed'))); - } - } - - /// Zentriert auf den ersten Wegpunkt (HTML-Demonstrator: homeBtn). - void _onHomePressed() { - final waypoints = ref.read(currentMissionProvider); - if (waypoints.isEmpty) { - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('No waypoints yet'))); - return; - } - final home = waypoints.first; - _mapController.move(LatLng(home.lat, home.lon), 16); - _handleMoveEnd(); - } - - /// Zoomt auf die gesamte Mission (HTML-Demonstrator: fitToWaypoints()). - void _onFitPressed() { - final waypoints = ref.read(currentMissionProvider); - if (waypoints.isEmpty) { - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('No waypoints to fit'))); - return; - } - final points = [for (final w in waypoints) LatLng(w.lat, w.lon)]; - _mapController.fitCamera( - CameraFit.bounds( - bounds: LatLngBounds.fromPoints(points), - padding: const EdgeInsets.fromLTRB(50, 50, 50, 140), - ), - ); - _handleMoveEnd(); } /// Oeffnet die Wegpunktliste als eigene Vollbild-Route statt als @@ -422,21 +366,6 @@ class _PlanScreenState extends ConsumerState { ), ], ), - Positioned( - // Direkt unterhalb der Plan/Fly-Kopfleiste (AppShell/TopModeBar), - // dieselbe Hoehe wie der Start der Draeder (top:56) - dort ist - // die Kopfleiste garantiert bereits zu Ende. - top: 62, - left: 0, - right: 0, - child: Center( - child: TopNavBar( - onSearch: _onSearch, - onHome: _onHomePressed, - onFit: _onFitPressed, - ), - ), - ), if (_mode == _ReticleMode.editing && targetWaypoint != null) Align( alignment: Alignment.center, diff --git a/app/lib/ui/widgets/map_search_controls.dart b/app/lib/ui/widgets/map_search_controls.dart new file mode 100644 index 0000000..f344cf8 --- /dev/null +++ b/app/lib/ui/widgets/map_search_controls.dart @@ -0,0 +1,165 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; + +import '../providers/current_mission_provider.dart'; +import '../providers/map_controller_provider.dart'; + +/// Ortssuche (Nominatim) + Home-/Fit-Buttons, eingebettet als Mittelteil +/// der Plan/Fly-Kopfleiste (HTML-Demonstrator: #searchWrap/#homeBtn/ +/// #fitBtn sitzen alle in derselben Leiste wie die Modus-Buttons). +/// +/// Vollstaendig selbststaendig (kein Callback-Durchreichen von aussen): +/// greift direkt auf den geteilten mapControllerProvider und +/// currentMissionProvider zu, da diese Aktionen konzeptionell zur +/// Kartensteuerung gehoeren, nicht zum Elternwidget. +class MapSearchControls extends ConsumerStatefulWidget { + const MapSearchControls({super.key}); + + @override + ConsumerState createState() => _MapSearchControlsState(); +} + +class _MapSearchControlsState extends ConsumerState { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _submitSearch() async { + final query = _controller.text.trim(); + FocusScope.of(context).unfocus(); + if (query.isEmpty) return; + + final messenger = ScaffoldMessenger.of(context); + try { + final uri = Uri.https('nominatim.openstreetmap.org', '/search', { + 'format': 'json', + 'addressdetails': '1', + 'accept-language': 'en', + 'limit': '1', + 'q': query, + }); + final response = await http + .get(uri, headers: {'User-Agent': 'dmc_app (Drone Mission Control)'}); + final results = jsonDecode(response.body) as List; + if (results.isEmpty) { + messenger.showSnackBar(const SnackBar(content: Text('Location not found'))); + return; + } + final first = results.first as Map; + final lat = double.parse(first['lat'] as String); + final lon = double.parse(first['lon'] as String); + ref.read(mapControllerProvider).move(LatLng(lat, lon), 15); + } catch (_) { + messenger.showSnackBar(const SnackBar(content: Text('Search failed'))); + } + } + + /// Zentriert auf den ersten Wegpunkt (HTML-Demonstrator: homeBtn). + void _onHomePressed() { + final waypoints = ref.read(currentMissionProvider); + if (waypoints.isEmpty) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('No waypoints yet'))); + return; + } + final home = waypoints.first; + ref.read(mapControllerProvider).move(LatLng(home.lat, home.lon), 16); + } + + /// Zoomt auf die gesamte Mission (HTML-Demonstrator: fitToWaypoints()). + void _onFitPressed() { + final waypoints = ref.read(currentMissionProvider); + if (waypoints.isEmpty) { + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('No waypoints to fit'))); + return; + } + final points = [for (final w in waypoints) LatLng(w.lat, w.lon)]; + ref.read(mapControllerProvider).fitCamera( + CameraFit.bounds( + bounds: LatLngBounds.fromPoints(points), + padding: const EdgeInsets.fromLTRB(50, 50, 50, 140), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Container( + height: 34, + padding: const EdgeInsets.only(left: 12, right: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(17), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + onSubmitted: (_) => _submitSearch(), + textInputAction: TextInputAction.search, + style: const TextStyle(color: Colors.white, fontSize: 13), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: 'Search location', + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.55), + ), + ), + ), + ), + IconButton( + onPressed: _submitSearch, + icon: const Icon(Icons.search, color: Colors.white, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 26, minHeight: 26), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + _NavIconButton(icon: Icons.home, onTap: _onHomePressed), + const SizedBox(width: 8), + _NavIconButton(icon: Icons.crop_free, onTap: _onFitPressed), + ], + ); + } +} + +class _NavIconButton extends StatelessWidget { + const _NavIconButton({required this.icon, required this.onTap}); + + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.black.withValues(alpha: 0.55), + ), + child: Icon(icon, color: Colors.white, size: 16), + ), + ); + } +} diff --git a/app/lib/ui/widgets/top_mode_bar.dart b/app/lib/ui/widgets/top_mode_bar.dart index c89a344..95d2ea2 100644 --- a/app/lib/ui/widgets/top_mode_bar.dart +++ b/app/lib/ui/widgets/top_mode_bar.dart @@ -3,11 +3,18 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../app_mode/app_mode_cubit.dart'; import '../dmc_colors.dart'; +import 'map_search_controls.dart'; /// Persistente Kopfleiste zum Umschalten zwischen Plan- und Fly-Modus /// (HTML-Demonstrator: #topBarWrap/#editModeBtn/#flyModeBtn). Die Leiste /// faerbt sich in der Farbe des aktiven Modus; der jeweils andere Modus wird /// als solider Button am Rand angeboten, um in ihn zu wechseln. +/// +/// Traegt ausserhalb des Fly-Modus zusaetzlich die Ortssuche und die +/// Home-/Fit-Buttons in derselben Pille (HTML-Demonstrator: #searchWrap/ +/// #homeBtn/#fitBtn sitzen alle in #topBarMiddle, nicht in einer separaten +/// Leiste) - dafuer lebt der MapController im mapControllerProvider statt +/// als PlanScreen-privates Feld, siehe dort. class TopModeBar extends StatelessWidget { const TopModeBar({super.key}); @@ -19,7 +26,7 @@ class TopModeBar extends StatelessWidget { return SafeArea( bottom: false, child: FractionallySizedBox( - widthFactor: 0.64, + widthFactor: isFly ? 0.64 : 0.86, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( @@ -40,7 +47,13 @@ class TopModeBar extends StatelessWidget { ), onTap: () => context.read().toPlan(), ), - const Spacer(), + if (isFly) + const Spacer() + else ...[ + const SizedBox(width: 12), + const Expanded(child: MapSearchControls()), + const SizedBox(width: 12), + ], _ModeButton( label: 'Fly', isActive: isFly, diff --git a/app/lib/ui/widgets/top_nav_bar.dart b/app/lib/ui/widgets/top_nav_bar.dart deleted file mode 100644 index 76aa907..0000000 --- a/app/lib/ui/widgets/top_nav_bar.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Suchleiste + Navigations-Buttons (HTML-Demonstrator: #searchWrap/ -/// #homeBtn/#fitBtn). Rendert unterhalb der Plan/Fly-Kopfleiste statt in -/// dieselbe Pille eingeschmolzen - TopModeBar liegt im AppShell, eine -/// Ebene ueber PlanScreen, das den MapController besitzt, den Suche/ -/// Home/Fit tatsaechlich benoetigen. Ein gemeinsamer Riverpod-State fuer -/// den MapController koennte das spaeter fusionieren. -class TopNavBar extends StatefulWidget { - const TopNavBar({ - super.key, - required this.onSearch, - required this.onHome, - required this.onFit, - }); - - final ValueChanged onSearch; - final VoidCallback onHome; - final VoidCallback onFit; - - @override - State createState() => _TopNavBarState(); -} - -class _TopNavBarState extends State { - final _controller = TextEditingController(); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _submit() { - widget.onSearch(_controller.text); - FocusScope.of(context).unfocus(); - } - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 260, - height: 38, - padding: const EdgeInsets.only(left: 12, right: 4), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.68), - borderRadius: BorderRadius.circular(19), - border: Border.all(color: Colors.white.withValues(alpha: 0.4)), - ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - onSubmitted: (_) => _submit(), - textInputAction: TextInputAction.search, - style: const TextStyle(color: Colors.white, fontSize: 14), - decoration: InputDecoration( - isDense: true, - border: InputBorder.none, - hintText: 'Search location', - hintStyle: - TextStyle(color: Colors.white.withValues(alpha: 0.55)), - ), - ), - ), - IconButton( - onPressed: _submit, - icon: const Icon(Icons.search, color: Colors.white, size: 18), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 30, minHeight: 30), - ), - ], - ), - ), - const SizedBox(width: 8), - _NavIconButton(icon: Icons.home, onTap: widget.onHome), - const SizedBox(width: 8), - _NavIconButton(icon: Icons.crop_free, onTap: widget.onFit), - ], - ); - } -} - -class _NavIconButton extends StatelessWidget { - const _NavIconButton({required this.icon, required this.onTap}); - - final IconData icon; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - width: 38, - height: 38, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Colors.black.withValues(alpha: 0.65), - border: Border.all(color: Colors.white.withValues(alpha: 0.4)), - ), - child: Icon(icon, color: Colors.white, size: 18), - ), - ); - } -} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 15afc5f..1538b5b 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -10,10 +10,12 @@ import 'package:dmc_app/ui/app_shell.dart'; import 'package:dmc_app/ui/providers/current_mission_provider.dart'; import 'package:dmc_app/ui/widgets/full_value_chart.dart'; import 'package:dmc_app/ui/widgets/halo_menu.dart'; +import 'package:dmc_app/ui/widgets/map_search_controls.dart'; +import 'package:dmc_app/ui/providers/map_controller_provider.dart'; import 'package:dmc_app/ui/widgets/reticle_button.dart'; -import 'package:dmc_app/ui/widgets/top_nav_bar.dart'; import 'package:dmc_app/ui/widgets/waypoint_chip.dart'; import 'package:dmc_app/ui/widgets/waypoint_list_panel.dart'; +import 'package:latlong2/latlong.dart'; Widget _wrap(Widget child) => ProviderScope( child: BlocProvider( @@ -311,11 +313,11 @@ void main() { expect(waypoints.single.speedMs, greaterThan(15)); }); - testWidgets('TopNavBar zeigt Suchfeld und Home-/Fit-Buttons', + testWidgets('MapSearchControls zeigt Suchfeld und Home-/Fit-Buttons', (tester) async { await _pumpWithContainer(tester); - expect(find.byType(TopNavBar), findsOneWidget); + expect(find.byType(MapSearchControls), findsOneWidget); expect(find.text('Search location'), findsOneWidget); expect(find.byIcon(Icons.home), findsOneWidget); expect(find.byIcon(Icons.crop_free), findsOneWidget); @@ -352,4 +354,47 @@ void main() { expect(find.text('Search failed'), findsNothing); expect(find.text('Location not found'), findsNothing); }); + + testWidgets( + 'Kameraposition bleibt beim Wechsel Plan -> Fly -> Plan erhalten', + (tester) async { + final container = ProviderContainer(); + final appModeCubit = AppModeCubit(); + addTearDown(() { + container.dispose(); + appModeCubit.close(); + }); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider.value( + value: appModeCubit, + child: const MaterialApp(home: AppShell()), + ), + ), + ); + await tester.pump(); + + const movedTo = LatLng(48.2082, 16.3738); + container.read(mapControllerProvider).move(movedTo, 14); + await tester.pump(); + + // Ready-to-Fly-Gate technisch erfuellen, um in den Fly-Modus zu gelangen + // (Doku 2.2/4.5) - hier nur, um die Kamera-Persistenz des geteilten + // mapControllerProvider ueber den Moduswechsel zu pruefen. + appModeCubit + ..missionUploadConfirmed() + ..missionVerifiedConfirmed() + ..toFly(); + await tester.pump(); + + appModeCubit.toPlan(); + await tester.pump(); + + final camera = container.read(mapControllerProvider).camera; + expect(camera.center.latitude, closeTo(movedTo.latitude, 0.0001)); + expect(camera.center.longitude, closeTo(movedTo.longitude, 0.0001)); + expect(camera.zoom, 14); + }); }