Measured the prototype screenshot pixel-for-pixel: content pills take up ~79% of the header's total height, matching its own CSS (height:38px, topBarWrap padding:5px 10px -> 48px total). The Flutter port had drifted to 34px for HeaderWindPill, MapSearchControls, and NavIconButton, while FlightModePill was left at the original 38px - so Fly mode looked inconsistent against itself, and both modes wasted more header height as margin than the prototype does. Bringing all four back to a shared 38px fixes both.
137 lines
5.0 KiB
Dart
137 lines
5.0 KiB
Dart
import 'package:flutter/material.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';
|
|
import 'nav_icon_button.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;
|
|
}
|
|
fitMapToWaypoints(ref, waypoints);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
// 38 statt vorher 34, deckungsgleich mit HeaderWindPill/
|
|
// FlightModePill/NavIconButton (Nutzerwunsch: einheitliche
|
|
// Kopfleisten-Hoehe wie im HTML-Demonstrator vermessen).
|
|
height: 38,
|
|
padding: const EdgeInsets.only(left: 12, right: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.black.withValues(alpha: 0.55),
|
|
borderRadius: BorderRadius.circular(19),
|
|
),
|
|
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),
|
|
],
|
|
);
|
|
}
|
|
}
|