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>
153 lines
5.7 KiB
Dart
153 lines
5.7 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../services/wind/wind_service.dart';
|
|
import '../providers/wind_provider.dart';
|
|
|
|
/// Zeigt die Rohdaten hinter dem interpolierten Wind an einem Ort:
|
|
/// gemessene Werte je fester Nabenhoehe (inkl. Boeen) und je Druckflaeche,
|
|
/// sowie das daraus interpolierte Ergebnis auf 120 m AGL (Doku 3.8/4.9:
|
|
/// Boeen nur hier, nicht in der Wegpunktanzeige). Fullscreen-Route wie
|
|
/// WaypointListPanel (HTML-Demonstrator: #windValidatePanel, ebenfalls
|
|
/// .panel).
|
|
class WindValidatePanel extends ConsumerStatefulWidget {
|
|
const WindValidatePanel({super.key, required this.lat, required this.lon});
|
|
|
|
final double lat;
|
|
final double lon;
|
|
|
|
@override
|
|
ConsumerState<WindValidatePanel> createState() => _WindValidatePanelState();
|
|
}
|
|
|
|
class _WindValidatePanelState extends ConsumerState<WindValidatePanel> {
|
|
late final Future<WindValidationResult> _future;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_future = ref
|
|
.read(windServiceProvider)
|
|
.fetchValidation(widget.lat, widget.lon);
|
|
}
|
|
|
|
static const _labelStyle = TextStyle(color: Colors.white, fontSize: 14, height: 1.6);
|
|
static const _errorStyle = TextStyle(color: Color(0xFFFF8A7A), fontSize: 14, height: 1.6);
|
|
static const _excludedStyle = TextStyle(
|
|
color: Colors.white38,
|
|
fontSize: 14,
|
|
height: 1.6,
|
|
decoration: TextDecoration.lineThrough,
|
|
);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: const Color(0xEB080808),
|
|
child: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
|
|
child: Row(
|
|
children: [
|
|
const Expanded(
|
|
child: Text(
|
|
'Wind Validation',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
icon: const Icon(Icons.close, color: Colors.white),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(color: Colors.white24, height: 1),
|
|
Expanded(
|
|
child: FutureBuilder<WindValidationResult>(
|
|
future: _future,
|
|
builder: (context, snapshot) {
|
|
if (!snapshot.hasData) {
|
|
return const Center(
|
|
child: CircularProgressIndicator(color: Color(0xFF4D9DFF)),
|
|
);
|
|
}
|
|
return _body(snapshot.data!);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _body(WindValidationResult result) {
|
|
return ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
Text(
|
|
'Location: ${result.lat.toStringAsFixed(5)}, ${result.lon.toStringAsFixed(5)} '
|
|
'· ground elevation ${result.elevationM.round()} m ASL',
|
|
style: _labelStyle,
|
|
),
|
|
const SizedBox(height: 12),
|
|
const Text('Fixed hub heights', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 15)),
|
|
const SizedBox(height: 4),
|
|
if (result.fixedRequestFailed)
|
|
const Text('Request failed', style: _errorStyle)
|
|
else ...[
|
|
for (final r in result.fixedReadings)
|
|
Text(
|
|
'${r.heightAglM} m AGL: '
|
|
'${r.speedMs != null ? '${r.speedMs!.toStringAsFixed(1)} m/s' : 'n/a'}'
|
|
'${r.dirFromDeg != null ? ', ${r.dirFromDeg!.round()}°' : ''}',
|
|
style: _labelStyle,
|
|
),
|
|
Text(
|
|
'Gusts (10 m AGL): ${result.gustMs != null ? '${result.gustMs!.toStringAsFixed(1)} m/s' : 'n/a'}',
|
|
style: _labelStyle,
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
const Text('Pressure levels', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 15)),
|
|
const Text(
|
|
'discarded if below ground or incomplete',
|
|
style: TextStyle(color: Colors.white38, fontSize: 12),
|
|
),
|
|
const SizedBox(height: 4),
|
|
if (result.pressureRequestFailed)
|
|
const Text('Request failed', style: _errorStyle)
|
|
else
|
|
for (final r in result.pressureReadings)
|
|
Text(
|
|
'${r.pressureHpa} hPa: '
|
|
'${r.heightAmslM != null ? '${r.heightAmslM!.round()} m ASL' : '?'}, '
|
|
'${r.speedMs != null ? '${r.speedMs!.toStringAsFixed(1)} m/s' : 'n/a'}'
|
|
'${r.excluded ? (r.heightAmslM != null && r.heightAmslM! < result.elevationM ? ' (below ground, excluded)' : ' (incomplete, excluded)') : ''}',
|
|
style: r.excluded ? _excludedStyle : _labelStyle,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
padding: const EdgeInsets.only(top: 12),
|
|
decoration: const BoxDecoration(
|
|
border: Border(top: BorderSide(color: Colors.white24)),
|
|
),
|
|
child: Text(
|
|
'Interpolated & referenced at 120 m AGL '
|
|
'(= ${(result.elevationM + 120).round()} m ASL, using pressure levels + fixed heights combined): '
|
|
'${result.interpolatedAt120 != null ? '${result.interpolatedAt120!.speedMs.toStringAsFixed(1)} m/s, ${result.interpolatedAt120!.dirFromDeg.round()}°' : 'n/a (missing data at boundary level)'}',
|
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14, height: 1.5),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|