From f65dceb338875eeea606383ce74d2ecc4df098f7 Mon Sep 17 00:00:00 2001 From: Constantin Leue <77660791+Consti3D@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:01:25 +0200 Subject: [PATCH] connection settings cleanup and mock test added. removed bluetooth implementation --- ....Design Journal Drone Mission Control.odp# | 1 - .../bluetooth_classic_transport.dart | 257 ----------------- .../bluetooth/bluetooth_device_info.dart | 23 -- .../bluetooth/bluetooth_device_selector.dart | 63 ----- .../bluetooth/bluetooth_system_settings.dart | 20 -- app/lib/transport/connection_type.dart | 22 +- .../bluetooth_connection_provider.dart | 110 -------- app/lib/ui/providers/telemetry_provider.dart | 7 +- app/lib/ui/screens/fly/fly_screen.dart | 28 +- .../ui/screens/settings/settings_screen.dart | 260 ++---------------- app/lib/ui/widgets/battery_indicator_bar.dart | 49 +++- app/lib/ui/widgets/mission_footer_bar.dart | 18 +- app/pubspec.lock | 64 ----- app/pubspec.yaml | 3 - .../bluetooth_device_selector_test.dart | 83 ------ 15 files changed, 113 insertions(+), 895 deletions(-) delete mode 100644 .~lock.Design Journal Drone Mission Control.odp# delete mode 100644 app/lib/transport/bluetooth/bluetooth_classic_transport.dart delete mode 100644 app/lib/transport/bluetooth/bluetooth_device_info.dart delete mode 100644 app/lib/transport/bluetooth/bluetooth_device_selector.dart delete mode 100644 app/lib/transport/bluetooth/bluetooth_system_settings.dart delete mode 100644 app/lib/ui/providers/bluetooth_connection_provider.dart delete mode 100644 app/test/transport/bluetooth_device_selector_test.dart diff --git a/.~lock.Design Journal Drone Mission Control.odp# b/.~lock.Design Journal Drone Mission Control.odp# deleted file mode 100644 index d088ecb..0000000 --- a/.~lock.Design Journal Drone Mission Control.odp# +++ /dev/null @@ -1 +0,0 @@ -,AI/Consti,AI,04.08.2026 16:22,file:///C:/Users/Consti/AppData/Roaming/LibreOffice/4; \ No newline at end of file diff --git a/app/lib/transport/bluetooth/bluetooth_classic_transport.dart b/app/lib/transport/bluetooth/bluetooth_classic_transport.dart deleted file mode 100644 index 1814908..0000000 --- a/app/lib/transport/bluetooth/bluetooth_classic_transport.dart +++ /dev/null @@ -1,257 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:flutter_classic_bluetooth/flutter_classic_bluetooth.dart'; -import 'package:permission_handler/permission_handler.dart'; - -import '../link_transport.dart'; -import 'bluetooth_device_info.dart'; -import 'bluetooth_device_selector.dart'; - -/// Bluetooth-Classic-(SPP)-Transport (Doku Kommunikationsschicht Abschnitt 3): -/// verbindet sich mit dem RadioMaster-Pocket-Backpack (mLRS Tx) ueber RFCOMM. -/// Setzt den in Abschnitt 3 beschriebenen Verbindungsablauf um: -/// -/// 1. Bluetooth-Adapter an? (kann ab Android 12 nicht mehr programmatisch -/// eingeschaltet werden - meldet stattdessen [LinkErrorReason.bluetoothOff]). -/// 2. Geraet bestimmen, ohne zu scannen: gemerkte MAC-Adresse, sonst erstes -/// gekoppeltes Geraet mit "mLRS"-Praefix, sonst -/// [LinkErrorReason.deviceSelectionRequired] (Oberflaeche zeigt -/// Auswahlliste, siehe [listBondedDevices]). -/// 3. Laufende Discovery abbrechen, falls eine laeuft. -/// 4. Socket oeffnen; danach uebernimmt der eingebaute Reconnect-Mechanismus -/// des Plugins (exponentielles Backoff 1s -> 30s) das Wiederverbinden bei -/// Verbindungsabbruch. -class BluetoothClassicTransport implements LinkTransport { - BluetoothClassicTransport({ - required Future Function() getRememberedAddress, - required Future Function(String address) rememberAddress, - FlutterClassicBluetooth? bluetooth, - }) : _getRememberedAddress = getRememberedAddress, - _rememberAddress = rememberAddress, - _bluetooth = bluetooth ?? FlutterClassicBluetooth(); - - final Future Function() _getRememberedAddress; - final Future Function(String address) _rememberAddress; - final FlutterClassicBluetooth _bluetooth; - - final _incomingController = StreamController.broadcast(); - final _stateController = StreamController.broadcast(); - - BtcReconnectingConnection? _link; - StreamSubscription? _linkDataSub; - StreamSubscription? _linkStateSub; - - BluetoothDeviceInfo? _connectedDevice; - LinkState _state = LinkState.disconnected; - LinkErrorReason? _lastErrorReason; - String? _lastErrorMessage; - - @override - Stream get incoming => _incomingController.stream; - - @override - Stream get state => _stateController.stream; - - @override - LinkErrorReason? get lastErrorReason => _lastErrorReason; - - @override - String? get lastErrorMessage => _lastErrorMessage; - - /// Das aktuell verbundene (oder zuletzt verbundene) Geraet, falls bekannt. - BluetoothDeviceInfo? get connectedDevice => _connectedDevice; - - /// Gekoppelte Geraete fuer die manuelle Auswahlliste (Doku 3.2c). Scannt - /// nicht - liefert nur bereits gekoppelte Geraete, das ist sofort verfuegbar. - Future> listBondedDevices() async { - final devices = await _bluetooth.getPairedDevices(); - return [ - for (final d in devices) - BluetoothDeviceInfo(address: d.address, name: d.name ?? d.alias), - ]; - } - - @override - Future connect() async { - _lastErrorReason = null; - _lastErrorMessage = null; - _setState(LinkState.connecting); - - try { - if (!await _bluetooth.isEnabled()) { - _fail( - LinkErrorReason.bluetoothOff, - 'Bluetooth-Adapter ist aus. Kann ab Android 12 nicht mehr aus der ' - 'App heraus eingeschaltet werden.', - ); - } - - final permissionOutcome = await _ensureConnectPermission(); - if (permissionOutcome == _PermissionOutcome.deniedPermanently) { - _fail( - LinkErrorReason.permissionDeniedPermanently, - '"Geraete in der Naehe"-Berechtigung dauerhaft abgelehnt.', - ); - } - if (permissionOutcome == _PermissionOutcome.denied) { - _fail( - LinkErrorReason.permissionDenied, - '"Geraete in der Naehe"-Berechtigung abgelehnt.', - ); - } - - final bonded = await listBondedDevices(); - final remembered = await _getRememberedAddress(); - final selection = selectBluetoothDevice( - bondedDevices: bonded, - rememberedAddress: remembered, - ); - - if (selection is BluetoothDeviceSelectionRequired) { - _fail( - LinkErrorReason.deviceSelectionRequired, - 'Kein gekoppeltes mLRS-Geraet automatisch bestimmbar ' - '(${bonded.length} gekoppelte Geraete zur Auswahl).', - ); - } - final device = (selection as BluetoothDeviceResolved).device; - - if (device.address != remembered) { - await _rememberAddress(device.address); - } - - try { - if (await _bluetooth.isDiscovering()) { - await _bluetooth.stopDiscovery(); - } - } catch (_) { - // Discovery laeuft ohnehin nicht (mehr) - unkritisch (Doku 3.3). - } - - _connectedDevice = device; - final link = _bluetooth.connectWithReconnect( - address: device.address, - uuid: BtcUuid.spp, - ); - _link = link; - _linkStateSub = link.state.listen(_onReconnectStateChanged); - _linkDataSub = link.input.listen(_incomingController.add); - } on BtcException catch (e) { - _fail(_reasonForException(e), e.message); - } - } - - @override - Future disconnect() async { - await _linkDataSub?.cancel(); - _linkDataSub = null; - await _linkStateSub?.cancel(); - _linkStateSub = null; - await _link?.close(); - _link = null; - _setState(LinkState.disconnected); - } - - @override - Future send(Uint8List data) async { - final link = _link; - if (link == null) { - throw const LinkConnectException( - LinkErrorReason.connectionFailed, - 'send() ohne aktive Verbindung aufgerufen.', - ); - } - await link.send(data); - } - - @override - void dispose() { - _linkDataSub?.cancel(); - _linkStateSub?.cancel(); - unawaited(_link?.close()); - _link = null; - _incomingController.close(); - _stateController.close(); - } - - void _onReconnectStateChanged(BtcReconnectState s) { - switch (s) { - case BtcReconnectState.connecting: - case BtcReconnectState.reconnecting: - _setState(LinkState.connecting); - case BtcReconnectState.connected: - _lastErrorReason = null; - _lastErrorMessage = null; - _setState(LinkState.connected); - case BtcReconnectState.closed: - _setState(LinkState.disconnected); - case BtcReconnectState.failed: - // Nur erreichbar, wenn BtcReconnectPolicy.maxAttempts gesetzt waere; - // die Standardpolicy (Doku 3, Robustheit: 1s->30s, unbegrenzt) haelt - // stattdessen einfach LinkState.connecting, bis es klappt oder - // disconnect() gerufen wird. Trotzdem hier behandelt, falls die - // Policy kuenftig eine Obergrenze bekommt. - _setError( - LinkErrorReason.connectionFailed, - _link?.lastError?.toString() ?? 'Verbindung fehlgeschlagen.', - ); - } - } - - /// Setzt Fehlergrund/-nachricht und meldet [LinkState.error] ueber den - /// Zustands-Stream, ohne selbst zu werfen - fuer asynchrone Beobachter - /// (z.B. den Reconnect-Zustands-Listener). - void _setError(LinkErrorReason reason, String message) { - _lastErrorReason = reason; - _lastErrorMessage = message; - _setState(LinkState.error); - } - - /// Wie [_setError], wirft danach zusaetzlich eine [LinkConnectException] - /// mit demselben Grund - siehe dortige Doku. Nur fuer Fehler, die - /// synchron innerhalb von [connect] erkannt werden. - Never _fail(LinkErrorReason reason, String message) { - _setError(reason, message); - throw LinkConnectException(reason, message); - } - - void _setState(LinkState next) { - _state = next; - if (!_stateController.isClosed) _stateController.add(next); - } - - LinkState get currentState => _state; - - Future<_PermissionOutcome> _ensureConnectPermission() async { - // MVP ist auf Android beschraenkt (Doku CLAUDE.md); auf anderen - // Plattformen (z.B. Windows-Desktop waehrend der UI-Entwicklung) gibt es - // keine "Geraete in der Naehe"-Laufzeitberechtigung zu pruefen. - if (!Platform.isAndroid) return _PermissionOutcome.granted; - - var status = await Permission.bluetoothConnect.status; - if (status.isGranted) return _PermissionOutcome.granted; - if (status.isPermanentlyDenied) { - return _PermissionOutcome.deniedPermanently; - } - - status = await Permission.bluetoothConnect.request(); - if (status.isGranted) return _PermissionOutcome.granted; - if (status.isPermanentlyDenied) { - return _PermissionOutcome.deniedPermanently; - } - return _PermissionOutcome.denied; - } - - LinkErrorReason _reasonForException(BtcException e) { - if (e is BtcPermissionException) return LinkErrorReason.permissionDenied; - if (e is BtcDisabledException) return LinkErrorReason.bluetoothOff; - if (e is BtcConnectionException || e is BtcTimeoutException) { - return LinkErrorReason.connectionFailed; - } - return LinkErrorReason.unknown; - } -} - -enum _PermissionOutcome { granted, denied, deniedPermanently } diff --git a/app/lib/transport/bluetooth/bluetooth_device_info.dart b/app/lib/transport/bluetooth/bluetooth_device_info.dart deleted file mode 100644 index 245966f..0000000 --- a/app/lib/transport/bluetooth/bluetooth_device_info.dart +++ /dev/null @@ -1,23 +0,0 @@ -/// DMC-eigenes, plugin-unabhaengiges Geraetemodell fuer gekoppelte -/// Bluetooth-Classic-Geraete (Doku Kommunikationsschicht Abschnitt 3.2). -/// Haelt `flutter_classic_bluetooth`-Typen aus der Oberflaeche heraus, damit -/// nur transport/bluetooth/ das Paket kennen muss. -class BluetoothDeviceInfo { - const BluetoothDeviceInfo({required this.address, this.name}); - - final String address; - final String? name; - - String get displayName => name ?? address; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is BluetoothDeviceInfo && other.address == address; - - @override - int get hashCode => address.hashCode; - - @override - String toString() => 'BluetoothDeviceInfo($address, $name)'; -} diff --git a/app/lib/transport/bluetooth/bluetooth_device_selector.dart b/app/lib/transport/bluetooth/bluetooth_device_selector.dart deleted file mode 100644 index 76a1f2b..0000000 --- a/app/lib/transport/bluetooth/bluetooth_device_selector.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'bluetooth_device_info.dart'; - -/// Praefix, an dem der mLRS-Backpack in seinem Bluetooth-Namen erkannt wird -/// (Doku Kommunikationsschicht Abschnitt 3.2). -const mLrsDeviceNamePrefix = 'mLRS'; - -/// Ergebnis der automatischen Geraeteauswahl (Doku Abschnitt 3.2): entweder -/// ein eindeutig bestimmtes Geraet, oder die Angabe, dass die Oberflaeche -/// eine Auswahlliste zeigen muss. -sealed class BluetoothDeviceSelection { - const BluetoothDeviceSelection(); -} - -class BluetoothDeviceResolved extends BluetoothDeviceSelection { - const BluetoothDeviceResolved(this.device); - final BluetoothDeviceInfo device; -} - -class BluetoothDeviceSelectionRequired extends BluetoothDeviceSelection { - const BluetoothDeviceSelectionRequired(this.candidates); - - /// Gekoppelte Geraete, aus denen die Oberflaeche eine Auswahl anbieten - /// muss (Doku 3.2c). - final List candidates; -} - -/// Reine Auswahllogik, unabhaengig vom Bluetooth-Plugin und ohne -/// Plattform-Channel - daher ohne Hardware testbar. -/// -/// Reihenfolge (Doku Abschnitt 3.2): -/// a) gemerkte MAC-Adresse aus den Einstellungen (falls noch gekoppelt) -/// b) erstes gekoppeltes Geraet, dessen Name mit "mLRS" beginnt -/// c) sonst: Auswahlliste anzeigen -BluetoothDeviceSelection selectBluetoothDevice({ - required List bondedDevices, - String? rememberedAddress, -}) { - if (rememberedAddress != null) { - final remembered = _firstWhereOrNull( - bondedDevices, - (d) => d.address == rememberedAddress, - ); - if (remembered != null) return BluetoothDeviceResolved(remembered); - } - - final mLrsDevice = _firstWhereOrNull( - bondedDevices, - (d) => (d.name ?? '').startsWith(mLrsDeviceNamePrefix), - ); - if (mLrsDevice != null) return BluetoothDeviceResolved(mLrsDevice); - - return BluetoothDeviceSelectionRequired(bondedDevices); -} - -BluetoothDeviceInfo? _firstWhereOrNull( - List devices, - bool Function(BluetoothDeviceInfo) test, -) { - for (final device in devices) { - if (test(device)) return device; - } - return null; -} diff --git a/app/lib/transport/bluetooth/bluetooth_system_settings.dart b/app/lib/transport/bluetooth/bluetooth_system_settings.dart deleted file mode 100644 index 6d84c00..0000000 --- a/app/lib/transport/bluetooth/bluetooth_system_settings.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:app_settings/app_settings.dart'; -import 'package:permission_handler/permission_handler.dart' as ph; - -/// Verlinkt zu den System-/App-Einstellungen fuer die drei Bluetooth- -/// Hinweisfaelle aus Doku Kommunikationsschicht Abschnitt 3/7 - haelt die -/// Wahl des Settings-Pakets aus ui/ heraus (Doku: kein Bluetooth-/ -/// Settings-Import ausserhalb von transport/). -abstract final class BluetoothSystemSettings { - /// "Bluetooth an?" negativ (Doku 3.1) bzw. der "Gerät koppeln"-Knopf - /// (Doku 7): oeffnet die System-Bluetooth-Einstellungen, in denen der - /// Nutzer sowohl den Adapter einschalten als auch ein neues Geraet koppeln - /// kann. - static Future openBluetoothSettings() => - AppSettings.openAppSettings(type: AppSettingsType.bluetooth); - - /// Berechtigung dauerhaft abgelehnt (Doku 3, Berechtigungen): oeffnet die - /// App-Einstellungen, in denen der Nutzer die Berechtigung manuell erteilen - /// kann, da Android den Dialog kein zweites Mal zeigt. - static Future openAppSettings() => ph.openAppSettings(); -} diff --git a/app/lib/transport/connection_type.dart b/app/lib/transport/connection_type.dart index aa3635c..e8d1bd7 100644 --- a/app/lib/transport/connection_type.dart +++ b/app/lib/transport/connection_type.dart @@ -1,14 +1,18 @@ -/// Verbindungsart, wie sie der Nutzer in den Einstellungen waehlt (Doku -/// Kommunikationsschicht Abschnitt 7: "Art: WLAN * USB (spaeter)"). -/// Protokollneutraler Enum-Typ analog zu [LinkState] - deshalb bewusst auch -/// hier in transport/ statt in der UI, aber ohne jede Netzwerk-/Plattform- -/// Abhaengigkeit, damit er gefahrlos von database/ und ui/ importiert werden -/// kann. +/// Verbindungsart, wie sie der Nutzer in den Einstellungen waehlt (MVP-Scope, +/// Doku: "nur WiFi/TCP"). Protokollneutraler Enum-Typ analog zu [LinkState] - +/// deshalb bewusst auch hier in transport/ statt in der UI, aber ohne jede +/// Netzwerk-/Plattform-Abhaengigkeit, damit er gefahrlos von database/ und +/// ui/ importiert werden kann. +/// +/// [mock] ist keine echte Transportart, sondern waehlt explizit +/// [MockFlightControllerLink] fuer die UI-Entwicklung/Testphase ohne +/// Hardware (siehe flightControllerLinkProvider) - vorher liefen 5G/ +/// Bluetooth/USB implizit auf denselben Mock hinaus, was in den Einstellungen +/// wie echte, nur noch nicht fertige Verbindungsarten aussah statt wie das +/// Test-Werkzeug, das es tatsaechlich ist. enum ConnectionType { wifi, - cellular5g, - bluetooth, - usb, + mock, } /// Vorgabe-Port der mLRS-WLAN-Bruecke (Doku Abschnitt 3: "Port 14550"). diff --git a/app/lib/ui/providers/bluetooth_connection_provider.dart b/app/lib/ui/providers/bluetooth_connection_provider.dart deleted file mode 100644 index 62336ca..0000000 --- a/app/lib/ui/providers/bluetooth_connection_provider.dart +++ /dev/null @@ -1,110 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../transport/bluetooth/bluetooth_classic_transport.dart'; -import '../../transport/bluetooth/bluetooth_device_info.dart'; -import '../../transport/link_transport.dart'; -import 'database_provider.dart'; - -/// App-lebenslanger Bluetooth-Classic-Transport (Doku Kommunikationsschicht -/// Abschnitt 3), verdrahtet gegen die gemerkte Geraete-MAC in den -/// Einstellungen. Bewusst nicht autoDispose: die Verbindung soll bestehen -/// bleiben, waehrend zwischen Fly- und Plan-Modus gewechselt wird, und erst -/// beim Verlassen des Fly-Modus explizit getrennt werden (Doku 3, -/// Robustheit: "Verbindung beim Verlassen des Fly-Modus sauber schliessen"). -final bluetoothTransportProvider = Provider((ref) { - final settingsRepo = ref.watch(appSettingsRepositoryProvider); - final transport = BluetoothClassicTransport( - getRememberedAddress: settingsRepo.loadConnectionDeviceAddress, - rememberAddress: settingsRepo.setConnectionDeviceAddress, - ); - ref.onDispose(transport.dispose); - return transport; -}); - -/// Reaktiver [LinkState] des Bluetooth-Transports fuer die Oberflaeche -/// (Verbindungsindikator, Abnahmekriterium: "Zustand jederzeit sichtbar"). -final linkStateProvider = StreamProvider((ref) { - final transport = ref.watch(bluetoothTransportProvider); - return transport.state; -}); - -/// Grund des zuletzt gemeldeten [LinkState.error] (Doku 3, Robustheit: "Grund -/// mitfuehren und in der Oberflaeche anzeigen"). Wird bei jedem neuen -/// LinkState neu ausgelesen, da der Transport ihn synchron mitfuehrt. -final linkErrorReasonProvider = Provider((ref) { - ref.watch(linkStateProvider); - return ref.watch(bluetoothTransportProvider).lastErrorReason; -}); - -/// Gekoppelte Geraete fuer die manuelle Auswahlliste (Doku 3.2c). Wird bei -/// jedem [LinkState]-Wechsel neu geladen, damit eine frisch angeschlossene -/// Kopplung (ueber den "Geraet koppeln"-Knopf) ohne expliziten Reload -/// auftaucht. -final bondedDevicesProvider = FutureProvider>((ref) { - ref.watch(linkStateProvider); - return ref.watch(bluetoothTransportProvider).listBondedDevices(); -}); - -/// Ein Eintrag im Verbindungsprotokoll (Doku 7: "einfache Textansicht der -/// letzten Ereignisse ... spart im Feld viel Ratearbeit"). -class ConnectionLogEntry { - const ConnectionLogEntry(this.timestamp, this.message); - - final DateTime timestamp; - final String message; -} - -/// Verbindungsprotokoll + Paketzaehler (Doku 7). Haengt sich an den -/// LinkState- und den Rohdaten-Stream des Transports, damit auch ohne -/// MSP-Decoder schon sichtbar ist, dass ueberhaupt Bytes ankommen. -class ConnectionLogNotifier extends Notifier> { - static const _maxEntries = 50; - - StreamSubscription? _stateSub; - StreamSubscription? _dataSub; - int _packetCount = 0; - - int get packetCount => _packetCount; - - @override - List build() { - final transport = ref.watch(bluetoothTransportProvider); - _stateSub?.cancel(); - _dataSub?.cancel(); - _packetCount = 0; - _stateSub = transport.state.listen(_onStateChanged); - _dataSub = transport.incoming.listen((_) => _packetCount++); - ref.onDispose(() { - _stateSub?.cancel(); - _dataSub?.cancel(); - }); - return const []; - } - - void _onStateChanged(LinkState linkState) { - final transport = ref.read(bluetoothTransportProvider); - final message = switch (linkState) { - LinkState.disconnected => 'Disconnected', - LinkState.connecting => 'Connecting...', - LinkState.connected => - 'Connected${transport.connectedDevice != null ? ' (${transport.connectedDevice!.displayName})' : ''}', - LinkState.error => - 'Error: ${transport.lastErrorMessage ?? transport.lastErrorReason?.name ?? 'unknown'}', - }; - _append(message); - } - - void _append(String message) { - final entry = ConnectionLogEntry(DateTime.now(), message); - final next = [...state, entry]; - state = next.length > _maxEntries - ? next.sublist(next.length - _maxEntries) - : next; - } -} - -final connectionLogProvider = - NotifierProvider>( - ConnectionLogNotifier.new); diff --git a/app/lib/ui/providers/telemetry_provider.dart b/app/lib/ui/providers/telemetry_provider.dart index e497d95..eab41d4 100644 --- a/app/lib/ui/providers/telemetry_provider.dart +++ b/app/lib/ui/providers/telemetry_provider.dart @@ -11,11 +11,8 @@ import 'wifi_connection_provider.dart'; /// FlightControllerLink fuer den Fly-Modus (Doku 3.1): bei WLAN als /// gewaehlter Verbindungsart ein echter MSP-Link ueber den UDP-Transport, -/// sonst (Bluetooth/5G/USB, oder solange die Einstellungen noch nicht -/// geladen sind) weiterhin der Mock fuer UI-Entwicklung ohne Hardware - -/// Bluetooth ist auf der eingesetzten mLRS-Bruecke ohnehin nicht moeglich -/// (Doku Kommunikationsschicht v2, Abschnitt 1), 5G/USB sind noch nicht -/// umgesetzt. +/// sonst (ConnectionType.mock, oder solange die Einstellungen noch nicht +/// geladen sind) der Mock fuer UI-Entwicklung/Testphase ohne Hardware. /// /// connectionType wird reaktiv beobachtet (ref.watch), nicht nur einmalig /// gelesen - beim ersten App-Start ist connectionSettingsProvider oft noch diff --git a/app/lib/ui/screens/fly/fly_screen.dart b/app/lib/ui/screens/fly/fly_screen.dart index 0ad03f3..bf23bc3 100644 --- a/app/lib/ui/screens/fly/fly_screen.dart +++ b/app/lib/ui/screens/fly/fly_screen.dart @@ -233,19 +233,21 @@ class _FlyScreenState extends ConsumerState { ); // Drohnen-Status fuer die Drohnen-Pille (Doku: "im fly modus zeigt die - // drone pille den zustand der drohne") - null solange keine Telemetrie - // vorliegt, dann zeigt BottomStatsBar weiterhin den Drohnennamen-Chip. - final droneStatus = telemetry != null - ? computeDroneStatus( - armed: telemetry.armed, - hasFix: telemetry.hasFix, - numSat: telemetry.numSat, - hdop: telemetry.hdop, - linkQuality: telemetry.linkQuality, - snrDb: telemetry.snrDb, - navMode: telemetry.navMode, - ) - : null; + // drone pille den zustand der drohne") - das Aussehen des Fly-Modus darf + // nicht am Vorhandensein eines Telemetrie-Frames haengen (sonst faellt + // BottomStatsBar auf den Drohnennamen-Chip zurueck, den Plan-Modus-Look). + // Ohne Frame (Verbindungsaufbau laeuft noch) liefern die Platzhalterwerte + // ueber computeDroneStatus zuverlaessig idle/rot/rot - eine ehrliche + // "keine Daten"-Anzeige statt eines unterschiedlichen Widget-Typs. + final droneStatus = computeDroneStatus( + armed: telemetry?.armed ?? false, + hasFix: telemetry?.hasFix ?? false, + numSat: telemetry?.numSat ?? 0, + hdop: telemetry?.hdop ?? 99.0, + linkQuality: telemetry?.linkQuality ?? 0, + snrDb: telemetry?.snrDb ?? -99, + navMode: telemetry?.navMode ?? 0, + ); return Stack( children: [ diff --git a/app/lib/ui/screens/settings/settings_screen.dart b/app/lib/ui/screens/settings/settings_screen.dart index b11101d..eb1e684 100644 --- a/app/lib/ui/screens/settings/settings_screen.dart +++ b/app/lib/ui/screens/settings/settings_screen.dart @@ -1,13 +1,10 @@ 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'; @@ -56,8 +53,8 @@ class _ConnectionSection extends ConsumerWidget { 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. + // 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), @@ -79,11 +76,7 @@ class _ConnectionSection extends ConsumerWidget { ], switch (selectedType) { ConnectionType.wifi => const _WifiPanel(), - ConnectionType.bluetooth => const _BluetoothPanel(), - ConnectionType.cellular5g => const _NotAvailablePanel( - label: '5G', - ), - ConnectionType.usb => const _NotAvailablePanel(label: 'USB'), + ConnectionType.mock => const _MockPanel(), }, ], ), @@ -98,21 +91,31 @@ class _ConnectionSection extends ConsumerWidget { /// 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)')), + DropdownMenuItem(value: ConnectionType.mock, child: Text('Mock (Test)')), ]; -class _NotAvailablePanel extends StatelessWidget { - const _NotAvailablePanel({required this.label}); - - final String label; +/// 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) { - return Text( - '$label support is not available yet.', - style: const TextStyle(fontSize: 12, color: Colors.grey), + 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), + ], ); } } @@ -352,161 +355,6 @@ class _WifiErrorHint extends StatelessWidget { } } -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). @@ -569,63 +417,3 @@ class _StatePill extends StatelessWidget { } } -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; -} diff --git a/app/lib/ui/widgets/battery_indicator_bar.dart b/app/lib/ui/widgets/battery_indicator_bar.dart index e369f09..03c4e49 100644 --- a/app/lib/ui/widgets/battery_indicator_bar.dart +++ b/app/lib/ui/widgets/battery_indicator_bar.dart @@ -26,8 +26,12 @@ import '../dmc_colors.dart'; class BatteryIndicatorBar extends StatelessWidget { const BatteryIndicatorBar({super.key, required this.percent}); - /// 0-100, ungeklemmter Rohwert aus TelemetryFrame.batteryPercent. - final int percent; + /// 0-100, ungeklemmter Rohwert aus TelemetryFrame.batteryPercent - null, + /// solange noch kein Telemetrie-Frame vorliegt (Verbindungsaufbau laeuft + /// noch). Rendert dann einen neutralen "keine Daten"-Zustand statt eines + /// Balkens bei 0%, das faelschlich als kritischer Ladestand gelesen werden + /// koennte (siehe MissionFooterBar.showBattery). + final int? percent; /// Absolute Positionen (vom linken Rand aus), an denen rot in orange und /// orange in gruen uebergeht - siehe Klassendoku. @@ -51,7 +55,46 @@ class BatteryIndicatorBar extends StatelessWidget { @override Widget build(BuildContext context) { - final clampedPercent = percent.clamp(0, 100); + final rawPercent = percent; + if (rawPercent == null) { + return SizedBox( + height: _pillHeight, + width: double.infinity, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + right: 0, + top: (_pillHeight - _barHeight) / 2, + height: _barHeight, + child: ColoredBox(color: Colors.white.withValues(alpha: _trackAlpha)), + ), + Center( + child: Container( + height: _pillHeight, + constraints: const BoxConstraints(minWidth: _pillMinWidth), + padding: const EdgeInsets.symmetric(horizontal: 6), + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: _pillNeutralAlpha), + borderRadius: BorderRadius.circular(_pillHeight / 2), + ), + child: const Text( + '--%', + style: TextStyle( + color: Colors.white70, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ); + } + final clampedPercent = rawPercent.clamp(0, 100); return SizedBox( height: _pillHeight, diff --git a/app/lib/ui/widgets/mission_footer_bar.dart b/app/lib/ui/widgets/mission_footer_bar.dart index ff67a8b..aae9be6 100644 --- a/app/lib/ui/widgets/mission_footer_bar.dart +++ b/app/lib/ui/widgets/mission_footer_bar.dart @@ -48,7 +48,11 @@ class MissionFooterBar extends ConsumerWidget { final FlyMissionStatus? flyMissionStatus; /// Nur im Fly-Modus gesetzt (Doku: "im fly modus zeigt die drone pille - /// den zustand der drohne") - siehe BottomStatsBar.droneStatus. + /// den zustand der drohne") - siehe BottomStatsBar.droneStatus. FlyScreen + /// setzt hier immer einen Wert (auch ohne Telemetrie-Frame, siehe dortige + /// Doku), Plan-Modus nie - dient deshalb hier zugleich als Fly/Plan- + /// Unterscheidung fuer die Sichtbarkeit von [batteryPercent], statt dafuer + /// ein eigenes Flag zu fuehren. final DroneStatus? droneStatus; final List warnings; @@ -61,8 +65,12 @@ class MissionFooterBar extends ConsumerWidget { /// Nur im Fly-Modus gesetzt (Doku: "Batterieanzeige im Fly-Modus /// hinzufuegen") - zeigt den Batteriebalken ueber der BottomStatsBar an, - /// unter einem ggf. ausgeklappten Hoehenprofil. Bleibt im Plan-Modus null - /// (keine Telemetrie), die Anzeige entfaellt dann vollstaendig. + /// unter einem ggf. ausgeklappten Hoehenprofil. Sichtbarkeit haengt an + /// [droneStatus] statt an diesem Wert selbst (siehe dort) - im Fly-Modus + /// bleibt der Balken deshalb auch ohne Telemetrie-Frame sichtbar, nur der + /// Prozentwert ist dann null (BatteryIndicatorBar zeigt dafuer einen + /// neutralen "keine Daten"-Zustand statt 0%, das faelschlich als + /// kritischer Ladestand gelesen werden koennte). final int? batteryPercent; /// Siehe BottomStatsBar.forceShowWarningsButton. @@ -105,10 +113,10 @@ class MissionFooterBar extends ConsumerWidget { activeIndex: activeIndex, ), ), - if (batteryPercent != null) + if (droneStatus != null) Padding( padding: const EdgeInsets.fromLTRB(0, 4, 0, 0), - child: BatteryIndicatorBar(percent: batteryPercent!), + child: BatteryIndicatorBar(percent: batteryPercent), ), BottomStatsBar( waypointCount: waypoints.length, diff --git a/app/pubspec.lock b/app/pubspec.lock index c63784b..46ee97c 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -17,14 +17,6 @@ packages: url: "https://pub.dev" source: hosted version: "12.1.0" - app_settings: - dependency: "direct main" - description: - name: app_settings - sha256: "5e7d6e5b60e90c40b3409d6fb33d1844497be3a2e959935da8b77bc437221db8" - url: "https://pub.dev" - source: hosted - version: "8.0.3" archive: dependency: transitive description: @@ -326,14 +318,6 @@ packages: url: "https://pub.dev" source: hosted version: "9.1.1" - flutter_classic_bluetooth: - dependency: "direct main" - description: - name: flutter_classic_bluetooth - sha256: "6343b5d0e0bd07fffc72ed7758170c74a1356a04177ca45856d332b58783f909" - url: "https://pub.dev" - source: hosted - version: "0.1.6" flutter_launcher_icons: dependency: "direct dev" description: @@ -704,54 +688,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - permission_handler: - dependency: "direct main" - description: - name: permission_handler - sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 - url: "https://pub.dev" - source: hosted - version: "12.0.3" - permission_handler_android: - dependency: transitive - description: - name: permission_handler_android - sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" - url: "https://pub.dev" - source: hosted - version: "13.0.1" - permission_handler_apple: - dependency: transitive - description: - name: permission_handler_apple - sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420" - url: "https://pub.dev" - source: hosted - version: "9.4.10" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.dev" - source: hosted - version: "0.1.3+5" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.dev" - source: hosted - version: "4.3.0" - permission_handler_windows: - dependency: transitive - description: - name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.dev" - source: hosted - version: "0.2.1" petitparser: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 274c92b..f0f3b51 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -55,9 +55,6 @@ dependencies: url_launcher: ^6.3.2 share_plus: ^13.3.0 file_picker: ">=12.0.0-beta.1 <13.0.0" - flutter_classic_bluetooth: ^0.1.6 - permission_handler: ^12.0.3 - app_settings: ^8.0.3 wakelock_plus: ^1.7.0 dev_dependencies: diff --git a/app/test/transport/bluetooth_device_selector_test.dart b/app/test/transport/bluetooth_device_selector_test.dart deleted file mode 100644 index 6bcd2e2..0000000 --- a/app/test/transport/bluetooth_device_selector_test.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:dmc_app/transport/bluetooth/bluetooth_device_info.dart'; -import 'package:dmc_app/transport/bluetooth/bluetooth_device_selector.dart'; - -void main() { - const remembered = BluetoothDeviceInfo(address: '11:11:11:11:11:11', name: 'Alt-Geraet'); - const mLrs = BluetoothDeviceInfo(address: '22:22:22:22:22:22', name: 'mLRS-a1b2'); - const other = BluetoothDeviceInfo(address: '33:33:33:33:33:33', name: 'Kopfhoerer'); - - test('gemerkte MAC-Adresse hat Vorrang vor mLRS-Praefix (Doku 3.2a)', () { - final result = selectBluetoothDevice( - bondedDevices: [other, mLrs, remembered], - rememberedAddress: remembered.address, - ); - - expect(result, isA()); - expect((result as BluetoothDeviceResolved).device, remembered); - }); - - test( - 'ohne (noch gueltige) gemerkte Adresse gewinnt das erste Geraet mit ' - 'mLRS-Praefix (Doku 3.2b)', () { - final result = selectBluetoothDevice( - bondedDevices: [other, mLrs], - rememberedAddress: null, - ); - - expect(result, isA()); - expect((result as BluetoothDeviceResolved).device, mLrs); - }); - - test( - 'gemerkte Adresse ist nicht mehr gekoppelt -> faellt auf mLRS-Praefix ' - 'zurueck statt zu scheitern', () { - final result = selectBluetoothDevice( - bondedDevices: [other, mLrs], - rememberedAddress: '99:99:99:99:99:99', - ); - - expect(result, isA()); - expect((result as BluetoothDeviceResolved).device, mLrs); - }); - - test( - 'weder gemerkte Adresse noch mLRS-Praefix -> Auswahlliste noetig ' - '(Doku 3.2c)', () { - final result = selectBluetoothDevice( - bondedDevices: [other], - rememberedAddress: null, - ); - - expect(result, isA()); - expect( - (result as BluetoothDeviceSelectionRequired).candidates, - [other], - ); - }); - - test('keine gekoppelten Geraete -> Auswahlliste ist leer, kein Fehler', () { - final result = selectBluetoothDevice( - bondedDevices: const [], - rememberedAddress: null, - ); - - expect(result, isA()); - expect((result as BluetoothDeviceSelectionRequired).candidates, isEmpty); - }); - - test('Praefix-Pruefung ist case-sensitiv wie in der Doku spezifiziert', () { - const lowerCase = BluetoothDeviceInfo( - address: '44:44:44:44:44:44', - name: 'mlrs-nicht-erkannt', - ); - - final result = selectBluetoothDevice( - bondedDevices: [lowerCase], - rememberedAddress: null, - ); - - expect(result, isA()); - }); -}