Auto-fetch wind, drop manual fetch/validate buttons, fix it in fly mode
Redesigns the waypoint list panel to match the drone-status/missions panels: no title bar, close button floats over the tab row, tabs get more vertical padding. Removes the "Fetch wind" and "Validate" header buttons along with the now-fully-orphaned WindValidatePanel screen and its WindService support code (fetchValidation, WindValidationResult and friends) - nothing else referenced them. Wind is now fetched automatically instead: HeaderWindNotifier gains ensurePerWaypointWind(), called when the waypoint list panel opens and when the header pill's per-waypoint toggle switches on. A lat/lon signature of the waypoint list tracks whether the fetched data is still current, so a ref.listen(currentMissionProvider) in the same notifier refetches only when the list itself changes (position added/ removed/moved) - not on every altitude/speed edit. Waypoints that already carry wind data (e.g. loaded from a saved mission) short- circuit the check instead of re-fetching pointlessly. Also wires per-waypoint wind markers into the fly-mode map, which never had them (plan_screen.dart built windMarkers; fly_screen.dart just never did) - the reported "doesn't work in fly mode" bug. windMarkerWidth() moved from a plan_screen.dart-private method to wind_marker_pill.dart so both screens can share it. Test containers in widget_test.dart now override windServiceProvider with a MockClient so the new auto-fetch trigger can't reach the real network during tests.
This commit is contained in:
@@ -15,64 +15,6 @@ class WindCombinedLevels {
|
||||
final double elevationM;
|
||||
}
|
||||
|
||||
class WindValidationFixedReading {
|
||||
const WindValidationFixedReading({
|
||||
required this.heightAglM,
|
||||
this.speedMs,
|
||||
this.dirFromDeg,
|
||||
});
|
||||
|
||||
final int heightAglM;
|
||||
final double? speedMs;
|
||||
final double? dirFromDeg;
|
||||
}
|
||||
|
||||
class WindValidationPressureReading {
|
||||
const WindValidationPressureReading({
|
||||
required this.pressureHpa,
|
||||
this.heightAmslM,
|
||||
this.speedMs,
|
||||
this.dirFromDeg,
|
||||
required this.excluded,
|
||||
});
|
||||
|
||||
final int pressureHpa;
|
||||
final double? heightAmslM;
|
||||
final double? speedMs;
|
||||
final double? dirFromDeg;
|
||||
|
||||
/// True, wenn dieser Stuetzpunkt unvollstaendig ist oder unterhalb der
|
||||
/// Bodenhoehe liegt und daher von der Interpolation ausgeschlossen wurde.
|
||||
final bool excluded;
|
||||
}
|
||||
|
||||
/// Ergebnis des Wind-Validierungspanels (Doku 3.8): rohe Messwerte auf
|
||||
/// festen Nabenhoehen und Druckflaechen, damit nachvollziehbar bleibt, wie
|
||||
/// der interpolierte Wert an [interpolatedAt120] zustande kam.
|
||||
class WindValidationResult {
|
||||
const WindValidationResult({
|
||||
required this.lat,
|
||||
required this.lon,
|
||||
required this.elevationM,
|
||||
required this.fixedRequestFailed,
|
||||
required this.fixedReadings,
|
||||
this.gustMs,
|
||||
required this.pressureRequestFailed,
|
||||
required this.pressureReadings,
|
||||
this.interpolatedAt120,
|
||||
});
|
||||
|
||||
final double lat;
|
||||
final double lon;
|
||||
final double elevationM;
|
||||
final bool fixedRequestFailed;
|
||||
final List<WindValidationFixedReading> fixedReadings;
|
||||
final double? gustMs;
|
||||
final bool pressureRequestFailed;
|
||||
final List<WindValidationPressureReading> pressureReadings;
|
||||
final WindSample? interpolatedAt120;
|
||||
}
|
||||
|
||||
/// Holt und kombiniert Winddaten von Open-Meteo (Doku 3.8). Zwei getrennte,
|
||||
/// fokussierte Requests (Druckflaechen + feste Nabenhoehen) statt eines
|
||||
/// kombinierten - ein 35-Variablen-Request verursachte im HTML-Demonstrator
|
||||
@@ -261,98 +203,4 @@ class WindService {
|
||||
windElevationM: combined.elevationM,
|
||||
);
|
||||
}
|
||||
|
||||
/// Detaillierte Rohdaten fuer das Wind-Validierungspanel: einzelne
|
||||
/// Messwerte je Nabenhoehe/Druckflaeche, damit nachvollziehbar bleibt, wie
|
||||
/// der interpolierte 120-m-Wert zustande kam (Doku 3.8/4.9: Boeen nur
|
||||
/// hier, nicht in der Wegpunktanzeige).
|
||||
Future<WindValidationResult> fetchValidation(double lat, double lon) async {
|
||||
final lats = lat.toStringAsFixed(5);
|
||||
final lons = lon.toStringAsFixed(5);
|
||||
final results = await Future.wait([
|
||||
_fetchJson(_fixedUri(lats, lons)),
|
||||
_fetchJson(_pressureUri(lats, lons)),
|
||||
]);
|
||||
final fixedRes = _asResultList(results[0], 1)[0];
|
||||
final pressureRes = _asResultList(results[1], 1)[0];
|
||||
|
||||
final elevation = ((fixedRes?['elevation'] as num?) ??
|
||||
(pressureRes?['elevation'] as num?))
|
||||
?.toDouble() ??
|
||||
0.0;
|
||||
|
||||
var fixedReadings = <WindValidationFixedReading>[];
|
||||
double? gust;
|
||||
final fHourly = fixedRes?['hourly'] as Map<String, dynamic>?;
|
||||
final fTime = fHourly?['time'] as List<dynamic>?;
|
||||
final fixedFailed = fHourly == null || fTime == null || fTime.isEmpty;
|
||||
if (!fixedFailed) {
|
||||
final idx = _nearestHourIndex(fTime);
|
||||
fixedReadings = [
|
||||
for (final h in _fixedHeights)
|
||||
WindValidationFixedReading(
|
||||
heightAglM: h,
|
||||
speedMs: ((fHourly['wind_speed_${h}m'] as List?)?[idx] as num?)
|
||||
?.toDouble(),
|
||||
dirFromDeg: ((fHourly['wind_direction_${h}m'] as List?)?[idx] as num?)
|
||||
?.toDouble(),
|
||||
),
|
||||
];
|
||||
gust = ((fHourly['wind_gusts_10m'] as List?)?[idx] as num?)?.toDouble();
|
||||
}
|
||||
|
||||
var pressureReadings = <WindValidationPressureReading>[];
|
||||
final pHourly = pressureRes?['hourly'] as Map<String, dynamic>?;
|
||||
final pTime = pHourly?['time'] as List<dynamic>?;
|
||||
final pressureFailed = pHourly == null || pTime == null || pTime.isEmpty;
|
||||
if (!pressureFailed) {
|
||||
final idx = _nearestHourIndex(pTime);
|
||||
pressureReadings = [
|
||||
for (final p in _pressureLevels)
|
||||
_pressureReadingAt(pHourly, idx, p, elevation),
|
||||
];
|
||||
}
|
||||
|
||||
final combined = buildCombinedWindLevels(pressureRes, fixedRes);
|
||||
final interpolated = combined.levels.isEmpty
|
||||
? null
|
||||
: interpolateWindAtHeight(combined.levels, elevation + 120);
|
||||
|
||||
return WindValidationResult(
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
elevationM: elevation,
|
||||
fixedRequestFailed: fixedFailed,
|
||||
fixedReadings: fixedReadings,
|
||||
gustMs: gust,
|
||||
pressureRequestFailed: pressureFailed,
|
||||
pressureReadings: pressureReadings,
|
||||
interpolatedAt120: interpolated,
|
||||
);
|
||||
}
|
||||
|
||||
WindValidationPressureReading _pressureReadingAt(
|
||||
Map<String, dynamic> hourly,
|
||||
int idx,
|
||||
int pressureHpa,
|
||||
double elevationM,
|
||||
) {
|
||||
final h = (hourly['geopotential_height_${pressureHpa}hPa'] as List?)?[idx];
|
||||
final s = (hourly['wind_speed_${pressureHpa}hPa'] as List?)?[idx];
|
||||
final d = (hourly['wind_direction_${pressureHpa}hPa'] as List?)?[idx];
|
||||
final heightAmslM = h is num ? h.toDouble() : null;
|
||||
final speedMs = s is num ? s.toDouble() : null;
|
||||
final dirFromDeg = d is num ? d.toDouble() : null;
|
||||
final excluded = heightAmslM == null ||
|
||||
speedMs == null ||
|
||||
dirFromDeg == null ||
|
||||
heightAmslM < elevationM;
|
||||
return WindValidationPressureReading(
|
||||
pressureHpa: pressureHpa,
|
||||
heightAmslM: heightAmslM,
|
||||
speedMs: speedMs,
|
||||
dirFromDeg: dirFromDeg,
|
||||
excluded: excluded,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../domain/waypoint/flat_waypoint_list.dart';
|
||||
import '../../domain/wind/wind_math.dart';
|
||||
import '../../services/wind/wind_service.dart';
|
||||
import 'current_mission_provider.dart';
|
||||
@@ -37,10 +40,38 @@ class HeaderWindState {
|
||||
|
||||
/// Haelt den fuer die Kopfleiste interpolierten Wind (120 m AGL ueber dem
|
||||
/// Referenzort: erster Wegpunkt, sonst aktuelles Kartenzentrum) - analog
|
||||
/// refreshHeaderWind()/headerWindLocation() im HTML-Demonstrator.
|
||||
/// refreshHeaderWind()/headerWindLocation() im HTML-Demonstrator. Verwaltet
|
||||
/// ausserdem den automatischen Pro-Wegpunkt-Windabruf (siehe
|
||||
/// [ensurePerWaypointWind]-Doku) - beides teilt sich denselben
|
||||
/// [windServiceProvider] und lebt bewusst in derselben, nicht-autoDispose
|
||||
/// Notifier-Klasse, damit der Abgleich mit der Wegpunktliste app-weit
|
||||
/// weiterlaeuft, unabhaengig davon, welche der beiden Oberflaechen
|
||||
/// (Wegpunktliste, Kopfleisten-Pille) ihn zuletzt ausgeloest hat.
|
||||
class HeaderWindNotifier extends Notifier<HeaderWindState> {
|
||||
/// Wurde der Pro-Wegpunkt-Windabruf schon mindestens einmal ausgeloest
|
||||
/// (Doku: "automatisch abrufen, wenn das menue geoeffnet wird oder die
|
||||
/// windanzeige ... aktiviert wird")? Erst danach reagiert [build] auf
|
||||
/// spaetere Aenderungen der Wegpunktliste automatisch.
|
||||
bool _perWaypointActivated = false;
|
||||
|
||||
/// Lat/Lon-Signatur der Wegpunktliste, fuer die der zuletzt bekannte
|
||||
/// Windstand gilt (entweder frisch abgerufen oder als bereits vorhanden
|
||||
/// erkannt, siehe [ensurePerWaypointWind]) - Aenderungen an anderen
|
||||
/// Wegpunkt-Feldern (Hoehe/Geschwindigkeit/Aktion) loesen bewusst KEINEN
|
||||
/// erneuten Abruf aus (Doku: "nur noch aktualisieren, wenn sich die
|
||||
/// wegpunktliste geaendert hat").
|
||||
List<(double, double)>? _lastSignature;
|
||||
|
||||
@override
|
||||
HeaderWindState build() => const HeaderWindState();
|
||||
HeaderWindState build() {
|
||||
ref.listen<List<Waypoint>>(currentMissionProvider, (previous, next) {
|
||||
if (!_perWaypointActivated) return;
|
||||
final signature = _signatureOf(next);
|
||||
if (_signatureEquals(signature, _lastSignature)) return;
|
||||
unawaited(_fetchPerWaypointWind(next, signature));
|
||||
});
|
||||
return const HeaderWindState();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final waypoints = ref.read(currentMissionProvider);
|
||||
@@ -65,27 +96,64 @@ class HeaderWindNotifier extends Notifier<HeaderWindState> {
|
||||
}
|
||||
|
||||
void toggleShowPerWaypoint() {
|
||||
state = state.copyWith(showPerWaypoint: !state.showPerWaypoint);
|
||||
final turningOn = !state.showPerWaypoint;
|
||||
state = state.copyWith(showPerWaypoint: turningOn);
|
||||
// Nur beim Einschalten abrufen (Doku: "wenn ... die windanzeige ...
|
||||
// aktiviert wird") - das Ausschalten blendet die Marker nur aus, die
|
||||
// Winddaten selbst bleiben gueltig.
|
||||
if (turningOn) unawaited(ensurePerWaypointWind());
|
||||
}
|
||||
|
||||
/// Holt Wind fuer alle Wegpunkte, falls fuer die aktuelle Wegpunktliste
|
||||
/// noch keiner bekannt ist (Doku: "wind automatisch abrufen, wenn das
|
||||
/// menue geoeffnet wird oder die windanzeige ... aktiviert wird") - vom
|
||||
/// Wegpunktlisten-Panel beim Oeffnen und von [toggleShowPerWaypoint]
|
||||
/// aufgerufen. Wiederholte Aufrufe fuer dieselbe (unveraenderte)
|
||||
/// Wegpunktliste loesen keinen erneuten Request aus; danach uebernimmt
|
||||
/// der Listener in [build] die Aktualisierung bei Aenderungen der Liste.
|
||||
///
|
||||
/// Hat die Wegpunktliste bereits fuer alle Eintraege Winddaten (z.B. aus
|
||||
/// einer gespeicherten/importierten Mission geladen), zaehlt das ebenso
|
||||
/// als "aktuell" wie ein frischer Abruf - kein unnoetiger Request nur
|
||||
/// wegen des Menue-Oeffnens.
|
||||
Future<void> ensurePerWaypointWind() async {
|
||||
_perWaypointActivated = true;
|
||||
final waypoints = ref.read(currentMissionProvider);
|
||||
if (waypoints.isEmpty) return;
|
||||
final signature = _signatureOf(waypoints);
|
||||
if (_signatureEquals(signature, _lastSignature)) return;
|
||||
if (waypoints.every((w) => w.windSpeedMs != null)) {
|
||||
_lastSignature = signature;
|
||||
return;
|
||||
}
|
||||
await _fetchPerWaypointWind(waypoints, signature);
|
||||
}
|
||||
|
||||
Future<void> _fetchPerWaypointWind(
|
||||
List<Waypoint> waypoints,
|
||||
List<(double, double)> signature,
|
||||
) async {
|
||||
// Vor dem eigentlichen Request gesetzt (nicht danach): fetchWindForWaypoints()
|
||||
// aendert nur die Windfelder, niemals lat/lon - die Signatur der
|
||||
// zurueckgeschriebenen Liste bleibt also identisch und der Listener in
|
||||
// [build] erkennt den eigenen replaceAll()-Aufruf unten korrekt als
|
||||
// "keine Aenderung", statt sich selbst erneut auszuloesen.
|
||||
_lastSignature = signature;
|
||||
final updated = await ref.read(windServiceProvider).fetchWindForWaypoints(waypoints);
|
||||
ref.read(currentMissionProvider.notifier).replaceAll(updated);
|
||||
}
|
||||
|
||||
List<(double, double)> _signatureOf(List<Waypoint> waypoints) =>
|
||||
[for (final w in waypoints) (w.lat, w.lon)];
|
||||
|
||||
bool _signatureEquals(List<(double, double)> a, List<(double, double)>? b) {
|
||||
if (b == null || a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
final headerWindProvider =
|
||||
NotifierProvider<HeaderWindNotifier, HeaderWindState>(HeaderWindNotifier.new);
|
||||
|
||||
/// Referenzort fuer Windabfragen ohne festen Bezugspunkt (Wind-
|
||||
/// Validierungspanel aus der Wegpunktliste): erster Wegpunkt, sonst
|
||||
/// aktuelles Kartenzentrum - analog headerWindLocation() im HTML-
|
||||
/// Demonstrator. Null, wenn die Karte noch nicht gemountet/gelayoutet ist.
|
||||
(double, double)? windReferenceLocation(WidgetRef ref) {
|
||||
final waypoints = ref.read(currentMissionProvider);
|
||||
if (waypoints.isNotEmpty) {
|
||||
final first = waypoints.first;
|
||||
return (first.lat, first.lon);
|
||||
}
|
||||
try {
|
||||
final center = ref.read(mapControllerProvider).camera.center;
|
||||
return (center.latitude, center.longitude);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import '../../providers/mission_meta_provider.dart';
|
||||
import '../../providers/system_message_log_provider.dart';
|
||||
import '../../providers/telemetry_provider.dart';
|
||||
import '../../providers/terrain_provider.dart';
|
||||
import '../../providers/wind_provider.dart';
|
||||
import '../settings/settings_screen.dart';
|
||||
import '../../widgets/drone_marker_icon.dart';
|
||||
import '../../widgets/drone_status_messages_panel.dart';
|
||||
@@ -34,6 +35,7 @@ import '../../widgets/home_point_icon.dart';
|
||||
import '../../widgets/mission_footer_bar.dart';
|
||||
import '../../widgets/mission_map.dart';
|
||||
import '../../widgets/waypoint_list_panel.dart';
|
||||
import '../../widgets/wind_marker_pill.dart';
|
||||
|
||||
/// Fly-Modus (Architektur-Doku 3.10/3.11): zeigt dieselbe Karte + Fusszeile
|
||||
/// wie der Plan-Modus, aber ohne Reticle/Draeder - im Flug wird die Route
|
||||
@@ -182,6 +184,11 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
||||
final telemetryAsync = ref.watch(telemetryProvider);
|
||||
final telemetry = telemetryAsync.value;
|
||||
final followDrone = ref.watch(followDroneProvider);
|
||||
// Wind-Marker pro Wegpunkt (Doku: "windanzeige pro wegpunkt im
|
||||
// flugmodus aktivieren, das funktioniert momentan nicht") - dieselbe
|
||||
// Kopfleisten-Pille/derselbe Zustand wie im Plan-Modus (PlanScreen),
|
||||
// hier bisher schlicht nie an MissionMap durchgereicht.
|
||||
final showPerWaypointWind = ref.watch(headerWindProvider).showPerWaypoint;
|
||||
// Beobachtet die automatische Ereigniserkennung (Connected/Lost/
|
||||
// Batterie/Failsafe/Verbindungstyp, Doku: "log von kritischen
|
||||
// ereignissen mit time stamp", erweitert um Verbindungsereignisse) fuer
|
||||
@@ -401,6 +408,21 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
||||
],
|
||||
droneMarker: droneMarker,
|
||||
homePointMarker: homePointMarker,
|
||||
windMarkers: [
|
||||
if (showPerWaypointWind)
|
||||
for (final wp in waypoints)
|
||||
if (wp.windSpeedMs != null && wp.windDirFromDeg != null)
|
||||
Marker(
|
||||
point: LatLng(wp.lat, wp.lon),
|
||||
width: windMarkerWidth(wp.windSpeedMs!),
|
||||
height: 22,
|
||||
alignment: const Alignment(1.1, -1.5),
|
||||
child: WindMarkerPill(
|
||||
speedMs: wp.windSpeedMs!,
|
||||
dirFromDeg: wp.windDirFromDeg!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Live-Hoehe/-Geschwindigkeit aus der Telemetrie (HTML-Demonstrator:
|
||||
// #altReadout/#spdReadout im Fly-Modus) - anders als dort keine
|
||||
|
||||
@@ -432,7 +432,7 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
if (wp.windSpeedMs != null && wp.windDirFromDeg != null)
|
||||
Marker(
|
||||
point: LatLng(wp.lat, wp.lon),
|
||||
width: _windMarkerWidth(wp.windSpeedMs!),
|
||||
width: windMarkerWidth(wp.windSpeedMs!),
|
||||
height: 22,
|
||||
alignment: const Alignment(1.1, -1.5),
|
||||
child: WindMarkerPill(
|
||||
@@ -556,19 +556,4 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
||||
borderColor: Colors.white,
|
||||
);
|
||||
}
|
||||
|
||||
/// flutter_map's Marker verlangt eine feste Breite (kein intrinsisches
|
||||
/// Sizing wie bei normalen Widgets) - eine einzelne fuer alle Faelle
|
||||
/// grosszuegig bemessene Breite liesse bei kurzen Werten (z.B. "1 m/s")
|
||||
/// sichtbaren Leerraum rechts neben dem Text. Stattdessen je Ziffernzahl
|
||||
/// berechnet (per Widget-Messung ermittelt: +11px/Ziffer, siehe
|
||||
/// WindMarkerPill).
|
||||
double _windMarkerWidth(double speedMs) {
|
||||
final digits = speedMs.round().abs().toString().length;
|
||||
// +1px Puffer: MarkerLayer rendert ueber eine eigene Transform-/
|
||||
// OverflowBox-Kette, die im echten Baum minimal (< 1px) anders rundet
|
||||
// als eine isolierte Messung - ohne Puffer fuehrte das zu einem
|
||||
// 0.25px-Overflow.
|
||||
return 81.0 + digits * 11.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import '../providers/wind_provider.dart';
|
||||
import 'full_value_chart.dart';
|
||||
import 'waypoint_chip.dart';
|
||||
import 'wind_arrow_icon.dart';
|
||||
import 'wind_validate_panel.dart';
|
||||
|
||||
/// Welcher Tab beim Oeffnen des Panels aktiv ist (Doku 3.6/3.10) - public,
|
||||
/// damit Aufrufer wie die Warnungsliste direkt auf Altitude/Speed
|
||||
@@ -86,6 +85,12 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tab = widget.initialTab;
|
||||
// Wind pro Wegpunkt automatisch abrufen, sobald das Panel geoeffnet
|
||||
// wird (Doku: "wind automatisch abrufen, wenn das menue geoeffnet
|
||||
// wird") - ersetzt den vorherigen manuellen "Fetch wind"-Knopf.
|
||||
// ensurePerWaypointWind() selbst entscheidet, ob dafuer ueberhaupt ein
|
||||
// Request noetig ist (siehe dortige Doku).
|
||||
ref.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scrollToActive();
|
||||
// Startet das Panel direkt auf dem Altitude-Tab (z.B. aus der
|
||||
@@ -120,39 +125,6 @@ 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
|
||||
@@ -186,83 +158,71 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
||||
return Material(
|
||||
color: const Color(0xEB080808),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
// Kein eigener Titel-Header mehr (Doku: "nach dem neuen
|
||||
// Designschema anpassen und ueberschrift entfernen", analog Drone-
|
||||
// Status- und Missions-/Drohnen-Panel) - der Schliessen-Knopf
|
||||
// schwebt stattdessen ueber der Tab-Zeile, die dafuer rechts Platz
|
||||
// frei laesst (siehe unten).
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Waypoints',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
_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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
children: [
|
||||
_tabButton('List', PanelTab.list),
|
||||
_tabButton('Altitude', PanelTab.altitude),
|
||||
_tabButton('Speed', PanelTab.speed),
|
||||
Padding(
|
||||
// Rechts Platz fuer den schwebenden Schliessen-Knopf,
|
||||
// damit "Speed" nicht darunter verschwindet.
|
||||
padding: const EdgeInsets.only(right: 48),
|
||||
child: Row(
|
||||
children: [
|
||||
_tabButton('List', PanelTab.list),
|
||||
_tabButton('Altitude', PanelTab.altitude),
|
||||
_tabButton('Speed', PanelTab.speed),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
Expanded(
|
||||
child: switch (_tab) {
|
||||
PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile),
|
||||
PanelTab.altitude => _chartTab(
|
||||
waypoints,
|
||||
routeGeometry,
|
||||
values: [for (final w in waypoints) w.altitudeM],
|
||||
min: DefaultDroneProfile.altMin,
|
||||
max: droneProfile.maxAltitudeM,
|
||||
step: DefaultDroneProfile.altStep,
|
||||
pxPerUnit: 5,
|
||||
unit: ' m',
|
||||
onChanged: widget.onAltitudeChanged,
|
||||
terrainPoints: terrainState.profile?.points,
|
||||
terrainDangerRanges: terrainState.profile != null
|
||||
? terrainDangerRanges(waypoints, terrainState.profile!)
|
||||
: null,
|
||||
terrainLoading: terrainState.loading,
|
||||
),
|
||||
PanelTab.speed => _chartTab(
|
||||
waypoints,
|
||||
routeGeometry,
|
||||
values: [for (final w in waypoints) w.speedMs],
|
||||
min: droneProfile.minSpeed,
|
||||
max: droneProfile.maxSpeed,
|
||||
step: DefaultDroneProfile.speedStep,
|
||||
pxPerUnit: 22,
|
||||
unit: ' m/s',
|
||||
onChanged: widget.onSpeedChanged,
|
||||
legWind: computeLegWind(waypoints),
|
||||
groundSpeeds: computeGroundSpeeds(waypoints),
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(color: Colors.white24, height: 1),
|
||||
Expanded(
|
||||
child: switch (_tab) {
|
||||
PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile),
|
||||
PanelTab.altitude => _chartTab(
|
||||
waypoints,
|
||||
routeGeometry,
|
||||
values: [for (final w in waypoints) w.altitudeM],
|
||||
min: DefaultDroneProfile.altMin,
|
||||
max: droneProfile.maxAltitudeM,
|
||||
step: DefaultDroneProfile.altStep,
|
||||
pxPerUnit: 5,
|
||||
unit: ' m',
|
||||
onChanged: widget.onAltitudeChanged,
|
||||
terrainPoints: terrainState.profile?.points,
|
||||
terrainDangerRanges: terrainState.profile != null
|
||||
? terrainDangerRanges(waypoints, terrainState.profile!)
|
||||
: null,
|
||||
terrainLoading: terrainState.loading,
|
||||
),
|
||||
PanelTab.speed => _chartTab(
|
||||
waypoints,
|
||||
routeGeometry,
|
||||
values: [for (final w in waypoints) w.speedMs],
|
||||
min: droneProfile.minSpeed,
|
||||
max: droneProfile.maxSpeed,
|
||||
step: DefaultDroneProfile.speedStep,
|
||||
pxPerUnit: 22,
|
||||
unit: ' m/s',
|
||||
onChanged: widget.onSpeedChanged,
|
||||
legWind: computeLegWind(waypoints),
|
||||
groundSpeeds: computeGroundSpeeds(waypoints),
|
||||
),
|
||||
},
|
||||
Positioned(
|
||||
top: 4,
|
||||
right: 4,
|
||||
child: IconButton(
|
||||
onPressed: widget.onClose,
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -286,7 +246,9 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
// 18 statt vorher 10 (analog Drone-Status-/Missions-Panel: Tabs
|
||||
// wirkten seit dem Wegfall der Titelzeile zu gequetscht).
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
@@ -542,49 +504,3 @@ 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,20 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'wind_arrow_icon.dart';
|
||||
|
||||
/// Breite des Marker-Rahmens fuer [WindMarkerPill] bei gegebener
|
||||
/// Windgeschwindigkeit - geteilt zwischen Plan- und Fly-Modus, die beide
|
||||
/// eigene Wind-Marker auf der Karte platzieren (MissionMap.windMarkers).
|
||||
/// Haengt von der Ziffernzahl der gerundeten Geschwindigkeit ab, damit z.B.
|
||||
/// "12 m/s" genauso wenig abgeschnitten wird wie "5 m/s".
|
||||
double windMarkerWidth(double speedMs) {
|
||||
final digits = speedMs.round().abs().toString().length;
|
||||
// +1px Puffer: MarkerLayer rendert ueber eine eigene Transform-/
|
||||
// OverflowBox-Kette, die im echten Baum minimal (< 1px) anders rundet als
|
||||
// eine isolierte Messung - ohne Puffer fuehrte das zu einem
|
||||
// 0.25px-Overflow.
|
||||
return 81.0 + digits * 11.0;
|
||||
}
|
||||
|
||||
/// Kleine Wind-Anzeige direkt an einem Wegpunkt auf der Karte (HTML-
|
||||
/// Demonstrator: divIcon in redrawMapLayers(), sichtbar wenn
|
||||
/// showPerWaypointWind aktiv ist und fuer den Wegpunkt Wind geladen wurde).
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
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