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(