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 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-07-28 09:37:22 +02:00
co-authored by Claude Sonnet 5
parent dc8e6f9d6e
commit 13da935f99
6 changed files with 267 additions and 203 deletions
@@ -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<MapController>((ref) {
final controller = MapController();
ref.onDispose(controller.dispose);
return controller;
});
+17 -88
View File
@@ -1,23 +1,21 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../../../domain/mission/default_drone_profile.dart'; import '../../../domain/mission/default_drone_profile.dart';
import '../../../domain/mission/route_geometry.dart'; import '../../../domain/mission/route_geometry.dart';
import '../../../domain/waypoint/flat_waypoint_list.dart'; import '../../../domain/waypoint/flat_waypoint_list.dart';
import '../../providers/current_mission_provider.dart'; import '../../providers/current_mission_provider.dart';
import '../../providers/map_controller_provider.dart';
import '../../widgets/bottom_stats_bar.dart'; import '../../widgets/bottom_stats_bar.dart';
import '../../widgets/halo_menu.dart'; import '../../widgets/halo_menu.dart';
import '../../widgets/mini_altitude_profile.dart'; import '../../widgets/mini_altitude_profile.dart';
import '../../widgets/mission_map.dart'; import '../../widgets/mission_map.dart';
import '../../widgets/reticle_button.dart'; import '../../widgets/reticle_button.dart';
import '../../widgets/top_nav_bar.dart';
import '../../widgets/value_wheel.dart'; import '../../widgets/value_wheel.dart';
import '../../widgets/waypoint_list_panel.dart'; import '../../widgets/waypoint_list_panel.dart';
@@ -39,7 +37,10 @@ class PlanScreen extends ConsumerStatefulWidget {
} }
class _PlanScreenState extends ConsumerState<PlanScreen> { class _PlanScreenState extends ConsumerState<PlanScreen> {
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<MapEvent>? _mapEventSub; StreamSubscription<MapEvent>? _mapEventSub;
_ReticleMode _mode = _ReticleMode.idle; _ReticleMode _mode = _ReticleMode.idle;
@@ -64,13 +65,13 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_mapController = ref.read(mapControllerProvider);
_mapEventSub = _mapController.mapEventStream.listen(_onMapEvent); _mapEventSub = _mapController.mapEventStream.listen(_onMapEvent);
} }
@override @override
void dispose() { void dispose() {
_mapEventSub?.cancel(); _mapEventSub?.cancel();
_mapController.dispose();
super.dispose(); super.dispose();
} }
@@ -78,7 +79,14 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
if (event is MapEventMoveEnd) { if (event is MapEventMoveEnd) {
_handleMoveEnd(); _handleMoveEnd();
} else if (event is MapEventMove) { } 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); final waypoints = ref.read(currentMissionProvider);
if (_targetIndex < waypoints.length) { if (_targetIndex < waypoints.length) {
final center = _mapController.camera.center; final center = _mapController.camera.center;
@@ -245,78 +253,14 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
}); });
} }
/// Zeile antippen zentriert die Karte auf diesen Wegpunkt (Doku 3.6). /// Zeile antippen zentriert die Karte auf diesen Wegpunkt (Doku 3.6). Die
/// Danach wird die Snap-Erkennung einmal manuell angestossen, da /// Snap-Erkennung reagiert automatisch ueber _onMapEvent (source ==
/// flutter_maps MapController.move() - anders als Leaflets panTo() - kein /// mapController), ein manueller Folgeaufruf ist nicht noetig.
/// MoveEnd-Event feuert.
void _onCenterOnWaypoint(int index) { void _onCenterOnWaypoint(int index) {
final waypoints = ref.read(currentMissionProvider); final waypoints = ref.read(currentMissionProvider);
if (index < 0 || index >= waypoints.length) return; if (index < 0 || index >= waypoints.length) return;
final wp = waypoints[index]; final wp = waypoints[index];
_mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom); _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<void> _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<String, dynamic>;
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 /// Oeffnet die Wegpunktliste als eigene Vollbild-Route statt als
@@ -422,21 +366,6 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
), ),
], ],
), ),
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) if (_mode == _ReticleMode.editing && targetWaypoint != null)
Align( Align(
alignment: Alignment.center, alignment: Alignment.center,
+165
View File
@@ -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<MapSearchControls> createState() => _MapSearchControlsState();
}
class _MapSearchControlsState extends ConsumerState<MapSearchControls> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _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<String, dynamic>;
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),
),
);
}
}
+15 -2
View File
@@ -3,11 +3,18 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../app_mode/app_mode_cubit.dart'; import '../../app_mode/app_mode_cubit.dart';
import '../dmc_colors.dart'; import '../dmc_colors.dart';
import 'map_search_controls.dart';
/// Persistente Kopfleiste zum Umschalten zwischen Plan- und Fly-Modus /// Persistente Kopfleiste zum Umschalten zwischen Plan- und Fly-Modus
/// (HTML-Demonstrator: #topBarWrap/#editModeBtn/#flyModeBtn). Die Leiste /// (HTML-Demonstrator: #topBarWrap/#editModeBtn/#flyModeBtn). Die Leiste
/// faerbt sich in der Farbe des aktiven Modus; der jeweils andere Modus wird /// faerbt sich in der Farbe des aktiven Modus; der jeweils andere Modus wird
/// als solider Button am Rand angeboten, um in ihn zu wechseln. /// 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 { class TopModeBar extends StatelessWidget {
const TopModeBar({super.key}); const TopModeBar({super.key});
@@ -19,7 +26,7 @@ class TopModeBar extends StatelessWidget {
return SafeArea( return SafeArea(
bottom: false, bottom: false,
child: FractionallySizedBox( child: FractionallySizedBox(
widthFactor: 0.64, widthFactor: isFly ? 0.64 : 0.86,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -40,7 +47,13 @@ class TopModeBar extends StatelessWidget {
), ),
onTap: () => context.read<AppModeCubit>().toPlan(), onTap: () => context.read<AppModeCubit>().toPlan(),
), ),
const Spacer(), if (isFly)
const Spacer()
else ...[
const SizedBox(width: 12),
const Expanded(child: MapSearchControls()),
const SizedBox(width: 12),
],
_ModeButton( _ModeButton(
label: 'Fly', label: 'Fly',
isActive: isFly, isActive: isFly,
-110
View File
@@ -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<String> onSearch;
final VoidCallback onHome;
final VoidCallback onFit;
@override
State<TopNavBar> createState() => _TopNavBarState();
}
class _TopNavBarState extends State<TopNavBar> {
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),
),
);
}
}
+48 -3
View File
@@ -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/providers/current_mission_provider.dart';
import 'package:dmc_app/ui/widgets/full_value_chart.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/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/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_chip.dart';
import 'package:dmc_app/ui/widgets/waypoint_list_panel.dart'; import 'package:dmc_app/ui/widgets/waypoint_list_panel.dart';
import 'package:latlong2/latlong.dart';
Widget _wrap(Widget child) => ProviderScope( Widget _wrap(Widget child) => ProviderScope(
child: BlocProvider( child: BlocProvider(
@@ -311,11 +313,11 @@ void main() {
expect(waypoints.single.speedMs, greaterThan(15)); expect(waypoints.single.speedMs, greaterThan(15));
}); });
testWidgets('TopNavBar zeigt Suchfeld und Home-/Fit-Buttons', testWidgets('MapSearchControls zeigt Suchfeld und Home-/Fit-Buttons',
(tester) async { (tester) async {
await _pumpWithContainer(tester); await _pumpWithContainer(tester);
expect(find.byType(TopNavBar), findsOneWidget); expect(find.byType(MapSearchControls), findsOneWidget);
expect(find.text('Search location'), findsOneWidget); expect(find.text('Search location'), findsOneWidget);
expect(find.byIcon(Icons.home), findsOneWidget); expect(find.byIcon(Icons.home), findsOneWidget);
expect(find.byIcon(Icons.crop_free), findsOneWidget); expect(find.byIcon(Icons.crop_free), findsOneWidget);
@@ -352,4 +354,47 @@ void main() {
expect(find.text('Search failed'), findsNothing); expect(find.text('Search failed'), findsNothing);
expect(find.text('Location not found'), 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<AppModeCubit>.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);
});
} }