Files
dmc/app/lib/ui/screens/fly/fly_screen.dart
T
Constantin Leue 5fd5d0117a Show the drone's locked home point on the fly-mode map
Adds MSP_WP (118) support to query the flightcontroller's stored home
point (WP#0 is a special case for this in iNAV's getWaypoint(), per
navigation.c) - the FC reports (0,0,0) rather than an error before one
is set, so parseMspHomePoint() treats that pair as "unset". Rendered
with a small pentagon house icon (design/homepoint-pentagon.svg,
ported to a CustomPainter like the other map icons).

Deliberately not polled continuously: a new home_point_provider.dart
refreshes it only at the three moments the FC's home point can
actually change - connect/reconnect, GPS fix acquired, and arming
(iNAV's default reset_home_type=FIRST_ARM only freezes it at the first
arm; before that it continuously follows the aircraft while disarmed).

Mock's implementation offsets the point 25m from the anchor so it
doesn't sit exactly under the drone marker during UI testing.
2026-08-09 21:10:00 +02:00

505 lines
21 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 '../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';
/// 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);
// 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 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<LatLng>? 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.
//
// 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,
),
// 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,
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,
),
),
);
}
}