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 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-07-28 15:22:36 +02:00
co-authored by Claude Sonnet 5
parent aaec7f0f72
commit 926d805156
7 changed files with 234 additions and 21 deletions
@@ -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<GeocodingResult?> 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<String, dynamic>;
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<String, dynamic> result) {
final address = result['address'] as Map<String, dynamic>?;
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';
}
}
@@ -0,0 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../services/geocoding/nominatim_service.dart';
final nominatimServiceProvider = Provider<NominatimService>(
(ref) => NominatimService(),
);
@@ -59,12 +59,20 @@ class CurrentMissionMetaNotifier extends Notifier<MissionMeta> {
/// Analog startNewMission() im HTML-Demonstrator: sichert zuerst noch
/// ausstehende Aenderungen der bisherigen Mission, dann leerer Neustart.
Future<void> startNew() async {
Future<void> 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<void> startNewFromPlace(String label) =>
_startNew(MissionMeta(name: label, placeName: label));
Future<void> _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);
}
+15 -18
View File
@@ -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<MapSearchControls> {
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<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);
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')));
}
+1 -1
View File
@@ -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(
@@ -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': <String, dynamic>{},
},
]);
});
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);
});
});
}
+62
View File
@@ -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 {