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:
Constantin Leue
2026-08-09 22:26:23 +02:00
parent 6b7977eca1
commit eca1c647b3
10 changed files with 368 additions and 570 deletions
+89 -21
View File
@@ -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;
}
}