diff --git a/app/lib/services/wind/wind_service.dart b/app/lib/services/wind/wind_service.dart index 1585557..8d43f96 100644 --- a/app/lib/services/wind/wind_service.dart +++ b/app/lib/services/wind/wind_service.dart @@ -15,64 +15,6 @@ class WindCombinedLevels { final double elevationM; } -class WindValidationFixedReading { - const WindValidationFixedReading({ - required this.heightAglM, - this.speedMs, - this.dirFromDeg, - }); - - final int heightAglM; - final double? speedMs; - final double? dirFromDeg; -} - -class WindValidationPressureReading { - const WindValidationPressureReading({ - required this.pressureHpa, - this.heightAmslM, - this.speedMs, - this.dirFromDeg, - required this.excluded, - }); - - final int pressureHpa; - final double? heightAmslM; - final double? speedMs; - final double? dirFromDeg; - - /// True, wenn dieser Stuetzpunkt unvollstaendig ist oder unterhalb der - /// Bodenhoehe liegt und daher von der Interpolation ausgeschlossen wurde. - final bool excluded; -} - -/// Ergebnis des Wind-Validierungspanels (Doku 3.8): rohe Messwerte auf -/// festen Nabenhoehen und Druckflaechen, damit nachvollziehbar bleibt, wie -/// der interpolierte Wert an [interpolatedAt120] zustande kam. -class WindValidationResult { - const WindValidationResult({ - required this.lat, - required this.lon, - required this.elevationM, - required this.fixedRequestFailed, - required this.fixedReadings, - this.gustMs, - required this.pressureRequestFailed, - required this.pressureReadings, - this.interpolatedAt120, - }); - - final double lat; - final double lon; - final double elevationM; - final bool fixedRequestFailed; - final List fixedReadings; - final double? gustMs; - final bool pressureRequestFailed; - final List pressureReadings; - final WindSample? interpolatedAt120; -} - /// Holt und kombiniert Winddaten von Open-Meteo (Doku 3.8). Zwei getrennte, /// fokussierte Requests (Druckflaechen + feste Nabenhoehen) statt eines /// kombinierten - ein 35-Variablen-Request verursachte im HTML-Demonstrator @@ -261,98 +203,4 @@ class WindService { windElevationM: combined.elevationM, ); } - - /// Detaillierte Rohdaten fuer das Wind-Validierungspanel: einzelne - /// Messwerte je Nabenhoehe/Druckflaeche, damit nachvollziehbar bleibt, wie - /// der interpolierte 120-m-Wert zustande kam (Doku 3.8/4.9: Boeen nur - /// hier, nicht in der Wegpunktanzeige). - Future 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 = []; - double? gust; - final fHourly = fixedRes?['hourly'] as Map?; - final fTime = fHourly?['time'] as List?; - 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 = []; - final pHourly = pressureRes?['hourly'] as Map?; - final pTime = pHourly?['time'] as List?; - 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 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, - ); - } } diff --git a/app/lib/ui/providers/wind_provider.dart b/app/lib/ui/providers/wind_provider.dart index c0ca276..0cb4c54 100644 --- a/app/lib/ui/providers/wind_provider.dart +++ b/app/lib/ui/providers/wind_provider.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../domain/waypoint/flat_waypoint_list.dart'; import '../../domain/wind/wind_math.dart'; import '../../services/wind/wind_service.dart'; import 'current_mission_provider.dart'; @@ -37,10 +40,38 @@ class HeaderWindState { /// Haelt den fuer die Kopfleiste interpolierten Wind (120 m AGL ueber dem /// Referenzort: erster Wegpunkt, sonst aktuelles Kartenzentrum) - analog -/// refreshHeaderWind()/headerWindLocation() im HTML-Demonstrator. +/// refreshHeaderWind()/headerWindLocation() im HTML-Demonstrator. Verwaltet +/// ausserdem den automatischen Pro-Wegpunkt-Windabruf (siehe +/// [ensurePerWaypointWind]-Doku) - beides teilt sich denselben +/// [windServiceProvider] und lebt bewusst in derselben, nicht-autoDispose +/// Notifier-Klasse, damit der Abgleich mit der Wegpunktliste app-weit +/// weiterlaeuft, unabhaengig davon, welche der beiden Oberflaechen +/// (Wegpunktliste, Kopfleisten-Pille) ihn zuletzt ausgeloest hat. class HeaderWindNotifier extends Notifier { + /// Wurde der Pro-Wegpunkt-Windabruf schon mindestens einmal ausgeloest + /// (Doku: "automatisch abrufen, wenn das menue geoeffnet wird oder die + /// windanzeige ... aktiviert wird")? Erst danach reagiert [build] auf + /// spaetere Aenderungen der Wegpunktliste automatisch. + bool _perWaypointActivated = false; + + /// Lat/Lon-Signatur der Wegpunktliste, fuer die der zuletzt bekannte + /// Windstand gilt (entweder frisch abgerufen oder als bereits vorhanden + /// erkannt, siehe [ensurePerWaypointWind]) - Aenderungen an anderen + /// Wegpunkt-Feldern (Hoehe/Geschwindigkeit/Aktion) loesen bewusst KEINEN + /// erneuten Abruf aus (Doku: "nur noch aktualisieren, wenn sich die + /// wegpunktliste geaendert hat"). + List<(double, double)>? _lastSignature; + @override - HeaderWindState build() => const HeaderWindState(); + HeaderWindState build() { + ref.listen>(currentMissionProvider, (previous, next) { + if (!_perWaypointActivated) return; + final signature = _signatureOf(next); + if (_signatureEquals(signature, _lastSignature)) return; + unawaited(_fetchPerWaypointWind(next, signature)); + }); + return const HeaderWindState(); + } Future refresh() async { final waypoints = ref.read(currentMissionProvider); @@ -65,27 +96,64 @@ class HeaderWindNotifier extends Notifier { } 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 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 _fetchPerWaypointWind( + List 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 waypoints) => + [for (final w in waypoints) (w.lat, w.lon)]; + + bool _signatureEquals(List<(double, double)> a, List<(double, double)>? b) { + if (b == null || a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; } } final headerWindProvider = NotifierProvider(HeaderWindNotifier.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; - } -} diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index e7c624b..7286dff 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -27,6 +27,7 @@ import '../../providers/mission_meta_provider.dart'; import '../../providers/system_message_log_provider.dart'; import '../../providers/telemetry_provider.dart'; import '../../providers/terrain_provider.dart'; +import '../../providers/wind_provider.dart'; import '../settings/settings_screen.dart'; import '../../widgets/drone_marker_icon.dart'; import '../../widgets/drone_status_messages_panel.dart'; @@ -34,6 +35,7 @@ import '../../widgets/home_point_icon.dart'; import '../../widgets/mission_footer_bar.dart'; import '../../widgets/mission_map.dart'; import '../../widgets/waypoint_list_panel.dart'; +import '../../widgets/wind_marker_pill.dart'; /// Fly-Modus (Architektur-Doku 3.10/3.11): zeigt dieselbe Karte + Fusszeile /// wie der Plan-Modus, aber ohne Reticle/Draeder - im Flug wird die Route @@ -182,6 +184,11 @@ class _FlyScreenState extends ConsumerState { final telemetryAsync = ref.watch(telemetryProvider); final telemetry = telemetryAsync.value; final followDrone = ref.watch(followDroneProvider); + // Wind-Marker pro Wegpunkt (Doku: "windanzeige pro wegpunkt im + // flugmodus aktivieren, das funktioniert momentan nicht") - dieselbe + // Kopfleisten-Pille/derselbe Zustand wie im Plan-Modus (PlanScreen), + // hier bisher schlicht nie an MissionMap durchgereicht. + final showPerWaypointWind = ref.watch(headerWindProvider).showPerWaypoint; // Beobachtet die automatische Ereigniserkennung (Connected/Lost/ // Batterie/Failsafe/Verbindungstyp, Doku: "log von kritischen // ereignissen mit time stamp", erweitert um Verbindungsereignisse) fuer @@ -401,6 +408,21 @@ class _FlyScreenState extends ConsumerState { ], droneMarker: droneMarker, homePointMarker: homePointMarker, + windMarkers: [ + if (showPerWaypointWind) + for (final wp in waypoints) + if (wp.windSpeedMs != null && wp.windDirFromDeg != null) + Marker( + point: LatLng(wp.lat, wp.lon), + width: windMarkerWidth(wp.windSpeedMs!), + height: 22, + alignment: const Alignment(1.1, -1.5), + child: WindMarkerPill( + speedMs: wp.windSpeedMs!, + dirFromDeg: wp.windDirFromDeg!, + ), + ), + ], ), // Live-Hoehe/-Geschwindigkeit aus der Telemetrie (HTML-Demonstrator: // #altReadout/#spdReadout im Fly-Modus) - anders als dort keine diff --git a/app/lib/ui/screens/plan/plan_screen.dart b/app/lib/ui/screens/plan/plan_screen.dart index c75ff5f..038de16 100644 --- a/app/lib/ui/screens/plan/plan_screen.dart +++ b/app/lib/ui/screens/plan/plan_screen.dart @@ -432,7 +432,7 @@ class _PlanScreenState extends ConsumerState { if (wp.windSpeedMs != null && wp.windDirFromDeg != null) Marker( point: LatLng(wp.lat, wp.lon), - width: _windMarkerWidth(wp.windSpeedMs!), + width: windMarkerWidth(wp.windSpeedMs!), height: 22, alignment: const Alignment(1.1, -1.5), child: WindMarkerPill( @@ -556,19 +556,4 @@ class _PlanScreenState extends ConsumerState { 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; - } } diff --git a/app/lib/ui/widgets/waypoint_list_panel.dart b/app/lib/ui/widgets/waypoint_list_panel.dart index 316c6d6..ecd5864 100644 --- a/app/lib/ui/widgets/waypoint_list_panel.dart +++ b/app/lib/ui/widgets/waypoint_list_panel.dart @@ -15,7 +15,6 @@ import '../providers/wind_provider.dart'; import 'full_value_chart.dart'; import 'waypoint_chip.dart'; import 'wind_arrow_icon.dart'; -import 'wind_validate_panel.dart'; /// Welcher Tab beim Oeffnen des Panels aktiv ist (Doku 3.6/3.10) - public, /// damit Aufrufer wie die Warnungsliste direkt auf Altitude/Speed @@ -86,6 +85,12 @@ class _WaypointListPanelState extends ConsumerState { void initState() { super.initState(); _tab = widget.initialTab; + // Wind pro Wegpunkt automatisch abrufen, sobald das Panel geoeffnet + // wird (Doku: "wind automatisch abrufen, wenn das menue geoeffnet + // wird") - ersetzt den vorherigen manuellen "Fetch wind"-Knopf. + // ensurePerWaypointWind() selbst entscheidet, ob dafuer ueberhaupt ein + // Request noetig ist (siehe dortige Doku). + ref.read(headerWindProvider.notifier).ensurePerWaypointWind(); WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToActive(); // Startet das Panel direkt auf dem Altitude-Tab (z.B. aus der @@ -120,39 +125,6 @@ class _WaypointListPanelState extends ConsumerState { super.dispose(); } - /// Holt Wind fuer alle Wegpunkte gebuendelt (Doku 3.8, HTML-Demonstrator: - /// fetchWindForWaypoints()) und schreibt das Ergebnis komplett zurueck. - Future _onFetchWind() async { - final waypoints = ref.read(currentMissionProvider); - final messenger = ScaffoldMessenger.of(context); - if (waypoints.isEmpty) { - messenger.showSnackBar( - const SnackBar(content: Text('No waypoints to fetch wind for')), - ); - return; - } - messenger.showSnackBar(const SnackBar(content: Text('Fetching wind…'))); - final updated = - await ref.read(windServiceProvider).fetchWindForWaypoints(waypoints); - ref.read(currentMissionProvider.notifier).replaceAll(updated); - messenger.showSnackBar(const SnackBar(content: Text('Wind data updated'))); - } - - /// Oeffnet das Wind-Validierungspanel fuer den aktuellen Referenzort - /// (erster Wegpunkt, sonst Kartenzentrum - Doku 3.8). - void _onValidateWind() { - final location = windReferenceLocation(ref); - if (location == null) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('No location available yet')), - ); - return; - } - Navigator.of(context).push(MaterialPageRoute( - builder: (_) => WindValidatePanel(lat: location.$1, lon: location.$2), - )); - } - /// Zentriert die Liste auf den aktuell aktiven Wegpunkt (Doku 3.6: das /// Panel oeffnet sich beim Editieren eines Wegpunkts ueber das /// Halo-Menue - ohne Auto-Scroll waere er bei laengeren Missionen nicht @@ -186,83 +158,71 @@ class _WaypointListPanelState extends ConsumerState { return Material( color: const Color(0xEB080808), child: SafeArea( - child: Column( + // Kein eigener Titel-Header mehr (Doku: "nach dem neuen + // Designschema anpassen und ueberschrift entfernen", analog Drone- + // Status- und Missions-/Drohnen-Panel) - der Schliessen-Knopf + // schwebt stattdessen ueber der Tab-Zeile, die dafuer rechts Platz + // frei laesst (siehe unten). + child: Stack( children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), - child: Row( - children: [ - const Expanded( - child: Text( - 'Waypoints', - style: TextStyle( - color: Colors.white, - fontSize: 20, - fontWeight: FontWeight.w700, - ), - ), - ), - _HeaderActionButton( - icon: Icons.air, - label: 'Fetch wind', - tint: const Color(0xFF4D9DFF), - onTap: _onFetchWind, - ), - const SizedBox(width: 8), - _HeaderActionButton( - icon: Icons.rule, - label: 'Validate', - tint: const Color(0xFFC8A04D), - onTap: _onValidateWind, - ), - IconButton( - onPressed: widget.onClose, - icon: const Icon(Icons.close, color: Colors.white), - ), - ], - ), - ), - Row( + Column( children: [ - _tabButton('List', PanelTab.list), - _tabButton('Altitude', PanelTab.altitude), - _tabButton('Speed', PanelTab.speed), + Padding( + // Rechts Platz fuer den schwebenden Schliessen-Knopf, + // damit "Speed" nicht darunter verschwindet. + padding: const EdgeInsets.only(right: 48), + child: Row( + children: [ + _tabButton('List', PanelTab.list), + _tabButton('Altitude', PanelTab.altitude), + _tabButton('Speed', PanelTab.speed), + ], + ), + ), + const Divider(color: Colors.white24, height: 1), + Expanded( + child: switch (_tab) { + PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile), + PanelTab.altitude => _chartTab( + waypoints, + routeGeometry, + values: [for (final w in waypoints) w.altitudeM], + min: DefaultDroneProfile.altMin, + max: droneProfile.maxAltitudeM, + step: DefaultDroneProfile.altStep, + pxPerUnit: 5, + unit: ' m', + onChanged: widget.onAltitudeChanged, + terrainPoints: terrainState.profile?.points, + terrainDangerRanges: terrainState.profile != null + ? terrainDangerRanges(waypoints, terrainState.profile!) + : null, + terrainLoading: terrainState.loading, + ), + PanelTab.speed => _chartTab( + waypoints, + routeGeometry, + values: [for (final w in waypoints) w.speedMs], + min: droneProfile.minSpeed, + max: droneProfile.maxSpeed, + step: DefaultDroneProfile.speedStep, + pxPerUnit: 22, + unit: ' m/s', + onChanged: widget.onSpeedChanged, + legWind: computeLegWind(waypoints), + groundSpeeds: computeGroundSpeeds(waypoints), + ), + }, + ), ], ), - const Divider(color: Colors.white24, height: 1), - Expanded( - child: switch (_tab) { - PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile), - PanelTab.altitude => _chartTab( - waypoints, - routeGeometry, - values: [for (final w in waypoints) w.altitudeM], - min: DefaultDroneProfile.altMin, - max: droneProfile.maxAltitudeM, - step: DefaultDroneProfile.altStep, - pxPerUnit: 5, - unit: ' m', - onChanged: widget.onAltitudeChanged, - terrainPoints: terrainState.profile?.points, - terrainDangerRanges: terrainState.profile != null - ? terrainDangerRanges(waypoints, terrainState.profile!) - : null, - terrainLoading: terrainState.loading, - ), - PanelTab.speed => _chartTab( - waypoints, - routeGeometry, - values: [for (final w in waypoints) w.speedMs], - min: droneProfile.minSpeed, - max: droneProfile.maxSpeed, - step: DefaultDroneProfile.speedStep, - pxPerUnit: 22, - unit: ' m/s', - onChanged: widget.onSpeedChanged, - legWind: computeLegWind(waypoints), - groundSpeeds: computeGroundSpeeds(waypoints), - ), - }, + Positioned( + top: 4, + right: 4, + child: IconButton( + onPressed: widget.onClose, + icon: const Icon(Icons.close, color: Colors.white), + ), ), ], ), @@ -286,7 +246,9 @@ class _WaypointListPanelState extends ConsumerState { } }, child: Container( - padding: const EdgeInsets.symmetric(vertical: 10), + // 18 statt vorher 10 (analog Drone-Status-/Missions-Panel: Tabs + // wirkten seit dem Wegfall der Titelzeile zu gequetscht). + padding: const EdgeInsets.symmetric(vertical: 18), decoration: BoxDecoration( border: Border( bottom: BorderSide( @@ -542,49 +504,3 @@ class _WaypointListPanelState extends ConsumerState { _ => '—', }; } - -/// 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, - ), - ), - ], - ), - ), - ); - } -} diff --git a/app/lib/ui/widgets/wind_marker_pill.dart b/app/lib/ui/widgets/wind_marker_pill.dart index e8c0acf..9aecc6a 100644 --- a/app/lib/ui/widgets/wind_marker_pill.dart +++ b/app/lib/ui/widgets/wind_marker_pill.dart @@ -2,6 +2,20 @@ import 'package:flutter/material.dart'; import 'wind_arrow_icon.dart'; +/// Breite des Marker-Rahmens fuer [WindMarkerPill] bei gegebener +/// Windgeschwindigkeit - geteilt zwischen Plan- und Fly-Modus, die beide +/// eigene Wind-Marker auf der Karte platzieren (MissionMap.windMarkers). +/// Haengt von der Ziffernzahl der gerundeten Geschwindigkeit ab, damit z.B. +/// "12 m/s" genauso wenig abgeschnitten wird wie "5 m/s". +double windMarkerWidth(double speedMs) { + final digits = speedMs.round().abs().toString().length; + // +1px Puffer: MarkerLayer rendert ueber eine eigene Transform-/ + // OverflowBox-Kette, die im echten Baum minimal (< 1px) anders rundet als + // eine isolierte Messung - ohne Puffer fuehrte das zu einem + // 0.25px-Overflow. + return 81.0 + digits * 11.0; +} + /// Kleine Wind-Anzeige direkt an einem Wegpunkt auf der Karte (HTML- /// Demonstrator: divIcon in redrawMapLayers(), sichtbar wenn /// showPerWaypointWind aktiv ist und fuer den Wegpunkt Wind geladen wurde). diff --git a/app/lib/ui/widgets/wind_validate_panel.dart b/app/lib/ui/widgets/wind_validate_panel.dart deleted file mode 100644 index b30afc9..0000000 --- a/app/lib/ui/widgets/wind_validate_panel.dart +++ /dev/null @@ -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 createState() => _WindValidatePanelState(); -} - -class _WindValidatePanelState extends ConsumerState { - late final Future _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( - 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), - ), - ), - ], - ); - } -} diff --git a/app/test/services/wind_service_test.dart b/app/test/services/wind_service_test.dart index 24d3b0b..c4f1fb1 100644 --- a/app/test/services/wind_service_test.dart +++ b/app/test/services/wind_service_test.dart @@ -142,79 +142,4 @@ void main() { expect(called, isFalse); }); }); - - group('WindService.fetchValidation', () { - test('markiert unvollstaendige/unter Boden liegende Druckflaechen als excluded', - () async { - final client = MockClient((request) async { - if (_isPressureRequest(request)) { - return _json({ - 'elevation': 50.0, - 'hourly': { - 'time': ['2024-06-01T12:00'], - 'wind_speed_1000hPa': [4.0], - 'wind_direction_1000hPa': [300.0], - 'geopotential_height_1000hPa': [5.0], // unter Bodenhoehe 50 - 'wind_speed_975hPa': [12.0], - 'wind_direction_975hPa': [300.0], - 'geopotential_height_975hPa': [800.0], - }, - }); - } - return _json({ - 'elevation': 50.0, - 'hourly': { - 'time': ['2024-06-01T12:00'], - 'wind_speed_10m': [1.0], - 'wind_direction_10m': [100.0], - 'wind_speed_80m': [9.0], - 'wind_direction_80m': [250.0], - 'wind_speed_120m': [10.0], - 'wind_direction_120m': [260.0], - 'wind_speed_180m': [11.0], - 'wind_direction_180m': [270.0], - 'wind_gusts_10m': [12.5], - }, - }); - }); - final service = WindService(client: client); - - final result = await service.fetchValidation(52.0, 4.0); - - expect(result.elevationM, closeTo(50.0, 0.001)); - expect(result.fixedRequestFailed, isFalse); - expect(result.pressureRequestFailed, isFalse); - expect(result.gustMs, closeTo(12.5, 0.001)); - - final level1000 = - result.pressureReadings.firstWhere((r) => r.pressureHpa == 1000); - expect(level1000.excluded, isTrue, - reason: 'liegt unter der Bodenhoehe'); - - final level975 = - result.pressureReadings.firstWhere((r) => r.pressureHpa == 975); - expect(level975.excluded, isFalse); - - final level950 = - result.pressureReadings.firstWhere((r) => r.pressureHpa == 950); - expect(level950.excluded, isTrue, - reason: 'keine Daten fuer diese Druckflaeche geliefert'); - - expect(result.interpolatedAt120, isNotNull); - }); - - test('meldet fehlgeschlagenen Request statt zu crashen', () async { - final client = MockClient((request) async { - if (_isPressureRequest(request)) return http.Response('', 500); - return http.Response('', 500); - }); - final service = WindService(client: client); - - final result = await service.fetchValidation(52.0, 4.0); - - expect(result.fixedRequestFailed, isTrue); - expect(result.pressureRequestFailed, isTrue); - expect(result.interpolatedAt120, isNull); - }); - }); } diff --git a/app/test/ui/providers/wind_provider_test.dart b/app/test/ui/providers/wind_provider_test.dart new file mode 100644 index 0000000..01ccb7e --- /dev/null +++ b/app/test/ui/providers/wind_provider_test.dart @@ -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> fetchWindForWaypoints(List 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.delayed(Duration.zero); + expect(container.read(headerWindProvider).showPerWaypoint, isTrue); + expect(service.fetchCalls, 1); + + notifier.toggleShowPerWaypoint(); // aus + await Future.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.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.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.delayed(Duration.zero); + + expect(service.fetchCalls, 0); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 9cdd73b..de1c3a4 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -18,6 +18,7 @@ import 'package:dmc_app/services/geocoding/nominatim_service.dart'; import 'package:dmc_app/services/sharing/drone_share_codec.dart'; import 'package:dmc_app/services/sharing/mission_share_codec.dart'; import 'package:dmc_app/services/sharing/sharing_service.dart'; +import 'package:dmc_app/services/wind/wind_service.dart'; import 'package:dmc_app/ui/app_shell.dart'; import 'package:dmc_app/ui/screens/fly/fly_screen.dart'; import 'package:dmc_app/ui/providers/active_drone_profile_provider.dart'; @@ -28,6 +29,7 @@ import 'package:dmc_app/ui/providers/mission_meta_provider.dart'; import 'package:dmc_app/ui/providers/sharing_provider.dart'; import 'package:dmc_app/ui/providers/system_message_log_provider.dart'; import 'package:dmc_app/ui/providers/telemetry_provider.dart'; +import 'package:dmc_app/ui/providers/wind_provider.dart'; import 'package:dmc_app/transport/mock/mock_flight_controller_link.dart'; import 'package:dmc_app/ui/widgets/drone_follow_icon.dart'; import 'package:dmc_app/ui/widgets/drone_status_messages_panel.dart'; @@ -89,6 +91,16 @@ ProviderContainer _testContainer() => ProviderContainer( ref.onDispose(link.disconnect); return link; }), + // Ohne diesen Override wuerde headerWindProvider.ensurePerWaypointWind() + // (seit dem automatischen Windabruf beim Oeffnen der Wegpunktliste/ + // Aktivieren der Kopfleisten-Pille) einen echten Netzwerkrequest + // ausloesen, sobald ein Test Wegpunkte ohne bereits gesetzte + // Winddaten anlegt - MockClient antwortet stattdessen sofort mit + // einem Fehlschlag, den WindService._fetchJson ohnehin schon + // abfaengt (liefert dann einfach kein Windergebnis). + windServiceProvider.overrideWithValue( + WindService(client: MockClient((request) async => http.Response('', 500))), + ), ], ); @@ -108,6 +120,9 @@ Widget _wrap(Widget child) => ProviderScope( ref.onDispose(link.disconnect); return link; }), + windServiceProvider.overrideWithValue( + WindService(client: MockClient((request) async => http.Response('', 500))), + ), ], child: BlocProvider( create: (_) => AppModeCubit(),