From 926d8051568b95312f624305928825630dac0323 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:22:36 +0200 Subject: [PATCH] Location search starts a new mission; shrink and center header bar Ortssuche verschob bisher nur die Karte - der HTML-Demonstrator beginnt bei einer Suche immer eine neue, nach dem gefundenen Ort benannte Mission (setMissionFromPlace(), aufgerufen aus doSearch()). Vorherige Aenderungen gehen dabei nicht verloren, da flushPendingAutosave() (hier: CurrentMissionMetaNotifier._flushNow()) zuerst noch ausstehende Autosaves schreibt. Die Nominatim-Suche wurde dafuer aus MapSearchControls in einen eigenstaendigen NominatimService extrahiert (analog WindService): injectable http.Client, damit sich die Suche in Tests ohne echten Netzwerkzugriff ueberschreiben laesst. CurrentMissionMetaNotifier bekam dafuer startNewFromPlace() (startNew() intern darauf umgebaut, um Duplikation zu vermeiden). Kopfleiste von 86%/64% (Plan-/Fly-Modus) auf einheitlich 70% Bildschirmbreite verkleinert - zentriert war sie durch Align(topCenter) + FractionallySizedBox bereits strukturell korrekt, wirkte bei der vollen Breite aber unausgewogen. Verifiziert: flutter analyze (0 issues), flutter test (64/64, davon 4 neue NominatimService-Tests und 1 neuer Widget-Test fuer den Missions-Reset bei Ortssuche), manuell auf Pixel_10a-Emulator - Suche nach "Rotterdam" setzt Fusszeile auf "Mission: Rotterdam" mit 0 Wegpunkten trotz zuvor bestehender Mission mit Wegpunkten. Co-Authored-By: Claude Sonnet 5 --- .../services/geocoding/nominatim_service.dart | 68 ++++++++++++++++++ app/lib/ui/providers/geocoding_provider.dart | 7 ++ .../ui/providers/mission_meta_provider.dart | 12 +++- app/lib/ui/widgets/map_search_controls.dart | 33 ++++----- app/lib/ui/widgets/top_mode_bar.dart | 2 +- app/test/services/nominatim_service_test.dart | 71 +++++++++++++++++++ app/test/widget_test.dart | 62 ++++++++++++++++ 7 files changed, 234 insertions(+), 21 deletions(-) create mode 100644 app/lib/services/geocoding/nominatim_service.dart create mode 100644 app/lib/ui/providers/geocoding_provider.dart create mode 100644 app/test/services/nominatim_service_test.dart diff --git a/app/lib/services/geocoding/nominatim_service.dart b/app/lib/services/geocoding/nominatim_service.dart new file mode 100644 index 0000000..46b3dc8 --- /dev/null +++ b/app/lib/services/geocoding/nominatim_service.dart @@ -0,0 +1,68 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// Ergebnis einer Ortssuche: Koordinaten plus ein kompakter Anzeigename, +/// der auch als Missionsname dient (siehe MapSearchControls). +class GeocodingResult { + const GeocodingResult({ + required this.label, + required this.lat, + required this.lon, + }); + + final String label; + final double lat; + final double lon; +} + +/// Geocoding ueber Nominatim (OpenStreetMap) - analog doSearch() im HTML- +/// Demonstrator. Injectable http.Client analog WindService, damit dies in +/// Tests ohne echten Netzwerkzugriff ueberschrieben werden kann. +class NominatimService { + NominatimService({http.Client? client}) : _client = client ?? http.Client(); + + final http.Client _client; + + Future search(String query) async { + final uri = Uri.https('nominatim.openstreetmap.org', '/search', { + 'format': 'json', + 'addressdetails': '1', + 'accept-language': 'en', + 'limit': '1', + 'q': query, + }); + final response = await _client + .get(uri, headers: {'User-Agent': 'dmc_app (Drone Mission Control)'}); + if (response.statusCode != 200) return null; + + final results = jsonDecode(response.body) as List; + if (results.isEmpty) return null; + + final first = results.first as Map; + return GeocodingResult( + label: _extractPlaceLabel(first), + lat: double.parse(first['lat'] as String), + lon: double.parse(first['lon'] as String), + ); + } + + /// Analog extractPlaceLabel() im HTML-Demonstrator: bevorzugt einen + /// kompakten Ortsnamen aus den Nominatim-Adressbestandteilen, faellt sonst + /// auf den ersten Abschnitt von display_name zurueck. + String _extractPlaceLabel(Map result) { + final address = result['address'] as Map?; + final label = address?['city'] ?? + address?['town'] ?? + address?['village'] ?? + address?['municipality'] ?? + address?['suburb'] ?? + address?['county']; + if (label is String && label.isNotEmpty) return label; + final displayName = result['display_name'] as String?; + if (displayName != null && displayName.isNotEmpty) { + return displayName.split(',').first; + } + return 'Unknown location'; + } +} diff --git a/app/lib/ui/providers/geocoding_provider.dart b/app/lib/ui/providers/geocoding_provider.dart new file mode 100644 index 0000000..905c907 --- /dev/null +++ b/app/lib/ui/providers/geocoding_provider.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../services/geocoding/nominatim_service.dart'; + +final nominatimServiceProvider = Provider( + (ref) => NominatimService(), +); diff --git a/app/lib/ui/providers/mission_meta_provider.dart b/app/lib/ui/providers/mission_meta_provider.dart index c83b286..c0f8d55 100644 --- a/app/lib/ui/providers/mission_meta_provider.dart +++ b/app/lib/ui/providers/mission_meta_provider.dart @@ -59,12 +59,20 @@ class CurrentMissionMetaNotifier extends Notifier { /// Analog startNewMission() im HTML-Demonstrator: sichert zuerst noch /// ausstehende Aenderungen der bisherigen Mission, dann leerer Neustart. - Future startNew() async { + Future startNew() => _startNew(const MissionMeta()); + + /// Analog setMissionFromPlace() im HTML-Demonstrator: eine Ortssuche (oder + /// GPS-Ortung) beginnt immer eine neue, leere Mission, benannt nach dem + /// gefundenen Ort - nicht nur ein Kartensprung. + Future startNewFromPlace(String label) => + _startNew(MissionMeta(name: label, placeName: label)); + + Future _startNew(MissionMeta newState) async { await _flushNow(); _suppressAutosave = true; ref.read(currentMissionProvider.notifier).replaceAll(const []); _suppressAutosave = false; - state = const MissionMeta(); + state = newState; await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(null); } diff --git a/app/lib/ui/widgets/map_search_controls.dart b/app/lib/ui/widgets/map_search_controls.dart index f344cf8..8a68e35 100644 --- a/app/lib/ui/widgets/map_search_controls.dart +++ b/app/lib/ui/widgets/map_search_controls.dart @@ -1,13 +1,13 @@ -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/geocoding_provider.dart'; import '../providers/map_controller_provider.dart'; +import '../providers/mission_meta_provider.dart'; +import '../providers/wind_provider.dart'; /// Ortssuche (Nominatim) + Home-/Fit-Buttons, eingebettet als Mittelteil /// der Plan/Fly-Kopfleiste (HTML-Demonstrator: #searchWrap/#homeBtn/ @@ -40,24 +40,21 @@ class _MapSearchControlsState extends ConsumerState { 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) { + final result = await ref.read(nominatimServiceProvider).search(query); + if (result == null) { 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); + ref.read(mapControllerProvider).move(LatLng(result.lat, result.lon), 15); + + // Eine Ortssuche beginnt immer eine neue, nach dem Ort benannte + // Mission statt nur die Karte zu verschieben (HTML-Demonstrator: + // setMissionFromPlace(), aufgerufen aus doSearch()) - vorherige + // Aenderungen werden dabei zuerst autosaved, gehen also nicht verloren. + await ref + .read(currentMissionMetaProvider.notifier) + .startNewFromPlace(result.label); + await ref.read(headerWindProvider.notifier).refresh(); } catch (_) { messenger.showSnackBar(const SnackBar(content: Text('Search failed'))); } diff --git a/app/lib/ui/widgets/top_mode_bar.dart b/app/lib/ui/widgets/top_mode_bar.dart index cb1586c..2d19d5e 100644 --- a/app/lib/ui/widgets/top_mode_bar.dart +++ b/app/lib/ui/widgets/top_mode_bar.dart @@ -27,7 +27,7 @@ class TopModeBar extends StatelessWidget { return SafeArea( bottom: false, child: FractionallySizedBox( - widthFactor: isFly ? 0.64 : 0.86, + widthFactor: 0.70, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( diff --git a/app/test/services/nominatim_service_test.dart b/app/test/services/nominatim_service_test.dart new file mode 100644 index 0000000..00ee22c --- /dev/null +++ b/app/test/services/nominatim_service_test.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:dmc_app/services/geocoding/nominatim_service.dart'; + +http.Response _json(Object body) => + http.Response(jsonEncode(body), 200, headers: { + 'content-type': 'application/json', + }); + +void main() { + group('NominatimService.search', () { + test('bevorzugt city aus address gegenueber display_name', () async { + final client = MockClient((request) async { + expect(request.url.queryParameters['q'], 'Amsterdam'); + return _json([ + { + 'lat': '52.37403', + 'lon': '4.88969', + 'display_name': 'Amsterdam, Noord-Holland, Netherlands', + 'address': {'city': 'Amsterdam', 'county': 'Noord-Holland'}, + }, + ]); + }); + final service = NominatimService(client: client); + + final result = await service.search('Amsterdam'); + + expect(result, isNotNull); + expect(result!.label, 'Amsterdam'); + expect(result.lat, closeTo(52.37403, 0.00001)); + expect(result.lon, closeTo(4.88969, 0.00001)); + }); + + test('faellt ohne strukturierte Adresse auf display_name zurueck', + () async { + final client = MockClient((request) async { + return _json([ + { + 'lat': '1.0', + 'lon': '2.0', + 'display_name': 'Somewhere Remote, Some Region', + 'address': {}, + }, + ]); + }); + final service = NominatimService(client: client); + + final result = await service.search('remote place'); + + expect(result!.label, 'Somewhere Remote'); + }); + + test('liefert null bei leeren Ergebnissen', () async { + final client = MockClient((request) async => _json([])); + final service = NominatimService(client: client); + + expect(await service.search('nowhere'), isNull); + }); + + test('liefert null bei fehlgeschlagenem Request', () async { + final client = MockClient((request) async => http.Response('', 500)); + final service = NominatimService(client: client); + + expect(await service.search('anywhere'), isNull); + }); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index b3b7478..6961633 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -1,16 +1,23 @@ +import 'dart:convert'; + import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:dmc_app/app_mode/app_mode_cubit.dart'; import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart'; import 'package:dmc_app/services/database/app_database.dart' hide DroneProfile, Mission; +import 'package:dmc_app/services/geocoding/nominatim_service.dart'; import 'package:dmc_app/ui/app_shell.dart'; import 'package:dmc_app/ui/providers/current_mission_provider.dart'; import 'package:dmc_app/ui/providers/database_provider.dart'; +import 'package:dmc_app/ui/providers/geocoding_provider.dart'; +import 'package:dmc_app/ui/providers/mission_meta_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/header_wind_pill.dart'; @@ -442,6 +449,61 @@ void main() { expect(find.text('Location not found'), findsNothing); }); + testWidgets( + 'Ortssuche legt eine neue, nach dem Ort benannte Mission an', + (tester) async { + final client = MockClient((request) async => http.Response( + jsonEncode([ + { + 'lat': '51.9225', + 'lon': '4.47917', + 'display_name': 'Rotterdam, South Holland, Netherlands', + 'address': {'city': 'Rotterdam'}, + }, + ]), + 200, + )); + final container = ProviderContainer( + overrides: [ + appDatabaseProvider.overrideWithValue(_testDatabase()), + nominatimServiceProvider + .overrideWithValue(NominatimService(client: client)), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider( + create: (_) => AppModeCubit(), + child: const MaterialApp(home: AppShell()), + ), + ), + ); + await tester.pump(); + + // Bestehenden Wegpunkt anlegen, damit sich pruefen laesst, dass die + // Suche die Mission wirklich neu beginnt statt nur die Karte zu + // verschieben (HTML-Demonstrator: setMissionFromPlace()). + await tester.tap(find.byType(ReticleButton)); + await tester.pump(); + expect(container.read(currentMissionProvider), hasLength(1)); + + await tester.enterText(find.byType(TextField), 'Rotterdam'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + await tester.pump(); + + expect(container.read(currentMissionMetaProvider).name, 'Rotterdam'); + expect(container.read(currentMissionMetaProvider).placeName, 'Rotterdam'); + expect(container.read(currentMissionProvider), isEmpty); + + // Autosave debounced ueber 800ms - abwarten, damit nach Testende kein + // Timer mehr aussteht (Doku 7.5). + await tester.pump(const Duration(milliseconds: 900)); + }); + testWidgets( 'Kameraposition bleibt beim Wechsel Plan -> Fly -> Plan erhalten', (tester) async {