diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e0a80290..2e8f47f0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,7 +16,7 @@ if (keystorePropertiesFile.exists()) { android { namespace = "com.meshcore.meshcore_open" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "29.0.14206865" compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -84,4 +84,5 @@ flutter { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") + implementation("com.github.mik3y:usb-serial-for-android:3.9.0") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b8dd623d..4ff626f2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ + + when (call.method) { + "listPorts" -> result.success(listUsbPorts()) + "connect" -> handleUsbConnect(call, result) + "write" -> handleUsbWrite(call, result) + "disconnect" -> { + closeUsbConnection() + result.success(null) + } + else -> result.notImplemented() + } + } + + EventChannel(flutterEngine.dartExecutor.binaryMessenger, usbEventChannelName) + .setStreamHandler( + object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + }, + ) + } + + override fun onDestroy() { + closeUsbConnection() + unregisterReceiver(permissionReceiver) + super.onDestroy() + } + + private fun registerUsbPermissionReceiver() { + val filter = IntentFilter(usbPermissionAction) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(permissionReceiver, filter, RECEIVER_NOT_EXPORTED) + } else { + @Suppress("DEPRECATION") + registerReceiver(permissionReceiver, filter) + } + } + + private fun listUsbPorts(): List { + val drivers = UsbSerialProber.getDefaultProber().findAllDrivers(usbManager) + return drivers.map { driver -> + val device = driver.device + val productName = device.productName ?: "USB Serial Device" + val vendorProduct = + String.format( + Locale.US, + "VID:%04X PID:%04X", + device.vendorId, + device.productId, + ) + "${device.deviceName} - $productName - $vendorProduct" + } + } + + private fun handleUsbConnect(call: MethodCall, result: MethodChannel.Result) { + val portName = call.argument("portName") + val baudRate = call.argument("baudRate") ?: 115200 + if (portName.isNullOrBlank()) { + result.error("usb_invalid_port", "Port name is required", null) + return + } + + val device = findUsbDevice(portName) + if (device == null) { + result.error("usb_device_missing", "USB device not found for $portName", null) + return + } + + if (usbManager.hasPermission(device)) { + openUsbDevice(device, baudRate, result) + return + } + + if (pendingConnectResult != null) { + result.error("usb_busy", "Another USB permission request is already pending", null) + return + } + + pendingConnectResult = result + pendingConnectPortName = portName + pendingConnectBaudRate = baudRate + + val permissionIntent = PendingIntent.getBroadcast( + this, + 0, + Intent(usbPermissionAction).setPackage(packageName), + pendingIntentFlags(), + ) + usbManager.requestPermission(device, permissionIntent) + } + + private fun handleUsbWrite(call: MethodCall, result: MethodChannel.Result) { + val data = call.argument("data") + val port = usbPort + if (data == null) { + result.error("usb_invalid_data", "Data is required", null) + return + } + if (port == null) { + result.error("usb_not_connected", "USB serial port is not connected", null) + return + } + + try { + port.write(data, 1000) + result.success(null) + } catch (error: Exception) { + result.error("usb_write_failed", error.message, null) + } + } + + private fun findUsbDevice(portName: String): UsbDevice? { + return usbManager.deviceList.values.firstOrNull { it.deviceName == portName } + } + + private fun openUsbDevice( + device: UsbDevice, + baudRate: Int, + result: MethodChannel.Result, + ) { + try { + closeUsbConnection() + + val driver = UsbSerialProber.getDefaultProber().probeDevice(device) + if (driver == null) { + result.error("usb_driver_missing", "No USB serial driver for ${device.deviceName}", null) + return + } + + val connection = usbManager.openDevice(device) + if (connection == null) { + result.error( + "usb_open_failed", + "UsbManager could not open ${device.deviceName}", + null, + ) + return + } + + val port = firstPort(driver) + if (port == null) { + connection.close() + result.error("usb_port_missing", "No USB serial port exposed by ${device.deviceName}", null) + return + } + + port.open(connection) + port.setParameters( + baudRate, + 8, + UsbSerialPort.STOPBITS_1, + UsbSerialPort.PARITY_NONE, + ) + port.rts = false + port.dtr = true + + usbConnection = connection + usbPort = port + + ioManager = + SerialInputOutputManager( + port, + object : SerialInputOutputManager.Listener { + override fun onNewData(data: ByteArray) { + mainHandler.post { + eventSink?.success(data) + } + } + + override fun onRunError(e: Exception) { + mainHandler.post { + eventSink?.error( + "usb_io_error", + e.message ?: "USB serial I/O error", + null, + ) + } + closeUsbConnection() + } + }, + ).also { manager -> + manager.start() + } + + result.success(null) + } catch (error: Exception) { + closeUsbConnection() + result.error("usb_connect_failed", error.message, null) + } + } + + private fun firstPort(driver: UsbSerialDriver): UsbSerialPort? { + return driver.ports.firstOrNull() + } + + private fun closeUsbConnection() { + try { + ioManager?.stop() + } catch (_: Exception) { + } + ioManager = null + + try { + usbPort?.close() + } catch (_: Exception) { + } + usbPort = null + + try { + usbConnection?.close() + } catch (_: Exception) { + } + usbConnection = null + } + + private fun pendingIntentFlags(): Int { + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + flags = flags or PendingIntent.FLAG_MUTABLE + } + return flags + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657b..eeea4583 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -2,6 +2,7 @@ allprojects { repositories { google() mavenCentral() + maven(url = "https://jitpack.io") } } diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index c57a85ac..f514f15a 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -20,6 +20,7 @@ import '../services/path_history_service.dart'; import '../services/app_settings_service.dart'; import '../services/background_service.dart'; import '../services/notification_service.dart'; +import '../services/usb_serial_service.dart'; import '../storage/channel_message_store.dart'; import '../storage/channel_order_store.dart'; import '../storage/channel_settings_store.dart'; @@ -82,6 +83,8 @@ enum MeshCoreConnectionState { disconnecting, } +enum MeshCoreTransportType { bluetooth, usb } + class RepeaterBatterySnapshot { final int millivolts; final DateTime updatedAt; @@ -108,6 +111,10 @@ class MeshCoreConnector extends ChangeNotifier { String? _lastDeviceId; String? _lastDeviceDisplayName; bool _manualDisconnect = false; + final UsbSerialService _usbSerialService = UsbSerialService(); + StreamSubscription? _usbFrameSubscription; + MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth; + String? _activeUsbPort; final List _scanResults = []; final List _contacts = []; @@ -154,6 +161,8 @@ class MeshCoreConnector extends ChangeNotifier { bool _hasLoadedChannels = false; bool _batteryRequested = false; bool _awaitingSelfInfo = false; + bool _hasReceivedDeviceInfo = false; + bool _pendingInitialChannelSync = false; bool _preserveContactsOnRefresh = false; static const int _defaultMaxContacts = 32; static const int _defaultMaxChannels = 8; @@ -217,6 +226,12 @@ class MeshCoreConnector extends ChangeNotifier { String? get deviceId => _deviceId; String get deviceIdLabel => _deviceId ?? 'Unknown'; + MeshCoreTransportType get activeTransport => _activeTransport; + String? get activeUsbPort => _activeUsbPort; + bool get isUsbTransportConnected => + _state == MeshCoreConnectionState.connected && + _activeTransport == MeshCoreTransportType.usb; + String get deviceDisplayName { if (_selfName != null && _selfName!.isNotEmpty) { return _selfName!; @@ -742,12 +757,17 @@ class MeshCoreConnector extends ChangeNotifier { } } + Future> listUsbPorts() => _usbSerialService.listPorts(); + Future connect(BluetoothDevice device, {String? displayName}) async { if (_state == MeshCoreConnectionState.connecting || _state == MeshCoreConnectionState.connected) { return; } + _activeTransport = MeshCoreTransportType.bluetooth; + _activeUsbPort = null; + await stopScan(); _setState(MeshCoreConnectionState.connecting); _device = device; @@ -832,6 +852,8 @@ class MeshCoreConnector extends ChangeNotifier { ); _setState(MeshCoreConnectionState.connected); + _hasReceivedDeviceInfo = false; + _pendingInitialChannelSync = true; await _requestDeviceInfo(); _startBatteryPolling(); @@ -845,9 +867,6 @@ class MeshCoreConnector extends ChangeNotifier { // Keep device clock aligned on every connection. await syncTime(); - - // Fetch channels so we can track unread counts for incoming messages - unawaited(getChannels()); } catch (e) { debugPrint("Connection error: $e"); await disconnect(manual: false); @@ -855,6 +874,63 @@ class MeshCoreConnector extends ChangeNotifier { } } + Future connectUsb({ + required String portName, + int baudRate = 115200, + }) async { + if (_state == MeshCoreConnectionState.connecting || + _state == MeshCoreConnectionState.connected) { + return; + } + + _activeTransport = MeshCoreTransportType.bluetooth; + _activeUsbPort = null; + + await stopScan(); + _cancelReconnectTimer(); + _manualDisconnect = false; + _activeTransport = MeshCoreTransportType.usb; + _activeUsbPort = portName; + unawaited(_backgroundService?.start()); + _setState(MeshCoreConnectionState.connecting); + + try { + await _usbFrameSubscription?.cancel(); + _usbFrameSubscription = null; + await _usbSerialService.connect(portName: portName, baudRate: baudRate); + await Future.delayed(const Duration(milliseconds: 200)); + _usbFrameSubscription = _usbSerialService.frameStream.listen( + _handleFrame, + onError: (error, stackTrace) { + debugPrint('USB transport error: $error'); + unawaited(disconnect(manual: false)); + }, + onDone: () { + unawaited(disconnect(manual: false)); + }, + ); + + _setState(MeshCoreConnectionState.connected); + _hasReceivedDeviceInfo = false; + _pendingInitialChannelSync = true; + await _requestDeviceInfo(); + _startBatteryPolling(); + final gotSelfInfo = await _waitForSelfInfo( + timeout: const Duration(seconds: 3), + ); + if (!gotSelfInfo) { + await refreshDeviceInfo(); + await _waitForSelfInfo(timeout: const Duration(seconds: 3)); + } + + await syncTime(); + } catch (error) { + debugPrint('USB connection error: $error'); + await disconnect(manual: false); + rethrow; + } + } + Future _waitForSelfInfo({required Duration timeout}) async { if (_selfPublicKey != null) return true; if (!isConnected) return false; @@ -886,7 +962,10 @@ class MeshCoreConnector extends ChangeNotifier { return result; } - bool get _shouldAutoReconnect => !_manualDisconnect && _lastDeviceId != null; + bool get _shouldAutoReconnect => + !_manualDisconnect && + _lastDeviceId != null && + _activeTransport == MeshCoreTransportType.bluetooth; void _cancelReconnectTimer() { _reconnectTimer?.cancel(); @@ -930,6 +1009,7 @@ class MeshCoreConnector extends ChangeNotifier { Future disconnect({bool manual = true}) async { if (_state == MeshCoreConnectionState.disconnecting) return; + final transportAtDisconnect = _activeTransport; if (manual) { _manualDisconnect = true; @@ -941,6 +1021,10 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.disconnecting); _stopBatteryPolling(); + await _usbFrameSubscription?.cancel(); + _usbFrameSubscription = null; + await _usbSerialService.disconnect(); + await _notifySubscription?.cancel(); _notifySubscription = null; @@ -980,6 +1064,8 @@ class MeshCoreConnector extends ChangeNotifier { _repeaterBatterySnapshots.clear(); _batteryRequested = false; _awaitingSelfInfo = false; + _hasReceivedDeviceInfo = false; + _pendingInitialChannelSync = false; _maxContacts = _defaultMaxContacts; _maxChannels = _defaultMaxChannels; _isSyncingQueuedMessages = false; @@ -993,8 +1079,11 @@ class MeshCoreConnector extends ChangeNotifier { _pendingGenericAckQueue.clear(); _reactionSendQueueSequence = 0; + _activeTransport = MeshCoreTransportType.bluetooth; + _activeUsbPort = null; + _setState(MeshCoreConnectionState.disconnected); - if (!manual) { + if (!manual && transportAtDisconnect == MeshCoreTransportType.bluetooth) { _scheduleReconnect(); } } @@ -1004,24 +1093,29 @@ class MeshCoreConnector extends ChangeNotifier { String? channelSendQueueId, bool expectsGenericAck = false, }) async { - if (!isConnected || _rxCharacteristic == null) { + if (!isConnected) { throw Exception("Not connected to a MeshCore device"); } - _bleDebugLogService?.logFrame(data, outgoing: true); - // Prefer write without response when supported; fall back to write with response. - final properties = _rxCharacteristic!.properties; - final canWriteWithoutResponse = properties.writeWithoutResponse; - final canWriteWithResponse = properties.write; - if (!canWriteWithoutResponse && !canWriteWithResponse) { - throw Exception("MeshCore RX characteristic does not support write"); + if (_activeTransport == MeshCoreTransportType.usb) { + await _usbSerialService.write(data); + } else { + if (_rxCharacteristic == null) { + throw Exception("MeshCore RX characteristic does not support write"); + } + // Prefer write without response when supported; fall back to write with response. + final properties = _rxCharacteristic!.properties; + final canWriteWithoutResponse = properties.writeWithoutResponse; + final canWriteWithResponse = properties.write; + if (!canWriteWithoutResponse && !canWriteWithResponse) { + throw Exception("MeshCore RX characteristic does not support write"); + } + await _rxCharacteristic!.write( + data.toList(), + withoutResponse: canWriteWithoutResponse, + ); } - - await _rxCharacteristic!.write( - data.toList(), - withoutResponse: canWriteWithoutResponse, - ); _trackPendingGenericAck( data, channelSendQueueId: channelSendQueueId, @@ -2000,10 +2094,12 @@ class MeshCoreConnector extends ChangeNotifier { // Auto-fetch contacts after getting self info getContacts(); + _maybeStartInitialChannelSync(); } void _handleDeviceInfo(Uint8List frame) { if (frame.length < 4) return; + _hasReceivedDeviceInfo = true; _firmwareVerCode = frame[1]; // Parse client_repeat from firmware v9+ (byte 80) @@ -2027,12 +2123,25 @@ class MeshCoreConnector extends ChangeNotifier { if (nextMaxChannels > previousMaxChannels) { unawaited(loadChannelSettings(maxChannels: nextMaxChannels)); unawaited(loadAllChannelMessages(maxChannels: nextMaxChannels)); - if (isConnected) { + if (isConnected && !_pendingInitialChannelSync) { unawaited(getChannels(maxChannels: nextMaxChannels)); } } } notifyListeners(); + _maybeStartInitialChannelSync(); + } + + void _maybeStartInitialChannelSync() { + if (!_pendingInitialChannelSync || !isConnected) { + return; + } + if (_selfPublicKey == null || !_hasReceivedDeviceInfo) { + return; + } + + _pendingInitialChannelSync = false; + unawaited(getChannels(maxChannels: _maxChannels)); } void _handleNoMoreMessages() { @@ -3591,6 +3700,8 @@ class MeshCoreConnector extends ChangeNotifier { _txCharacteristic = null; // Preserve deviceId and displayName for UI display during reconnection // They're only cleared on manual disconnect via disconnect() method + _hasReceivedDeviceInfo = false; + _pendingInitialChannelSync = false; _maxContacts = _defaultMaxContacts; _maxChannels = _defaultMaxChannels; _isSyncingQueuedMessages = false; @@ -3671,10 +3782,12 @@ class MeshCoreConnector extends ChangeNotifier { void dispose() { _scanSubscription?.cancel(); _connectionSubscription?.cancel(); + _usbFrameSubscription?.cancel(); _notifySubscription?.cancel(); _reconnectTimer?.cancel(); _batteryPollTimer?.cancel(); _receivedFramesController.close(); + _usbSerialService.dispose(); // Flush pending unread writes before disposal _unreadStore.flush(); diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 2dbcf5e6..0d64508f 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1801,5 +1801,14 @@ "contacts_unread": "Непрочетено", "contacts_searchRepeaters": "Търсене на {number}{str} повтарящи се...", "contacts_searchContactsNoNumber": "Търси контакти...", - "contacts_searchUsers": "Търсене на {number}{str} потребители..." + "contacts_searchUsers": "Търсене на {number}{str} потребители...", + "connectionChoiceUsbLabel": "USB", + "connectionChoiceBluetoothLabel": "Bluetooth", + "connectionChoiceTitle": "Изберете метода на връзка.", + "connectionChoiceSubtitle": "Изберете как искате да получите вашия устройство MeshCore.", + "usbScreenTitle": "Връзката чрез USB ще бъде налична скоро.", + "usbScreenSubtitle": "Създаваме път за комуникация, базиран на последователно предаване на данни, за Android и настолни компютри.", + "usbScreenStatus": "Ще бъде достъпно скоро", + "usbScreenNote": "След като бъде внедрена поддръжката за USB, ще изберете сериен порт и ще се свържете директно към вашето устройство MeshCore.", + "usbScreenEmptyState": "Няма открити USB устройства. Включете едно и опитайте отново." } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 07190a9a..0c49f8df 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1829,5 +1829,14 @@ "contacts_searchRepeaters": "Suche {number}{str} Repeater...", "contacts_searchFavorites": "Suche {number}{str} Favoriten...", "contacts_searchUsers": "Suche {number}{str} Benutzer...", - "contacts_searchRoomServers": "Suche {number}{str} Raumserver..." + "contacts_searchRoomServers": "Suche {number}{str} Raumserver...", + "connectionChoiceSubtitle": "Wählen Sie, wie Sie Ihr MeshCore-Gerät erreichen möchten.", + "connectionChoiceUsbLabel": "USB", + "connectionChoiceBluetoothLabel": "Bluetooth", + "connectionChoiceTitle": "Wählen Sie Ihre bevorzugte Verbindungsmethode.", + "usbScreenTitle": "Die USB-Verbindung wird bald verfügbar sein.", + "usbScreenSubtitle": "Wir entwickeln eine Verbindung, die sowohl für Android- als auch für Desktop-Geräte geeignet ist und auf einer seriellen Schnittstelle basiert.", + "usbScreenStatus": "Bald verfügbar", + "usbScreenNote": "Sobald die USB-Unterstützung implementiert ist, wählen Sie einen seriellen Anschluss und verbinden Sie ihn direkt mit Ihrem MeshCore-Gerät.", + "usbScreenEmptyState": "Keine USB-Geräte gefunden. Schließen Sie eines an und aktualisieren Sie." } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f0b05874..9719c7c5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -46,6 +46,15 @@ } }, "scanner_title": "MeshCore Open", + "connectionChoiceTitle": "Choose your connection method", + "connectionChoiceSubtitle": "Select how you would like to reach your MeshCore device.", + "connectionChoiceUsbLabel": "USB", + "connectionChoiceBluetoothLabel": "Bluetooth", + "usbScreenTitle": "Connect over USB", + "usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.", + "usbScreenStatus": "Select a USB device", + "usbScreenNote": "USB serial is active on supported Android devices and desktop platforms.", + "usbScreenEmptyState": "No USB devices found. Plug one in and refresh.", "scanner_scanning": "Scanning for devices...", "scanner_connecting": "Connecting...", "scanner_disconnecting": "Disconnecting...", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 47765c6f..48431e38 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1829,5 +1829,14 @@ "contacts_searchFavorites": "Buscar {number}{str} Favoritos...", "contacts_searchUsers": "Buscar {number}{str} Usuarios...", "contacts_searchRepeaters": "Buscar {number}{str} Repetidores...", - "contacts_searchRoomServers": "Buscar {number}{str} servidores de sala..." + "contacts_searchRoomServers": "Buscar {number}{str} servidores de sala...", + "connectionChoiceTitle": "Seleccione su método de conexión.", + "connectionChoiceSubtitle": "Seleccione la forma en que desea acceder a su dispositivo MeshCore.", + "connectionChoiceBluetoothLabel": "Bluetooth", + "connectionChoiceUsbLabel": "USB", + "usbScreenTitle": "La conexión USB estará disponible próximamente.", + "usbScreenSubtitle": "Estamos creando una conexión en serie para dispositivos Android y de escritorio.", + "usbScreenStatus": "Próximamente", + "usbScreenNote": "Una vez que se implemente el soporte para USB, seleccionará un puerto serie y se conectará directamente a su dispositivo MeshCore.", + "usbScreenEmptyState": "No se detectaron dispositivos USB. Conecte uno y vuelva a intentar." } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b742dc97..61f6551d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1801,5 +1801,14 @@ "contacts_searchUsers": "Rechercher {number}{str} utilisateurs...", "contacts_searchRoomServers": "Rechercher {number}{str} serveurs de salle...", "contacts_searchRepeaters": "Rechercher {number}{str} Répéteurs...", - "contacts_searchContactsNoNumber": "Rechercher des contacts..." + "contacts_searchContactsNoNumber": "Rechercher des contacts...", + "connectionChoiceTitle": "Choisissez votre méthode de connexion.", + "connectionChoiceBluetoothLabel": "Bluetooth", + "connectionChoiceUsbLabel": "USB", + "connectionChoiceSubtitle": "Choisissez la méthode de livraison que vous préférez pour votre appareil MeshCore.", + "usbScreenTitle": "La connexion USB sera disponible prochainement.", + "usbScreenSubtitle": "Nous mettons en place un chemin de connexion basé sur une série pour les appareils Android et les ordinateurs de bureau.", + "usbScreenStatus": "Bientôt", + "usbScreenNote": "Une fois que le support USB sera disponible, vous sélectionnerez un port série et vous connecterez directement à votre appareil MeshCore.", + "usbScreenEmptyState": "Aucun périphérique USB n'a été trouvé. Connectez-en un et rafraîchissez." } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 82adad80..827d1e79 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1801,5 +1801,14 @@ "contacts_searchFavorites": "Cerca {number}{str} Preferiti...", "contacts_unread": "Non letti", "contacts_searchRepeaters": "Cerca {number}{str} Ripetitori...", - "contacts_searchRoomServers": "Cerca {number}{str} server Room..." + "contacts_searchRoomServers": "Cerca {number}{str} server Room...", + "connectionChoiceTitle": "Scegli il metodo di connessione che preferisci.", + "connectionChoiceSubtitle": "Seleziona il metodo che preferisci per accedere al tuo dispositivo MeshCore.", + "connectionChoiceBluetoothLabel": "Bluetooth", + "connectionChoiceUsbLabel": "USB", + "usbScreenTitle": "La connessione USB sarà disponibile a breve.", + "usbScreenSubtitle": "Stiamo sviluppando un percorso di connessione basato su serie per Android e per i desktop.", + "usbScreenStatus": "Arriverà presto", + "usbScreenNote": "Una volta che il supporto USB sarà disponibile, selezionerete una porta seriale e vi connetterete direttamente al vostro dispositivo MeshCore.", + "usbScreenEmptyState": "Nessun dispositivo USB rilevato. Collegare uno e riavviare." } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index c48994c6..162b7603 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -316,6 +316,60 @@ abstract class AppLocalizations { /// **'MeshCore Open'** String get scanner_title; + /// No description provided for @connectionChoiceTitle. + /// + /// In en, this message translates to: + /// **'Choose your connection method'** + String get connectionChoiceTitle; + + /// No description provided for @connectionChoiceSubtitle. + /// + /// In en, this message translates to: + /// **'Select how you would like to reach your MeshCore device.'** + String get connectionChoiceSubtitle; + + /// No description provided for @connectionChoiceUsbLabel. + /// + /// In en, this message translates to: + /// **'USB'** + String get connectionChoiceUsbLabel; + + /// No description provided for @connectionChoiceBluetoothLabel. + /// + /// In en, this message translates to: + /// **'Bluetooth'** + String get connectionChoiceBluetoothLabel; + + /// No description provided for @usbScreenTitle. + /// + /// In en, this message translates to: + /// **'Connect over USB'** + String get usbScreenTitle; + + /// No description provided for @usbScreenSubtitle. + /// + /// In en, this message translates to: + /// **'Choose a detected serial device and connect directly to your MeshCore node.'** + String get usbScreenSubtitle; + + /// No description provided for @usbScreenStatus. + /// + /// In en, this message translates to: + /// **'Select a USB device'** + String get usbScreenStatus; + + /// No description provided for @usbScreenNote. + /// + /// In en, this message translates to: + /// **'USB serial is active on supported Android devices and desktop platforms.'** + String get usbScreenNote; + + /// No description provided for @usbScreenEmptyState. + /// + /// In en, this message translates to: + /// **'No USB devices found. Plug one in and refresh.'** + String get usbScreenEmptyState; + /// No description provided for @scanner_scanning. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index c168b7c4..861bf6a5 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -108,6 +108,37 @@ class AppLocalizationsBg extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => 'Изберете метода на връзка.'; + + @override + String get connectionChoiceSubtitle => + 'Изберете как искате да получите вашия устройство MeshCore.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => 'Връзката чрез USB ще бъде налична скоро.'; + + @override + String get usbScreenSubtitle => + 'Създаваме път за комуникация, базиран на последователно предаване на данни, за Android и настолни компютри.'; + + @override + String get usbScreenStatus => 'Ще бъде достъпно скоро'; + + @override + String get usbScreenNote => + 'След като бъде внедрена поддръжката за USB, ще изберете сериен порт и ще се свържете директно към вашето устройство MeshCore.'; + + @override + String get usbScreenEmptyState => + 'Няма открити USB устройства. Включете едно и опитайте отново.'; + @override String get scanner_scanning => 'Сканиране за устройства...'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c7eb9273..f768dbbe 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -108,6 +108,38 @@ class AppLocalizationsDe extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => + 'Wählen Sie Ihre bevorzugte Verbindungsmethode.'; + + @override + String get connectionChoiceSubtitle => + 'Wählen Sie, wie Sie Ihr MeshCore-Gerät erreichen möchten.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => 'Die USB-Verbindung wird bald verfügbar sein.'; + + @override + String get usbScreenSubtitle => + 'Wir entwickeln eine Verbindung, die sowohl für Android- als auch für Desktop-Geräte geeignet ist und auf einer seriellen Schnittstelle basiert.'; + + @override + String get usbScreenStatus => 'Bald verfügbar'; + + @override + String get usbScreenNote => + 'Sobald die USB-Unterstützung implementiert ist, wählen Sie einen seriellen Anschluss und verbinden Sie ihn direkt mit Ihrem MeshCore-Gerät.'; + + @override + String get usbScreenEmptyState => + 'Keine USB-Geräte gefunden. Schließen Sie eines an und aktualisieren Sie.'; + @override String get scanner_scanning => 'Scannen nach Geräten...'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 44580624..2c287f7b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -108,6 +108,37 @@ class AppLocalizationsEn extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => 'Choose your connection method'; + + @override + String get connectionChoiceSubtitle => + 'Select how you would like to reach your MeshCore device.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => 'Connect over USB'; + + @override + String get usbScreenSubtitle => + 'Choose a detected serial device and connect directly to your MeshCore node.'; + + @override + String get usbScreenStatus => 'Select a USB device'; + + @override + String get usbScreenNote => + 'USB serial is active on supported Android devices and desktop platforms.'; + + @override + String get usbScreenEmptyState => + 'No USB devices found. Plug one in and refresh.'; + @override String get scanner_scanning => 'Scanning for devices...'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index ce4b615c..32680ab4 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -108,6 +108,38 @@ class AppLocalizationsEs extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => 'Seleccione su método de conexión.'; + + @override + String get connectionChoiceSubtitle => + 'Seleccione la forma en que desea acceder a su dispositivo MeshCore.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => + 'La conexión USB estará disponible próximamente.'; + + @override + String get usbScreenSubtitle => + 'Estamos creando una conexión en serie para dispositivos Android y de escritorio.'; + + @override + String get usbScreenStatus => 'Próximamente'; + + @override + String get usbScreenNote => + 'Una vez que se implemente el soporte para USB, seleccionará un puerto serie y se conectará directamente a su dispositivo MeshCore.'; + + @override + String get usbScreenEmptyState => + 'No se detectaron dispositivos USB. Conecte uno y vuelva a intentar.'; + @override String get scanner_scanning => 'Escaneando dispositivos...'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 61184443..dae34782 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -108,6 +108,38 @@ class AppLocalizationsFr extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => 'Choisissez votre méthode de connexion.'; + + @override + String get connectionChoiceSubtitle => + 'Choisissez la méthode de livraison que vous préférez pour votre appareil MeshCore.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => + 'La connexion USB sera disponible prochainement.'; + + @override + String get usbScreenSubtitle => + 'Nous mettons en place un chemin de connexion basé sur une série pour les appareils Android et les ordinateurs de bureau.'; + + @override + String get usbScreenStatus => 'Bientôt'; + + @override + String get usbScreenNote => + 'Une fois que le support USB sera disponible, vous sélectionnerez un port série et vous connecterez directement à votre appareil MeshCore.'; + + @override + String get usbScreenEmptyState => + 'Aucun périphérique USB n\'a été trouvé. Connectez-en un et rafraîchissez.'; + @override String get scanner_scanning => 'Recherche de périphériques...'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 20873b9e..b138671b 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -108,6 +108,38 @@ class AppLocalizationsIt extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => + 'Scegli il metodo di connessione che preferisci.'; + + @override + String get connectionChoiceSubtitle => + 'Seleziona il metodo che preferisci per accedere al tuo dispositivo MeshCore.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => 'La connessione USB sarà disponibile a breve.'; + + @override + String get usbScreenSubtitle => + 'Stiamo sviluppando un percorso di connessione basato su serie per Android e per i desktop.'; + + @override + String get usbScreenStatus => 'Arriverà presto'; + + @override + String get usbScreenNote => + 'Una volta che il supporto USB sarà disponibile, selezionerete una porta seriale e vi connetterete direttamente al vostro dispositivo MeshCore.'; + + @override + String get usbScreenEmptyState => + 'Nessun dispositivo USB rilevato. Collegare uno e riavviare.'; + @override String get scanner_scanning => 'Scansione in corso per i dispositivi...'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 323981da..f582abc9 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -108,6 +108,37 @@ class AppLocalizationsNl extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => 'Kies uw verbindingsmethode'; + + @override + String get connectionChoiceSubtitle => + 'Kies hoe u uw MeshCore-apparaat wilt bereiken.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => 'USB-verbinding is binnenkort beschikbaar.'; + + @override + String get usbScreenSubtitle => + 'We ontwikkelen een verbindingspad op basis van seriële communicatie, zowel voor Android als voor desktop-computers.'; + + @override + String get usbScreenStatus => 'Komende week'; + + @override + String get usbScreenNote => + 'Zodra de USB-ondersteuning is geïnstalleerd, selecteert u een seriële poort en verbindt u direct met uw MeshCore-apparaat.'; + + @override + String get usbScreenEmptyState => + 'Geen USB-apparaten gevonden. Sluit er een aan en herlaad.'; + @override String get scanner_scanning => 'Scannen naar apparaten...'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index e033359e..2f103b55 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -108,6 +108,37 @@ class AppLocalizationsPl extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => 'Wybierz metodę połączenia.'; + + @override + String get connectionChoiceSubtitle => + 'Wybierz, w jaki sposób chcesz uzyskać dostęp do swojego urządzenia MeshCore.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => 'Połączenie USB będzie dostępne wkrótce.'; + + @override + String get usbScreenSubtitle => + 'Tworzymy ścieżkę połączenia opartą na protokole szeregowym, przeznaczoną zarówno dla urządzeń z systemem Android, jak i dla komputerów stacjonarnych.'; + + @override + String get usbScreenStatus => 'Wkrótce'; + + @override + String get usbScreenNote => + 'Po wdrożeniu wsparcia dla USB, wybierzesz port szeregowy i połączysz się bezpośrednio z urządzeniem MeshCore.'; + + @override + String get usbScreenEmptyState => + 'Nie znaleziono żadnych urządzeń USB. Podłącz jedno i zaktualizuj.'; + @override String get scanner_scanning => 'Skanowanie urządzeń...'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index dd194529..283bada5 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -108,6 +108,37 @@ class AppLocalizationsPt extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => 'Escolha o método de conexão desejado.'; + + @override + String get connectionChoiceSubtitle => + 'Selecione a forma como você deseja acessar seu dispositivo MeshCore.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => 'A conexão USB estará disponível em breve.'; + + @override + String get usbScreenSubtitle => + 'Estamos criando um caminho de conexão baseado em série para dispositivos Android e de desktop.'; + + @override + String get usbScreenStatus => 'Em breve'; + + @override + String get usbScreenNote => + 'Assim que o suporte USB for implementado, você poderá selecionar uma porta serial e conectar-se diretamente ao seu dispositivo MeshCore.'; + + @override + String get usbScreenEmptyState => + 'Nenhum dispositivo USB encontrado. Conecte um e atualize.'; + @override String get scanner_scanning => 'Procurando por dispositivos...'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 5f5591dc..16877827 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -108,6 +108,38 @@ class AppLocalizationsRu extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => 'Выберите способ подключения'; + + @override + String get connectionChoiceSubtitle => + 'Выберите, каким способом вы хотите получить свой устройство MeshCore.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => + 'Подключение через USB будет доступно в ближайшее время.'; + + @override + String get usbScreenSubtitle => + 'Мы создаем последовательную схему подключения для устройств на базе Android и настольных компьютеров.'; + + @override + String get usbScreenStatus => 'Скоро'; + + @override + String get usbScreenNote => + 'Как только появится поддержка USB, вы сможете выбрать последовательный порт и напрямую подключиться к вашему устройству MeshCore.'; + + @override + String get usbScreenEmptyState => + 'Не обнаружено никаких устройств USB. Подключите одно из них и обновите список.'; + @override String get scanner_scanning => 'Поиск устройств...'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 82e4d060..89490908 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -108,6 +108,37 @@ class AppLocalizationsSk extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => 'Vyberte si metódu prepojenia.'; + + @override + String get connectionChoiceSubtitle => + 'Vyberte si, ako chcete dosiahnuť váš zariadenie MeshCore.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => 'Pripojenie cez USB bude k dispozícii čoskoro.'; + + @override + String get usbScreenSubtitle => + 'Vytvárajeme komunikačný systém založený na sériovej komunikácii pre Android a stolné počítače.'; + + @override + String get usbScreenStatus => 'Čoskoro'; + + @override + String get usbScreenNote => + 'Po implementácii podpory pre USB, budete môcť vybrať sériový port a priamo sa pripojiť k vašmu zariadeniu MeshCore.'; + + @override + String get usbScreenEmptyState => + 'Nenašli sa žiadne USB zariadenia. Pripojte jedno a obnovte.'; + @override String get scanner_scanning => 'Skrívania zariadení...'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 9b1bfc97..bd0c4d57 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -108,6 +108,37 @@ class AppLocalizationsSl extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => 'Izberite svoj način povezave.'; + + @override + String get connectionChoiceSubtitle => + 'Izberite, kako želite dostopati do svojega naprave MeshCore.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => 'Vnos preko USB-ja bo v kratkem na voljo.'; + + @override + String get usbScreenSubtitle => + 'Gradimo pot za serijsko povezavo za Android in računalnike.'; + + @override + String get usbScreenStatus => 'Čez kratko časa'; + + @override + String get usbScreenNote => + 'Ko bo podpora za USB na voljo, boste izbrali serijsko vrata in se neposredno povezali z vašim napravem MeshCore.'; + + @override + String get usbScreenEmptyState => + 'Niti en USB naprave niso bilo najdeno. Povežite eno in posodobite.'; + @override String get scanner_scanning => 'Skeniram za naprave...'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index ddfcf41a..183250dc 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -108,6 +108,37 @@ class AppLocalizationsSv extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => 'Välj din anslutningsmetod'; + + @override + String get connectionChoiceSubtitle => + 'Välj hur du vill komma åt din MeshCore-enhet.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => 'USB-anslutning kommer snart'; + + @override + String get usbScreenSubtitle => + 'Vi skapar en seriebaserad anslutningsväg för både Android- och skrivbordsenheter.'; + + @override + String get usbScreenStatus => 'Kommer snart'; + + @override + String get usbScreenNote => + 'När USB-stöd är implementerat, kommer du att välja en seriell port och ansluta direkt till din MeshCore-enhet.'; + + @override + String get usbScreenEmptyState => + 'Inga USB-enheter hittades. Anslut en och uppdatera.'; + @override String get scanner_scanning => 'Söker efter enheter...'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index b44a7cb3..19feaac1 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -108,6 +108,38 @@ class AppLocalizationsUk extends AppLocalizations { @override String get scanner_title => 'MeshCore Open'; + @override + String get connectionChoiceTitle => 'Виберіть спосіб зв\'язку'; + + @override + String get connectionChoiceSubtitle => + 'Виберіть, яким способом ви бажаєте отримати доступ до вашого пристрою MeshCore.'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => 'Bluetooth'; + + @override + String get usbScreenTitle => + 'Підключення через USB буде доступне найближчим часом.'; + + @override + String get usbScreenSubtitle => + 'Ми створюємо серійний шлях з\'єднання для Android та десктопних комп\'ютерів.'; + + @override + String get usbScreenStatus => 'Скоро'; + + @override + String get usbScreenNote => + 'Після того, як буде реалізовано підтримку USB, ви виберете серійний порт і підключитесь безпосередньо до вашого пристрою MeshCore.'; + + @override + String get usbScreenEmptyState => + 'Не знайдено жодних пристроїв USB. Підключіть один і перезавантажте.'; + @override String get scanner_scanning => 'Пошук пристроїв...'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index b3d85ed5..fdb9f1da 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -108,6 +108,33 @@ class AppLocalizationsZh extends AppLocalizations { @override String get scanner_title => '连接设备'; + @override + String get connectionChoiceTitle => '选择您的连接方式'; + + @override + String get connectionChoiceSubtitle => '请选择您希望如何访问 MeshCore 设备的选项。'; + + @override + String get connectionChoiceUsbLabel => 'USB'; + + @override + String get connectionChoiceBluetoothLabel => '蓝牙'; + + @override + String get usbScreenTitle => 'USB 连接即将推出'; + + @override + String get usbScreenSubtitle => '我们正在构建一个基于串行的连接路径,用于Android和桌面设备。'; + + @override + String get usbScreenStatus => '即将推出'; + + @override + String get usbScreenNote => '一旦USB支持功能上线,您就可以选择一个串口,并直接连接到您的MeshCore设备。'; + + @override + String get usbScreenEmptyState => '未找到任何 USB 设备。请插入一个,然后刷新。'; + @override String get scanner_scanning => '正在搜索设备...'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 35cb3751..2f094058 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1801,5 +1801,14 @@ "contacts_searchContactsNoNumber": "Zoek contacten...", "contacts_searchUsers": "Zoek {number}{str} gebruikers...", "contacts_searchFavorites": "Zoek {number}{str} favorieten...", - "contacts_searchRoomServers": "Zoek {number}{str} Room servers..." + "contacts_searchRoomServers": "Zoek {number}{str} Room servers...", + "connectionChoiceTitle": "Kies uw verbindingsmethode", + "connectionChoiceUsbLabel": "USB", + "connectionChoiceSubtitle": "Kies hoe u uw MeshCore-apparaat wilt bereiken.", + "connectionChoiceBluetoothLabel": "Bluetooth", + "usbScreenTitle": "USB-verbinding is binnenkort beschikbaar.", + "usbScreenSubtitle": "We ontwikkelen een verbindingspad op basis van seriële communicatie, zowel voor Android als voor desktop-computers.", + "usbScreenStatus": "Komende week", + "usbScreenNote": "Zodra de USB-ondersteuning is geïnstalleerd, selecteert u een seriële poort en verbindt u direct met uw MeshCore-apparaat.", + "usbScreenEmptyState": "Geen USB-apparaten gevonden. Sluit er een aan en herlaad." } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 23c4cbcd..8988e02a 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1801,5 +1801,14 @@ "contacts_searchFavorites": "Wyszukaj {number}{str} ulubione...", "contacts_searchRoomServers": "Wyszukaj {number}{str} serwerów Room...", "contacts_searchUsers": "Wyszukaj {number}{str} Użytkowników...", - "contacts_searchRepeaters": "Wyszukaj {number}{str} powtórników..." + "contacts_searchRepeaters": "Wyszukaj {number}{str} powtórników...", + "connectionChoiceBluetoothLabel": "Bluetooth", + "connectionChoiceSubtitle": "Wybierz, w jaki sposób chcesz uzyskać dostęp do swojego urządzenia MeshCore.", + "connectionChoiceTitle": "Wybierz metodę połączenia.", + "connectionChoiceUsbLabel": "USB", + "usbScreenTitle": "Połączenie USB będzie dostępne wkrótce.", + "usbScreenSubtitle": "Tworzymy ścieżkę połączenia opartą na protokole szeregowym, przeznaczoną zarówno dla urządzeń z systemem Android, jak i dla komputerów stacjonarnych.", + "usbScreenStatus": "Wkrótce", + "usbScreenNote": "Po wdrożeniu wsparcia dla USB, wybierzesz port szeregowy i połączysz się bezpośrednio z urządzeniem MeshCore.", + "usbScreenEmptyState": "Nie znaleziono żadnych urządzeń USB. Podłącz jedno i zaktualizuj." } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 05792c68..296590fc 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1801,5 +1801,14 @@ "contacts_searchUsers": "Pesquisar {number}{str} Usuários...", "contacts_searchContactsNoNumber": "Pesquisar Contatos...", "contacts_unread": "Não lido", - "contacts_searchRoomServers": "Pesquisar {number}{str} servidores de sala..." + "contacts_searchRoomServers": "Pesquisar {number}{str} servidores de sala...", + "connectionChoiceSubtitle": "Selecione a forma como você deseja acessar seu dispositivo MeshCore.", + "connectionChoiceUsbLabel": "USB", + "connectionChoiceBluetoothLabel": "Bluetooth", + "connectionChoiceTitle": "Escolha o método de conexão desejado.", + "usbScreenTitle": "A conexão USB estará disponível em breve.", + "usbScreenSubtitle": "Estamos criando um caminho de conexão baseado em série para dispositivos Android e de desktop.", + "usbScreenStatus": "Em breve", + "usbScreenNote": "Assim que o suporte USB for implementado, você poderá selecionar uma porta serial e conectar-se diretamente ao seu dispositivo MeshCore.", + "usbScreenEmptyState": "Nenhum dispositivo USB encontrado. Conecte um e atualize." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 9460f445..65b3792f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1041,5 +1041,14 @@ "contacts_unread": "Непрочитанное", "contacts_searchRoomServers": "Поиск {number}{str} серверов комнат...", "contacts_searchFavorites": "Поиск {number}{str} избранного...", - "contacts_searchUsers": "Поиск {number}{str} пользователей..." + "contacts_searchUsers": "Поиск {number}{str} пользователей...", + "connectionChoiceSubtitle": "Выберите, каким способом вы хотите получить свой устройство MeshCore.", + "connectionChoiceTitle": "Выберите способ подключения", + "connectionChoiceUsbLabel": "USB", + "connectionChoiceBluetoothLabel": "Bluetooth", + "usbScreenTitle": "Подключение через USB будет доступно в ближайшее время.", + "usbScreenSubtitle": "Мы создаем последовательную схему подключения для устройств на базе Android и настольных компьютеров.", + "usbScreenStatus": "Скоро", + "usbScreenNote": "Как только появится поддержка USB, вы сможете выбрать последовательный порт и напрямую подключиться к вашему устройству MeshCore.", + "usbScreenEmptyState": "Не обнаружено никаких устройств USB. Подключите одно из них и обновите список." } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 5bc00c65..7178166c 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1801,5 +1801,14 @@ "contacts_searchRepeaters": "Hľadať {number}{str} opakovače...", "contacts_searchUsers": "Hľadať {number}{str} používateľov...", "contacts_searchContactsNoNumber": "Hľadať kontakty...", - "contacts_unread": "Neprečítané" + "contacts_unread": "Neprečítané", + "connectionChoiceBluetoothLabel": "Bluetooth", + "connectionChoiceUsbLabel": "USB", + "connectionChoiceTitle": "Vyberte si metódu prepojenia.", + "connectionChoiceSubtitle": "Vyberte si, ako chcete dosiahnuť váš zariadenie MeshCore.", + "usbScreenTitle": "Pripojenie cez USB bude k dispozícii čoskoro.", + "usbScreenSubtitle": "Vytvárajeme komunikačný systém založený na sériovej komunikácii pre Android a stolné počítače.", + "usbScreenStatus": "Čoskoro", + "usbScreenNote": "Po implementácii podpory pre USB, budete môcť vybrať sériový port a priamo sa pripojiť k vašmu zariadeniu MeshCore.", + "usbScreenEmptyState": "Nenašli sa žiadne USB zariadenia. Pripojte jedno a obnovte." } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 226a715d..416106ac 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1801,5 +1801,14 @@ "contacts_searchRoomServers": "Išči {number}{str} strežnikov sob...", "contacts_searchContactsNoNumber": "Iskanje stikov...", "contacts_searchRepeaters": "Išči {number}{str} ponavljalnike...", - "contacts_searchUsers": "Išči {number}{str} uporabnikov..." + "contacts_searchUsers": "Išči {number}{str} uporabnikov...", + "connectionChoiceBluetoothLabel": "Bluetooth", + "connectionChoiceUsbLabel": "USB", + "connectionChoiceTitle": "Izberite svoj način povezave.", + "connectionChoiceSubtitle": "Izberite, kako želite dostopati do svojega naprave MeshCore.", + "usbScreenTitle": "Vnos preko USB-ja bo v kratkem na voljo.", + "usbScreenSubtitle": "Gradimo pot za serijsko povezavo za Android in računalnike.", + "usbScreenStatus": "Čez kratko časa", + "usbScreenNote": "Ko bo podpora za USB na voljo, boste izbrali serijsko vrata in se neposredno povezali z vašim napravem MeshCore.", + "usbScreenEmptyState": "Niti en USB naprave niso bilo najdeno. Povežite eno in posodobite." } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index bfccfffb..9bbcd31c 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1801,5 +1801,14 @@ "contacts_searchRepeaters": "Sök {number}{str} upprepningsenheter...", "contacts_searchFavorites": "Sök {number}{str} Favoriter...", "contacts_searchUsers": "Sök {number}{str} användare...", - "contacts_searchRoomServers": "Sök {number}{str} Room-servrar..." + "contacts_searchRoomServers": "Sök {number}{str} Room-servrar...", + "connectionChoiceUsbLabel": "USB", + "connectionChoiceBluetoothLabel": "Bluetooth", + "connectionChoiceSubtitle": "Välj hur du vill komma åt din MeshCore-enhet.", + "connectionChoiceTitle": "Välj din anslutningsmetod", + "usbScreenTitle": "USB-anslutning kommer snart", + "usbScreenSubtitle": "Vi skapar en seriebaserad anslutningsväg för både Android- och skrivbordsenheter.", + "usbScreenStatus": "Kommer snart", + "usbScreenNote": "När USB-stöd är implementerat, kommer du att välja en seriell port och ansluta direkt till din MeshCore-enhet.", + "usbScreenEmptyState": "Inga USB-enheter hittades. Anslut en och uppdatera." } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 6063bc87..9a9919cb 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1801,5 +1801,14 @@ "contacts_searchFavorites": "Пошук {number}{str} улюблених...", "contacts_searchContactsNoNumber": "Пошук контактів...", "contacts_searchRepeaters": "Пошук {number}{str} ретрансляторів...", - "contacts_unread": "Непрочитане" + "contacts_unread": "Непрочитане", + "connectionChoiceSubtitle": "Виберіть, яким способом ви бажаєте отримати доступ до вашого пристрою MeshCore.", + "connectionChoiceUsbLabel": "USB", + "connectionChoiceTitle": "Виберіть спосіб зв'язку", + "connectionChoiceBluetoothLabel": "Bluetooth", + "usbScreenTitle": "Підключення через USB буде доступне найближчим часом.", + "usbScreenSubtitle": "Ми створюємо серійний шлях з'єднання для Android та десктопних комп'ютерів.", + "usbScreenStatus": "Скоро", + "usbScreenNote": "Після того, як буде реалізовано підтримку USB, ви виберете серійний порт і підключитесь безпосередньо до вашого пристрою MeshCore.", + "usbScreenEmptyState": "Не знайдено жодних пристроїв USB. Підключіть один і перезавантажте." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index db4953ef..660b221a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1806,5 +1806,14 @@ "contacts_searchRepeaters": "搜索 {number}{str} 重复器...", "contacts_searchContactsNoNumber": "搜索联系人...", "contacts_searchRoomServers": "搜索 {number}{str} 房间服务器...", - "contacts_searchFavorites": "搜索 {number}{str} 收藏..." + "contacts_searchFavorites": "搜索 {number}{str} 收藏...", + "connectionChoiceSubtitle": "请选择您希望如何访问 MeshCore 设备的选项。", + "connectionChoiceBluetoothLabel": "蓝牙", + "connectionChoiceTitle": "选择您的连接方式", + "connectionChoiceUsbLabel": "USB", + "usbScreenTitle": "USB 连接即将推出", + "usbScreenSubtitle": "我们正在构建一个基于串行的连接路径,用于Android和桌面设备。", + "usbScreenStatus": "即将推出", + "usbScreenNote": "一旦USB支持功能上线,您就可以选择一个串口,并直接连接到您的MeshCore设备。", + "usbScreenEmptyState": "未找到任何 USB 设备。请插入一个,然后刷新。" } diff --git a/lib/main.dart b/lib/main.dart index 9e53e215..dd503fd8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ import 'screens/chrome_required_screen.dart'; import 'utils/platform_info.dart'; import 'connector/meshcore_connector.dart'; -import 'screens/scanner_screen.dart'; +import 'screens/connection_choice_screen.dart'; import 'services/storage_service.dart'; import 'services/message_retry_service.dart'; import 'services/path_history_service.dart'; @@ -192,7 +192,7 @@ class MeshCoreApp extends StatelessWidget { }, home: (PlatformInfo.isWeb && !PlatformInfo.isChrome) ? const ChromeRequiredScreen() - : const ScannerScreen(), + : const ConnectionChoiceScreen(), ); }, ), diff --git a/lib/screens/connection_choice_screen.dart b/lib/screens/connection_choice_screen.dart new file mode 100644 index 00000000..a2ea183b --- /dev/null +++ b/lib/screens/connection_choice_screen.dart @@ -0,0 +1,201 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../l10n/l10n.dart'; +import 'scanner_screen.dart'; +import 'usb_screen.dart'; + +/// Entry point that lets the user choose between USB or Bluetooth. +class ConnectionChoiceScreen extends StatelessWidget { + const ConnectionChoiceScreen({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: FittedBox( + fit: BoxFit.scaleDown, + child: Text(l10n.appTitle, textAlign: TextAlign.center), + ), + centerTitle: true, + automaticallyImplyLeading: false, + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: LayoutBuilder( + builder: (context, constraints) { + final availableHeight = constraints.maxHeight.isFinite + ? constraints.maxHeight + : 600.0; + final gap = math.max( + 8.0, + math.min(20.0, availableHeight * 0.035), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + flex: 3, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + l10n.connectionChoiceTitle, + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + SizedBox(height: math.max(4.0, gap * 0.5)), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + l10n.connectionChoiceSubtitle, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ], + ), + ), + ), + SizedBox(height: gap), + Expanded( + flex: 4, + child: _ConnectionMethodButton( + icon: Icons.usb, + label: l10n.connectionChoiceUsbLabel, + color: theme.colorScheme.primaryContainer, + iconColor: theme.colorScheme.onPrimaryContainer, + onPressed: () { + debugPrint( + 'ConnectionChoiceScreen: USB selected, opening UsbScreen', + ); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UsbScreen()), + ); + }, + ), + ), + SizedBox(height: gap), + Expanded( + flex: 4, + child: _ConnectionMethodButton( + icon: Icons.bluetooth, + label: l10n.connectionChoiceBluetoothLabel, + color: theme.colorScheme.surfaceContainerHighest, + iconColor: theme.colorScheme.onSurfaceVariant, + onPressed: () { + debugPrint( + 'ConnectionChoiceScreen: Bluetooth selected, opening ScannerScreen', + ); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ScannerScreen(), + ), + ); + }, + ), + ), + ], + ); + }, + ), + ), + ), + ); + } +} + +class _ConnectionMethodButton extends StatelessWidget { + const _ConnectionMethodButton({ + required this.icon, + required this.label, + required this.onPressed, + required this.color, + required this.iconColor, + }); + + final IconData icon; + final String label; + final VoidCallback onPressed; + final Color color; + final Color iconColor; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: color, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + minimumSize: const Size.fromHeight(0), + ), + onPressed: onPressed, + child: LayoutBuilder( + builder: (context, constraints) { + final availableHeight = constraints.maxHeight.isFinite + ? constraints.maxHeight + : 200.0; + final availableWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth + : 320.0; + final isCompact = availableHeight < 72.0 || availableWidth < 180.0; + final baseGap = isCompact ? 8.0 : 12.0; + final content = Flex( + direction: isCompact ? Axis.horizontal : Axis.vertical, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: isCompact ? 24.0 : 60.0, color: iconColor), + SizedBox( + width: isCompact ? baseGap : 0, + height: isCompact ? 0 : baseGap, + ), + Text( + label, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.visible, + style: + (isCompact + ? theme.textTheme.titleMedium + : theme.textTheme.titleLarge) + ?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ); + + return Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: math.max(0, availableWidth - 12), + maxHeight: math.max(0, availableHeight - 12), + ), + child: content, + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index 4017408c..713239ff 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -20,6 +20,7 @@ class ScannerScreen extends StatefulWidget { class _ScannerScreenState extends State { bool _changedNavigation = false; + late final MeshCoreConnector _connector; late final VoidCallback _connectionListener; BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown; late StreamSubscription _bluetoothStateSubscription; @@ -27,12 +28,12 @@ class _ScannerScreenState extends State { @override void initState() { super.initState(); - final connector = Provider.of(context, listen: false); + _connector = Provider.of(context, listen: false); _connectionListener = () { - if (connector.state == MeshCoreConnectionState.disconnected) { + if (_connector.state == MeshCoreConnectionState.disconnected) { _changedNavigation = false; - } else if (connector.state == MeshCoreConnectionState.connected && + } else if (_connector.state == MeshCoreConnectionState.connected && !_changedNavigation) { _changedNavigation = true; if (mounted) { @@ -43,7 +44,7 @@ class _ScannerScreenState extends State { } }; - connector.addListener(_connectionListener); + _connector.addListener(_connectionListener); _bluetoothStateSubscription = FlutterBluePlus.adapterState.listen( (state) { @@ -53,7 +54,7 @@ class _ScannerScreenState extends State { }); // Cancel scan if Bluetooth turns off while scanning if (state != BluetoothAdapterState.on) { - unawaited(connector.stopScan()); + unawaited(_connector.stopScan()); } } }, @@ -65,16 +66,25 @@ class _ScannerScreenState extends State { @override void dispose() { - final connector = Provider.of(context, listen: false); - connector.removeListener(_connectionListener); + _connector.removeListener(_connectionListener); unawaited(_bluetoothStateSubscription.cancel()); super.dispose(); } @override Widget build(BuildContext context) { + final canPop = Navigator.of(context).canPop(); return Scaffold( appBar: AppBar( + leading: canPop + ? IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + debugPrint('ScannerScreen: back button pressed'); + Navigator.of(context).maybePop(); + }, + ) + : null, title: AdaptiveAppBarTitle(context.l10n.scanner_title), centerTitle: true, automaticallyImplyLeading: false, diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart new file mode 100644 index 00000000..e542d616 --- /dev/null +++ b/lib/screens/usb_screen.dart @@ -0,0 +1,456 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../connector/meshcore_connector.dart'; +import '../l10n/l10n.dart'; +import 'contacts_screen.dart'; + +class UsbScreen extends StatefulWidget { + const UsbScreen({super.key}); + + @override + State createState() => _UsbScreenState(); +} + +class _UsbScreenState extends State { + final List _ports = []; + bool _isLoadingPorts = true; + bool _isConnecting = false; + bool _navigatedToContacts = false; + String? _selectedPort; + String? _errorText; + late final MeshCoreConnector _connector; + late final VoidCallback _connectionListener; + + @override + void initState() { + super.initState(); + _connector = context.read(); + _connectionListener = () { + if (!mounted) return; + if (_connector.state == MeshCoreConnectionState.disconnected) { + _navigatedToContacts = false; + if (_isConnecting) { + setState(() { + _isConnecting = false; + }); + } + } + if (_connector.state == MeshCoreConnectionState.connected && + _connector.isUsbTransportConnected && + !_navigatedToContacts) { + _navigatedToContacts = true; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const ContactsScreen()), + ); + } + }; + _connector.addListener(_connectionListener); + unawaited(_loadPorts()); + } + + @override + void dispose() { + _connector.removeListener(_connectionListener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + debugPrint('UsbScreen: back button pressed'); + Navigator.of(context).maybePop(); + }, + ), + title: Text( + l10n.connectionChoiceUsbLabel, + style: theme.textTheme.titleLarge, + ), + centerTitle: true, + ), + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final availableHeight = constraints.maxHeight.isFinite + ? constraints.maxHeight + : 600.0; + final availableWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth + : 800.0; + final gap = math.max(8.0, math.min(16.0, availableHeight * 0.025)); + final iconSize = math.max( + 28.0, + math.min(72.0, availableHeight * 0.12), + ); + final isNarrow = availableWidth < 460.0; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + flex: 3, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.usb, + size: iconSize, + color: theme.colorScheme.primary, + ), + SizedBox(height: gap), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + l10n.usbScreenTitle, + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + SizedBox(height: math.max(4.0, gap * 0.5)), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + l10n.usbScreenSubtitle, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + SizedBox(height: gap), + FittedBox( + fit: BoxFit.scaleDown, + child: Chip( + label: Text( + _selectedPort == null + ? l10n.usbScreenStatus + : _friendlyPortName(_selectedPort!), + overflow: TextOverflow.ellipsis, + ), + backgroundColor: + theme.colorScheme.surfaceContainerHighest, + ), + ), + ], + ), + ), + ), + SizedBox(height: gap), + Expanded(child: _buildPortList(context)), + if (_errorText != null) ...[ + SizedBox(height: gap), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + _errorText!, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + ), + ], + SizedBox(height: gap), + if (isNarrow) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + OutlinedButton.icon( + onPressed: _isLoadingPorts || _isConnecting + ? null + : () { + debugPrint( + 'UsbScreen: refresh ports pressed', + ); + _loadPorts(); + }, + icon: const Icon(Icons.refresh), + label: Text(l10n.repeater_refresh), + ), + SizedBox(height: gap), + FilledButton.icon( + onPressed: _canConnect + ? () { + final rawPortName = _normalizedPortName( + _selectedPort!, + ); + debugPrint( + 'UsbScreen: connect pressed for $_selectedPort (raw: $rawPortName)', + ); + _connectSelectedPort(); + } + : null, + icon: _isConnecting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.usb), + label: Text(l10n.common_connect), + ), + ], + ) + else + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _isLoadingPorts || _isConnecting + ? null + : () { + debugPrint( + 'UsbScreen: refresh ports pressed', + ); + _loadPorts(); + }, + icon: const Icon(Icons.refresh), + label: Text(l10n.repeater_refresh), + ), + ), + SizedBox(width: gap), + Expanded( + child: FilledButton.icon( + onPressed: _canConnect + ? () { + final rawPortName = _normalizedPortName( + _selectedPort!, + ); + debugPrint( + 'UsbScreen: connect pressed for $_selectedPort (raw: $rawPortName)', + ); + _connectSelectedPort(); + } + : null, + icon: _isConnecting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.usb), + label: Text(l10n.common_connect), + ), + ), + ], + ), + SizedBox(height: math.max(4.0, gap * 0.75)), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + l10n.usbScreenNote, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ); + } + + bool get _canConnect => + !_isLoadingPorts && + !_isConnecting && + _selectedPort != null && + _selectedPort!.isNotEmpty; + + Widget _buildPortList(BuildContext context) { + final theme = Theme.of(context); + final l10n = context.l10n; + + if (_isLoadingPorts) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 12), + Text(l10n.common_loading), + ], + ), + ); + } + + if (_ports.isEmpty) { + return Center( + child: Text( + l10n.usbScreenEmptyState, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } + + return ListView.separated( + itemCount: _ports.length, + itemBuilder: (context, index) { + final port = _ports[index]; + final isSelected = port == _selectedPort; + final displayName = _friendlyPortName(port); + final rawName = _normalizedPortName(port); + final showRawName = rawName != displayName; + return Material( + color: isSelected + ? theme.colorScheme.primaryContainer + : theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + child: ListTile( + onTap: _isConnecting + ? null + : () { + setState(() { + _selectedPort = port; + _errorText = null; + }); + debugPrint('UsbScreen: selected port $port'); + }, + leading: Icon( + Icons.usb, + color: isSelected + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant, + ), + title: Text( + displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + color: isSelected ? theme.colorScheme.onPrimaryContainer : null, + ), + ), + subtitle: showRawName + ? Text( + rawName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: isSelected + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant, + ), + ) + : null, + trailing: isSelected + ? Icon( + Icons.check_circle, + color: theme.colorScheme.onPrimaryContainer, + ) + : null, + ), + ); + }, + separatorBuilder: (context, index) => const SizedBox(height: 10), + ); + } + + Future _loadPorts() async { + if (!mounted) return; + + setState(() { + _isLoadingPorts = true; + _errorText = null; + }); + + try { + final ports = await _connector.listUsbPorts(); + if (!mounted) return; + setState(() { + _ports + ..clear() + ..addAll(ports); + if (_ports.isEmpty) { + _selectedPort = null; + } else if (!_ports.contains(_selectedPort)) { + _selectedPort = _ports.first; + } + _isLoadingPorts = false; + }); + } catch (error) { + if (!mounted) return; + setState(() { + _ports.clear(); + _selectedPort = null; + _errorText = error.toString(); + _isLoadingPorts = false; + }); + } + } + + Future _connectSelectedPort() async { + final selectedPort = _selectedPort; + if (selectedPort == null || selectedPort.isEmpty) { + return; + } + final rawPortName = _normalizedPortName(selectedPort); + + setState(() { + _isConnecting = true; + _errorText = null; + }); + + try { + await _connector.connectUsb(portName: rawPortName); + } catch (error) { + if (!mounted) return; + setState(() { + _isConnecting = false; + _errorText = error.toString(); + }); + } + } + + String _normalizedPortName(String portLabel) { + final separatorIndex = portLabel.indexOf(' - '); + final normalized = separatorIndex >= 0 + ? portLabel.substring(0, separatorIndex) + : portLabel; + return normalized.trim(); + } + + String _friendlyPortName(String portLabel) { + final separatorIndex = portLabel.indexOf(' - '); + if (separatorIndex < 0) { + return portLabel.trim(); + } + final friendlyName = portLabel.substring(separatorIndex + 3).trim(); + if (friendlyName.isEmpty) { + return _normalizedPortName(portLabel); + } + return friendlyName; + } +} diff --git a/lib/services/usb_serial_service.dart b/lib/services/usb_serial_service.dart new file mode 100644 index 00000000..7e9027df --- /dev/null +++ b/lib/services/usb_serial_service.dart @@ -0,0 +1,284 @@ +import 'dart:async'; + +import 'package:flserial/flserial.dart'; +import 'package:flserial/flserial_exception.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Wraps the native flserial plugin to expose a stream of raw bytes for the +/// MeshCore connector to consume. +class UsbSerialService { + UsbSerialService(); + + static const MethodChannel _androidMethodChannel = MethodChannel( + 'meshcore_open/android_usb_serial', + ); + static const EventChannel _androidEventChannel = EventChannel( + 'meshcore_open/android_usb_serial_events', + ); + static const int _serialTxFrameStart = 0x3c; + static const int _serialRxFrameStart = 0x3e; + static const int _serialHeaderLength = 3; + + final StreamController _frameController = + StreamController.broadcast(); + final FlSerial _serial = FlSerial(); + final List _rxBuffer = []; + StreamSubscription? _androidDataSubscription; + StreamSubscription? _dataSubscription; + UsbSerialStatus _status = UsbSerialStatus.disconnected; + String? _connectedPortName; + + UsbSerialStatus get status => _status; + String? get activePortName => _connectedPortName; + Stream get frameStream => _frameController.stream; + bool get _useAndroidUsbHost => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + + bool get isConnected { + if (_useAndroidUsbHost) { + return _status == UsbSerialStatus.connected; + } + return _status == UsbSerialStatus.connected && + _serial.isOpen() == FlOpenStatus.open; + } + + Future> listPorts() async { + if (_useAndroidUsbHost) { + final ports = await _androidMethodChannel.invokeListMethod( + 'listPorts', + ); + return ports ?? []; + } + return Future.value(FlSerial.listPorts()); + } + + Future connect({ + required String portName, + int baudRate = 115200, + }) async { + if (_status == UsbSerialStatus.connected || + _status == UsbSerialStatus.connecting) { + throw StateError('USB serial transport is already active'); + } + + _status = UsbSerialStatus.connecting; + final normalizedPortName = _normalizePortName(portName); + + if (_useAndroidUsbHost) { + try { + await _androidMethodChannel.invokeMethod('connect', { + 'portName': normalizedPortName, + 'baudRate': baudRate, + }); + debugPrint( + 'USB serial opened port=$normalizedPortName on Android via USB host bridge', + ); + } on PlatformException catch (error) { + _status = UsbSerialStatus.disconnected; + throw StateError(error.message ?? error.code); + } + } else { + _serial.init(); + + try { + final status = _serial.openPort(normalizedPortName, baudRate); + if (status != FlOpenStatus.open) { + throw StateError( + 'Failed to open USB port $normalizedPortName ($status)', + ); + } + _serial.setByteSize8(); + _serial.setBitParityNone(); + _serial.setStopBits1(); + _serial.setFlowControlNone(); + _serial.setRTS(false); + _serial.setDTR(true); + debugPrint( + 'USB serial opened port=$normalizedPortName cts=${_serial.getCTS()} dsr=${_serial.getDSR()} dtr=true rts=false', + ); + } on FlSerialException catch (error) { + _serial.free(); + _status = UsbSerialStatus.disconnected; + throw StateError( + 'Failed to open USB port $normalizedPortName: ${error.msg} (${error.error})', + ); + } catch (error) { + _serial.free(); + _status = UsbSerialStatus.disconnected; + rethrow; + } + } + + _connectedPortName = normalizedPortName; + if (_useAndroidUsbHost) { + _androidDataSubscription = _androidEventChannel + .receiveBroadcastStream() + .listen( + _handleAndroidData, + onError: _handleSerialError, + onDone: _handleSerialDone, + ); + } else { + _dataSubscription = _serial.onSerialData.stream.listen( + _handleSerialData, + onError: _handleSerialError, + onDone: _handleSerialDone, + ); + } + _status = UsbSerialStatus.connected; + } + + Future write(Uint8List data) async { + if (!isConnected) { + throw StateError('USB serial port is not open'); + } + final packet = Uint8List(_serialHeaderLength + data.length); + packet[0] = _serialTxFrameStart; + packet[1] = data.length & 0xff; + packet[2] = (data.length >> 8) & 0xff; + packet.setRange(_serialHeaderLength, packet.length, data); + _logFrameSummary('USB TX frame', data); + if (_useAndroidUsbHost) { + try { + await _androidMethodChannel.invokeMethod('write', { + 'data': packet, + }); + } on PlatformException catch (error) { + throw StateError(error.message ?? error.code); + } + } else { + _serial.write(packet); + } + } + + Future disconnect() async { + if (_status == UsbSerialStatus.disconnected) return; + + _status = UsbSerialStatus.disconnecting; + _connectedPortName = null; + await _androidDataSubscription?.cancel(); + _androidDataSubscription = null; + await _dataSubscription?.cancel(); + _dataSubscription = null; + + if (_useAndroidUsbHost) { + try { + await _androidMethodChannel.invokeMethod('disconnect'); + } catch (_) { + // Ignore errors while closing. + } + } else { + try { + if (_serial.isOpen() == FlOpenStatus.open) { + _serial.closePort(); + } + } catch (_) { + // Ignore errors while closing. + } + + _serial.free(); + } + _status = UsbSerialStatus.disconnected; + } + + void dispose() { + unawaited(disconnect()); + unawaited(_frameController.close()); + } + + void _handleSerialData(FlSerialEventArgs event) { + try { + final bytes = event.serial.readList(); + if (bytes.isNotEmpty) { + _ingestRawBytes(Uint8List.fromList(bytes)); + } + } catch (error, stack) { + _frameController.addError(error, stack); + } + } + + void _handleAndroidData(dynamic data) { + if (data is Uint8List) { + _ingestRawBytes(data); + return; + } + if (data is ByteData) { + _ingestRawBytes(data.buffer.asUint8List()); + return; + } + _frameController.addError( + StateError('Unexpected Android USB event payload: ${data.runtimeType}'), + ); + } + + void _handleSerialError(Object error, [StackTrace? stackTrace]) { + _frameController.addError(error, stackTrace); + } + + void _handleSerialDone() { + unawaited(disconnect()); + } + + String _normalizePortName(String portName) { + final separatorIndex = portName.indexOf(' - '); + final normalized = separatorIndex >= 0 + ? portName.substring(0, separatorIndex) + : portName; + return normalized.trim(); + } + + void _ingestRawBytes(Uint8List bytes) { + if (bytes.isEmpty) { + return; + } + _rxBuffer.addAll(bytes); + _drainRxBuffer(); + } + + void _drainRxBuffer() { + while (true) { + if (_rxBuffer.isEmpty) { + return; + } + + if (_rxBuffer.first != _serialRxFrameStart && + _rxBuffer.first != _serialTxFrameStart) { + _rxBuffer.removeAt(0); + continue; + } + + if (_rxBuffer.length < _serialHeaderLength) { + return; + } + + final payloadLength = _rxBuffer[1] | (_rxBuffer[2] << 8); + final packetLength = _serialHeaderLength + payloadLength; + if (_rxBuffer.length < packetLength) { + return; + } + + final frameStart = _rxBuffer.first; + final payload = Uint8List.fromList( + _rxBuffer.sublist(_serialHeaderLength, packetLength), + ); + _rxBuffer.removeRange(0, packetLength); + if (frameStart != _serialRxFrameStart) { + debugPrint( + 'USB ignored packet start=0x${frameStart.toRadixString(16).padLeft(2, '0')} len=${payload.length}', + ); + } + _frameController.add(payload); + } + } + + void _logFrameSummary(String prefix, Uint8List bytes) { + if (bytes.isEmpty) { + debugPrint('$prefix len=0'); + return; + } + debugPrint('$prefix code=${bytes[0]} len=${bytes.length}'); + } +} + +enum UsbSerialStatus { disconnected, connecting, connected, disconnecting } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f16b4c34..379e36fa 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flserial ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/pubspec.yaml b/pubspec.yaml index f85530f4..9e82770f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 flutter_blue_plus: ^2.1.0 + flserial: ^0.3.5 provider: ^6.1.5+1 shared_preferences: ^2.2.2 uuid: ^4.3.3 diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index daf32c2f..97c813c0 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -89,9 +89,11 @@ endif() # Copy the native assets provided by the build.dart from all packages. set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) +if(EXISTS "${NATIVE_ASSETS_DIR}") + install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4c358e7f..f02857f4 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flserial flutter_local_notifications_windows )