import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../transport/bluetooth/bluetooth_device_info.dart'; import '../../../transport/bluetooth/bluetooth_system_settings.dart'; import '../../../transport/connection_type.dart'; import '../../../transport/flight_controller_link.dart'; import '../../../transport/link_transport.dart'; import '../../dmc_colors.dart'; import '../../providers/bluetooth_connection_provider.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 die anderen Verbindungsarten // 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.bluetooth => const _BluetoothPanel(), ConnectionType.cellular5g => const _NotAvailablePanel( label: '5G', ), ConnectionType.usb => const _NotAvailablePanel(label: 'USB'), }, ], ), ), ); } } /// 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.cellular5g, child: Text('5G (soon)')), DropdownMenuItem(value: ConnectionType.bluetooth, child: Text('Bluetooth')), DropdownMenuItem(value: ConnectionType.usb, child: Text('USB (soon)')), ]; class _NotAvailablePanel extends StatelessWidget { const _NotAvailablePanel({required this.label}); final String label; @override Widget build(BuildContext context) { return Text( '$label support is not available yet.', style: const TextStyle(fontSize: 12, color: Colors.grey), ); } } /// 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); final connectedSsid = ref.watch(wifiConnectedSsidProvider).value; // MSP-Telemetrie nur beobachten, wenn der Transport bereits verbunden // ist - sonst wuerde allein das Oeffnen dieses Screens ueber // telemetryProvider einen neuen Verbindungsversuch (samt Systemdialog) // auslösen. Ist bereits eine echte Verbindung aktiv (z.B. weil der // Fly-Modus sie aufgebaut hat), liefert dies 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 = linkState == LinkState.connected ? ref.watch(telemetryProvider).value : null; 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), _StatePill(state: linkState, connectedLabel: connectedSsid ?? 'unknown'), ], ), 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'), subtitle: const Text( 'Only once a network has been remembered.', 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'), if (t.activeWaypointIndex != null) _field('Active WP', '${t.activeWaypointIndex}'), ], ); } 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)), ); } } class _BluetoothPanel extends ConsumerWidget { const _BluetoothPanel(); @override Widget build(BuildContext context, WidgetRef ref) { final linkState = ref.watch(linkStateProvider).value; final errorReason = ref.watch(linkErrorReasonProvider); final bondedDevices = ref.watch(bondedDevicesProvider); final connectionSettings = ref.watch(connectionSettingsProvider).value; final log = ref.watch(connectionLogProvider); final packetCount = ref.watch(connectionLogProvider.notifier).packetCount; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text('Bluetooth', style: Theme.of(context).textTheme.titleSmall), const Spacer(), _StatePill(state: linkState), ], ), if (errorReason != null) ...[ const SizedBox(height: 12), _ErrorHint(reason: errorReason), ], const SizedBox(height: 16), Row( children: [ ElevatedButton( onPressed: () => _connect(ref), child: const Text('Connect'), ), const SizedBox(width: 8), OutlinedButton( onPressed: () => ref.read(bluetoothTransportProvider).disconnect(), child: const Text('Disconnect'), ), const SizedBox(width: 8), TextButton( onPressed: BluetoothSystemSettings.openBluetoothSettings, child: const Text('Pair device'), ), ], ), const SizedBox(height: 16), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Auto-connect when entering Fly mode'), value: connectionSettings?.connectionAutoConnect ?? true, onChanged: (value) async { await ref .read(appSettingsRepositoryProvider) .setConnectionAutoConnect(value); ref.invalidate(connectionSettingsProvider); }, ), const SizedBox(height: 8), Text('Paired devices', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 4), bondedDevices.when( data: (devices) => devices.isEmpty ? const Text( 'No paired devices yet. Pair one once via the system ' 'dialog using "Pair device".', style: TextStyle(fontSize: 12, color: Colors.grey), ) : RadioGroup( groupValue: connectionSettings?.connectionDeviceAddress, onChanged: (address) => _selectDevice(ref, address, devices), child: Column( children: [ for (final device in devices) RadioListTile( contentPadding: EdgeInsets.zero, dense: true, value: device.address, title: Text(device.displayName), subtitle: Text( device.address, style: const TextStyle(fontSize: 11), ), ), ], ), ), loading: () => const Padding( padding: EdgeInsets.symmetric(vertical: 8), child: LinearProgressIndicator(), ), error: (e, _) => Text( 'Could not read paired devices: $e', style: const TextStyle(fontSize: 12, color: Colors.grey), ), ), 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(bluetoothTransportProvider).connect(); } on LinkConnectException { // Grund/Nachricht sind bereits ueber lastErrorReason/state gemeldet // (siehe LinkConnectException-Doku) - die Oberflaeche reagiert darauf // ueber linkErrorReasonProvider/_ErrorHint, hier ist nichts weiter zu tun. } } String _formatTime(DateTime t) => '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}'; Future _selectDevice( WidgetRef ref, String? address, List devices, ) async { if (address == null) return; await ref .read(appSettingsRepositoryProvider) .setConnectionDeviceAddress(address); ref.invalidate(connectionSettingsProvider); try { await ref.read(bluetoothTransportProvider).connect(); } on LinkConnectException { // siehe _connect() oben } } } /// 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, this.connectedLabel}); final LinkState? state; /// WLAN-Netzname, falls bekannt - erscheint im verbundenen Zustand als /// "Connected to {name}" statt des generischen "Connected" (Doku: "die /// connected pille soll den WiFi-Namen anzeigen"). final String? connectedLabel; @override Widget build(BuildContext context) { final (label, color) = switch (state) { LinkState.connected => ( connectedLabel != null ? 'Connected to $connectedLabel' : '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)), ); } } class _ErrorHint extends StatelessWidget { const _ErrorHint({required this.reason}); final LinkErrorReason reason; @override Widget build(BuildContext context) { final (text, action) = switch (reason) { LinkErrorReason.bluetoothOff => ( 'Bluetooth is off. Please enable it in system settings.', _HintAction('Settings', BluetoothSystemSettings.openBluetoothSettings), ), LinkErrorReason.permissionDenied => ( 'The "Nearby devices" permission is required. Please tap ' '"Connect" again.', null, ), LinkErrorReason.permissionDeniedPermanently => ( 'The "Nearby devices" permission was permanently denied. Please ' 'grant it manually in the app settings.', _HintAction('App settings', BluetoothSystemSettings.openAppSettings), ), LinkErrorReason.deviceSelectionRequired => ( 'No paired mLRS device found. Please select a device below.', null, ), LinkErrorReason.connectionFailed => ( 'Connection to the device failed.', null, ), // networkUnavailable/networkLost betreffen nur den WLAN-Transport // (siehe _WifiErrorHint) - hier nur der Vollstaendigkeit halber, damit // der gemeinsame LinkErrorReason-Enum exhaustiv bleibt. LinkErrorReason.networkUnavailable || LinkErrorReason.networkLost || LinkErrorReason.unknown => ('Unknown connection error.', null), }; return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: DmcColors.warnRed.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Expanded(child: Text(text, style: const TextStyle(fontSize: 12))), if (action != null) TextButton(onPressed: action.onTap, child: Text(action.label)), ], ), ); } } class _HintAction { const _HintAction(this.label, this.onTap); final String label; final Future Function() onTap; }