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;
|
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,
|
/// Holt und kombiniert Winddaten von Open-Meteo (Doku 3.8). Zwei getrennte,
|
||||||
/// fokussierte Requests (Druckflaechen + feste Nabenhoehen) statt eines
|
/// fokussierte Requests (Druckflaechen + feste Nabenhoehen) statt eines
|
||||||
/// kombinierten - ein 35-Variablen-Request verursachte im HTML-Demonstrator
|
/// kombinierten - ein 35-Variablen-Request verursachte im HTML-Demonstrator
|
||||||
@@ -261,98 +203,4 @@ class WindService {
|
|||||||
windElevationM: combined.elevationM,
|
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 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../domain/waypoint/flat_waypoint_list.dart';
|
||||||
import '../../domain/wind/wind_math.dart';
|
import '../../domain/wind/wind_math.dart';
|
||||||
import '../../services/wind/wind_service.dart';
|
import '../../services/wind/wind_service.dart';
|
||||||
import 'current_mission_provider.dart';
|
import 'current_mission_provider.dart';
|
||||||
@@ -37,10 +40,38 @@ class HeaderWindState {
|
|||||||
|
|
||||||
/// Haelt den fuer die Kopfleiste interpolierten Wind (120 m AGL ueber dem
|
/// Haelt den fuer die Kopfleiste interpolierten Wind (120 m AGL ueber dem
|
||||||
/// Referenzort: erster Wegpunkt, sonst aktuelles Kartenzentrum) - analog
|
/// 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> {
|
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
|
@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 {
|
Future<void> refresh() async {
|
||||||
final waypoints = ref.read(currentMissionProvider);
|
final waypoints = ref.read(currentMissionProvider);
|
||||||
@@ -65,27 +96,64 @@ class HeaderWindNotifier extends Notifier<HeaderWindState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void toggleShowPerWaypoint() {
|
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 =
|
final headerWindProvider =
|
||||||
NotifierProvider<HeaderWindNotifier, HeaderWindState>(HeaderWindNotifier.new);
|
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/system_message_log_provider.dart';
|
||||||
import '../../providers/telemetry_provider.dart';
|
import '../../providers/telemetry_provider.dart';
|
||||||
import '../../providers/terrain_provider.dart';
|
import '../../providers/terrain_provider.dart';
|
||||||
|
import '../../providers/wind_provider.dart';
|
||||||
import '../settings/settings_screen.dart';
|
import '../settings/settings_screen.dart';
|
||||||
import '../../widgets/drone_marker_icon.dart';
|
import '../../widgets/drone_marker_icon.dart';
|
||||||
import '../../widgets/drone_status_messages_panel.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_footer_bar.dart';
|
||||||
import '../../widgets/mission_map.dart';
|
import '../../widgets/mission_map.dart';
|
||||||
import '../../widgets/waypoint_list_panel.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
|
/// 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
|
/// 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 telemetryAsync = ref.watch(telemetryProvider);
|
||||||
final telemetry = telemetryAsync.value;
|
final telemetry = telemetryAsync.value;
|
||||||
final followDrone = ref.watch(followDroneProvider);
|
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/
|
// Beobachtet die automatische Ereigniserkennung (Connected/Lost/
|
||||||
// Batterie/Failsafe/Verbindungstyp, Doku: "log von kritischen
|
// Batterie/Failsafe/Verbindungstyp, Doku: "log von kritischen
|
||||||
// ereignissen mit time stamp", erweitert um Verbindungsereignisse) fuer
|
// ereignissen mit time stamp", erweitert um Verbindungsereignisse) fuer
|
||||||
@@ -401,6 +408,21 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
|
|||||||
],
|
],
|
||||||
droneMarker: droneMarker,
|
droneMarker: droneMarker,
|
||||||
homePointMarker: homePointMarker,
|
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:
|
// Live-Hoehe/-Geschwindigkeit aus der Telemetrie (HTML-Demonstrator:
|
||||||
// #altReadout/#spdReadout im Fly-Modus) - anders als dort keine
|
// #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)
|
if (wp.windSpeedMs != null && wp.windDirFromDeg != null)
|
||||||
Marker(
|
Marker(
|
||||||
point: LatLng(wp.lat, wp.lon),
|
point: LatLng(wp.lat, wp.lon),
|
||||||
width: _windMarkerWidth(wp.windSpeedMs!),
|
width: windMarkerWidth(wp.windSpeedMs!),
|
||||||
height: 22,
|
height: 22,
|
||||||
alignment: const Alignment(1.1, -1.5),
|
alignment: const Alignment(1.1, -1.5),
|
||||||
child: WindMarkerPill(
|
child: WindMarkerPill(
|
||||||
@@ -556,19 +556,4 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
|
|||||||
borderColor: Colors.white,
|
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 'full_value_chart.dart';
|
||||||
import 'waypoint_chip.dart';
|
import 'waypoint_chip.dart';
|
||||||
import 'wind_arrow_icon.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,
|
/// Welcher Tab beim Oeffnen des Panels aktiv ist (Doku 3.6/3.10) - public,
|
||||||
/// damit Aufrufer wie die Warnungsliste direkt auf Altitude/Speed
|
/// damit Aufrufer wie die Warnungsliste direkt auf Altitude/Speed
|
||||||
@@ -86,6 +85,12 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tab = widget.initialTab;
|
_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((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_scrollToActive();
|
_scrollToActive();
|
||||||
// Startet das Panel direkt auf dem Altitude-Tab (z.B. aus der
|
// Startet das Panel direkt auf dem Altitude-Tab (z.B. aus der
|
||||||
@@ -120,39 +125,6 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
super.dispose();
|
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
|
/// Zentriert die Liste auf den aktuell aktiven Wegpunkt (Doku 3.6: das
|
||||||
/// Panel oeffnet sich beim Editieren eines Wegpunkts ueber das
|
/// Panel oeffnet sich beim Editieren eines Wegpunkts ueber das
|
||||||
/// Halo-Menue - ohne Auto-Scroll waere er bei laengeren Missionen nicht
|
/// Halo-Menue - ohne Auto-Scroll waere er bei laengeren Missionen nicht
|
||||||
@@ -186,83 +158,71 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
return Material(
|
return Material(
|
||||||
color: const Color(0xEB080808),
|
color: const Color(0xEB080808),
|
||||||
child: SafeArea(
|
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: [
|
children: [
|
||||||
Padding(
|
Column(
|
||||||
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(
|
|
||||||
children: [
|
children: [
|
||||||
_tabButton('List', PanelTab.list),
|
Padding(
|
||||||
_tabButton('Altitude', PanelTab.altitude),
|
// Rechts Platz fuer den schwebenden Schliessen-Knopf,
|
||||||
_tabButton('Speed', PanelTab.speed),
|
// 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),
|
Positioned(
|
||||||
Expanded(
|
top: 4,
|
||||||
child: switch (_tab) {
|
right: 4,
|
||||||
PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile),
|
child: IconButton(
|
||||||
PanelTab.altitude => _chartTab(
|
onPressed: widget.onClose,
|
||||||
waypoints,
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
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),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -286,7 +246,9 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
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(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
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';
|
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-
|
/// Kleine Wind-Anzeige direkt an einem Wegpunkt auf der Karte (HTML-
|
||||||
/// Demonstrator: divIcon in redrawMapLayers(), sichtbar wenn
|
/// Demonstrator: divIcon in redrawMapLayers(), sichtbar wenn
|
||||||
/// showPerWaypointWind aktiv ist und fuer den Wegpunkt Wind geladen wurde).
|
/// 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);
|
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/drone_share_codec.dart';
|
||||||
import 'package:dmc_app/services/sharing/mission_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/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/app_shell.dart';
|
||||||
import 'package:dmc_app/ui/screens/fly/fly_screen.dart';
|
import 'package:dmc_app/ui/screens/fly/fly_screen.dart';
|
||||||
import 'package:dmc_app/ui/providers/active_drone_profile_provider.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/sharing_provider.dart';
|
||||||
import 'package:dmc_app/ui/providers/system_message_log_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/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/transport/mock/mock_flight_controller_link.dart';
|
||||||
import 'package:dmc_app/ui/widgets/drone_follow_icon.dart';
|
import 'package:dmc_app/ui/widgets/drone_follow_icon.dart';
|
||||||
import 'package:dmc_app/ui/widgets/drone_status_messages_panel.dart';
|
import 'package:dmc_app/ui/widgets/drone_status_messages_panel.dart';
|
||||||
@@ -89,6 +91,16 @@ ProviderContainer _testContainer() => ProviderContainer(
|
|||||||
ref.onDispose(link.disconnect);
|
ref.onDispose(link.disconnect);
|
||||||
return link;
|
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);
|
ref.onDispose(link.disconnect);
|
||||||
return link;
|
return link;
|
||||||
}),
|
}),
|
||||||
|
windServiceProvider.overrideWithValue(
|
||||||
|
WindService(client: MockClient((request) async => http.Response('', 500))),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: BlocProvider(
|
child: BlocProvider(
|
||||||
create: (_) => AppModeCubit(),
|
create: (_) => AppModeCubit(),
|
||||||
|
|||||||
Reference in New Issue
Block a user