Auto-fetch wind, drop manual fetch/validate buttons, fix it in fly mode

Redesigns the waypoint list panel to match the drone-status/missions
panels: no title bar, close button floats over the tab row, tabs get
more vertical padding.

Removes the "Fetch wind" and "Validate" header buttons along with the
now-fully-orphaned WindValidatePanel screen and its WindService
support code (fetchValidation, WindValidationResult and friends) -
nothing else referenced them.

Wind is now fetched automatically instead: HeaderWindNotifier gains
ensurePerWaypointWind(), called when the waypoint list panel opens and
when the header pill's per-waypoint toggle switches on. A lat/lon
signature of the waypoint list tracks whether the fetched data is
still current, so a ref.listen(currentMissionProvider) in the same
notifier refetches only when the list itself changes (position added/
removed/moved) - not on every altitude/speed edit. Waypoints that
already carry wind data (e.g. loaded from a saved mission) short-
circuit the check instead of re-fetching pointlessly.

Also wires per-waypoint wind markers into the fly-mode map, which
never had them (plan_screen.dart built windMarkers; fly_screen.dart
just never did) - the reported "doesn't work in fly mode" bug.
windMarkerWidth() moved from a plan_screen.dart-private method to
wind_marker_pill.dart so both screens can share it.

Test containers in widget_test.dart now override windServiceProvider
with a MockClient so the new auto-fetch trigger can't reach the real
network during tests.
This commit is contained in:
Constantin Leue
2026-08-09 22:26:23 +02:00
parent 6b7977eca1
commit eca1c647b3
10 changed files with 368 additions and 570 deletions
-152
View File
@@ -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,
);
}
} }
+89 -21
View File
@@ -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;
}
}
+22
View File
@@ -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
+1 -16
View File
@@ -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;
}
} }
+70 -154
View File
@@ -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,
),
),
],
),
),
);
}
}
+14
View File
@@ -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).
-152
View File
@@ -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),
),
),
],
);
}
}
-75
View File
@@ -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);
});
}
+15
View File
@@ -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(),