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:
co-authored by
Claude Sonnet 5
parent
aaec7f0f72
commit
926d805156
@@ -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
|
/// Analog startNewMission() im HTML-Demonstrator: sichert zuerst noch
|
||||||
/// ausstehende Aenderungen der bisherigen Mission, dann leerer Neustart.
|
/// 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();
|
await _flushNow();
|
||||||
_suppressAutosave = true;
|
_suppressAutosave = true;
|
||||||
ref.read(currentMissionProvider.notifier).replaceAll(const []);
|
ref.read(currentMissionProvider.notifier).replaceAll(const []);
|
||||||
_suppressAutosave = false;
|
_suppressAutosave = false;
|
||||||
state = const MissionMeta();
|
state = newState;
|
||||||
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(null);
|
await ref.read(appSettingsRepositoryProvider).setCurrentMissionId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
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 '../providers/current_mission_provider.dart';
|
import '../providers/current_mission_provider.dart';
|
||||||
|
import '../providers/geocoding_provider.dart';
|
||||||
import '../providers/map_controller_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
|
/// Ortssuche (Nominatim) + Home-/Fit-Buttons, eingebettet als Mittelteil
|
||||||
/// der Plan/Fly-Kopfleiste (HTML-Demonstrator: #searchWrap/#homeBtn/
|
/// der Plan/Fly-Kopfleiste (HTML-Demonstrator: #searchWrap/#homeBtn/
|
||||||
@@ -40,24 +40,21 @@ class _MapSearchControlsState extends ConsumerState<MapSearchControls> {
|
|||||||
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
try {
|
try {
|
||||||
final uri = Uri.https('nominatim.openstreetmap.org', '/search', {
|
final result = await ref.read(nominatimServiceProvider).search(query);
|
||||||
'format': 'json',
|
if (result == null) {
|
||||||
'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')));
|
messenger.showSnackBar(const SnackBar(content: Text('Location not found')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final first = results.first as Map<String, dynamic>;
|
ref.read(mapControllerProvider).move(LatLng(result.lat, result.lon), 15);
|
||||||
final lat = double.parse(first['lat'] as String);
|
|
||||||
final lon = double.parse(first['lon'] as String);
|
// Eine Ortssuche beginnt immer eine neue, nach dem Ort benannte
|
||||||
ref.read(mapControllerProvider).move(LatLng(lat, lon), 15);
|
// 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 (_) {
|
} catch (_) {
|
||||||
messenger.showSnackBar(const SnackBar(content: Text('Search failed')));
|
messenger.showSnackBar(const SnackBar(content: Text('Search failed')));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class TopModeBar extends StatelessWidget {
|
|||||||
return SafeArea(
|
return SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: FractionallySizedBox(
|
child: FractionallySizedBox(
|
||||||
widthFactor: isFly ? 0.64 : 0.86,
|
widthFactor: 0.70,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
decoration: BoxDecoration(
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.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:flutter_test/flutter_test.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/app_mode/app_mode_cubit.dart';
|
||||||
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.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/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/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/providers/database_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/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/header_wind_pill.dart';
|
import 'package:dmc_app/ui/widgets/header_wind_pill.dart';
|
||||||
@@ -442,6 +449,61 @@ void main() {
|
|||||||
expect(find.text('Location not found'), findsNothing);
|
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(
|
testWidgets(
|
||||||
'Kameraposition bleibt beim Wechsel Plan -> Fly -> Plan erhalten',
|
'Kameraposition bleibt beim Wechsel Plan -> Fly -> Plan erhalten',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user