Files
dmc/app/lib/ui/screens/fly/fly_screen.dart
T
Constantin LeueandClaude Sonnet 5 46e51b3920 Drohnen-Status-Pille zeigt bei Verbindungsverlust rot/disconnected statt eingefrorener Werte
Waehrend eines Verbindungsverlusts frieren die TelemetryFrame-Werte
bewusst ein (telemetryProvider.value bleibt auf dem letzten Stand, siehe
vorherigen Commit) - die Fusszeilen-Pille im Fly-Modus hat daraus bislang
weiterhin den zuletzt bekannten (ggf. laengst veralteten) Zustand
abgeleitet, statt den Verbindungsverlust selbst widerzuspiegeln.

- domain/telemetry/drone_status.dart: neuer DroneState.disconnected,
  computeDroneStatus() bekommt einen neuen Parameter `connected` - hat
  Vorrang vor allem anderen (auch vor Emergency) und uebersteuert
  connectionQuality auf rot. positionQuality bleibt bewusst unveraendert
  (folgt weiterhin den eingefrorenen Werten) - laut Anfrage sollen nur
  Drohnen-Icon und Verbindungsqualitaet, nicht die Positionsqualitaet, rot
  erzwungen werden.
- fly_screen.dart: `connected: !telemetryAsync.hasError` statt der
  bisherigen Ableitung aus einzelnen Telemetriewerten.
- bottom_stats_bar.dart: neues Label/Farbe ("disconnected", rot) fuer den
  neuen Zustand in der Drohnen-Status-Pille.

Auf dem Pixel_10a-Emulator verifiziert: WiFi-Verbindungsart (noch nie
verbunden) zeigt bereits korrekt rotes Papierflieger-Icon, rote
Verbindungsqualitaet, rotes Satelliten-Icon und "disconnected" in rot;
Mock-Verbindung zeigt unveraendert normal gruen "ready".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 17:38:55 +02:00

429 lines
18 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/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/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);
// 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,
);
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: _openSystemMessagesPanel,
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,
),
),
);
}
}