import 'package:flutter/material.dart'; import 'package:flutter/services.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/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/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.bluetooth; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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: const [ DropdownMenuItem(value: ConnectionType.wifi, child: Text('WiFi')), DropdownMenuItem( value: ConnectionType.cellular5g, child: Text('5G (coming soon)'), ), DropdownMenuItem( value: ConnectionType.bluetooth, child: Text('Bluetooth'), ), DropdownMenuItem( value: ConnectionType.usb, child: Text('USB (coming soon)'), ), ], 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'), }, ], ), ), ); } } 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), ); } } class _WifiPanel extends ConsumerStatefulWidget { const _WifiPanel(); @override ConsumerState<_WifiPanel> createState() => _WifiPanelState(); } class _WifiPanelState extends ConsumerState<_WifiPanel> { late final TextEditingController _hostController; late final TextEditingController _portController; bool _controllersSeeded = false; @override void initState() { super.initState(); _hostController = TextEditingController(); _portController = TextEditingController(); } @override void dispose() { _hostController.dispose(); _portController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final linkState = ref.watch(wifiLinkStateProvider).value; final errorReason = ref.watch(wifiLinkErrorReasonProvider); final connectionSettings = ref.watch(connectionSettingsProvider).value; final log = ref.watch(wifiConnectionLogProvider); final packetCount = ref.watch(wifiConnectionLogProvider.notifier).packetCount; // Controller einmalig aus den gespeicherten Einstellungen befuellen - // nicht bei jedem Rebuild, sonst wird waehrend der Nutzereingabe // ueberschrieben. if (!_controllersSeeded && connectionSettings != null) { _controllersSeeded = true; _hostController.text = connectionSettings.connectionWifiHost ?? ''; _portController.text = connectionSettings.connectionWifiPort.toString(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text('WiFi (UDP)', style: Theme.of(context).textTheme.titleSmall), const Spacer(), _StatePill(state: linkState), ], ), const SizedBox(height: 4), const Text( 'Opens a local UDP socket and listens for the mLRS bridge ' '(port 14550 by default). The peer address is learned from the ' 'first incoming packet.', style: TextStyle(fontSize: 12, color: Colors.grey), ), if (errorReason != null) ...[ const SizedBox(height: 12), _WifiErrorHint(reason: errorReason), ], const SizedBox(height: 16), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( flex: 2, child: TextField( controller: _hostController, decoration: const InputDecoration( labelText: 'Host (optional)', hintText: 'auto (learned from traffic)', isDense: true, border: OutlineInputBorder(), ), onSubmitted: (value) => _saveHost(value), onEditingComplete: () => _saveHost(_hostController.text), ), ), const SizedBox(width: 8), Expanded( child: TextField( controller: _portController, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration( labelText: 'Port', isDense: true, border: OutlineInputBorder(), ), onSubmitted: (value) => _savePort(value), onEditingComplete: () => _savePort(_portController.text), ), ), ], ), const SizedBox(height: 16), Row( children: [ ElevatedButton( onPressed: () => _connect(ref), child: const Text('Connect'), ), const SizedBox(width: 8), OutlinedButton( onPressed: () => ref.read(wifiTransportProvider).disconnect(), child: const Text('Disconnect'), ), ], ), 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 _saveHost(String value) async { final trimmed = value.trim(); await ref .read(appSettingsRepositoryProvider) .setConnectionWifiHost(trimmed.isEmpty ? null : trimmed); ref.invalidate(connectionSettingsProvider); } Future _savePort(String value) async { final port = int.tryParse(value); if (port == null || port <= 0 || port > 65535) return; await ref.read(appSettingsRepositoryProvider).setConnectionWifiPort(port); ref.invalidate(connectionSettingsProvider); } 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')}'; } class _WifiErrorHint extends StatelessWidget { const _WifiErrorHint({required this.reason}); final LinkErrorReason reason; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: DmcColors.warnRed.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Text( 'Connection error: ${reason.name}.', 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 } } } 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)), ); } } 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, ), 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; }