import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../transport/connection_type.dart'; import '../../../transport/flight_controller_link.dart'; import '../../../transport/link_transport.dart'; import '../../dmc_colors.dart'; import '../../providers/connection_settings_provider.dart'; import '../../providers/database_provider.dart'; import '../../providers/telemetry_provider.dart'; import '../../providers/wifi_connection_provider.dart'; /// Einstellungen: Drohnen-Profile (Doku 3.5/7.3, noch nicht hier /// angebunden - siehe DroneProfileEditor) und Verbindung (Doku /// Kommunikationsschicht Abschnitt 7). class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Settings')), // Bewusst nicht symmetrisch: links den echten Geraete-Inset einhalten // (z.B. eine Frontkamera-Aussparung im Querformat), rechts bis an den // Bildschirmrand - anders als in der Fusszeile (BottomStatsBar) soll // hier keine gespiegelte/erzwungene Symmetrie zwischen beiden Seiten // entstehen. body: ListView( padding: EdgeInsets.only( left: 16 + MediaQuery.of(context).padding.left, top: 16, bottom: 16, ), children: const [_ConnectionSection()], ), ); } } class _ConnectionSection extends ConsumerWidget { const _ConnectionSection(); @override Widget build(BuildContext context, WidgetRef ref) { final connectionSettings = ref.watch(connectionSettingsProvider).value; final selectedType = connectionSettings?.connectionType ?? ConnectionType.wifi; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Fuer WLAN rendert _WifiPanel das Typ-Dropdown selbst als Teil // seiner kompakten ersten Zeile (zusammen mit Connect/Disconnect // und den Status-Pillen) - fuer Mock bleibt die bisherige, // einfachere Kopfzeile bestehen. if (selectedType != ConnectionType.wifi) ...[ Text('Connection', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 12), DropdownButtonFormField( initialValue: selectedType, decoration: const InputDecoration( labelText: 'Type', isDense: true, border: OutlineInputBorder(), ), items: _connectionTypeItems, onChanged: (type) async { if (type == null) return; await ref.read(appSettingsRepositoryProvider).setConnectionType(type); ref.invalidate(connectionSettingsProvider); }, ), const SizedBox(height: 16), ], switch (selectedType) { ConnectionType.wifi => const _WifiPanel(), ConnectionType.mock => const _MockPanel(), }, ], ), ), ); } } /// Geteilt zwischen der kompakten WLAN-Zeile ([_WifiPanel]) und der /// generischen Kopfzeile fuer die anderen Verbindungsarten /// ([_ConnectionSection]), damit die Beschriftungen an beiden Stellen /// gleich lauten. const _connectionTypeItems = [ DropdownMenuItem(value: ConnectionType.wifi, child: Text('WiFi mLRS UDP')), DropdownMenuItem(value: ConnectionType.mock, child: Text('Mock (Test)')), ]; /// Zeigt die simulierte Telemetrie von [MockFlightControllerLink] (Doku: /// "Mock-Daten fuer die Live-Details-Ansicht nutzbar machen") - der Mock /// verbindet sich selbst ohne "Connect"-Knopf (siehe telemetryProvider), es /// gibt hier also nichts zu bedienen, nur zur Kontrolle zu beobachten. class _MockPanel extends ConsumerWidget { const _MockPanel(); @override Widget build(BuildContext context, WidgetRef ref) { final telemetry = ref.watch(telemetryProvider).value; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Simulated telemetry for UI testing without hardware.', style: TextStyle(fontSize: 12, color: Colors.grey), ), const SizedBox(height: 12), Text('Telemetry', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 4), _TelemetryFields(telemetry: telemetry), ], ); } } /// SSID-Praefix und Port werden bewusst nicht mehr als Textfelder angeboten /// (Doku: "Preset WiFi mLRS UDP, alles automatisch setzen") - die /// gespeicherten Defaults ("mLRS-"/14550, siehe AppDatabase) passen fuer die /// mLRS-WLAN-Bruecke immer, ein manuelles Ueberschreiben war in der Praxis /// nur eine Fehlerquelle beim Verbindungsaufbau. class _WifiPanel extends ConsumerWidget { const _WifiPanel(); @override Widget build(BuildContext context, WidgetRef ref) { final linkState = ref.watch(wifiLinkStateProvider).value; final errorReason = ref.watch(wifiLinkErrorReasonProvider); final isReceiving = ref.watch(wifiStreamActivityProvider); final connectionSettings = ref.watch(connectionSettingsProvider).value; final log = ref.watch(wifiConnectionLogProvider); final packetCount = ref.watch(wifiPacketCountProvider); // telemetryProvider loest selbst keinen Verbindungsversuch aus, solange // WLAN/MSP noch nicht ueber den "Connect"-Knopf verbunden ist (siehe // dortige Doku) - hier also gefahrlos unbedingt beobachtbar. Liefert bei // aktiver Verbindung denselben Provider/dieselbe MSP-Client-Instanz wie // FlyScreen - keine zweite, konkurrierende Abfrage-Schleife. Der // Telemetrie-Strom selbst ist nicht auf einen GPS-Fix gegated (siehe // TelemetryFrame-Doku) - die Felder erscheinen hier also auch ohne Fix, // sobald ueberhaupt eine MSP-Antwort ankommt. final telemetry = ref.watch(telemetryProvider).value; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ SizedBox( width: 260, child: DropdownButtonFormField( initialValue: ConnectionType.wifi, decoration: const InputDecoration(isDense: true, border: OutlineInputBorder()), items: _connectionTypeItems, onChanged: (type) async { if (type == null) return; await ref.read(appSettingsRepositoryProvider).setConnectionType(type); ref.invalidate(connectionSettingsProvider); }, ), ), const SizedBox(width: 8), _ConnectToggleButton( state: linkState, onConnect: () => _connect(ref), onDisconnect: () => ref.read(wifiTransportProvider).disconnect(), ), const Spacer(), _StreamActivityPill(isReceiving: isReceiving, packetCount: packetCount), const SizedBox(width: 6), // Zeigt bewusst nur "Connected" statt "Connected to $ssid" (Doku // Abschnitt 4: "SSID-Anzeige in den Settings aufgegeben") - // NetworkCapabilities.getTransportInfo() liefert auf echter // Hardware konsequent nur die redigierte SSID/BSSID // (""), siehe MlrsNetworkPlugin.ssidOf()-Doku fuer // Details. Bewusst keine ACCESS_FINE_LOCATION-Berechtigung nur // fuer diese kosmetische Anzeige hinzugefuegt. _StatePill(state: linkState), ], ), if (errorReason != null) ...[ const SizedBox(height: 12), _WifiErrorHint(reason: errorReason), ], const SizedBox(height: 12), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Auto-connect when entering Fly mode'), // Trigger aktuell bewusst deaktiviert (Doku: "kein connect beim // Wechsel in den Fly-Modus, um das Debuggen auf echter Hardware // zu vereinfachen") - die Einstellung bleibt fuer eine spaetere // Reaktivierung erhalten, wirkt sich derzeit aber auf nichts aus. subtitle: const Text( 'Currently inactive - connect manually above.', style: TextStyle(fontSize: 11), ), value: connectionSettings?.connectionAutoConnect ?? true, onChanged: (value) async { await ref .read(appSettingsRepositoryProvider) .setConnectionAutoConnect(value); ref.invalidate(connectionSettingsProvider); }, ), const SizedBox(height: 8), Text('Telemetry', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 4), _TelemetryFields(telemetry: telemetry), const SizedBox(height: 16), Text( 'Connection log ($packetCount packets received)', style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 4), SizedBox( height: 120, child: log.isEmpty ? const Text( 'No events yet.', style: TextStyle(fontSize: 12, color: Colors.grey), ) : ListView.builder( reverse: true, itemCount: log.length, itemBuilder: (context, index) { final entry = log[log.length - 1 - index]; return Text( '${_formatTime(entry.timestamp)} ${entry.message}', style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), ); }, ), ), ], ); } Future _connect(WidgetRef ref) async { try { await ref.read(wifiTransportProvider).connect(); } on LinkConnectException { // Grund/Nachricht sind bereits ueber wifiLinkErrorReasonProvider // gemeldet, siehe _WifiErrorHint. } } String _formatTime(DateTime t) => '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}'; } /// Empfangene, ueber MSP dekodierte Telemetriefelder (Doku: "vor dem /// Connection log die empfangenen Telemetriefelder anzeigen") - zum /// schnellen Pruefen auf echter Hardware, ohne in den Fly-Modus wechseln zu /// muessen. class _TelemetryFields extends StatelessWidget { const _TelemetryFields({required this.telemetry}); final TelemetryFrame? telemetry; @override Widget build(BuildContext context) { final t = telemetry; if (t == null) { return const Text( 'No telemetry yet.', style: TextStyle(fontSize: 12, color: Colors.grey), ); } return Wrap( spacing: 16, runSpacing: 4, children: [ _field('GPS', t.hasFix ? 'Fix (${t.numSat} sats)' : 'No fix (${t.numSat} sats)'), _field('Lat', t.lat.toStringAsFixed(6)), _field('Lon', t.lon.toStringAsFixed(6)), _field('Alt', '${t.altitudeM.toStringAsFixed(1)} m'), _field('Speed', '${t.speedMs.toStringAsFixed(1)} m/s'), _field('Heading', '${t.headingDeg.toStringAsFixed(0)}°'), _field('Armed', t.armed ? 'Yes' : 'No'), _field('Battery', '${t.batteryPercent}%'), _field('Link quality', '${t.linkQuality}%'), _field('Flight mode', t.flightMode), if (t.activeWaypointIndex != null) // +1: activeWaypointIndex ist 0-basiert (Array-Index), fuer // Menschen zaehlt man Wegpunkte ab 1 (siehe TelemetryFrame-Doku). _field('Active WP', '${t.activeWaypointIndex! + 1}'), ], ); } Widget _field(String label, String value) => Text( '$label: $value', style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), ); } /// Runder Connect/Disconnect-Umschalter fuer die kompakte erste Zeile des /// WLAN-Panels (Doku: "kompakter gestalten") - ein Knopf statt zwei, der je /// nach [state] Beschriftung und Aktion wechselt. class _ConnectToggleButton extends StatelessWidget { const _ConnectToggleButton({ required this.state, required this.onConnect, required this.onDisconnect, }); final LinkState? state; final VoidCallback onConnect; final VoidCallback onDisconnect; @override Widget build(BuildContext context) { final isActive = state == LinkState.connected || state == LinkState.connecting; final color = isActive ? DmcColors.warnRed : DmcColors.liveGreen; return OutlinedButton( onPressed: isActive ? onDisconnect : onConnect, style: OutlinedButton.styleFrom( shape: const StadiumBorder(), side: BorderSide(color: color), foregroundColor: color, ), child: Text(isActive ? 'Disconnect' : 'Connect'), ); } } class _WifiErrorHint extends StatelessWidget { const _WifiErrorHint({required this.reason}); final LinkErrorReason reason; @override Widget build(BuildContext context) { final text = switch (reason) { LinkErrorReason.networkUnavailable => 'No matching WiFi network found, or the system connection dialog ' 'was dismissed. Make sure the mLRS bridge is powered on and not ' 'saved/connected in system WiFi settings, then tap "Connect" ' 'again.', LinkErrorReason.networkLost => 'The mLRS WiFi network was lost (out of range or bridge powered ' 'off). Tap "Connect" to reconnect.', LinkErrorReason.connectionFailed => 'Connection to the bridge failed.', _ => 'Connection error: ${reason.name}.', }; return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: DmcColors.warnRed.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Text(text, style: const TextStyle(fontSize: 12)), ); } } /// Zeigt neben [_StatePill], ob gerade UDP-Pakete ankommen ("receiving") - /// unabhaengig vom Verbindungszustand, der Socket kann offen sein, ohne /// dass die Gegenstelle sendet (siehe wifiStreamActivityProvider). class _StreamActivityPill extends StatelessWidget { const _StreamActivityPill({required this.isReceiving, required this.packetCount}); final bool isReceiving; /// Gesamtzahl empfangener Pakete (Doku: "Receiving-Pille soll empfangene /// Pakete anzeigen und automatisch aktualisieren") - reaktiv ueber /// wifiPacketCountProvider, aktualisiert sich also mit jedem Paket. final int packetCount; @override Widget build(BuildContext context) { final color = isReceiving ? DmcColors.liveGreen : Colors.grey; final label = isReceiving ? 'Receiving ($packetCount)' : 'Not receiving'; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), border: Border.all(color: color), ), child: Text(label, style: TextStyle(color: color, fontSize: 12)), ); } } class _StatePill extends StatelessWidget { const _StatePill({required this.state}); final LinkState? state; @override Widget build(BuildContext context) { final (label, color) = switch (state) { LinkState.connected => ('Connected', DmcColors.liveGreen), LinkState.connecting => ('Connecting…', DmcColors.pafAmber), LinkState.error => ('Error', DmcColors.warnRed), LinkState.disconnected || null => ('Disconnected', Colors.grey), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), border: Border.all(color: color), ), child: Text(label, style: TextStyle(color: color, fontSize: 12)), ); } }