Kartendrehung (Doku 4.6): flutter_map's Zwei-Finger-Twist-Geste bewusst
deaktiviert (InteractionOptions mit InteractiveFlag.all & ~rotate in
mission_map.dart) - die Drohne fliegt nordorientiert, eine gedrehte Karte
wuerde die Peilung von Wegpunkten/Reticle nur verwirren, zumal der HTML-
Demonstrator gar keine Drehung kennt.
Warnungsliste (Doku 3.10), analog dem #warningsBtn/#warningsPanel-Flow im
HTML-Demonstrator:
- domain/mission/mission_warnings.dart: computeMissionWarnings() sammelt
alle aktuell zutreffenden Probleme (Route-Geometrie ausserhalb der
Drohnenfaehigkeiten, zu starker Wind, zu geringer Bodenabstand,
Reichweite/Ausdauer ueberschritten) zu einer Liste aus {text, action}-
Eintraegen - reine Funktion aus bereits vorhandenen Zwischenergebnissen,
unabhaengig von Riverpod/Widgets testbar.
- domain/wind/wind_math.dart: computeLegWindBad() ergaenzt (Bodenge-
schwindigkeit <= 1 m/s oder Windgeschwindigkeit > Drohnen-Maximum) -
bislang gab es nur die Windkomponente/Bodengeschwindigkeit selbst, aber
keine "zu stark"-Markierung.
- ui/widgets/warnings_panel.dart: Vollbild-Liste, jede Zeile verweist per
Aktionslabel auf den Loesungs-Screen ("Open altitude view"/"Open speed
view"/"Show on map").
- ui/widgets/bottom_stats_bar.dart: roter Warnungs-Button neben den
Mission-/Drohnen-Chips, nur sichtbar solange Warnungen aktiv sind.
- ui/widgets/waypoint_list_panel.dart: `_PanelTab` zu `PanelTab` public
gemacht plus neuer `initialTab`-Parameter, damit das Panel direkt auf
dem Altitude- oder Speed-Tab geoeffnet werden kann statt immer auf der
Liste zu starten.
- ui/providers/map_controller_provider.dart: fitMapToWaypoints()-Hilfs-
funktion extrahiert und in MapSearchControls' Fit-Button (vorher eigene
Kopie der Logik) sowie fuer die "Show on map"-Warnungsaktion
wiederverwendet.
- ui/screens/plan/plan_screen.dart: berechnet die Warnungsliste, ersetzt
den bisherigen Einzel-Banner (nur "Route exceeds...") durch alle
aktuellen Warnungstexte (durch " · " getrennt, wie im HTML-Demonstrator)
und verdrahtet Warnungs-Button/-Liste mit der passenden Navigation.
Getestet: 4 neue Unit-Tests fuer computeLegWindBad, 7 fuer
computeMissionWarnings (inkl. Grenzfaelle: veraltetes Gelaendeprofil,
kein Limit bei maxRangeM/maxEnduranceMin <= 0), 2 neue Widget-Tests
(Warnungs-Button erscheint bei Reichweiten-Ueberschreitung und oeffnet
die Liste; Antippen einer Wind-Warnung oeffnet den Speed-Tab direkt).
Alle 110 Tests sowie flutter analyze bestehen. Manuell auf dem
Pixel_10a-Emulator verifiziert: Steigraten-Ueberschreitung (Altitude auf
220m bei kurzer Distanz) macht Route/Banner/Warnungs-Button rot,
Warnungsliste zeigt den Eintrag mit "Show on map", Antippen schliesst
die Liste und zoomt die Karte korrekt auf die Bounding-Box beider
Wegpunkte.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
156 lines
5.3 KiB
Dart
156 lines
5.3 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';
|
|
|
|
/// 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(
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
}
|