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:
co-authored by
Claude Sonnet 5
parent
7a26002a63
commit
1b13c45cac
@@ -27,6 +27,8 @@ class FullValueChart extends StatefulWidget {
|
||||
required this.pxPerUnit,
|
||||
required this.unit,
|
||||
required this.onChanged,
|
||||
this.legWind,
|
||||
this.groundSpeeds,
|
||||
});
|
||||
|
||||
final List<Waypoint> waypoints;
|
||||
@@ -41,6 +43,14 @@ class FullValueChart extends StatefulWidget {
|
||||
final String unit;
|
||||
final void Function(int index, double value) onChanged;
|
||||
|
||||
/// Kopf-/Rueckenwind-Komponente pro Leg (nur Speed-Tab, Doku 3.8) -
|
||||
/// Laenge waypoints.length - 1, null wo noch kein Wind geladen ist.
|
||||
final List<double?>? legWind;
|
||||
|
||||
/// Erreichbare Bodengeschwindigkeit pro Leg (nur Speed-Tab), gleiche
|
||||
/// Laenge/Null-Semantik wie [legWind].
|
||||
final List<double?>? groundSpeeds;
|
||||
|
||||
static const _distance = Distance(roundResult: false);
|
||||
|
||||
static List<double> cumulativeDistances(List<Waypoint> waypoints) {
|
||||
@@ -66,9 +76,14 @@ class _FullValueChartState extends State<FullValueChart> {
|
||||
static const _hitToleranceX = 28.0;
|
||||
|
||||
({double lo, double hi}) _range() {
|
||||
if (widget.values.isEmpty) return (lo: widget.min, hi: widget.max);
|
||||
final dataMin = widget.values.reduce(math.min);
|
||||
final dataMax = widget.values.reduce(math.max);
|
||||
final allVals = <double>[
|
||||
...widget.values,
|
||||
for (final v in widget.legWind ?? const <double?>[]) ?v,
|
||||
for (final v in widget.groundSpeeds ?? const <double?>[]) ?v,
|
||||
];
|
||||
if (allVals.isEmpty) return (lo: widget.min, hi: widget.max);
|
||||
final dataMin = allVals.reduce(math.min);
|
||||
final dataMax = allVals.reduce(math.max);
|
||||
final span = dataMax - dataMin;
|
||||
final pad = math.max(span * 0.35, widget.step * 2);
|
||||
var lo = dataMin - pad;
|
||||
@@ -79,8 +94,11 @@ class _FullValueChartState extends State<FullValueChart> {
|
||||
lo = mid - minSpan / 2;
|
||||
hi = mid + minSpan / 2;
|
||||
}
|
||||
if (lo < widget.min) lo = widget.min;
|
||||
if (hi > widget.max) hi = widget.max;
|
||||
// Nicht auf widget.min/max (gueltiger Drohnen-Geschwindigkeitsbereich)
|
||||
// klemmen: Windkomponenten/Bodengeschwindigkeit (Doku 3.8) sind keine
|
||||
// gueltigen Soll-Geschwindigkeiten und duerfen den sichtbaren Bereich
|
||||
// durchaus unter min bzw. ueber max ziehen (HTML-Demonstrator:
|
||||
// computeSpeedChartRange() klemmt ebenfalls nicht auf SPD_MIN/SPD_MAX).
|
||||
if (hi <= lo) {
|
||||
lo = widget.min;
|
||||
hi = widget.max;
|
||||
@@ -154,6 +172,8 @@ class _FullValueChartState extends State<FullValueChart> {
|
||||
lo: range.lo,
|
||||
hi: range.hi,
|
||||
unit: widget.unit,
|
||||
legWind: widget.legWind,
|
||||
groundSpeeds: widget.groundSpeeds,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -173,6 +193,8 @@ class _ChartPainter extends CustomPainter {
|
||||
required this.lo,
|
||||
required this.hi,
|
||||
required this.unit,
|
||||
this.legWind,
|
||||
this.groundSpeeds,
|
||||
});
|
||||
|
||||
final List<Waypoint> waypoints;
|
||||
@@ -184,6 +206,8 @@ class _ChartPainter extends CustomPainter {
|
||||
final double lo;
|
||||
final double hi;
|
||||
final String unit;
|
||||
final List<double?>? legWind;
|
||||
final List<double?>? groundSpeeds;
|
||||
|
||||
static const _padX = 50.0;
|
||||
static const _padY = 20.0;
|
||||
@@ -238,6 +262,8 @@ class _ChartPainter extends CustomPainter {
|
||||
v += step;
|
||||
}
|
||||
|
||||
_paintWindAndGroundSpeed(canvas, boxW, boxH);
|
||||
|
||||
// Verbindungslinien (rot bei nicht fliegbarer Steig-/Sinkrate).
|
||||
for (var i = 0; i < n - 1; i++) {
|
||||
final bad = i < legBad.length && legBad[i];
|
||||
@@ -269,10 +295,136 @@ class _ChartPainter extends CustomPainter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wind-Diamanten je Leg (Kopf-/Rueckenwind-Komponente) und die daraus
|
||||
/// erreichbare Bodengeschwindigkeit als gestrichelte Stufenlinie - nur auf
|
||||
/// dem Speed-Tab genutzt (Doku 3.8, windHtml/groundSpeedHtml im HTML-
|
||||
/// Demonstrator). Vor den eigentlichen Soll-Werte-Punkten gezeichnet,
|
||||
/// damit diese immer obenauf liegen.
|
||||
void _paintWindAndGroundSpeed(Canvas canvas, double boxW, double boxH) {
|
||||
final wind = legWind;
|
||||
if (wind != null && wind.any((v) => v != null)) {
|
||||
final wZeroY = _yAt(0, boxH);
|
||||
_drawDashedLine(
|
||||
canvas,
|
||||
Offset(_padX, wZeroY),
|
||||
Offset(boxW - _padX, wZeroY),
|
||||
Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.4)
|
||||
..strokeWidth = 1.5,
|
||||
dashWidth: 3,
|
||||
gapWidth: 4,
|
||||
);
|
||||
final zeroLabel = TextPainter(
|
||||
text: TextSpan(
|
||||
text: '0 (no wind effect on track)',
|
||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.55), fontSize: 10),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
zeroLabel.paint(canvas, Offset(_padX, wZeroY - 5 - zeroLabel.height));
|
||||
|
||||
final windPts = <Offset>[];
|
||||
for (var i = 0; i < wind.length; i++) {
|
||||
final w = wind[i];
|
||||
if (w == null) continue;
|
||||
final mx = (_xAt(i, boxW) + _xAt(i + 1, boxW)) / 2;
|
||||
final my = _yAt(w, boxH);
|
||||
windPts.add(Offset(mx, my));
|
||||
|
||||
final isTail = w >= 0;
|
||||
final color = isTail ? const Color(0xFF3ECF8E) : const Color(0xFFE2574A);
|
||||
final diamond = Path()
|
||||
..moveTo(mx, my - 7)
|
||||
..lineTo(mx + 7, my)
|
||||
..lineTo(mx, my + 7)
|
||||
..lineTo(mx - 7, my)
|
||||
..close();
|
||||
canvas.drawPath(diamond, Paint()..color = color);
|
||||
canvas.drawPath(
|
||||
diamond,
|
||||
Paint()
|
||||
..color = const Color(0xFF0C0C0C)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1,
|
||||
);
|
||||
|
||||
final valueLabel = TextPainter(
|
||||
text: TextSpan(
|
||||
text: '${isTail ? '+' : ''}${w.toStringAsFixed(1)}',
|
||||
style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w600),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
valueLabel.paint(
|
||||
canvas,
|
||||
Offset(mx - valueLabel.width / 2, my - 12 - valueLabel.height),
|
||||
);
|
||||
}
|
||||
|
||||
final connectPaint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.25)
|
||||
..strokeWidth = 1;
|
||||
for (var i = 0; i < windPts.length - 1; i++) {
|
||||
_drawDashedLine(canvas, windPts[i], windPts[i + 1], connectPaint,
|
||||
dashWidth: 2, gapWidth: 3);
|
||||
}
|
||||
}
|
||||
|
||||
final ground = groundSpeeds;
|
||||
if (ground != null) {
|
||||
double? prevLevelY;
|
||||
final groundPaint = Paint()
|
||||
..color = const Color(0xFF94BBE9).withValues(alpha: 0.55)
|
||||
..strokeWidth = 1.5;
|
||||
for (var i = 0; i < ground.length; i++) {
|
||||
final g = ground[i];
|
||||
if (g == null) {
|
||||
prevLevelY = null;
|
||||
continue;
|
||||
}
|
||||
final gy = _yAt(g, boxH);
|
||||
final gx0 = _xAt(i, boxW);
|
||||
final gx1 = _xAt(i + 1, boxW);
|
||||
_drawDashedLine(canvas, Offset(gx0, gy), Offset(gx1, gy), groundPaint,
|
||||
dashWidth: 5, gapWidth: 3);
|
||||
if (prevLevelY != null) {
|
||||
_drawDashedLine(canvas, Offset(gx0, prevLevelY), Offset(gx0, gy),
|
||||
groundPaint, dashWidth: 5, gapWidth: 3);
|
||||
}
|
||||
prevLevelY = gy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawDashedLine(
|
||||
Canvas canvas,
|
||||
Offset p1,
|
||||
Offset p2,
|
||||
Paint paint, {
|
||||
double dashWidth = 4,
|
||||
double gapWidth = 3,
|
||||
}) {
|
||||
final total = (p2 - p1).distance;
|
||||
if (total == 0) return;
|
||||
final direction = (p2 - p1) / total;
|
||||
var traveled = 0.0;
|
||||
while (traveled < total) {
|
||||
final segEnd = math.min(traveled + dashWidth, total);
|
||||
canvas.drawLine(
|
||||
p1 + direction * traveled,
|
||||
p1 + direction * segEnd,
|
||||
paint,
|
||||
);
|
||||
traveled += dashWidth + gapWidth;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ChartPainter oldDelegate) =>
|
||||
oldDelegate.values != values ||
|
||||
oldDelegate.activeIndex != activeIndex ||
|
||||
oldDelegate.lo != lo ||
|
||||
oldDelegate.hi != hi;
|
||||
oldDelegate.hi != hi ||
|
||||
oldDelegate.legWind != legWind ||
|
||||
oldDelegate.groundSpeeds != groundSpeeds;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/wind_provider.dart';
|
||||
import 'wind_arrow_icon.dart';
|
||||
|
||||
/// Kopfleisten-Pille mit Windrichtung/-geschwindigkeit auf 120 m AGL ueber
|
||||
/// dem Referenzort (Doku 3.8). Antippen schaltet die Wind-Marker an jedem
|
||||
/// Wegpunkt ein/aus (HTML-Demonstrator: showPerWaypointWind).
|
||||
class HeaderWindPill extends ConsumerWidget {
|
||||
const HeaderWindPill({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(headerWindProvider);
|
||||
final sample = state.sample;
|
||||
final active = state.showPerWaypoint;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => ref.read(headerWindProvider.notifier).toggleShowPerWaypoint(),
|
||||
child: Container(
|
||||
height: 34,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? const Color(0xFF4D9DFF).withValues(alpha: 0.32)
|
||||
: Colors.black.withValues(alpha: 0.55),
|
||||
borderRadius: BorderRadius.circular(17),
|
||||
border: Border.all(
|
||||
color: active
|
||||
? const Color(0xFF4D9DFF)
|
||||
: Colors.white.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (sample != null) ...[
|
||||
WindArrowIcon(dirFromDeg: sample.dirFromDeg, size: 15),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
sample != null ? '${sample.speedMs.round()} m/s' : '—',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../app_mode/app_mode_cubit.dart';
|
||||
import '../dmc_colors.dart';
|
||||
import 'header_wind_pill.dart';
|
||||
import 'map_search_controls.dart';
|
||||
|
||||
/// Persistente Kopfleiste zum Umschalten zwischen Plan- und Fly-Modus
|
||||
@@ -10,11 +11,11 @@ import 'map_search_controls.dart';
|
||||
/// faerbt sich in der Farbe des aktiven Modus; der jeweils andere Modus wird
|
||||
/// als solider Button am Rand angeboten, um in ihn zu wechseln.
|
||||
///
|
||||
/// Traegt ausserhalb des Fly-Modus zusaetzlich die Ortssuche und die
|
||||
/// Home-/Fit-Buttons in derselben Pille (HTML-Demonstrator: #searchWrap/
|
||||
/// #homeBtn/#fitBtn sitzen alle in #topBarMiddle, nicht in einer separaten
|
||||
/// Leiste) - dafuer lebt der MapController im mapControllerProvider statt
|
||||
/// als PlanScreen-privates Feld, siehe dort.
|
||||
/// Traegt ausserhalb des Fly-Modus zusaetzlich die Wind-Pille, Ortssuche und
|
||||
/// die Home-/Fit-Buttons in derselben Pille (HTML-Demonstrator: #headerWindPill/
|
||||
/// #searchWrap/#homeBtn/#fitBtn sitzen alle in #topBarMiddle, nicht in einer
|
||||
/// separaten Leiste) - dafuer lebt der MapController im mapControllerProvider
|
||||
/// statt als PlanScreen-privates Feld, siehe dort.
|
||||
class TopModeBar extends StatelessWidget {
|
||||
const TopModeBar({super.key});
|
||||
|
||||
@@ -50,7 +51,9 @@ class TopModeBar extends StatelessWidget {
|
||||
if (isFly)
|
||||
const Spacer()
|
||||
else ...[
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 8),
|
||||
const HeaderWindPill(),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: MapSearchControls()),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Windpfeil, der in die Richtung zeigt, in die der Wind weht (nicht woher
|
||||
/// er kommt) - analog windArrowSvg() im HTML-Demonstrator, dort ebenfalls
|
||||
/// um (dirFrom + 180) Grad gedreht.
|
||||
class WindArrowIcon extends StatelessWidget {
|
||||
const WindArrowIcon({
|
||||
super.key,
|
||||
required this.dirFromDeg,
|
||||
this.size = 14,
|
||||
this.color = Colors.white,
|
||||
});
|
||||
|
||||
final double dirFromDeg;
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final towardDeg = (dirFromDeg + 180) % 360;
|
||||
return Transform.rotate(
|
||||
angle: towardDeg * math.pi / 180,
|
||||
child: Icon(Icons.navigation, size: size, color: color),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user