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>
163 lines
5.5 KiB
Dart
163 lines
5.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
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/
|
|
/// #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 result = await ref.read(nominatimServiceProvider).search(query);
|
|
if (result == null) {
|
|
messenger.showSnackBar(const SnackBar(content: Text('Location not found')));
|
|
return;
|
|
}
|
|
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')));
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
),
|
|
);
|
|
}
|
|
}
|