From b09d00ab9f69f7041e8d2e7fe4009d8e7eb78362 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:47:38 +0200 Subject: [PATCH] Rename the Fly-mode Warnings tab to System Messages, log connection/upload events Renames DroneEvent/DroneEventSeverity/droneEventLogProvider/ DroneStatusWarningsPanel to SystemMessage/SystemMessageSeverity/ systemMessageLogProvider/DroneStatusMessagesPanel throughout, matching what the tab now actually shows - general system messages, not just drone-health warnings. Adds a new SystemMessageSeverity.info level (blue) for messages that aren't a warning/error, and three new message sources on top of the existing battery/failsafe detection: - "Connected", logged the first time telemetryProvider produces a frame after having none - the mirror image of the existing "Connection lost" detection (which already fires on the first stream error after having had data), so no new transport-specific dependency was needed. - "Connection type changed to X", from watching connectionSettingsProvider (skips the initial load so it doesn't fire on every app start). - "Mission sent (N waypoints)" / "Mission upload failed: ...", logged from FlyScreen's send handler via a new public log() method on the notifier, alongside the existing snackbar. Verified on the Pixel_10a emulator: entering Fly mode logs "Connected" once the mock telemetry starts, and tapping the send button logs "Mission sent (3 waypoints)" right after the snackbar. Co-Authored-By: Claude Sonnet 5 --- .../{drone_event.dart => system_message.dart} | 46 +++--- .../telemetry/telemetry_field_status.dart | 2 +- .../providers/drone_event_log_provider.dart | 64 -------- .../system_message_log_provider.dart | 110 ++++++++++++++ app/lib/ui/screens/fly/fly_screen.dart | 37 +++-- app/lib/ui/widgets/bottom_stats_bar.dart | 11 +- ....dart => drone_status_messages_panel.dart} | 52 ++++--- ...ent_test.dart => system_message_test.dart} | 28 ++-- .../system_message_log_provider_test.dart | 142 ++++++++++++++++++ app/test/widget_test.dart | 38 +++++ 10 files changed, 386 insertions(+), 144 deletions(-) rename app/lib/domain/telemetry/{drone_event.dart => system_message.dart} (53%) delete mode 100644 app/lib/ui/providers/drone_event_log_provider.dart create mode 100644 app/lib/ui/providers/system_message_log_provider.dart rename app/lib/ui/widgets/{drone_status_warnings_panel.dart => drone_status_messages_panel.dart} (86%) rename app/test/domain/{drone_event_test.dart => system_message_test.dart} (78%) create mode 100644 app/test/ui/providers/system_message_log_provider_test.dart diff --git a/app/lib/domain/telemetry/drone_event.dart b/app/lib/domain/telemetry/system_message.dart similarity index 53% rename from app/lib/domain/telemetry/drone_event.dart rename to app/lib/domain/telemetry/system_message.dart index 3a1f7e1..15236f2 100644 --- a/app/lib/domain/telemetry/drone_event.dart +++ b/app/lib/domain/telemetry/system_message.dart @@ -1,14 +1,17 @@ import '../../transport/flight_controller_link.dart'; -/// Schweregrad eines Eintrags im Ereignis-Log (Doku: "log von kritischen -/// ereignissen mit time stamp ... connection lost, battery low/critical, -/// fail safe mode activated"). -enum DroneEventSeverity { warning, critical } +/// Schweregrad eines Eintrags im System-Messages-Log (Doku: "log von +/// kritischen ereignissen mit time stamp ... connection lost, battery +/// low/critical, fail safe mode activated", erweitert um allgemeine +/// Systemmeldungen wie Verbindungsauf-/-abbau, Verbindungsart-Wechsel und +/// Missionsuebertragung). [info] ist fuer Meldungen ohne Alarmcharakter - +/// reine Statusinformation, kein Warn-/Fehlerzustand. +enum SystemMessageSeverity { info, warning, critical } -/// Ein einzelner Log-Eintrag mit Zeitstempel (Doku: "Warnings Tab ... log -/// von kritischen Ereignissen mit time stamp"). -class DroneEvent { - const DroneEvent({ +/// Ein einzelner Log-Eintrag mit Zeitstempel (Doku: "System Messages Tab +/// ... log von kritischen Ereignissen mit time stamp"). +class SystemMessage { + const SystemMessage({ required this.timestamp, required this.message, required this.severity, @@ -16,44 +19,45 @@ class DroneEvent { final DateTime timestamp; final String message; - final DroneEventSeverity severity; + final SystemMessageSeverity severity; } /// Erkennt Uebergaenge zwischen zwei aufeinanderfolgenden Telemetrie-Frames, /// die einen Log-Eintrag wert sind - flankengetriggert (nur beim /// Ueberschreiten einer Schwelle, nicht bei jedem Frame, solange der -/// Zustand anhaelt). "Connection lost" gehoert NICHT hierher, da es keinen -/// [current]-Frame gibt, wenn die Verbindung abreisst - das wird auf -/// Provider-Ebene aus dem Stream-Fehler abgeleitet (siehe -/// drone_event_log_provider.dart). +/// Zustand anhaelt). "Connection lost"/"Connected" gehoeren NICHT hierher, +/// da es dafuer keine zwei [TelemetryFrame]s zum Vergleichen gibt (die +/// Verbindung selbst liefert ja gerade keinen/wieder einen Frame) - das wird +/// auf Provider-Ebene aus dem AsyncValue-Zustand von telemetryProvider +/// abgeleitet (siehe system_message_log_provider.dart). /// /// Batterie-Schwellen identisch zu den Ampelfarben der BatteryIndicatorBar /// (20%/30%) - dieselbe Semantik, nur zusaetzlich protokolliert statt nur /// angezeigt. "kritisch" hat Vorrang: wird beim selben Uebergang sowohl die /// 30%- als auch die 20%-Schwelle unterschritten (z.B. Ladestand sackt in /// einem Zyklus von 35% auf 15%), wird nur der kritische Eintrag erzeugt. -List detectDroneEvents({ +List detectSystemMessages({ required TelemetryFrame? previous, required TelemetryFrame current, required DateTime now, }) { - final events = []; + final events = []; if (previous != null && previous.batteryPercent > 20 && current.batteryPercent <= 20) { - events.add(DroneEvent( + events.add(SystemMessage( timestamp: now, message: 'Battery critical (${current.batteryPercent}%)', - severity: DroneEventSeverity.critical, + severity: SystemMessageSeverity.critical, )); } else if (previous != null && previous.batteryPercent > 30 && current.batteryPercent <= 30) { - events.add(DroneEvent( + events.add(SystemMessage( timestamp: now, message: 'Battery low (${current.batteryPercent}%)', - severity: DroneEventSeverity.warning, + severity: SystemMessageSeverity.warning, )); } @@ -61,10 +65,10 @@ List detectDroneEvents({ if (previous != null && previous.navMode != navModeEmergency && current.navMode == navModeEmergency) { - events.add(DroneEvent( + events.add(SystemMessage( timestamp: now, message: 'Failsafe mode activated', - severity: DroneEventSeverity.critical, + severity: SystemMessageSeverity.critical, )); } diff --git a/app/lib/domain/telemetry/telemetry_field_status.dart b/app/lib/domain/telemetry/telemetry_field_status.dart index 97ae4b8..3b9ae38 100644 --- a/app/lib/domain/telemetry/telemetry_field_status.dart +++ b/app/lib/domain/telemetry/telemetry_field_status.dart @@ -80,7 +80,7 @@ TelemetryFieldStatus computeTelemetryFieldStatus({ profile.gpsSatRedMin, ), // Ohne Fix ist der HDOP-Wert bedeutungslos/veraltet (siehe Anzeige in - // drone_status_warnings_panel.dart, die dafuer bereits '—' zeigt) - dann + // drone_status_messages_panel.dart, die dafuer bereits '—' zeigt) - dann // unabhaengig vom Zahlenwert rot statt eines irrefuehrend guten Signals. hdop: hasFix ? _levelLowerIsBetter(hdop, profile.hdopGreenMax, profile.hdopRedMax) diff --git a/app/lib/ui/providers/drone_event_log_provider.dart b/app/lib/ui/providers/drone_event_log_provider.dart deleted file mode 100644 index 41ec73f..0000000 --- a/app/lib/ui/providers/drone_event_log_provider.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../domain/telemetry/drone_event.dart'; -import '../../transport/flight_controller_link.dart'; -import 'telemetry_provider.dart'; - -/// Fuehrt das Ereignis-Log fuer den neuen Warnings-Tab (Doku: -/// "log von kritischen ereignissen mit time stamp") - beobachtet -/// [telemetryProvider] dauerhaft (bewusst KEIN autoDispose, siehe -/// telemetry_provider.dart-Doku zur analogen Begruendung bei -/// flightControllerLinkProvider), damit ein Ereignis auch dann protokolliert -/// wird, wenn die Warnings-Ansicht selbst gerade nicht geoeffnet ist. -/// Beginnt zu beobachten, sobald irgendein Widget diesen Provider erstmals -/// liest (siehe FlyScreen) - vor diesem Zeitpunkt liegende Ereignisse -/// erscheinen nicht im Log, das ist fuer ein Live-Log erwartbar. -class DroneEventLogNotifier extends Notifier> { - TelemetryFrame? _previousFrame; - bool _hadData = false; - - @override - List build() { - ref.listen>(telemetryProvider, (previous, next) { - next.when( - data: (frame) { - final events = detectDroneEvents( - previous: _previousFrame, - current: frame, - now: DateTime.now(), - ); - _previousFrame = frame; - _hadData = true; - if (events.isNotEmpty) { - state = [...events.reversed, ...state]; - } - }, - error: (error, stackTrace) { - // Nur melden, wenn zuvor bereits Frames flossen - sonst wuerde - // schon das (normale) Ausbleiben der allerersten Verbindung als - // "Connection lost" auftauchen. - if (_hadData) { - state = [ - DroneEvent( - timestamp: DateTime.now(), - message: 'Connection lost', - severity: DroneEventSeverity.critical, - ), - ...state, - ]; - } - _previousFrame = null; - _hadData = false; - }, - loading: () {}, - ); - }); - return []; - } - - void clear() => state = []; -} - -final droneEventLogProvider = - NotifierProvider>( - DroneEventLogNotifier.new); diff --git a/app/lib/ui/providers/system_message_log_provider.dart b/app/lib/ui/providers/system_message_log_provider.dart new file mode 100644 index 0000000..9765993 --- /dev/null +++ b/app/lib/ui/providers/system_message_log_provider.dart @@ -0,0 +1,110 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../domain/telemetry/system_message.dart'; +import '../../transport/connection_type.dart'; +import '../../transport/flight_controller_link.dart'; +import 'connection_settings_provider.dart'; +import 'telemetry_provider.dart'; + +/// Fuehrt das System-Messages-Log fuer den gleichnamigen Tab im +/// Drohnen-Status-Menue (Doku: "aendere das warnings Menü in System +/// Messages ... Verbindung hergestellt/verloren, Verbindungstyp geaendert, +/// Wegpunkte uebertragen") - beobachtet [telemetryProvider] und +/// [connectionSettingsProvider] dauerhaft (bewusst KEIN autoDispose, siehe +/// telemetry_provider.dart-Doku zur analogen Begruendung bei +/// flightControllerLinkProvider), damit ein Ereignis auch dann protokolliert +/// wird, wenn die System-Messages-Ansicht selbst gerade nicht geoeffnet ist. +/// Beginnt zu beobachten, sobald irgendein Widget diesen Provider erstmals +/// liest (siehe FlyScreen) - vor diesem Zeitpunkt liegende Ereignisse +/// erscheinen nicht im Log, das ist fuer ein Live-Log erwartbar. +class SystemMessageLogNotifier extends Notifier> { + TelemetryFrame? _previousFrame; + bool _hadData = false; + + @override + List build() { + ref.listen>(telemetryProvider, (previous, next) { + next.when( + data: (frame) { + // Flankengetriggert wie "Connection lost" unten, nur umgekehrt - + // anders als dort ist hier kein Sonderfall fuer die allererste + // Verbindung noetig: der Uebergang "noch keine Daten" -> "erste + // Daten" ist immer ein echtes "Connected", auch beim allerersten + // Mal. + if (!_hadData) { + _log('Connected', SystemMessageSeverity.info); + } + final events = detectSystemMessages( + previous: _previousFrame, + current: frame, + now: DateTime.now(), + ); + _previousFrame = frame; + _hadData = true; + if (events.isNotEmpty) { + state = [...events.reversed, ...state]; + } + }, + error: (error, stackTrace) { + // Nur melden, wenn zuvor bereits Frames flossen - sonst wuerde + // schon das (normale) Ausbleiben der allerersten Verbindung als + // "Connection lost" auftauchen. + if (_hadData) { + _log('Connection lost', SystemMessageSeverity.critical); + } + _previousFrame = null; + _hadData = false; + }, + loading: () {}, + ); + }); + + // Verbindungsart-Wechsel (Doku: "Verbindungstyp geaendert") - nur ab dem + // zweiten geladenen Wert protokollieren, sonst wuerde bereits das + // erstmalige Laden der Einstellungen aus der DB als "Wechsel" erscheinen. + ConnectionType? previousType; + var sawFirstLoad = false; + ref.listen(connectionSettingsProvider, (previous, next) { + final type = next.value?.connectionType; + if (type == null) return; + if (!sawFirstLoad) { + sawFirstLoad = true; + previousType = type; + return; + } + if (type != previousType) { + _log('Connection type changed to ${_connectionTypeLabel(type)}', + SystemMessageSeverity.info); + previousType = type; + } + }); + + return []; + } + + String _connectionTypeLabel(ConnectionType type) => switch (type) { + ConnectionType.wifi => 'WiFi mLRS UDP', + ConnectionType.mock => 'Mock (Test)', + }; + + /// Fuegt einen Eintrag manuell hinzu (Doku: "Wegpunkte uebertragen") - fuer + /// Ereignisse, die nicht aus der Telemetrie/den Einstellungen ableitbar + /// sind, sondern vom Aufloeser einer konkreten Aktion (z.B. FlyScreen nach + /// einem Missions-Upload) gemeldet werden. + void log(String message, {SystemMessageSeverity severity = SystemMessageSeverity.info}) { + _log(message, severity); + } + + void _log(String message, SystemMessageSeverity severity) { + state = [ + SystemMessage(timestamp: DateTime.now(), message: message, severity: severity), + ...state, + ]; + } + + void clear() => state = []; +} + +final systemMessageLogProvider = + NotifierProvider>( + SystemMessageLogNotifier.new); diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index 7084d34..190b6ca 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -12,20 +12,21 @@ 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/drone_event_log_provider.dart'; import '../../providers/follow_drone_provider.dart'; import '../../providers/map_controller_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_warnings_panel.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'; @@ -122,9 +123,9 @@ class _FlyScreenState extends ConsumerState { ); } - void _openDroneStatusWarningsPanel() { + void _openSystemMessagesPanel() { Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const DroneStatusWarningsPanel.warnings(), + builder: (_) => const DroneStatusMessagesPanel.messages(), )); } @@ -134,7 +135,9 @@ class _FlyScreenState extends ConsumerState { /// 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. + /// Verbindungsversuch auszuloesen. Protokolliert Erfolg/Fehlschlag + /// zusaetzlich im System-Messages-Log (Doku: "wenn ... Wegpunkte + /// übertragen werden"). Future _onSendTap() async { final waypoints = ref.read(currentMissionProvider); final messenger = ScaffoldMessenger.of(context); @@ -144,6 +147,7 @@ class _FlyScreenState extends ConsumerState { } setState(() => _sending = true); + final log = ref.read(systemMessageLogProvider.notifier); try { final service = MissionSyncService( ref.read(flightControllerLinkProvider), @@ -151,8 +155,10 @@ class _FlyScreenState extends ConsumerState { ); await service.syncAndUpload(FlatWaypointList(waypoints)); messenger.showSnackBar(const SnackBar(content: Text('Mission sent'))); + log.log('Mission sent (${waypoints.length} waypoints)'); } catch (e) { messenger.showSnackBar(SnackBar(content: Text('Send failed: $e'))); + log.log('Mission upload failed: $e', severity: SystemMessageSeverity.critical); } finally { if (mounted) setState(() => _sending = false); } @@ -164,15 +170,16 @@ class _FlyScreenState extends ConsumerState { 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); + // Beobachtet das System-Messages-Log dauerhaft ab dem ersten Eintritt in + // den Fly-Modus (Doku: "log von kritischen ereignissen mit time stamp", + // erweitert um Verbindungs-/Uebertragungsereignisse) - haelt + // system_message_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 oben) - System Messages bleiben + // ueber die Drohnen-Status-Pille erreichbar. + ref.watch(systemMessageLogProvider); // Solange der ueber den GPS-Knopf in der Kopfleiste (FlyMapControls) // aktivierte Folgemodus aktiv ist, jede neue Telemetrie-Position @@ -343,7 +350,7 @@ class _FlyScreenState extends ConsumerState { onSendTap: _onSendTap, sending: _sending, onDetailsTap: _openWaypointList, - onWarningsTap: _openDroneStatusWarningsPanel, + onWarningsTap: _openSystemMessagesPanel, onSettingsTap: () => Navigator.of(context).push(MaterialPageRoute( builder: (_) => const SettingsScreen(), )), diff --git a/app/lib/ui/widgets/bottom_stats_bar.dart b/app/lib/ui/widgets/bottom_stats_bar.dart index 9e45332..0f05d4d 100644 --- a/app/lib/ui/widgets/bottom_stats_bar.dart +++ b/app/lib/ui/widgets/bottom_stats_bar.dart @@ -10,7 +10,7 @@ import '../dmc_colors.dart'; import '../providers/active_drone_profile_provider.dart'; import '../providers/altitude_profile_provider.dart'; import '../providers/mission_meta_provider.dart'; -import 'drone_status_warnings_panel.dart'; +import 'drone_status_messages_panel.dart'; import 'missions_drones_panel.dart'; import 'mountain_peak_icon.dart'; import 'paper_plane_icon.dart'; @@ -90,9 +90,10 @@ class BottomStatsBar extends ConsumerWidget { /// Nur im Fly-Modus gesetzt (Doku: "im fly screen den warnings button /// durch einen wp send button ersetzen") - wenn gesetzt, ersetzt der /// Sende-Knopf (Papierflieger, horizontal, pink) den runden Warnungs- - /// Knopf komplett, unabhaengig von [warnings]. Warnungen/Ereignis-Log - /// bleiben im Fly-Modus weiterhin ueber die Drohnen-Status-Pille - /// erreichbar (dort als eigener Tab), siehe DroneStatusWarningsPanel. + /// Knopf komplett, unabhaengig von [warnings]. System Messages (Doku: + /// "aendere das warnings Menü in System Messages") bleiben im Fly-Modus + /// weiterhin ueber die Drohnen-Status-Pille erreichbar (dort als eigener + /// Tab), siehe DroneStatusMessagesPanel. final VoidCallback? onSendTap; /// Zeigt einen Ladeindikator statt des Papierflieger-Icons und ignoriert @@ -187,7 +188,7 @@ class BottomStatsBar extends ConsumerWidget { // Profile werden waehrend des Fluges ohnehin // nicht gewechselt. onTap: () => Navigator.of(context).push(MaterialPageRoute( - builder: (_) => const DroneStatusWarningsPanel.status(), + builder: (_) => const DroneStatusMessagesPanel.status(), )), ) : _NameChip( diff --git a/app/lib/ui/widgets/drone_status_warnings_panel.dart b/app/lib/ui/widgets/drone_status_messages_panel.dart similarity index 86% rename from app/lib/ui/widgets/drone_status_warnings_panel.dart rename to app/lib/ui/widgets/drone_status_messages_panel.dart index c28609e..0c84f40 100644 --- a/app/lib/ui/widgets/drone_status_warnings_panel.dart +++ b/app/lib/ui/widgets/drone_status_messages_panel.dart @@ -1,35 +1,35 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../domain/telemetry/drone_event.dart'; import '../../domain/telemetry/drone_status.dart'; +import '../../domain/telemetry/system_message.dart'; import '../../domain/telemetry/telemetry_field_status.dart'; import '../dmc_colors.dart'; import '../providers/active_drone_profile_provider.dart'; -import '../providers/drone_event_log_provider.dart'; +import '../providers/system_message_log_provider.dart'; import '../providers/telemetry_provider.dart'; -enum _PanelTab { status, warnings } +enum _PanelTab { status, messages } /// Vollbild-Statusmenue fuer den Fly-Modus (Doku: "ein neues menue anlegen /// fuer die neue drone status pille, nach dem vorbild des menues fuer -/// drones and missions, aber jetzt fuer drone status and warnings") - +/// drones and missions, aber jetzt fuer drone status and system messages") - /// dieselbe Tab-Struktur wie [MissionsDronesPanel], nur mit den beiden -/// telemetriebezogenen Tabs Status/Warnings statt Missions/Drones. +/// telemetriebezogenen Tabs Status/System Messages statt Missions/Drones. /// Erreichbar ueber die Drohnen-Status-Pille (Status-Tab) bzw. den -/// Warnungs-Knopf (Warnings-Tab) in der Fusszeile im Fly-Modus (siehe -/// bottom_stats_bar.dart/fly_screen.dart). -class DroneStatusWarningsPanel extends ConsumerStatefulWidget { - const DroneStatusWarningsPanel.status({super.key}) : _initialTab = _PanelTab.status; - const DroneStatusWarningsPanel.warnings({super.key}) : _initialTab = _PanelTab.warnings; +/// Sende-Knopf-Nachbarn/Warnungs-Knopf (System-Messages-Tab) in der +/// Fusszeile im Fly-Modus (siehe bottom_stats_bar.dart/fly_screen.dart). +class DroneStatusMessagesPanel extends ConsumerStatefulWidget { + const DroneStatusMessagesPanel.status({super.key}) : _initialTab = _PanelTab.status; + const DroneStatusMessagesPanel.messages({super.key}) : _initialTab = _PanelTab.messages; final _PanelTab _initialTab; @override - ConsumerState createState() => _DroneStatusWarningsPanelState(); + ConsumerState createState() => _DroneStatusMessagesPanelState(); } -class _DroneStatusWarningsPanelState extends ConsumerState { +class _DroneStatusMessagesPanelState extends ConsumerState { late _PanelTab _tab = widget._initialTab; @override @@ -45,7 +45,7 @@ class _DroneStatusWarningsPanelState extends ConsumerState _statusTab(), - _PanelTab.warnings => _warningsTab(), + _PanelTab.messages => _messagesTab(), }, ), ], @@ -253,26 +253,30 @@ class _DroneStatusWarningsPanelState extends ConsumerState _eventRow(events[index]), + itemBuilder: (context, index) => _messageRow(events[index]), ); } - Widget _eventRow(DroneEvent event) { - final color = event.severity == DroneEventSeverity.critical - ? DmcColors.warnRed - : DmcColors.pafAmber; + static const _severityColors = { + SystemMessageSeverity.critical: DmcColors.warnRed, + SystemMessageSeverity.warning: DmcColors.pafAmber, + SystemMessageSeverity.info: DmcColors.setBlue, + }; + + Widget _messageRow(SystemMessage event) { + final color = _severityColors[event.severity]!; return Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), diff --git a/app/test/domain/drone_event_test.dart b/app/test/domain/system_message_test.dart similarity index 78% rename from app/test/domain/drone_event_test.dart rename to app/test/domain/system_message_test.dart index 37f3ee0..eabc96f 100644 --- a/app/test/domain/drone_event_test.dart +++ b/app/test/domain/system_message_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:dmc_app/domain/telemetry/drone_event.dart'; +import 'package:dmc_app/domain/telemetry/system_message.dart'; import 'package:dmc_app/transport/flight_controller_link.dart'; TelemetryFrame _frame({int batteryPercent = 80, int navMode = 0}) { @@ -27,9 +27,9 @@ TelemetryFrame _frame({int batteryPercent = 80, int navMode = 0}) { void main() { final now = DateTime(2026, 1, 1, 12, 0, 0); - group('detectDroneEvents - battery', () { + group('detectSystemMessages - battery', () { test('kein Ereignis ohne vorherigen Frame', () { - final events = detectDroneEvents( + final events = detectSystemMessages( previous: null, current: _frame(batteryPercent: 15), now: now, @@ -38,7 +38,7 @@ void main() { }); test('kein Ereignis, solange keine Schwelle unterschritten wird', () { - final events = detectDroneEvents( + final events = detectSystemMessages( previous: _frame(batteryPercent: 80), current: _frame(batteryPercent: 75), now: now, @@ -47,29 +47,29 @@ void main() { }); test('"Battery low" beim Unterschreiten von 30%', () { - final events = detectDroneEvents( + final events = detectSystemMessages( previous: _frame(batteryPercent: 32), current: _frame(batteryPercent: 28), now: now, ); expect(events, hasLength(1)); - expect(events.single.severity, DroneEventSeverity.warning); + expect(events.single.severity, SystemMessageSeverity.warning); expect(events.single.message, contains('Battery low')); }); test('"Battery critical" beim Unterschreiten von 20%', () { - final events = detectDroneEvents( + final events = detectSystemMessages( previous: _frame(batteryPercent: 22), current: _frame(batteryPercent: 18), now: now, ); expect(events, hasLength(1)); - expect(events.single.severity, DroneEventSeverity.critical); + expect(events.single.severity, SystemMessageSeverity.critical); expect(events.single.message, contains('Battery critical')); }); test('nur "Battery critical", wenn beide Schwellen im selben Schritt fallen', () { - final events = detectDroneEvents( + final events = detectSystemMessages( previous: _frame(batteryPercent: 35), current: _frame(batteryPercent: 15), now: now, @@ -79,7 +79,7 @@ void main() { }); test('kein erneutes Ereignis, solange der Ladestand unter der Schwelle bleibt', () { - final events = detectDroneEvents( + final events = detectSystemMessages( previous: _frame(batteryPercent: 25), current: _frame(batteryPercent: 22), now: now, @@ -88,20 +88,20 @@ void main() { }); }); - group('detectDroneEvents - failsafe', () { + group('detectSystemMessages - failsafe', () { test('"Failsafe mode activated" beim Wechsel in Emergency (navMode 15)', () { - final events = detectDroneEvents( + final events = detectSystemMessages( previous: _frame(navMode: 3), current: _frame(navMode: 15), now: now, ); expect(events, hasLength(1)); - expect(events.single.severity, DroneEventSeverity.critical); + expect(events.single.severity, SystemMessageSeverity.critical); expect(events.single.message, 'Failsafe mode activated'); }); test('kein erneutes Ereignis, solange Emergency anhaelt', () { - final events = detectDroneEvents( + final events = detectSystemMessages( previous: _frame(navMode: 15), current: _frame(navMode: 15), now: now, diff --git a/app/test/ui/providers/system_message_log_provider_test.dart b/app/test/ui/providers/system_message_log_provider_test.dart new file mode 100644 index 0000000..6a664d0 --- /dev/null +++ b/app/test/ui/providers/system_message_log_provider_test.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:dmc_app/domain/telemetry/system_message.dart'; +import 'package:dmc_app/transport/connection_type.dart'; +import 'package:dmc_app/transport/flight_controller_link.dart'; +import 'package:dmc_app/ui/providers/connection_settings_provider.dart'; +import 'package:dmc_app/ui/providers/system_message_log_provider.dart'; +import 'package:dmc_app/ui/providers/telemetry_provider.dart'; + +TelemetryFrame _frame() => const TelemetryFrame( + lat: 52.5, + lon: 13.4, + hasFix: true, + numSat: 10, + altitudeM: 100, + speedMs: 15, + headingDeg: 0, + armed: false, + batteryPercent: 80, + batteryVoltage: 22.0, + currentA: 10.0, + linkQuality: 90, + snrDb: 5, + hdop: 1.0, + navMode: 0, + flightMode: 'Idle', + ); + +typedef _ConnectionSettings = ({ + int? activeDroneProfileId, + int? currentMissionId, + String? connectionDeviceAddress, + bool connectionAutoConnect, + ConnectionType connectionType, + String? connectionWifiHost, + int connectionWifiPort, + String connectionWifiSsidPrefix, + String? connectionWifiLastSsid, +}); + +_ConnectionSettings _settings(ConnectionType type) => ( + activeDroneProfileId: null, + currentMissionId: null, + connectionDeviceAddress: null, + connectionAutoConnect: true, + connectionType: type, + connectionWifiHost: null, + connectionWifiPort: 14550, + connectionWifiSsidPrefix: 'mLRS-', + connectionWifiLastSsid: null, + ); + +void main() { + test('loggt "Connected" beim ersten Telemetrie-Frame und "Connection ' + 'lost" bei einem anschliessenden Stream-Fehler', () async { + final controller = StreamController(); + addTearDown(controller.close); + final container = ProviderContainer(overrides: [ + telemetryProvider.overrideWith((ref) => controller.stream), + connectionSettingsProvider + .overrideWith((ref) async => _settings(ConnectionType.mock)), + ]); + addTearDown(container.dispose); + container.listen(systemMessageLogProvider, (previous, next) {}); + + controller.add(_frame()); + await Future.delayed(Duration.zero); + + final afterConnect = container.read(systemMessageLogProvider); + expect(afterConnect, hasLength(1)); + expect(afterConnect.single.message, 'Connected'); + expect(afterConnect.single.severity, SystemMessageSeverity.info); + + controller.addError(StateError('link down')); + await Future.delayed(Duration.zero); + + final afterLost = container.read(systemMessageLogProvider); + expect(afterLost.first.message, 'Connection lost'); + expect(afterLost.first.severity, SystemMessageSeverity.critical); + }); + + test('loggt keine "Connected"-Meldung, solange nie ein Frame ankommt', () async { + final container = ProviderContainer(overrides: [ + telemetryProvider.overrideWith((ref) => const Stream.empty()), + connectionSettingsProvider + .overrideWith((ref) async => _settings(ConnectionType.mock)), + ]); + addTearDown(container.dispose); + + container.listen(systemMessageLogProvider, (previous, next) {}); + await Future.delayed(Duration.zero); + + expect(container.read(systemMessageLogProvider), isEmpty); + }); + + test('loggt "Connection type changed" bei einer Aenderung, nicht beim ' + 'initialen Laden der Einstellungen', () async { + var type = ConnectionType.wifi; + final container = ProviderContainer(overrides: [ + telemetryProvider.overrideWith((ref) => const Stream.empty()), + connectionSettingsProvider.overrideWith((ref) async => _settings(type)), + ]); + addTearDown(container.dispose); + + container.listen(systemMessageLogProvider, (previous, next) {}); + await container.read(connectionSettingsProvider.future); + expect(container.read(systemMessageLogProvider), isEmpty); + + type = ConnectionType.mock; + container.invalidate(connectionSettingsProvider); + await container.read(connectionSettingsProvider.future); + + final log = container.read(systemMessageLogProvider); + expect(log.single.message, 'Connection type changed to Mock (Test)'); + expect(log.single.severity, SystemMessageSeverity.info); + }); + + test('log() fuegt einen Eintrag manuell hinzu (Doku: Wegpunkte uebertragen)', () async { + final container = ProviderContainer(overrides: [ + telemetryProvider.overrideWith((ref) => const Stream.empty()), + connectionSettingsProvider + .overrideWith((ref) async => _settings(ConnectionType.mock)), + ]); + addTearDown(container.dispose); + + container.read(systemMessageLogProvider.notifier).log('Mission sent (3 waypoints)'); + var log = container.read(systemMessageLogProvider); + expect(log.single.message, 'Mission sent (3 waypoints)'); + expect(log.single.severity, SystemMessageSeverity.info); + + container.read(systemMessageLogProvider.notifier).log( + 'Mission upload failed: timeout', + severity: SystemMessageSeverity.critical, + ); + log = container.read(systemMessageLogProvider); + expect(log.first.message, 'Mission upload failed: timeout'); + expect(log.first.severity, SystemMessageSeverity.critical); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 6d52220..0eba47d 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -33,6 +33,7 @@ import 'package:dmc_app/ui/widgets/header_wind_pill.dart'; import 'package:dmc_app/ui/widgets/map_search_controls.dart'; import 'package:dmc_app/ui/providers/map_controller_provider.dart'; import 'package:dmc_app/ui/widgets/missions_drones_panel.dart'; +import 'package:dmc_app/ui/widgets/paper_plane_icon.dart'; import 'package:dmc_app/ui/widgets/reticle_button.dart'; import 'package:dmc_app/ui/widgets/waypoint_chip.dart'; import 'package:dmc_app/ui/widgets/waypoint_list_panel.dart'; @@ -1001,6 +1002,43 @@ void main() { await _stopTelemetry(container); }); + testWidgets( + 'Drohnen-Status-Pille oeffnet "Drone Status & System Messages", ' + 'System-Messages-Tab zeigt "Connected" nach der ersten Telemetrie', + (tester) async { + final container = _testContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: BlocProvider( + create: (_) => AppModeCubit(), + child: const MaterialApp(home: AppShell()), + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Fly')); + await tester.pump(); + // MockFlightControllerLink tickt erst nach 1s (siehe oben). + await tester.pump(const Duration(seconds: 1, milliseconds: 100)); + + // Zwei PaperPlaneIcon-Instanzen im Fly-Fusszeile (Drohnen-Status-Pille + // UND der pinke Sende-Knopf, siehe bottom_stats_bar.dart) - die Pille + // steht im Widget-Baum zuerst. + await tester.tap(find.byType(PaperPlaneIcon).first); + await tester.pumpAndSettle(); + expect(find.text('Drone Status & System Messages'), findsOneWidget); + + await tester.tap(find.text('System Messages')); + await tester.pumpAndSettle(); + expect(find.text('Connected'), findsOneWidget); + + await _stopTelemetry(container); + }); + testWidgets('Fit-Button im Fly-Modus ohne Wegpunkte zeigt Hinweis statt Absturz', (tester) async { final container = _testContainer();