import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../domain/telemetry/drone_status.dart'; import '../../domain/telemetry/system_message.dart'; import '../../domain/telemetry/telemetry_field_status.dart'; import '../../transport/flight_controller_link.dart'; import '../dmc_colors.dart'; import '../providers/active_drone_profile_provider.dart'; import '../providers/system_message_log_provider.dart'; import '../providers/telemetry_provider.dart'; 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 system messages") - /// dieselbe Tab-Struktur wie [MissionsDronesPanel], nur mit den beiden /// telemetriebezogenen Tabs Status/System Messages statt Missions/Drones. /// Erreichbar ueber die Drohnen-Status-Pille (Status-Tab) bzw. den /// 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() => _DroneStatusMessagesPanelState(); } class _DroneStatusMessagesPanelState extends ConsumerState { late _PanelTab _tab = widget._initialTab; @override Widget build(BuildContext context) { return Material( color: const Color(0xEB080808), child: SafeArea( child: Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), child: Row( children: [ const Expanded( child: Text( 'Drone Status & System Messages', style: TextStyle( color: Colors.white, fontSize: 20, fontWeight: FontWeight.w700, ), ), ), IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close, color: Colors.white), ), ], ), ), Row( children: [ _tabButton('Status', _PanelTab.status), _tabButton('System Messages', _PanelTab.messages), ], ), const Divider(color: Colors.white24, height: 1), Expanded( child: switch (_tab) { _PanelTab.status => _statusTab(), _PanelTab.messages => _messagesTab(), }, ), ], ), ), ); } Widget _tabButton(String label, _PanelTab tab) { final active = _tab == tab; return Expanded( child: InkWell( onTap: () => setState(() => _tab = tab), child: Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( border: Border( bottom: BorderSide( color: active ? const Color(0xFF4D9DFF) : Colors.transparent, width: 2, ), ), ), child: Text( label, textAlign: TextAlign.center, style: TextStyle( color: active ? Colors.white : Colors.white60, fontWeight: FontWeight.w600, fontSize: 14, ), ), ), ), ); } // --- Status --- // Zeigt die Statuszeilen unabhaengig davon, ob bereits ein Telemetrie-Frame // vorliegt (Doku: "endloser Lade-Screen ohne die Datenfelder anzuzeigen" - // z.B. WLAN gewaehlt, aber noch nicht verbunden) - `telemetry` bleibt dann // dauerhaft AsyncLoading, .value ist einfach null. Analog zu FlyScreen. // droneStatus (siehe dortige Doku) werden Platzhalterwerte verwendet, damit // computeTelemetryFieldStatus trotzdem eine (dann durchgehend rote) Ampel // liefert, statt hier zusaetzlich zwischen "noch nie verbunden" und // "Verbindung verloren" zu unterscheiden. Einzelne Werte zeigen dennoch // '—' statt eines erfundenen Messwerts (0 A/0 V saehe sonst wie ein echter, // kritischer Messwert statt wie "keine Daten" aus). Widget _statusTab() { final frame = ref.watch(telemetryProvider).value; final droneProfile = ref.watch(activeDroneProfileProvider); final fieldStatus = computeTelemetryFieldStatus( numSat: frame?.numSat ?? 0, hasFix: frame?.hasFix ?? false, hdop: frame?.hdop ?? 99.0, currentA: frame?.currentA ?? 0, batteryVoltage: frame?.batteryVoltage ?? 0, linkQuality: frame?.linkQuality ?? 0, snrDb: frame?.snrDb ?? -99, profile: droneProfile, hasData: frame != null, ); return ListView( padding: const EdgeInsets.all(16), children: [ // Eigene, volle Zeile ganz oben statt Teil des 2-Spalten-Rasters // (Doku: "eine zeile ganz oben fuer sensor status") - die einzelnen // Sensor-Badges brauchen mehr horizontalen Platz als eine // Wertkachel bieten wuerde. _sensorStatusRow(frame), const SizedBox(height: 8), ..._statusGrid([ // Arming + erste 3 Temperaturen als eigenes Zeilenpaar direkt // unter der Sensor-Status-Zeile (Doku), noch vor den // farbcodierten Feldern. _statusRow( 'Arming', frame == null ? '—' : (frame.armed ? 'Armed' : 'Disarmed'), level: frame?.armed == true ? SignalLevel.green : null, ), _statusRow('Temperatures', _formatTemperatures(frame)), // Farbcodierte Felder (Doku: "drone status felder im menue auch // farblich kodieren", Schwellwerte je Drohnenprofil - siehe // computeTelemetryFieldStatus), informelle Felder ohne Ampel- // Bewertung (Koordinaten/Heading/Alt/Speed/Vertical speed) // danach. Je 2 Felder pro Zeile (Doku: "platz effizienter // nutzen"). _statusRow('GPS satellites', frame != null ? '${frame.numSat}' : '—', level: fieldStatus.numSat), _statusRow( 'GPS precision (HDOP)', frame != null && frame.hasFix ? frame.hdop.toStringAsFixed(2) : '—', level: fieldStatus.hdop, ), _statusRow( 'Current draw', frame != null ? '${frame.currentA.toStringAsFixed(1)} A' : '—', level: fieldStatus.currentA, ), _statusRow( 'Battery voltage', frame != null ? '${frame.batteryVoltage.toStringAsFixed(2)} V' : '—', level: fieldStatus.batteryVoltage, ), _statusRow('RX quality', frame != null ? '${frame.linkQuality}%' : '—', level: fieldStatus.rxQuality), _statusRow('RX SNR', frame != null ? '${frame.snrDb} dB' : '—', level: fieldStatus.rxSnr), _statusRow( 'GPS coordinates', frame != null && frame.hasFix ? '${frame.lat.toStringAsFixed(6)}, ${frame.lon.toStringAsFixed(6)}' : '—', ), _statusRow('Heading', frame != null ? '${frame.headingDeg.round()}°' : '—'), _statusRow('Altitude', _formatAltitude(frame)), _statusRow('Speed', frame != null ? '${frame.speedMs.toStringAsFixed(1)} m/s' : '—'), // Ans Ende der Liste (Doku: "ans ende der liste noch die // steig/sinkrate hinzufuegen") - bildet mit "Speed" das letzte // Zeilenpaar. _statusRow('Vertical speed', _formatVerticalSpeed(frame)), ]), ], ); } /// Ordnet die Statuszeilen zu je 2 pro Zeile an (Doku: "2 felder pro zeile /// im drone status menue erlauben um platz effizienter zu nutzen") - bei /// ungerader Feldanzahl bleibt die letzte Zeile einspaltig statt sie /// kuenstlich zu strecken. List _statusGrid(List tiles) { final rows = []; for (var i = 0; i < tiles.length; i += 2) { final hasPair = i + 1 < tiles.length; // Kein CrossAxisAlignment.stretch: diese Row sitzt direkt als // ListView-Kind, das seinen Kindern unbegrenzte Hoehe gibt (0<=h<= // Infinity) - stretch verlangt aber eine endliche Hoehe, um die Kacheln // darauf zu strecken, und wirft sonst "BoxConstraints forces an // infinite height" (fuehrte zum komplett schwarzen Status-Tab). Default // (center) reicht, da beide Kacheln einer Zeile ohnehin fast gleich // hohen Inhalt haben. rows.add(Row( children: [ Expanded(child: tiles[i]), if (hasPair) ...[ const SizedBox(width: 8), Expanded(child: tiles[i + 1]), ], ], )); } return rows; } static const _signalColors = { SignalLevel.green: DmcColors.flyModeColor, SignalLevel.yellow: DmcColors.pafAmber, SignalLevel.red: DmcColors.warnRed, }; // Label ueber Wert statt nebeneinander (Doku: "2 felder pro zeile") - bei // halber Zeilenbreite kollidiert ein nebeneinander angeordnetes Label wie // "GPS precision (HDOP)" sonst mit dem Wert; gestapelt bleibt auch bei // laengeren Labeln/Werten genug Platz. Widget _statusRow(String label, String value, {SignalLevel? level}) { final valueColor = level != null ? _signalColors[level]! : Colors.white; return Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.04), borderRadius: BorderRadius.circular(10), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: const TextStyle(color: Colors.white70, fontSize: 12), overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Row( children: [ if (level != null) ...[ Container( width: 8, height: 8, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(color: valueColor, shape: BoxShape.circle), ), ], Flexible( child: Text( value, style: TextStyle(color: valueColor, fontWeight: FontWeight.w600), overflow: TextOverflow.ellipsis, ), ), ], ), ], ), ); } // --- Sensor status --- /// Lokale Kopie der Sensor-Bit-Bedeutung (siehe `MspSensorStatusBits` in /// transport/msp/msp_commands.dart) - bewusst nicht von dort importiert, /// damit dieses UI-Widget kein MSP-/Protokollwissen braucht. /// [TelemetryFrame.sensorStatusBits] ist protokollneutral durchgereicht, /// genau wie `navMode` (siehe die entsprechenden lokalen Konstanten in /// domain/telemetry/drone_status.dart). static const _sensorBits = [ (bit: 1 << 0, label: 'ACC'), (bit: 1 << 1, label: 'BARO'), (bit: 1 << 2, label: 'MAG'), (bit: 1 << 3, label: 'GPS'), (bit: 1 << 4, label: 'RNG'), (bit: 1 << 5, label: 'OF'), (bit: 1 << 6, label: 'PITOT'), (bit: 1 << 7, label: 'TEMP'), ]; static const _sensorHardwareFailureBit = 1 << 15; Widget _sensorStatusRow(TelemetryFrame? frame) { final bits = frame?.sensorStatusBits ?? 0; final hardwareFailure = (bits & _sensorHardwareFailureBit) != 0; return Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.04), borderRadius: BorderRadius.circular(10), border: hardwareFailure ? Border.all(color: DmcColors.warnRed.withValues(alpha: 0.6)) : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Sensor Status', style: TextStyle(color: Colors.white70, fontSize: 12), ), const SizedBox(height: 6), Wrap( spacing: 6, runSpacing: 6, children: [ for (final s in _sensorBits) _sensorChip(s.label, frame != null && (bits & s.bit) != 0), if (hardwareFailure) _sensorChip('HW FAULT', true, color: DmcColors.warnRed), ], ), ], ), ); } Widget _sensorChip(String label, bool active, {Color? color}) { final chipColor = color ?? (active ? DmcColors.flyModeColor : Colors.white38); return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: chipColor.withValues(alpha: active ? 0.18 : 0.06), borderRadius: BorderRadius.circular(6), border: Border.all(color: chipColor.withValues(alpha: active ? 0.8 : 0.3)), ), child: Text( label, style: TextStyle(color: chipColor, fontSize: 11, fontWeight: FontWeight.w600), ), ); } // Rohe GPS-Hoehe im selben Feld wie die barometrisch/GPS-fusionierte // Schaetzung (Doku: "GPS Höhe auch anzeigen im gleichen Feld wie die // normale Höhe") statt einer eigenen Kachel - nur bei hasFix angehaengt, // da die GPS-Hoehe ohne Fix bedeutungslos/veraltet ist (analog zu GPS // coordinates/HDOP oben). String _formatAltitude(TelemetryFrame? frame) { if (frame == null) return '—'; final estimated = '${frame.altitudeM.toStringAsFixed(0)} m'; if (!frame.hasFix) return estimated; return '$estimated (GPS ${frame.gpsAltitudeM.toStringAsFixed(0)} m)'; } String _formatTemperatures(TelemetryFrame? frame) { if (frame == null) return '—'; return frame.temperaturesC.map((t) => t == null ? '—' : '${t.round()}°').join(' / '); } String _formatVerticalSpeed(TelemetryFrame? frame) { if (frame == null) return '—'; final v = frame.verticalSpeedMs; final sign = v >= 0 ? '+' : '-'; return '$sign${v.abs().toStringAsFixed(1)} m/s'; } // --- System Messages --- Widget _messagesTab() { final events = ref.watch(systemMessageLogProvider); if (events.isEmpty) { return const Center( child: Text('No messages yet', style: TextStyle(color: Colors.white60, fontSize: 15)), ); } return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), itemCount: events.length, itemBuilder: (context, index) => _messageRow(events[index]), ); } 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), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.04), border: Border.all(color: color.withValues(alpha: 0.4)), borderRadius: BorderRadius.circular(10), ), child: Row( children: [ Text(_formatTime(event.timestamp), style: const TextStyle(color: Colors.white60)), const SizedBox(width: 12), Expanded( child: Text( event.message, style: TextStyle(color: color, fontWeight: FontWeight.w600), ), ), ], ), ); } String _formatTime(DateTime dt) => '${dt.hour.toString().padLeft(2, '0')}:' '${dt.minute.toString().padLeft(2, '0')}:' '${dt.second.toString().padLeft(2, '0')}'; }