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/mission_stats.dart'; import '../../../domain/mission/mission_warnings.dart'; import '../../../domain/mission/route_geometry.dart'; import '../../../domain/telemetry/drone_status.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/drone_event_log_provider.dart'; import '../../providers/follow_drone_provider.dart'; import '../../providers/map_controller_provider.dart'; import '../../providers/telemetry_provider.dart'; import '../../providers/terrain_provider.dart'; import '../settings/settings_screen.dart'; import '../../widgets/drone_marker_icon.dart'; import '../../widgets/drone_status_warnings_panel.dart'; import '../../widgets/mission_footer_bar.dart'; import '../../widgets/mission_map.dart'; import '../../widgets/waypoint_list_panel.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 createState() => _FlyScreenState(); } class _FlyScreenState extends ConsumerState { late final MapController _mapController; StreamSubscription? _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( 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 _openDroneStatusWarningsPanel() { Navigator.of(context).push(MaterialPageRoute( builder: (_) => const DroneStatusWarningsPanel.warnings(), )); } /// 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. Future _onSendTap() async { final waypoints = ref.read(currentMissionProvider); final messenger = ScaffoldMessenger.of(context); if (waypoints.isEmpty) { messenger.showSnackBar(const SnackBar(content: Text('No waypoints to send'))); return; } setState(() => _sending = true); try { final service = MissionSyncService( ref.read(flightControllerLinkProvider), context.read(), ); await service.syncAndUpload(FlatWaypointList(waypoints)); messenger.showSnackBar(const SnackBar(content: Text('Mission sent'))); } catch (e) { messenger.showSnackBar(SnackBar(content: Text('Send failed: $e'))); } finally { if (mounted) setState(() => _sending = false); } } @override Widget build(BuildContext context) { final waypoints = ref.watch(currentMissionProvider); final droneProfile = ref.watch(activeDroneProfileProvider); final telemetry = ref.watch(telemetryProvider).value; final followDrone = ref.watch(followDroneProvider); // Beobachtet das Ereignis-Log dauerhaft ab dem ersten Eintritt in den // Fly-Modus (Doku: "log von kritischen ereignissen mit time stamp") - // haelt drone_event_log_provider.dart's Notifier am Leben, solange // dieser Screen existiert, siehe dessen Klassendoku. Der Rueckgabewert // selbst wird hier nicht mehr gebraucht (der Warnungs-Knopf, der ihn // frueher fuer forceShowWarningsButton auswertete, ist im Fly-Modus // durch den Sende-Knopf ersetzt, siehe onSendTap unten) - Warnungen/ // Ereignis-Log bleiben ueber die Drohnen-Status-Pille erreichbar. ref.watch(droneEventLogProvider); // 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>(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); } }); 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 aktiven Wegpunkt (Doku 3.4: // TelemetryFrame.activeWaypointIndex aus MSP_NAV_STATUS) - derselbe // Darstellungsstil wie das Reticle-Gummiband im Plan-Modus (MissionMap. // rubberBandLine), hier aber Drohne -> naechster Wegpunkt statt letzter // Wegpunkt -> Kartenmitte. List? rubberBand; final activeWpIndex = telemetry?.activeWaypointIndex; if (telemetry != null && telemetry.hasFix && activeWpIndex != null && activeWpIndex >= 0 && activeWpIndex < waypoints.length) { final target = waypoints[activeWpIndex]; rubberBand = [ LatLng(telemetry.lat, telemetry.lon), LatLng(target.lat, target.lon), ]; } // 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: telemetry != null && telemetry.hasFix ? telemetry.lat : null, currentLon: telemetry != null && telemetry.hasFix ? telemetry.lon : null, currentSpeedMs: telemetry?.speedMs, ); // 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. final droneStatus = computeDroneStatus( 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, ); 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, ), // 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: _liveReadout('Alt', telemetry?.altitudeM, 'm')), ), 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, droneStatus: droneStatus, warnings: warnings, showWarningsBanner: false, batteryPercent: telemetry?.batteryPercent, onSendTap: _onSendTap, sending: _sending, onDetailsTap: _openWaypointList, onWarningsTap: _openDroneStatusWarningsPanel, onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute( builder: (_) => const SettingsScreen(), )), ), ), ], ); } 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, ), ), ); } }