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;
}
class WindValidationFixedReading {
const WindValidationFixedReading({
required this.heightAglM,
this.speedMs,
this.dirFromDeg,
});
final int heightAglM;
final double? speedMs;
final double? dirFromDeg;
}
class WindValidationPressureReading {
const WindValidationPressureReading({
required this.pressureHpa,
this.heightAmslM,
this.speedMs,
this.dirFromDeg,
required this.excluded,
});
final int pressureHpa;
final double? heightAmslM;
final double? speedMs;
final double? dirFromDeg;
/// True, wenn dieser Stuetzpunkt unvollstaendig ist oder unterhalb der
/// Bodenhoehe liegt und daher von der Interpolation ausgeschlossen wurde.
final bool excluded;
}
/// Ergebnis des Wind-Validierungspanels (Doku 3.8): rohe Messwerte auf
/// festen Nabenhoehen und Druckflaechen, damit nachvollziehbar bleibt, wie
/// der interpolierte Wert an [interpolatedAt120] zustande kam.
class WindValidationResult {
const WindValidationResult({
required this.lat,
required this.lon,
required this.elevationM,
required this.fixedRequestFailed,
required this.fixedReadings,
this.gustMs,
required this.pressureRequestFailed,
required this.pressureReadings,
this.interpolatedAt120,
});
final double lat;
final double lon;
final double elevationM;
final bool fixedRequestFailed;
final List<WindValidationFixedReading> fixedReadings;
final double? gustMs;
final bool pressureRequestFailed;
final List<WindValidationPressureReading> pressureReadings;
final WindSample? interpolatedAt120;
}
/// Holt und kombiniert Winddaten von Open-Meteo (Doku 3.8). Zwei getrennte,
/// fokussierte Requests (Druckflaechen + feste Nabenhoehen) statt eines
/// kombinierten - ein 35-Variablen-Request verursachte im HTML-Demonstrator
@@ -261,98 +203,4 @@ class WindService {
windElevationM: combined.elevationM,
);
}
/// Detaillierte Rohdaten fuer das Wind-Validierungspanel: einzelne
/// Messwerte je Nabenhoehe/Druckflaeche, damit nachvollziehbar bleibt, wie
/// der interpolierte 120-m-Wert zustande kam (Doku 3.8/4.9: Boeen nur
/// hier, nicht in der Wegpunktanzeige).
Future<WindValidationResult> fetchValidation(double lat, double lon) async {
final lats = lat.toStringAsFixed(5);
final lons = lon.toStringAsFixed(5);
final results = await Future.wait([
_fetchJson(_fixedUri(lats, lons)),
_fetchJson(_pressureUri(lats, lons)),
]);
final fixedRes = _asResultList(results[0], 1)[0];
final pressureRes = _asResultList(results[1], 1)[0];
final elevation = ((fixedRes?['elevation'] as num?) ??
(pressureRes?['elevation'] as num?))
?.toDouble() ??
0.0;
var fixedReadings = <WindValidationFixedReading>[];
double? gust;
final fHourly = fixedRes?['hourly'] as Map<String, dynamic>?;
final fTime = fHourly?['time'] as List<dynamic>?;
final fixedFailed = fHourly == null || fTime == null || fTime.isEmpty;
if (!fixedFailed) {
final idx = _nearestHourIndex(fTime);
fixedReadings = [
for (final h in _fixedHeights)
WindValidationFixedReading(
heightAglM: h,
speedMs: ((fHourly['wind_speed_${h}m'] as List?)?[idx] as num?)
?.toDouble(),
dirFromDeg: ((fHourly['wind_direction_${h}m'] as List?)?[idx] as num?)
?.toDouble(),
),
];
gust = ((fHourly['wind_gusts_10m'] as List?)?[idx] as num?)?.toDouble();
}
var pressureReadings = <WindValidationPressureReading>[];
final pHourly = pressureRes?['hourly'] as Map<String, dynamic>?;
final pTime = pHourly?['time'] as List<dynamic>?;
final pressureFailed = pHourly == null || pTime == null || pTime.isEmpty;
if (!pressureFailed) {
final idx = _nearestHourIndex(pTime);
pressureReadings = [
for (final p in _pressureLevels)
_pressureReadingAt(pHourly, idx, p, elevation),
];
}
final combined = buildCombinedWindLevels(pressureRes, fixedRes);
final interpolated = combined.levels.isEmpty
? null
: interpolateWindAtHeight(combined.levels, elevation + 120);
return WindValidationResult(
lat: lat,
lon: lon,
elevationM: elevation,
fixedRequestFailed: fixedFailed,
fixedReadings: fixedReadings,
gustMs: gust,
pressureRequestFailed: pressureFailed,
pressureReadings: pressureReadings,
interpolatedAt120: interpolated,
);
}
WindValidationPressureReading _pressureReadingAt(
Map<String, dynamic> hourly,
int idx,
int pressureHpa,
double elevationM,
) {
final h = (hourly['geopotential_height_${pressureHpa}hPa'] as List?)?[idx];
final s = (hourly['wind_speed_${pressureHpa}hPa'] as List?)?[idx];
final d = (hourly['wind_direction_${pressureHpa}hPa'] as List?)?[idx];
final heightAmslM = h is num ? h.toDouble() : null;
final speedMs = s is num ? s.toDouble() : null;
final dirFromDeg = d is num ? d.toDouble() : null;
final excluded = heightAmslM == null ||
speedMs == null ||
dirFromDeg == null ||
heightAmslM < elevationM;
return WindValidationPressureReading(
pressureHpa: pressureHpa,
heightAmslM: heightAmslM,
speedMs: speedMs,
dirFromDeg: dirFromDeg,
excluded: excluded,
);
}
}
+89 -21
View File
@@ -1,5 +1,8 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/waypoint/flat_waypoint_list.dart';
import '../../domain/wind/wind_math.dart';
import '../../services/wind/wind_service.dart';
import 'current_mission_provider.dart';
@@ -37,10 +40,38 @@ class HeaderWindState {
/// Haelt den fuer die Kopfleiste interpolierten Wind (120 m AGL ueber dem
/// Referenzort: erster Wegpunkt, sonst aktuelles Kartenzentrum) - analog
/// refreshHeaderWind()/headerWindLocation() im HTML-Demonstrator.
/// refreshHeaderWind()/headerWindLocation() im HTML-Demonstrator. Verwaltet
/// ausserdem den automatischen Pro-Wegpunkt-Windabruf (siehe
/// [ensurePerWaypointWind]-Doku) - beides teilt sich denselben
/// [windServiceProvider] und lebt bewusst in derselben, nicht-autoDispose
/// Notifier-Klasse, damit der Abgleich mit der Wegpunktliste app-weit
/// weiterlaeuft, unabhaengig davon, welche der beiden Oberflaechen
/// (Wegpunktliste, Kopfleisten-Pille) ihn zuletzt ausgeloest hat.
class HeaderWindNotifier extends Notifier<HeaderWindState> {
/// Wurde der Pro-Wegpunkt-Windabruf schon mindestens einmal ausgeloest
/// (Doku: "automatisch abrufen, wenn das menue geoeffnet wird oder die
/// windanzeige ... aktiviert wird")? Erst danach reagiert [build] auf
/// spaetere Aenderungen der Wegpunktliste automatisch.
bool _perWaypointActivated = false;
/// Lat/Lon-Signatur der Wegpunktliste, fuer die der zuletzt bekannte
/// Windstand gilt (entweder frisch abgerufen oder als bereits vorhanden
/// erkannt, siehe [ensurePerWaypointWind]) - Aenderungen an anderen
/// Wegpunkt-Feldern (Hoehe/Geschwindigkeit/Aktion) loesen bewusst KEINEN
/// erneuten Abruf aus (Doku: "nur noch aktualisieren, wenn sich die
/// wegpunktliste geaendert hat").
List<(double, double)>? _lastSignature;
@override
HeaderWindState build() => const HeaderWindState();
HeaderWindState build() {
ref.listen<List<Waypoint>>(currentMissionProvider, (previous, next) {
if (!_perWaypointActivated) return;
final signature = _signatureOf(next);
if (_signatureEquals(signature, _lastSignature)) return;
unawaited(_fetchPerWaypointWind(next, signature));
});
return const HeaderWindState();
}
Future<void> refresh() async {
final waypoints = ref.read(currentMissionProvider);
@@ -65,27 +96,64 @@ class HeaderWindNotifier extends Notifier<HeaderWindState> {
}
void toggleShowPerWaypoint() {
state = state.copyWith(showPerWaypoint: !state.showPerWaypoint);
final turningOn = !state.showPerWaypoint;
state = state.copyWith(showPerWaypoint: turningOn);
// Nur beim Einschalten abrufen (Doku: "wenn ... die windanzeige ...
// aktiviert wird") - das Ausschalten blendet die Marker nur aus, die
// Winddaten selbst bleiben gueltig.
if (turningOn) unawaited(ensurePerWaypointWind());
}
/// Holt Wind fuer alle Wegpunkte, falls fuer die aktuelle Wegpunktliste
/// noch keiner bekannt ist (Doku: "wind automatisch abrufen, wenn das
/// menue geoeffnet wird oder die windanzeige ... aktiviert wird") - vom
/// Wegpunktlisten-Panel beim Oeffnen und von [toggleShowPerWaypoint]
/// aufgerufen. Wiederholte Aufrufe fuer dieselbe (unveraenderte)
/// Wegpunktliste loesen keinen erneuten Request aus; danach uebernimmt
/// der Listener in [build] die Aktualisierung bei Aenderungen der Liste.
///
/// Hat die Wegpunktliste bereits fuer alle Eintraege Winddaten (z.B. aus
/// einer gespeicherten/importierten Mission geladen), zaehlt das ebenso
/// als "aktuell" wie ein frischer Abruf - kein unnoetiger Request nur
/// wegen des Menue-Oeffnens.
Future<void> ensurePerWaypointWind() async {
_perWaypointActivated = true;
final waypoints = ref.read(currentMissionProvider);
if (waypoints.isEmpty) return;
final signature = _signatureOf(waypoints);
if (_signatureEquals(signature, _lastSignature)) return;
if (waypoints.every((w) => w.windSpeedMs != null)) {
_lastSignature = signature;
return;
}
await _fetchPerWaypointWind(waypoints, signature);
}
Future<void> _fetchPerWaypointWind(
List<Waypoint> waypoints,
List<(double, double)> signature,
) async {
// Vor dem eigentlichen Request gesetzt (nicht danach): fetchWindForWaypoints()
// aendert nur die Windfelder, niemals lat/lon - die Signatur der
// zurueckgeschriebenen Liste bleibt also identisch und der Listener in
// [build] erkennt den eigenen replaceAll()-Aufruf unten korrekt als
// "keine Aenderung", statt sich selbst erneut auszuloesen.
_lastSignature = signature;
final updated = await ref.read(windServiceProvider).fetchWindForWaypoints(waypoints);
ref.read(currentMissionProvider.notifier).replaceAll(updated);
}
List<(double, double)> _signatureOf(List<Waypoint> waypoints) =>
[for (final w in waypoints) (w.lat, w.lon)];
bool _signatureEquals(List<(double, double)> a, List<(double, double)>? b) {
if (b == null || a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}
final headerWindProvider =
NotifierProvider<HeaderWindNotifier, HeaderWindState>(HeaderWindNotifier.new);
/// Referenzort fuer Windabfragen ohne festen Bezugspunkt (Wind-
/// Validierungspanel aus der Wegpunktliste): erster Wegpunkt, sonst
/// aktuelles Kartenzentrum - analog headerWindLocation() im HTML-
/// Demonstrator. Null, wenn die Karte noch nicht gemountet/gelayoutet ist.
(double, double)? windReferenceLocation(WidgetRef ref) {
final waypoints = ref.read(currentMissionProvider);
if (waypoints.isNotEmpty) {
final first = waypoints.first;
return (first.lat, first.lon);
}
try {
final center = ref.read(mapControllerProvider).camera.center;
return (center.latitude, center.longitude);
} catch (_) {
return null;
}
}
+22
View File
@@ -27,6 +27,7 @@ import '../../providers/mission_meta_provider.dart';
import '../../providers/system_message_log_provider.dart';
import '../../providers/telemetry_provider.dart';
import '../../providers/terrain_provider.dart';
import '../../providers/wind_provider.dart';
import '../settings/settings_screen.dart';
import '../../widgets/drone_marker_icon.dart';
import '../../widgets/drone_status_messages_panel.dart';
@@ -34,6 +35,7 @@ import '../../widgets/home_point_icon.dart';
import '../../widgets/mission_footer_bar.dart';
import '../../widgets/mission_map.dart';
import '../../widgets/waypoint_list_panel.dart';
import '../../widgets/wind_marker_pill.dart';
/// Fly-Modus (Architektur-Doku 3.10/3.11): zeigt dieselbe Karte + Fusszeile
/// wie der Plan-Modus, aber ohne Reticle/Draeder - im Flug wird die Route
@@ -182,6 +184,11 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
final telemetryAsync = ref.watch(telemetryProvider);
final telemetry = telemetryAsync.value;
final followDrone = ref.watch(followDroneProvider);
// Wind-Marker pro Wegpunkt (Doku: "windanzeige pro wegpunkt im
// flugmodus aktivieren, das funktioniert momentan nicht") - dieselbe
// Kopfleisten-Pille/derselbe Zustand wie im Plan-Modus (PlanScreen),
// hier bisher schlicht nie an MissionMap durchgereicht.
final showPerWaypointWind = ref.watch(headerWindProvider).showPerWaypoint;
// Beobachtet die automatische Ereigniserkennung (Connected/Lost/
// Batterie/Failsafe/Verbindungstyp, Doku: "log von kritischen
// ereignissen mit time stamp", erweitert um Verbindungsereignisse) fuer
@@ -401,6 +408,21 @@ class _FlyScreenState extends ConsumerState<FlyScreen> {
],
droneMarker: droneMarker,
homePointMarker: homePointMarker,
windMarkers: [
if (showPerWaypointWind)
for (final wp in waypoints)
if (wp.windSpeedMs != null && wp.windDirFromDeg != null)
Marker(
point: LatLng(wp.lat, wp.lon),
width: windMarkerWidth(wp.windSpeedMs!),
height: 22,
alignment: const Alignment(1.1, -1.5),
child: WindMarkerPill(
speedMs: wp.windSpeedMs!,
dirFromDeg: wp.windDirFromDeg!,
),
),
],
),
// Live-Hoehe/-Geschwindigkeit aus der Telemetrie (HTML-Demonstrator:
// #altReadout/#spdReadout im Fly-Modus) - anders als dort keine
+1 -16
View File
@@ -432,7 +432,7 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
if (wp.windSpeedMs != null && wp.windDirFromDeg != null)
Marker(
point: LatLng(wp.lat, wp.lon),
width: _windMarkerWidth(wp.windSpeedMs!),
width: windMarkerWidth(wp.windSpeedMs!),
height: 22,
alignment: const Alignment(1.1, -1.5),
child: WindMarkerPill(
@@ -556,19 +556,4 @@ class _PlanScreenState extends ConsumerState<PlanScreen> {
borderColor: Colors.white,
);
}
/// flutter_map's Marker verlangt eine feste Breite (kein intrinsisches
/// Sizing wie bei normalen Widgets) - eine einzelne fuer alle Faelle
/// grosszuegig bemessene Breite liesse bei kurzen Werten (z.B. "1 m/s")
/// sichtbaren Leerraum rechts neben dem Text. Stattdessen je Ziffernzahl
/// berechnet (per Widget-Messung ermittelt: +11px/Ziffer, siehe
/// WindMarkerPill).
double _windMarkerWidth(double speedMs) {
final digits = speedMs.round().abs().toString().length;
// +1px Puffer: MarkerLayer rendert ueber eine eigene Transform-/
// OverflowBox-Kette, die im echten Baum minimal (< 1px) anders rundet
// als eine isolierte Messung - ohne Puffer fuehrte das zu einem
// 0.25px-Overflow.
return 81.0 + digits * 11.0;
}
}
+70 -154
View File
@@ -15,7 +15,6 @@ import '../providers/wind_provider.dart';
import 'full_value_chart.dart';
import 'waypoint_chip.dart';
import 'wind_arrow_icon.dart';
import 'wind_validate_panel.dart';
/// Welcher Tab beim Oeffnen des Panels aktiv ist (Doku 3.6/3.10) - public,
/// damit Aufrufer wie die Warnungsliste direkt auf Altitude/Speed
@@ -86,6 +85,12 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
void initState() {
super.initState();
_tab = widget.initialTab;
// Wind pro Wegpunkt automatisch abrufen, sobald das Panel geoeffnet
// wird (Doku: "wind automatisch abrufen, wenn das menue geoeffnet
// wird") - ersetzt den vorherigen manuellen "Fetch wind"-Knopf.
// ensurePerWaypointWind() selbst entscheidet, ob dafuer ueberhaupt ein
// Request noetig ist (siehe dortige Doku).
ref.read(headerWindProvider.notifier).ensurePerWaypointWind();
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToActive();
// Startet das Panel direkt auf dem Altitude-Tab (z.B. aus der
@@ -120,39 +125,6 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
super.dispose();
}
/// Holt Wind fuer alle Wegpunkte gebuendelt (Doku 3.8, HTML-Demonstrator:
/// fetchWindForWaypoints()) und schreibt das Ergebnis komplett zurueck.
Future<void> _onFetchWind() async {
final waypoints = ref.read(currentMissionProvider);
final messenger = ScaffoldMessenger.of(context);
if (waypoints.isEmpty) {
messenger.showSnackBar(
const SnackBar(content: Text('No waypoints to fetch wind for')),
);
return;
}
messenger.showSnackBar(const SnackBar(content: Text('Fetching wind…')));
final updated =
await ref.read(windServiceProvider).fetchWindForWaypoints(waypoints);
ref.read(currentMissionProvider.notifier).replaceAll(updated);
messenger.showSnackBar(const SnackBar(content: Text('Wind data updated')));
}
/// Oeffnet das Wind-Validierungspanel fuer den aktuellen Referenzort
/// (erster Wegpunkt, sonst Kartenzentrum - Doku 3.8).
void _onValidateWind() {
final location = windReferenceLocation(ref);
if (location == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('No location available yet')),
);
return;
}
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => WindValidatePanel(lat: location.$1, lon: location.$2),
));
}
/// Zentriert die Liste auf den aktuell aktiven Wegpunkt (Doku 3.6: das
/// Panel oeffnet sich beim Editieren eines Wegpunkts ueber das
/// Halo-Menue - ohne Auto-Scroll waere er bei laengeren Missionen nicht
@@ -186,83 +158,71 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
return Material(
color: const Color(0xEB080808),
child: SafeArea(
child: Column(
// Kein eigener Titel-Header mehr (Doku: "nach dem neuen
// Designschema anpassen und ueberschrift entfernen", analog Drone-
// Status- und Missions-/Drohnen-Panel) - der Schliessen-Knopf
// schwebt stattdessen ueber der Tab-Zeile, die dafuer rechts Platz
// frei laesst (siehe unten).
child: Stack(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 12),
child: Row(
children: [
const Expanded(
child: Text(
'Waypoints',
style: TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
),
_HeaderActionButton(
icon: Icons.air,
label: 'Fetch wind',
tint: const Color(0xFF4D9DFF),
onTap: _onFetchWind,
),
const SizedBox(width: 8),
_HeaderActionButton(
icon: Icons.rule,
label: 'Validate',
tint: const Color(0xFFC8A04D),
onTap: _onValidateWind,
),
IconButton(
onPressed: widget.onClose,
icon: const Icon(Icons.close, color: Colors.white),
),
],
),
),
Row(
Column(
children: [
_tabButton('List', PanelTab.list),
_tabButton('Altitude', PanelTab.altitude),
_tabButton('Speed', PanelTab.speed),
Padding(
// Rechts Platz fuer den schwebenden Schliessen-Knopf,
// damit "Speed" nicht darunter verschwindet.
padding: const EdgeInsets.only(right: 48),
child: Row(
children: [
_tabButton('List', PanelTab.list),
_tabButton('Altitude', PanelTab.altitude),
_tabButton('Speed', PanelTab.speed),
],
),
),
const Divider(color: Colors.white24, height: 1),
Expanded(
child: switch (_tab) {
PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile),
PanelTab.altitude => _chartTab(
waypoints,
routeGeometry,
values: [for (final w in waypoints) w.altitudeM],
min: DefaultDroneProfile.altMin,
max: droneProfile.maxAltitudeM,
step: DefaultDroneProfile.altStep,
pxPerUnit: 5,
unit: ' m',
onChanged: widget.onAltitudeChanged,
terrainPoints: terrainState.profile?.points,
terrainDangerRanges: terrainState.profile != null
? terrainDangerRanges(waypoints, terrainState.profile!)
: null,
terrainLoading: terrainState.loading,
),
PanelTab.speed => _chartTab(
waypoints,
routeGeometry,
values: [for (final w in waypoints) w.speedMs],
min: droneProfile.minSpeed,
max: droneProfile.maxSpeed,
step: DefaultDroneProfile.speedStep,
pxPerUnit: 22,
unit: ' m/s',
onChanged: widget.onSpeedChanged,
legWind: computeLegWind(waypoints),
groundSpeeds: computeGroundSpeeds(waypoints),
),
},
),
],
),
const Divider(color: Colors.white24, height: 1),
Expanded(
child: switch (_tab) {
PanelTab.list => _listTab(waypoints, routeGeometry, droneProfile),
PanelTab.altitude => _chartTab(
waypoints,
routeGeometry,
values: [for (final w in waypoints) w.altitudeM],
min: DefaultDroneProfile.altMin,
max: droneProfile.maxAltitudeM,
step: DefaultDroneProfile.altStep,
pxPerUnit: 5,
unit: ' m',
onChanged: widget.onAltitudeChanged,
terrainPoints: terrainState.profile?.points,
terrainDangerRanges: terrainState.profile != null
? terrainDangerRanges(waypoints, terrainState.profile!)
: null,
terrainLoading: terrainState.loading,
),
PanelTab.speed => _chartTab(
waypoints,
routeGeometry,
values: [for (final w in waypoints) w.speedMs],
min: droneProfile.minSpeed,
max: droneProfile.maxSpeed,
step: DefaultDroneProfile.speedStep,
pxPerUnit: 22,
unit: ' m/s',
onChanged: widget.onSpeedChanged,
legWind: computeLegWind(waypoints),
groundSpeeds: computeGroundSpeeds(waypoints),
),
},
Positioned(
top: 4,
right: 4,
child: IconButton(
onPressed: widget.onClose,
icon: const Icon(Icons.close, color: Colors.white),
),
),
],
),
@@ -286,7 +246,9 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
}
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
// 18 statt vorher 10 (analog Drone-Status-/Missions-Panel: Tabs
// wirkten seit dem Wegfall der Titelzeile zu gequetscht).
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
@@ -542,49 +504,3 @@ class _WaypointListPanelState extends ConsumerState<WaypointListPanel> {
_ => '—',
};
}
/// Kleiner getoenter Aktions-Button im Panel-Header (HTML-Demonstrator:
/// #windFetchBtn/#windValidateBtn).
class _HeaderActionButton extends StatelessWidget {
const _HeaderActionButton({
required this.icon,
required this.label,
required this.tint,
required this.onTap,
});
final IconData icon;
final String label;
final Color tint;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: tint.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: tint.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: Colors.white),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}
+14
View File
@@ -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).
-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),
),
),
],
);
}
}