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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -142,79 +142,4 @@ void main() {
|
||||
expect(called, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('WindService.fetchValidation', () {
|
||||
test('markiert unvollstaendige/unter Boden liegende Druckflaechen als excluded',
|
||||
() async {
|
||||
final client = MockClient((request) async {
|
||||
if (_isPressureRequest(request)) {
|
||||
return _json({
|
||||
'elevation': 50.0,
|
||||
'hourly': {
|
||||
'time': ['2024-06-01T12:00'],
|
||||
'wind_speed_1000hPa': [4.0],
|
||||
'wind_direction_1000hPa': [300.0],
|
||||
'geopotential_height_1000hPa': [5.0], // unter Bodenhoehe 50
|
||||
'wind_speed_975hPa': [12.0],
|
||||
'wind_direction_975hPa': [300.0],
|
||||
'geopotential_height_975hPa': [800.0],
|
||||
},
|
||||
});
|
||||
}
|
||||
return _json({
|
||||
'elevation': 50.0,
|
||||
'hourly': {
|
||||
'time': ['2024-06-01T12:00'],
|
||||
'wind_speed_10m': [1.0],
|
||||
'wind_direction_10m': [100.0],
|
||||
'wind_speed_80m': [9.0],
|
||||
'wind_direction_80m': [250.0],
|
||||
'wind_speed_120m': [10.0],
|
||||
'wind_direction_120m': [260.0],
|
||||
'wind_speed_180m': [11.0],
|
||||
'wind_direction_180m': [270.0],
|
||||
'wind_gusts_10m': [12.5],
|
||||
},
|
||||
});
|
||||
});
|
||||
final service = WindService(client: client);
|
||||
|
||||
final result = await service.fetchValidation(52.0, 4.0);
|
||||
|
||||
expect(result.elevationM, closeTo(50.0, 0.001));
|
||||
expect(result.fixedRequestFailed, isFalse);
|
||||
expect(result.pressureRequestFailed, isFalse);
|
||||
expect(result.gustMs, closeTo(12.5, 0.001));
|
||||
|
||||
final level1000 =
|
||||
result.pressureReadings.firstWhere((r) => r.pressureHpa == 1000);
|
||||
expect(level1000.excluded, isTrue,
|
||||
reason: 'liegt unter der Bodenhoehe');
|
||||
|
||||
final level975 =
|
||||
result.pressureReadings.firstWhere((r) => r.pressureHpa == 975);
|
||||
expect(level975.excluded, isFalse);
|
||||
|
||||
final level950 =
|
||||
result.pressureReadings.firstWhere((r) => r.pressureHpa == 950);
|
||||
expect(level950.excluded, isTrue,
|
||||
reason: 'keine Daten fuer diese Druckflaeche geliefert');
|
||||
|
||||
expect(result.interpolatedAt120, isNotNull);
|
||||
});
|
||||
|
||||
test('meldet fehlgeschlagenen Request statt zu crashen', () async {
|
||||
final client = MockClient((request) async {
|
||||
if (_isPressureRequest(request)) return http.Response('', 500);
|
||||
return http.Response('', 500);
|
||||
});
|
||||
final service = WindService(client: client);
|
||||
|
||||
final result = await service.fetchValidation(52.0, 4.0);
|
||||
|
||||
expect(result.fixedRequestFailed, isTrue);
|
||||
expect(result.pressureRequestFailed, isTrue);
|
||||
expect(result.interpolatedAt120, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:dmc_app/domain/waypoint/flat_waypoint_list.dart';
|
||||
import 'package:dmc_app/services/wind/wind_service.dart';
|
||||
import 'package:dmc_app/ui/providers/current_mission_provider.dart';
|
||||
import 'package:dmc_app/ui/providers/wind_provider.dart';
|
||||
|
||||
/// Zaehlt fetchWindForWaypoints()-Aufrufe statt echte HTTP-Requests zu
|
||||
/// stellen - schreibt synthetische Winddaten zurueck, damit
|
||||
/// ensurePerWaypointWind()s "bereits vorhanden"-Kurzschluss (siehe dortige
|
||||
/// Doku) bei einem zweiten Aufruf greifen kann.
|
||||
class _CountingWindService extends WindService {
|
||||
int fetchCalls = 0;
|
||||
|
||||
@override
|
||||
Future<List<Waypoint>> fetchWindForWaypoints(List<Waypoint> waypoints) async {
|
||||
fetchCalls++;
|
||||
return [
|
||||
for (final w in waypoints) w.copyWith(windSpeedMs: 5, windDirFromDeg: 200),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Waypoint _wp(double lat, double lon, {double? windSpeedMs}) => Waypoint(
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
altitudeM: 60,
|
||||
speedMs: 15,
|
||||
catchRadiusM: 60,
|
||||
windSpeedMs: windSpeedMs,
|
||||
windDirFromDeg: windSpeedMs != null ? 180 : null,
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('ensurePerWaypointWind() fetches once for a list without wind data', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0));
|
||||
|
||||
await container.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||
|
||||
expect(service.fetchCalls, 1);
|
||||
expect(container.read(currentMissionProvider).single.windSpeedMs, 5);
|
||||
});
|
||||
|
||||
test('ensurePerWaypointWind() does not fetch when every waypoint already '
|
||||
'has wind data (e.g. loaded from a saved mission)', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0, windSpeedMs: 3));
|
||||
|
||||
await container.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||
|
||||
expect(service.fetchCalls, 0);
|
||||
});
|
||||
|
||||
test('a second ensurePerWaypointWind() call for the unchanged list does '
|
||||
'not fetch again', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0));
|
||||
|
||||
final notifier = container.read(headerWindProvider.notifier);
|
||||
await notifier.ensurePerWaypointWind();
|
||||
await notifier.ensurePerWaypointWind();
|
||||
|
||||
expect(service.fetchCalls, 1);
|
||||
});
|
||||
|
||||
test('toggleShowPerWaypoint() fetches when turning on, not when turning off', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0));
|
||||
|
||||
final notifier = container.read(headerWindProvider.notifier);
|
||||
notifier.toggleShowPerWaypoint(); // an
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(container.read(headerWindProvider).showPerWaypoint, isTrue);
|
||||
expect(service.fetchCalls, 1);
|
||||
|
||||
notifier.toggleShowPerWaypoint(); // aus
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(container.read(headerWindProvider).showPerWaypoint, isFalse);
|
||||
expect(service.fetchCalls, 1, reason: 'Ausschalten loest keinen Abruf aus');
|
||||
});
|
||||
|
||||
test('after activation, changing the waypoint list (e.g. adding a point) '
|
||||
'triggers an automatic refetch', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
final missionNotifier = container.read(currentMissionProvider.notifier);
|
||||
missionNotifier.addWaypoint(_wp(0, 0));
|
||||
|
||||
await container.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||
expect(service.fetchCalls, 1);
|
||||
|
||||
missionNotifier.addWaypoint(_wp(0, 0.01));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(service.fetchCalls, 2,
|
||||
reason: 'die veraenderte Wegpunktliste loest automatisch neu aus');
|
||||
});
|
||||
|
||||
test('after activation, editing only altitude (position unchanged) does '
|
||||
'NOT trigger a refetch', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
final missionNotifier = container.read(currentMissionProvider.notifier);
|
||||
missionNotifier.addWaypoint(_wp(0, 0));
|
||||
|
||||
await container.read(headerWindProvider.notifier).ensurePerWaypointWind();
|
||||
expect(service.fetchCalls, 1);
|
||||
|
||||
missionNotifier.setAltitude(0, 150);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(service.fetchCalls, 1,
|
||||
reason: 'nur die Position ist Teil der Aenderungs-Signatur, nicht die Hoehe');
|
||||
});
|
||||
|
||||
test('before activation (menu never opened, pill never toggled), list '
|
||||
'changes trigger no fetch at all', () async {
|
||||
final service = _CountingWindService();
|
||||
final container = ProviderContainer(overrides: [
|
||||
windServiceProvider.overrideWithValue(service),
|
||||
]);
|
||||
addTearDown(container.dispose);
|
||||
// Beobachtet headerWindProvider, ohne ensurePerWaypointWind() oder
|
||||
// toggleShowPerWaypoint() aufzurufen - build() registriert den Listener
|
||||
// zwar, er soll aber inaktiv bleiben.
|
||||
container.read(headerWindProvider);
|
||||
|
||||
container.read(currentMissionProvider.notifier).addWaypoint(_wp(0, 0));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(service.fetchCalls, 0);
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import 'package:dmc_app/services/geocoding/nominatim_service.dart';
|
||||
import 'package:dmc_app/services/sharing/drone_share_codec.dart';
|
||||
import 'package:dmc_app/services/sharing/mission_share_codec.dart';
|
||||
import 'package:dmc_app/services/sharing/sharing_service.dart';
|
||||
import 'package:dmc_app/services/wind/wind_service.dart';
|
||||
import 'package:dmc_app/ui/app_shell.dart';
|
||||
import 'package:dmc_app/ui/screens/fly/fly_screen.dart';
|
||||
import 'package:dmc_app/ui/providers/active_drone_profile_provider.dart';
|
||||
@@ -28,6 +29,7 @@ import 'package:dmc_app/ui/providers/mission_meta_provider.dart';
|
||||
import 'package:dmc_app/ui/providers/sharing_provider.dart';
|
||||
import 'package:dmc_app/ui/providers/system_message_log_provider.dart';
|
||||
import 'package:dmc_app/ui/providers/telemetry_provider.dart';
|
||||
import 'package:dmc_app/ui/providers/wind_provider.dart';
|
||||
import 'package:dmc_app/transport/mock/mock_flight_controller_link.dart';
|
||||
import 'package:dmc_app/ui/widgets/drone_follow_icon.dart';
|
||||
import 'package:dmc_app/ui/widgets/drone_status_messages_panel.dart';
|
||||
@@ -89,6 +91,16 @@ ProviderContainer _testContainer() => ProviderContainer(
|
||||
ref.onDispose(link.disconnect);
|
||||
return link;
|
||||
}),
|
||||
// Ohne diesen Override wuerde headerWindProvider.ensurePerWaypointWind()
|
||||
// (seit dem automatischen Windabruf beim Oeffnen der Wegpunktliste/
|
||||
// Aktivieren der Kopfleisten-Pille) einen echten Netzwerkrequest
|
||||
// ausloesen, sobald ein Test Wegpunkte ohne bereits gesetzte
|
||||
// Winddaten anlegt - MockClient antwortet stattdessen sofort mit
|
||||
// einem Fehlschlag, den WindService._fetchJson ohnehin schon
|
||||
// abfaengt (liefert dann einfach kein Windergebnis).
|
||||
windServiceProvider.overrideWithValue(
|
||||
WindService(client: MockClient((request) async => http.Response('', 500))),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -108,6 +120,9 @@ Widget _wrap(Widget child) => ProviderScope(
|
||||
ref.onDispose(link.disconnect);
|
||||
return link;
|
||||
}),
|
||||
windServiceProvider.overrideWithValue(
|
||||
WindService(client: MockClient((request) async => http.Response('', 500))),
|
||||
),
|
||||
],
|
||||
child: BlocProvider(
|
||||
create: (_) => AppModeCubit(),
|
||||
|
||||
Reference in New Issue
Block a user