Files
Constantin Leue eca1c647b3 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.
2026-08-09 22:26:23 +02:00

543 lines
23 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:latlong2/latlong.dart';
import '../../../app_mode/app_mode_cubit.dart';
import '../../../domain/mission/drone_profile.dart';
import '../../../domain/mission/mission_stats.dart';
import '../../../domain/mission/mission_warnings.dart';
import '../../../domain/mission/route_geometry.dart';
import '../../../domain/telemetry/drone_status.dart';
import '../../../domain/telemetry/system_message.dart';
import '../../../domain/waypoint/flat_waypoint_list.dart';
import '../../../domain/wind/wind_math.dart';
import '../../../services/mission_sync_service.dart';
import '../../../transport/flight_controller_link.dart';
import '../../providers/active_drone_profile_provider.dart';
import '../../providers/current_mission_provider.dart';
import '../../providers/follow_drone_provider.dart';
import '../../providers/home_point_provider.dart';
import '../../providers/map_controller_provider.dart';
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';
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
/// beobachtet statt neu geplant. Der Kartencontroller ist derselbe
/// app-lebenslange Provider wie im Plan-Modus (Doku: mapControllerProvider),
/// die Kameraposition bleibt also beim Umschalten erhalten. Wegpunkte lassen
/// sich weiterhin ueber die Liste anpassen (z.B. spontane Hoehenaenderung),
/// nur das Karten-Reticle zum Anlegen/Verschieben entfaellt hier.
class FlyScreen extends ConsumerStatefulWidget {
const FlyScreen({super.key});
@override
ConsumerState<FlyScreen> createState() => _FlyScreenState();
}
class _FlyScreenState extends ConsumerState<FlyScreen> {
late final MapController _mapController;
StreamSubscription<MapEvent>? _mapEventSub;
bool _sending = false;
@override
void initState() {
super.initState();
_mapController = ref.read(mapControllerProvider);
_mapEventSub = _mapController.mapEventStream.listen(_onMapEvent);
}
@override
void dispose() {
_mapEventSub?.cancel();
super.dispose();
}
/// Jede manuelle Kartengeste (Pan/Pinch/Fling, ...) bricht den ueber den
/// GPS-Knopf in der Kopfleiste (FlyMapControls) aktivierten Folgemodus ab
/// - nur Bewegungen mit source == mapController sind programmatisch
/// (durch die Telemetrie-Nachfuehrung unten selbst oder andere
/// Kartensprmuenge ausgeloest) und sollen ihn nicht beenden.
void _onMapEvent(MapEvent event) {
if (ref.read(followDroneProvider) &&
event.source != MapEventSource.mapController) {
ref.read(followDroneProvider.notifier).set(false);
}
}
void _onCenterOnWaypoint(int index) {
final waypoints = ref.read(currentMissionProvider);
if (index < 0 || index >= waypoints.length) return;
final wp = waypoints[index];
// Programmatischer Sprung (source == mapController) - _onMapEvent
// erkennt das nicht als manuelle Geste, der Folgemodus muss also
// explizit beendet werden (sonst zieht der naechste Telemetrie-Frame
// die Kamera sofort wieder vom antippten Wegpunkt weg).
ref.read(followDroneProvider.notifier).set(false);
_mapController.move(LatLng(wp.lat, wp.lon), _mapController.camera.zoom);
}
void _openWaypointList({PanelTab initialTab = PanelTab.list}) {
Navigator.of(context).push(
PageRouteBuilder<void>(
opaque: true,
transitionDuration: const Duration(milliseconds: 150),
reverseTransitionDuration: const Duration(milliseconds: 150),
pageBuilder: (routeContext, animation, secondaryAnimation) {
return FadeTransition(
opacity: animation,
child: WaypointListPanel(
activeIndex: -1,
initialTab: initialTab,
onClose: () => Navigator.of(routeContext).pop(),
onCenterOnWaypoint: (index) {
_onCenterOnWaypoint(index);
Navigator.of(routeContext).pop();
},
onDelete: (index) =>
ref.read(currentMissionProvider.notifier).removeAt(index),
onAltitudeChanged: (index, value) => ref
.read(currentMissionProvider.notifier)
.setAltitude(index, value),
onSpeedChanged: (index, value) =>
ref.read(currentMissionProvider.notifier).setSpeed(index, value),
onCatchRadiusChanged: (index, value) => ref
.read(currentMissionProvider.notifier)
.setCatchRadius(index, value),
onActionChanged: (index, action) =>
ref.read(currentMissionProvider.notifier).setAction(index, action),
),
);
},
),
);
}
void _openSystemMessagesPanel() {
Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => const DroneStatusMessagesPanel.messages(),
));
}
/// Laedt die aktuelle Mission auf den Flightcontroller (Doku 2.2/4.5:
/// Connect -> Upload -> Verify) - MissionSyncService kennt selbst kein
/// UI, deshalb hier Ladezustand/Fehleranzeige. uploadMission() der
/// jeweiligen FlightControllerLink-Implementierung verweigert den Versand
/// bereits selbst im armed-Zustand (Doku 4.5); dieser Handler prueft nur
/// den trivialen Fall (keine Wegpunkte), um nicht unnoetig einen
/// Verbindungsversuch auszuloesen. Protokolliert Erfolg/Fehlschlag
/// zusaetzlich im System-Messages-Log (Doku: "wenn ... Wegpunkte
/// übertragen werden") inklusive Missionsname. Wird sowohl vom Sende-
/// Knopf als auch automatisch bei einem Missionswechsel aufgerufen (siehe
/// den ref.listen(currentMissionMetaProvider, ...) unten) - der
/// _sending-Schutz verhindert dabei ein doppeltes Senden, falls beides
/// ueberlappt.
Future<void> _onSendTap() async {
if (_sending) return;
final waypoints = ref.read(currentMissionProvider);
final missionName = ref.read(currentMissionMetaProvider).name;
final messenger = ScaffoldMessenger.of(context);
if (waypoints.isEmpty) {
messenger.showSnackBar(const SnackBar(content: Text('No waypoints to send')));
return;
}
setState(() => _sending = true);
final log = ref.read(systemMessageLogProvider.notifier);
try {
final service = MissionSyncService(
ref.read(flightControllerLinkProvider),
context.read<AppModeCubit>(),
);
await service.syncAndUpload(FlatWaypointList(waypoints));
messenger.showSnackBar(const SnackBar(content: Text('Mission sent')));
log.log('Mission sent: "$missionName" (${waypoints.length} waypoints)');
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Send failed: $e')));
log.log('Mission upload failed: "$missionName": $e',
severity: SystemMessageSeverity.critical);
} finally {
if (mounted) setState(() => _sending = false);
}
}
@override
Widget build(BuildContext context) {
final waypoints = ref.watch(currentMissionProvider);
final droneProfile = ref.watch(activeDroneProfileProvider);
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
// die Dauer des Fly-Modus - siehe systemMessageAutoLogProvider-Doku,
// warum das bewusst NICHT systemMessageLogProvider selbst ist (der
// bliebe sonst auch im Plan-Modus dauerhaft an telemetryProvider
// gekoppelt). Der Rueckgabewert wird hier nicht gebraucht, nur der
// Beobachtungs-Seiteneffekt.
ref.watch(systemMessageAutoLogProvider);
// Homepoint-Abfrage bei Verbindungsaufbau/GPS-Fix/Armen (Doku: "den auf
// der Drohne gelockten Homepoint abfragen ... an diesen drei Stellen
// macht es Sinn") - analog systemMessageAutoLogProvider nur der
// Beobachtungs-Seiteneffekt, der eigentliche Wert kommt gleich aus
// homePointProvider.
ref.watch(homePointAutoRefreshProvider);
final homePoint = ref.watch(homePointProvider);
// Solange der ueber den GPS-Knopf in der Kopfleiste (FlyMapControls)
// aktivierte Folgemodus aktiv ist, jede neue Telemetrie-Position
// programmatisch nachziehen (source == mapController, siehe
// _onMapEvent - bricht den Modus dadurch nicht selbst wieder ab).
ref.listen<AsyncValue<TelemetryFrame>>(telemetryProvider, (previous, next) {
if (!followDrone) return;
final frame = next.value;
if (frame != null && frame.hasFix) {
_mapController.move(LatLng(frame.lat, frame.lon), _mapController.camera.zoom);
}
});
// Ein Missionswechsel waehrend des Fly-Modus (z.B. ueber die Mission-
// Pille in der Fusszeile, die auch dort erreichbar bleibt) wird geloggt
// und laedt die neue Route automatisch auf den Flightcontroller (Doku:
// "Wechsel der Mission ... nur im fly modus loggen", "automatisch die
// Wegpunktliste übertragen, wenn die Mission gewechselt wird im fly
// modus") - beides sitzt bewusst hier statt in
// mission_meta_provider.dart, das den App-Modus (AppModeCubit,
// flutter_bloc) nicht kennt; dieser ref.listen existiert dagegen nur,
// waehrend FlyScreen gemountet ist. switchSeq statt eines simplen
// id-Vergleichs, weil der erste Autosave einer neuen Mission ebenfalls
// deren id setzt, ohne dass die Mission dabei "gewechselt" wurde (siehe
// MissionMeta-Doku).
ref.listen<MissionMeta>(currentMissionMetaProvider, (previous, next) {
if (previous != null && next.switchSeq != previous.switchSeq) {
ref.read(systemMessageLogProvider.notifier).log('Mission changed to "${next.name}"');
_onSendTap();
}
});
// Drohnenprofilwechsel waehrend des Fly-Modus (Doku: "Wechsel ... des
// Drohnenprofils ... nur im fly modus loggen") - anders als beim
// Missionswechsel bewusst OHNE automatischen Neuversand (Doku sieht das
// explizit nur fuer den Missionswechsel vor).
ref.listen<DroneProfile>(activeDroneProfileProvider, (previous, next) {
if (previous != null && previous.id != next.id) {
ref.read(systemMessageLogProvider.notifier).log('Drone profile changed to "${next.name}"');
}
});
final routeGeometry = buildRouteGeometry(
waypoints,
minTurnRadius: droneProfile.minTurnRadius,
maxClimbRate: droneProfile.maxClimbRate,
maxDescentRate: droneProfile.maxDescentRate,
);
final missionStats = computeMissionStats(waypoints);
final safeEdgeInset = math.max(
MediaQuery.of(context).padding.left,
MediaQuery.of(context).padding.right,
);
// Gleiche Reservierung fuer die Fusszeile wie die Rad-Readouts im
// Plan-Modus (PlanScreen: wheelBottomInset), damit die Alt/Speed-
// Anzeige zwischen Kopf- und Fusszeile vertikal zentriert erscheint,
// statt am Kartenrand zu kleben.
final footerBottomInset = math.max(
MediaQuery.of(context).size.height * 0.06,
124.0,
);
final terrainProfile = ref.watch(terrainProvider).profile;
final warnings = computeMissionWarnings(
waypoints: waypoints,
routeGeometry: routeGeometry,
legWindBad: computeLegWindBad(waypoints, droneProfile.maxSpeed),
droneProfile: droneProfile,
totalDistanceM: missionStats.distanceM,
totalDurationSec: missionStats.durationSec,
terrain: terrainProfile,
);
// Vor der ersten Telemetrie (Verbindungsaufbau laeuft noch, oder Mock
// hat noch nicht getickt) und ohne GPS-Fix (TelemetryFrame wird bewusst
// nicht auf einen Fix gegated, siehe dortige Doku - ohne Fix meldet der
// FC aber oft (0,0) oder eine veraltete Position) auf dem ersten
// Wegpunkt statt an einer erfundenen Stelle anzeigen.
final droneMarker = telemetry != null && telemetry.hasFix
? Marker(
point: LatLng(telemetry.lat, telemetry.lon),
width: 40,
height: 40,
alignment: Alignment.center,
child: DroneMarkerIcon(headingDeg: telemetry.headingDeg),
)
: waypoints.isEmpty
? null
: Marker(
point: LatLng(waypoints.first.lat, waypoints.first.lon),
width: 40,
height: 40,
alignment: Alignment.center,
child: const DroneMarkerIcon(),
);
// Gestrichelte Linie von der Drohne zum aktuellen Flugziel (Doku 3.4:
// TelemetryFrame.activeWaypointIndex aus MSP_NAV_STATUS) - derselbe
// Darstellungsstil wie das Reticle-Gummiband im Plan-Modus (MissionMap.
// rubberBandLine), hier aber Drohne -> Flugziel statt letzter Wegpunkt
// -> Kartenmitte. Das Ziel selbst (Missionswegpunkt oder bei Return-to-
// Home der Homepoint) kommt aus computeRubberBandTarget, siehe dortige
// Doku fuer den RTH-Sonderfall.
final activeWpIndex = telemetry?.activeWaypointIndex;
final homePointLatLng =
homePoint == null ? null : LatLng(homePoint.lat, homePoint.lon);
final currentPosition = telemetry != null && telemetry.hasFix
? LatLng(telemetry.lat, telemetry.lon)
: null;
List<LatLng>? rubberBand;
if (currentPosition != null && telemetry != null) {
final target = computeRubberBandTarget(
navMode: telemetry.navMode,
activeWaypointIndex: activeWpIndex,
waypoints: waypoints,
homePoint: homePointLatLng,
);
if (target != null) {
rubberBand = [currentPosition, target];
}
}
// Live-Missionsstatus fuer die Details-Pille (Doku: "aktiver Wegpunkt
// 3/23, Distanz und Zeit zum naechsten Wegpunkt" statt der statischen
// Plan-Werte) - siehe FlyMissionStatus-Klassendoku dazu, warum das aus
// Position+Wegpunktkoordinaten berechnet wird statt direkt von MSP
// abgefragt.
final flyMissionStatus = computeFlyMissionStatus(
waypoints: waypoints,
activeWaypointIndex: activeWpIndex,
currentLat: currentPosition?.latitude,
currentLon: currentPosition?.longitude,
currentSpeedMs: telemetry?.speedMs,
);
// Entfernung zum Homepoint als letztes Element der Details-Pille (Doku:
// "zeige die Entfernung zum Home Punkt als letztes element in der
// details pille im fly modus an").
final distanceToHomeM = computeDistanceToHomeM(
currentPosition: currentPosition,
homePoint: homePointLatLng,
);
// Drohnen-Status fuer die Drohnen-Pille (Doku: "im fly modus zeigt die
// drone pille den zustand der drohne") - das Aussehen des Fly-Modus darf
// nicht am Vorhandensein eines Telemetrie-Frames haengen (sonst faellt
// BottomStatsBar auf den Drohnennamen-Chip zurueck, den Plan-Modus-Look).
// Ohne Frame (Verbindungsaufbau laeuft noch) liefern die Platzhalterwerte
// ueber computeDroneStatus zuverlaessig idle/rot/rot - eine ehrliche
// "keine Daten"-Anzeige statt eines unterschiedlichen Widget-Typs.
//
// connected: telemetryAsync.hasError statt telemetry == null, weil
// TelemetryFrame-Werte bei einem Verbindungsverlust bewusst auf dem
// letzten Stand "einfrieren" (Doku: "im Drone Detail Menü okay, dass
// Daten einfrieren" - telemetry bleibt also nicht null, siehe
// telemetryProvider-Doku zu AsyncError mit erhaltenem previous value).
// Die Pille selbst soll den Verbindungsverlust trotzdem sofort ehrlich
// als "disconnected" zeigen, statt den zuletzt bekannten (ggf. laengst
// veralteten) Zustand fortzuschreiben.
final droneStatus = computeDroneStatus(
connected: !telemetryAsync.hasError,
armed: telemetry?.armed ?? false,
hasFix: telemetry?.hasFix ?? false,
numSat: telemetry?.numSat ?? 0,
hdop: telemetry?.hdop ?? 99.0,
linkQuality: telemetry?.linkQuality ?? 0,
snrDb: telemetry?.snrDb ?? -99,
navMode: telemetry?.navMode ?? 0,
);
// Homepoint-Marker (Doku: "benutze dieses Icon, um den Homepoint
// anzuzeigen") - null solange homePointAutoRefreshProvider noch keinen
// der drei Trigger-Momente gesehen hat bzw. der FC (noch) keinen
// Homepoint gesetzt hat (siehe MspCommands.getWp/parseMspHomePoint-Doku).
final homePointMarker = homePoint == null
? null
: Marker(
point: LatLng(homePoint.lat, homePoint.lon),
width: 32,
height: 32,
alignment: Alignment.center,
child: const HomePointIcon(),
);
return Stack(
children: [
MissionMap(
mapController: _mapController,
routeSegments: routeGeometry.segments,
rubberBandLine: rubberBand,
waypointMarkers: [
for (var i = 0; i < waypoints.length; i++)
CircleMarker(
point: LatLng(waypoints[i].lat, waypoints[i].lon),
radius: 6,
color: i < routeGeometry.vertexBad.length && routeGeometry.vertexBad[i]
? const Color(0xFFE2574A)
: const Color(0xFF2B2B28),
borderStrokeWidth: 1.5,
borderColor: Colors.white,
),
],
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
// SET-Zeile, weil es im Waypoint-Fly-Modus kein editierbares
// Rad-Widget (curAlt/curSpeed) gibt, dessen Wert sinnvoll waere.
Positioned(
top: 56,
bottom: footerBottomInset,
left: 14 + safeEdgeInset,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_liveReadout('Alt', telemetry?.altitudeM, 'm'),
const SizedBox(height: 6),
// Steig-/Sinkrate direkt unter der Hoehenanzeige (Nutzerwunsch)
// - Pfeil statt Vorzeichen (+/-), da ein Pfeil auf einen Blick
// erfassbar ist, waehrend "+"/"-" bei kleiner Schrift leicht
// uebersehen wird.
_verticalSpeedReadout(telemetry?.verticalSpeedMs),
],
),
),
),
Positioned(
top: 56,
bottom: footerBottomInset,
right: 14 + safeEdgeInset,
child: Center(child: _liveReadout('Speed', telemetry?.speedMs, 'm/s')),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: MissionFooterBar(
waypoints: waypoints,
legClimbBad: routeGeometry.legClimbBad,
vertexBad: routeGeometry.vertexBad,
activeIndex: -1,
totalDistanceM: missionStats.distanceM,
totalDurationSec: missionStats.durationSec,
flyMissionStatus: flyMissionStatus,
distanceToHomeM: distanceToHomeM,
droneStatus: droneStatus,
warnings: warnings,
showWarningsBanner: false,
batteryPercent: telemetry?.batteryPercent,
onSendTap: _onSendTap,
sending: _sending,
onDetailsTap: _openWaypointList,
onWarningsTap: _openSystemMessagesPanel,
onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute(
builder: (_) => const SettingsScreen(),
)),
),
),
],
);
}
/// Steig-/Sinkrate unter der Hoehenanzeige (siehe _liveReadout-Aufrufer
/// oben) - zeigt bewusst einen Pfeil statt eines Vorzeichens vor der Zahl
/// (Nutzerwunsch), der Betrag bleibt trotzdem als Wert sichtbar. >= 0 gilt
/// als Steigen, analog zur Vorzeichen-Konvention in
/// drone_status_messages_panel.dart._formatVerticalSpeed.
Widget _verticalSpeedReadout(double? valueMs) {
final Widget content;
if (valueMs == null) {
content = const Text(
'—',
style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
);
} else {
content = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
valueMs < 0 ? Icons.arrow_downward : Icons.arrow_upward,
color: Colors.white,
size: 15,
),
const SizedBox(width: 4),
Text(
'${valueMs.abs().toStringAsFixed(1)} m/s',
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
),
],
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(6),
),
child: content,
);
}
Widget _liveReadout(String label, double? value, String unit) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'$label: ${value != null ? '${value.round()} $unit' : '—'}',
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
);
}
}