Add wind analysis: header pill, fetch/validate menu, speed-plot overlay

Portiert das Wind-System des HTML-Demonstrators (Doku 3.8) vollstaendig:
Open-Meteo-Winddaten, interpoliert zwischen Druckflaechen- und festen
Nabenhoehen-Stuetzpunkten auf die tatsaechliche Zielhoehe.

Domain (lib/domain/wind/wind_math.dart, reines Dart): Hoehen-
Interpolation (linear + zirkulaer fuer Windrichtung), Peilung zwischen
zwei Punkten, Kopf-/Rueckenwind-Komponente pro Flugleg - 1:1 uebernommen
aus interpolateWindAtHeight()/bearingBetween()/headTailwindComponent()
des Demonstrators, per Unit-Tests abgesichert.

Service (lib/services/wind/wind_service.dart): zwei getrennte,
fokussierte Open-Meteo-Requests statt eines kombinierten (Doku 4.10 -
ein 35-Variablen-Request verursachte im Demonstrator Modellauswahl-
Fehler). Drei Verbraucher: Kopfleisten-Pille (120 m AGL ueber
Referenzort), gebuendelter Wegpunkt-Fetch (ein Request fuer alle
Koordinaten via Open-Meteos Mehrfach-Location-Syntax) und das
Validierungspanel (rohe Messwerte je Nabenhoehe/Druckflaeche). Per
MockClient getestet (kein echter Netzwerkzugriff in Tests).

UI:
- HeaderWindPill (TopModeBar, nur ausserhalb Fly-Modus): zeigt
  Windrichtung/-geschwindigkeit, Antippen schaltet perspektivisch die
  Wind-Marker der Wegpunkte um (Zustand vorbereitet, HTML-Demonstrator:
  showPerWaypointWind).
- WaypointListPanel: "Fetch wind"/"Validate"-Buttons im Panel-Header,
  neue WIND-Spalte in der Liste.
- WindValidatePanel: neue Vollbild-Route (gleiches Muster wie
  WaypointListPanel) mit Rohdaten je Hoehenstufe inkl. Boeen (Doku 4.9:
  Boeen nur hier, nicht in der Wegpunktanzeige) und Interpolationsergebnis.
- FullValueChart (Speed-Tab): Kopf-/Rueckenwind-Diamanten pro Leg samt
  gestrichelter Nulllinie sowie gestrichelte Bodengeschwindigkeits-
  Stufenlinie.

Bug gefunden und gefixt waehrend der Emulator-Verifikation: die
Y-Achsen-Autoskalierung klemmte weiterhin auf den gueltigen Drehrad-
Wertebereich (13-25 m/s), wodurch nahe Null liegende Windkomponenten
(bei schwachem Wind der Normalfall) ausserhalb des sichtbaren Bereichs
lagen und weder Diamanten noch Nulllinie zu sehen waren. Der HTML-
Demonstrator klemmt computeSpeedChartRange() bewusst nicht auf SPD_MIN/
SPD_MAX; das Klemmen in FullValueChart._range() jetzt entsprechend
entfernt.

Waypoint-Modell um windSpeedMs/windDirFromDeg/windElevationM erweitert
(nullable, reine Planungshilfe - der Encoder liest diese Felder nie,
Doku 3.2). currentMissionProvider bekommt replaceAll() fuer den
gebuendelten Wind-Fetch, der alle Wegpunkte gleichzeitig aktualisiert.

Verifiziert: flutter analyze (0 issues), flutter test (44/44, davon 14
neue Wind-Mathematik- und 6 neue WindService-Tests), manuell auf
Pixel_10a-Emulator mit echtem Open-Meteo-Netzwerkzugriff - Kopfleisten-
Pille zeigt Live-Wind, Fetch/Validate fuellen Liste bzw. oeffnen das
Validierungspanel mit echten Messwerten, Speed-Plot zeigt nach dem Fix
Diamanten/Nulllinie/Bodengeschwindigkeit korrekt skaliert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Constantin Leue
2026-07-28 13:32:15 +02:00
co-authored by Claude Sonnet 5
parent 7a26002a63
commit 1b13c45cac
14 changed files with 1473 additions and 12 deletions
+116
View File
@@ -4,9 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/mission/default_drone_profile.dart';
import '../../domain/mission/route_geometry.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../../domain/wind/wind_math.dart';
import '../providers/current_mission_provider.dart';
import '../providers/wind_provider.dart';
import 'full_value_chart.dart';
import 'waypoint_chip.dart';
import 'wind_arrow_icon.dart';
import 'wind_validate_panel.dart';
enum _PanelTab { list, altitude, speed }
@@ -76,6 +80,39 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
super.dispose();
}
/// Holt Wind fuer alle Wegpunkte gebuendelt (Doku 3.8, HTML-Demonstrator:
/// fetchWindForWaypoints()) und schreibt das Ergebnis komplett zurueck.
Future<void> _onFetchWind() async {
final waypoints = ref.read(currentMissionProvider);
final messenger = ScaffoldMessenger.of(context);
if (waypoints.isEmpty) {
messenger.showSnackBar(
const SnackBar(content: Text('No waypoints to fetch wind for')),
);
return;
}
messenger.showSnackBar(const SnackBar(content: Text('Fetching wind…')));
final updated =
await ref.read(windServiceProvider).fetchWindForWaypoints(waypoints);
ref.read(currentMissionProvider.notifier).replaceAll(updated);
messenger.showSnackBar(const SnackBar(content: Text('Wind data updated')));
}
/// Oeffnet das Wind-Validierungspanel fuer den aktuellen Referenzort
/// (erster Wegpunkt, sonst Kartenzentrum - Doku 3.8).
void _onValidateWind() {
final location = windReferenceLocation(ref);
if (location == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No location available yet')),
);
return;
}
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => WindValidatePanel(lat: location.$1, lon: location.$2),
));
}
/// Zentriert die Liste auf den aktuell aktiven Wegpunkt (Doku 3.6: das
/// Panel oeffnet sich beim Editieren eines Wegpunkts ueber das
/// Halo-Menue - ohne Auto-Scroll waere er bei laengeren Missionen nicht
@@ -123,6 +160,19 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
),
),
),
_HeaderActionButton(
icon: Icons.air,
label: 'Fetch wind',
tint: const Color(0xFF4D9DFF),
onTap: _onFetchWind,
),
const SizedBox(width: 8),
_HeaderActionButton(
icon: Icons.rule,
label: 'Validate',
tint: const Color(0xFFC8A04D),
onTap: _onValidateWind,
),
IconButton(
onPressed: widget.onClose,
icon: const Icon(Icons.close, color: Colors.white),
@@ -162,6 +212,8 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
pxPerUnit: 22,
unit: ' m/s',
onChanged: widget.onSpeedChanged,
legWind: computeLegWind(waypoints),
groundSpeeds: computeGroundSpeeds(waypoints),
),
},
),
@@ -215,6 +267,8 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
required double pxPerUnit,
required String unit,
required void Function(int index, double value) onChanged,
List<double?>? legWind,
List<double?>? groundSpeeds,
}) {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 16, 8, 8),
@@ -230,6 +284,8 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
pxPerUnit: pxPerUnit,
unit: unit,
onChanged: onChanged,
legWind: legWind,
groundSpeeds: groundSpeeds,
),
);
}
@@ -247,6 +303,7 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
Expanded(child: Text('SPEED', style: _headerStyle)),
Expanded(child: Text('EXECUTE', style: _headerStyle)),
Expanded(child: Text('CATCH', style: _headerStyle)),
Expanded(child: Text('WIND', style: _headerStyle)),
SizedBox(width: 76),
],
),
@@ -375,6 +432,19 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
: Text('${wp.catchRadiusM.round()} m',
style: const TextStyle(color: Colors.white)),
),
Expanded(
child: wp.windSpeedMs != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [
WindArrowIcon(dirFromDeg: wp.windDirFromDeg!, size: 13),
const SizedBox(width: 4),
Text('${wp.windSpeedMs!.round()} m/s',
style: const TextStyle(color: Colors.white)),
],
)
: const Text('—', style: TextStyle(color: Colors.white38)),
),
IconButton(
onPressed: () => setState(
() => _rowEditIndex = isEditing ? -1 : index,
@@ -405,3 +475,49 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
_ => '—',
};
}
/// Kleiner getoenter Aktions-Button im Panel-Header (HTML-Demonstrator:
/// #windFetchBtn/#windValidateBtn).
class _HeaderActionButton extends StatelessWidget {
const _HeaderActionButton({
required this.icon,
required this.label,
required this.tint,
required this.onTap,
});
final IconData icon;
final String label;
final Color tint;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: tint.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: tint.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: Colors.white),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}