From 7a2bb20bf732da4c7dbb8ef7f161c5b90d991c0e Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Sat, 7 Mar 2026 20:07:19 -0500 Subject: [PATCH 1/6] feat: Add TCP connection support and UI integration - Implemented TCP transport service for native platforms. - Added TCP connection screen with input fields for host and port. - Integrated TCP connection options into the scanner and USB screens. - Updated localization files for new TCP-related strings. - Added tests for TCP connection flow and error handling. - Enhanced USB screen to include TCP connection option. - Improved layout to ensure no overflow in narrow widths for scanner and USB screens. --- lib/connector/meshcore_connector.dart | 99 ++++++- lib/connector/meshcore_connector_tcp.dart | 37 +++ lib/connector/meshcore_connector_usb.dart | 3 + lib/l10n/app_bg.arb | 30 +- lib/l10n/app_de.arb | 30 +- lib/l10n/app_en.arb | 30 +- lib/l10n/app_es.arb | 30 +- lib/l10n/app_fr.arb | 30 +- lib/l10n/app_it.arb | 30 +- lib/l10n/app_localizations.dart | 84 ++++++ lib/l10n/app_localizations_bg.dart | 47 +++ lib/l10n/app_localizations_de.dart | 50 ++++ lib/l10n/app_localizations_en.dart | 47 +++ lib/l10n/app_localizations_es.dart | 47 +++ lib/l10n/app_localizations_fr.dart | 49 ++++ lib/l10n/app_localizations_it.dart | 48 ++++ lib/l10n/app_localizations_nl.dart | 48 ++++ lib/l10n/app_localizations_pl.dart | 49 ++++ lib/l10n/app_localizations_pt.dart | 49 ++++ lib/l10n/app_localizations_ru.dart | 48 ++++ lib/l10n/app_localizations_sk.dart | 47 +++ lib/l10n/app_localizations_sl.dart | 47 +++ lib/l10n/app_localizations_sv.dart | 47 +++ lib/l10n/app_localizations_uk.dart | 48 ++++ lib/l10n/app_localizations_zh.dart | 46 +++ lib/l10n/app_nl.arb | 30 +- lib/l10n/app_pl.arb | 30 +- lib/l10n/app_pt.arb | 30 +- lib/l10n/app_ru.arb | 30 +- lib/l10n/app_sk.arb | 30 +- lib/l10n/app_sl.arb | 30 +- lib/l10n/app_sv.arb | 30 +- lib/l10n/app_uk.arb | 30 +- lib/l10n/app_zh.arb | 30 +- lib/screens/scanner_screen.dart | 116 ++++---- lib/screens/tcp_screen.dart | 272 ++++++++++++++++++ lib/screens/usb_screen.dart | 105 ++++--- lib/services/tcp_transport_service.dart | 2 + .../tcp_transport_service_native.dart | 205 +++++++++++++ lib/services/tcp_transport_service_web.dart | 35 +++ test/screens/tcp_flow_test.dart | 170 +++++++++++ test/screens/usb_flow_test.dart | 83 ++++-- .../tcp_transport_service_native_test.dart | 136 +++++++++ 43 files changed, 2391 insertions(+), 123 deletions(-) create mode 100644 lib/connector/meshcore_connector_tcp.dart create mode 100644 lib/screens/tcp_screen.dart create mode 100644 lib/services/tcp_transport_service.dart create mode 100644 lib/services/tcp_transport_service_native.dart create mode 100644 lib/services/tcp_transport_service_web.dart create mode 100644 test/screens/tcp_flow_test.dart create mode 100644 test/services/tcp_transport_service_native_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 89aeca0..135299e 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -22,6 +22,7 @@ import '../services/app_settings_service.dart'; import '../services/background_service.dart'; import '../services/notification_service.dart'; import 'meshcore_connector_usb.dart'; +import 'meshcore_connector_tcp.dart'; import '../storage/channel_message_store.dart'; import '../storage/channel_order_store.dart'; import '../storage/channel_settings_store.dart'; @@ -86,7 +87,7 @@ enum MeshCoreConnectionState { disconnecting, } -enum MeshCoreTransportType { bluetooth, usb } +enum MeshCoreTransportType { bluetooth, usb, tcp } class RepeaterBatterySnapshot { final int millivolts; @@ -116,6 +117,8 @@ class MeshCoreConnector extends ChangeNotifier { bool _manualDisconnect = false; final MeshCoreUsbManager _usbManager = MeshCoreUsbManager(); StreamSubscription? _usbFrameSubscription; + final MeshCoreTcpManager _tcpManager = MeshCoreTcpManager(); + StreamSubscription? _tcpFrameSubscription; MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth; final List _scanResults = []; @@ -255,6 +258,10 @@ class MeshCoreConnector extends ChangeNotifier { bool get isUsbTransportConnected => _state == MeshCoreConnectionState.connected && _activeTransport == MeshCoreTransportType.usb; + String? get activeTcpEndpoint => _tcpManager.activeEndpoint; + bool get isTcpTransportConnected => + _state == MeshCoreConnectionState.connected && + _activeTransport == MeshCoreTransportType.tcp; String get deviceDisplayName { if (_selfName != null && _selfName!.isNotEmpty) { @@ -659,6 +666,7 @@ class MeshCoreConnector extends ChangeNotifier { _appDebugLogService = appDebugLogService; _backgroundService = backgroundService; _usbManager.setDebugLogService(_appDebugLogService); + _tcpManager.setDebugLogService(_appDebugLogService); // Initialize notification service _notificationService.initialize(); @@ -964,6 +972,70 @@ class MeshCoreConnector extends ChangeNotifier { } } + Future connectTcp({required String host, required int port}) async { + if (_state == MeshCoreConnectionState.connecting || + _state == MeshCoreConnectionState.connected) { + _appDebugLogService?.warn( + 'connectTcp ignored: already $_state', + tag: 'TCP', + ); + return; + } + + _appDebugLogService?.info('connectTcp: endpoint=$host:$port', tag: 'TCP'); + + await stopScan(); + _cancelReconnectTimer(); + _manualDisconnect = false; + _resetConnectionHandshakeState(); + _activeTransport = MeshCoreTransportType.tcp; + _setState(MeshCoreConnectionState.connecting); + + try { + await _tcpFrameSubscription?.cancel(); + _tcpFrameSubscription = null; + await _tcpManager.connect(host: host, port: port); + notifyListeners(); + + await Future.delayed(const Duration(milliseconds: 200)); + _tcpFrameSubscription = _tcpManager.frameStream.listen( + _handleFrame, + onError: (error, stackTrace) { + _appDebugLogService?.error('TCP transport error: $error', tag: 'TCP'); + unawaited(disconnect(manual: false)); + }, + onDone: () { + _appDebugLogService?.warn('TCP frame stream ended', tag: 'TCP'); + unawaited(disconnect(manual: false)); + }, + ); + + _setState(MeshCoreConnectionState.connected); + _pendingInitialChannelSync = true; + await _requestDeviceInfo(); + _startBatteryPolling(); + + var gotSelfInfo = await _waitForSelfInfo( + timeout: const Duration(seconds: 3), + ); + if (!gotSelfInfo) { + await refreshDeviceInfo(); + gotSelfInfo = await _waitForSelfInfo( + timeout: const Duration(seconds: 3), + ); + } + if (!gotSelfInfo) { + throw StateError('Timed out waiting for SELF_INFO during TCP connect'); + } + + await syncTime(); + } catch (error) { + _appDebugLogService?.error('TCP connection error: $error', tag: 'TCP'); + await disconnect(manual: false); + rethrow; + } + } + Future connect(BluetoothDevice device, {String? displayName}) async { if (_state == MeshCoreConnectionState.connecting || _state == MeshCoreConnectionState.connected) { @@ -1230,6 +1302,7 @@ class MeshCoreConnector extends ChangeNotifier { bool get _shouldGateInitialChannelSync => _activeTransport == MeshCoreTransportType.usb || + _activeTransport == MeshCoreTransportType.tcp || (_activeTransport == MeshCoreTransportType.bluetooth && PlatformInfo.isWeb); @@ -1276,9 +1349,11 @@ class MeshCoreConnector extends ChangeNotifier { Future disconnect({bool manual = true}) async { if (_state == MeshCoreConnectionState.disconnecting) return; final transportAtDisconnect = _activeTransport; - final transportLabel = transportAtDisconnect == MeshCoreTransportType.usb - ? 'USB' - : 'BLE'; + final transportLabel = switch (transportAtDisconnect) { + MeshCoreTransportType.bluetooth => 'BLE', + MeshCoreTransportType.usb => 'USB', + MeshCoreTransportType.tcp => 'TCP', + }; _appDebugLogService?.info( 'Starting disconnect transport=$transportLabel manual=$manual', @@ -1298,6 +1373,9 @@ class MeshCoreConnector extends ChangeNotifier { await _usbFrameSubscription?.cancel(); _usbFrameSubscription = null; await _usbManager.disconnect(); + await _tcpFrameSubscription?.cancel(); + _tcpFrameSubscription = null; + await _tcpManager.disconnect(); await _notifySubscription?.cancel(); _notifySubscription = null; @@ -1379,6 +1457,8 @@ class MeshCoreConnector extends ChangeNotifier { if (_activeTransport == MeshCoreTransportType.usb) { await _usbManager.write(data); + } else if (_activeTransport == MeshCoreTransportType.tcp) { + await _tcpManager.write(data); } else { if (_rxCharacteristic == null) { throw Exception("MeshCore RX characteristic not available"); @@ -2338,7 +2418,8 @@ class MeshCoreConnector extends ChangeNotifier { } if (_pendingDeferredChannelSyncAfterContacts && (_activeTransport == MeshCoreTransportType.bluetooth || - _activeTransport == MeshCoreTransportType.usb)) { + _activeTransport == MeshCoreTransportType.usb || + _activeTransport == MeshCoreTransportType.tcp)) { _pendingDeferredChannelSyncAfterContacts = false; _pendingInitialChannelSync = false; unawaited(getChannels()); @@ -2505,14 +2586,16 @@ class MeshCoreConnector extends ChangeNotifier { if (PlatformInfo.isWeb && _activeTransport == MeshCoreTransportType.bluetooth) { _pendingInitialContactsSync = true; - } else if (_activeTransport == MeshCoreTransportType.usb) { + } else if (_activeTransport == MeshCoreTransportType.usb || + _activeTransport == MeshCoreTransportType.tcp) { _pendingDeferredChannelSyncAfterContacts = true; getContacts(); } else { getContacts(); } if (_shouldGateInitialChannelSync && - _activeTransport != MeshCoreTransportType.usb) { + _activeTransport != MeshCoreTransportType.usb && + _activeTransport != MeshCoreTransportType.tcp) { _maybeStartInitialChannelSync(); } } @@ -4274,12 +4357,14 @@ class MeshCoreConnector extends ChangeNotifier { _scanSubscription?.cancel(); _connectionSubscription?.cancel(); _usbFrameSubscription?.cancel(); + _tcpFrameSubscription?.cancel(); _notifySubscription?.cancel(); _notifyListenersTimer?.cancel(); _reconnectTimer?.cancel(); _batteryPollTimer?.cancel(); _receivedFramesController.close(); _usbManager.dispose(); + _tcpManager.dispose(); // Flush pending unread writes before disposal _unreadStore.flush(); diff --git a/lib/connector/meshcore_connector_tcp.dart b/lib/connector/meshcore_connector_tcp.dart new file mode 100644 index 0000000..01c2b92 --- /dev/null +++ b/lib/connector/meshcore_connector_tcp.dart @@ -0,0 +1,37 @@ +import 'dart:typed_data'; + +import '../services/app_debug_log_service.dart'; +import '../services/tcp_transport_service.dart'; + +class MeshCoreTcpManager { + final TcpTransportService _service = TcpTransportService(); + AppDebugLogService? _debugLog; + + String? get activeEndpoint => _service.activeEndpoint; + bool get isConnected => _service.isConnected; + Stream get frameStream => _service.frameStream; + + void setDebugLogService(AppDebugLogService? service) { + _debugLog = service; + _service.setDebugLogService(service); + } + + Future connect({required String host, required int port}) async { + _debugLog?.info('TcpManager.connect endpoint=$host:$port', tag: 'TCP'); + await _service.connect(host: host, port: port); + } + + Future disconnect() async { + if (!_service.isConnected && _service.activeEndpoint == null) { + return; + } + _debugLog?.info('TcpManager.disconnect', tag: 'TCP'); + await _service.disconnect(); + } + + Future write(Uint8List data) => _service.write(data); + + void dispose() { + _service.dispose(); + } +} diff --git a/lib/connector/meshcore_connector_usb.dart b/lib/connector/meshcore_connector_usb.dart index 376ae1a..74e7355 100644 --- a/lib/connector/meshcore_connector_usb.dart +++ b/lib/connector/meshcore_connector_usb.dart @@ -53,6 +53,9 @@ class MeshCoreUsbManager { } Future disconnect() async { + if (!_service.isConnected && _activePortKey == null) { + return; + } _debugLog?.info('UsbManager.disconnect', tag: 'USB'); await _service.disconnect(); _activePortKey = null; diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index 94d8997..972d376 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1859,5 +1859,33 @@ "usbConnectionFailed": "Неуспешно свързване през USB: {error}", "usbStatus_notConnected": "Изберете USB устройство", "usbStatus_searching": "Търсене на USB устройства...", - "usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка." + "usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostHint": "192.168.40.10", + "tcpScreenTitle": "Свържете се чрез TCP", + "connectionChoiceTcpLabel": "TCP", + "tcpHostLabel": "IP адрес", + "tcpPortLabel": "Пристанище", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Въведете крайната точка и свържете се.", + "tcpStatus_connecting": "Свързване към TCP крайния пункт...", + "tcpStatus_connectingTo": "Свързване към {endpoint}...", + "tcpErrorHostRequired": "Необходим е IP адрес.", + "tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.", + "tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.", + "tcpErrorTimedOut": "Връзката TCP изтекла.", + "tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9ba0f51..d6ff0b0 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1887,5 +1887,33 @@ "usbStatus_notConnected": "Wählen Sie ein USB-Gerät aus", "usbStatus_connecting": "Verbindung zum USB-Gerät...", "usbConnectionFailed": "Fehler beim USB-Verbindungsaufbau: {error}", - "usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält." + "usbErrorConnectTimedOut": "Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostLabel": "IP-Adresse", + "connectionChoiceTcpLabel": "TCP", + "tcpHostHint": "192.168.40.10", + "tcpScreenTitle": "Verbinden über TCP", + "tcpPortLabel": "Hafen", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Geben Sie den Endpunkt ein und verbinden Sie sich.", + "tcpStatus_connecting": "Verbindung zum TCP-Endpunkt hergestellt...", + "tcpStatus_connectingTo": "Verbindung zu {endpoint}...", + "tcpErrorHostRequired": "Eine IP-Adresse ist erforderlich.", + "tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.", + "tcpErrorUnsupported": "Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.", + "tcpErrorTimedOut": "Die TCP-Verbindung ist abgelaufen.", + "tcpConnectionFailed": "Fehler beim TCP-Verbindungsaufbau: {error}" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 2605628..544d574 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -49,6 +49,34 @@ "scanner_title": "MeshCore Open", "connectionChoiceUsbLabel": "USB", "connectionChoiceBluetoothLabel": "Bluetooth", + "connectionChoiceTcpLabel": "TCP", + "tcpScreenTitle": "Connect over TCP", + "tcpHostLabel": "IP Address", + "tcpHostHint": "192.168.40.10", + "tcpPortLabel": "Port", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Enter endpoint and connect", + "tcpStatus_connecting": "Connecting to TCP endpoint...", + "tcpStatus_connectingTo": "Connecting to {endpoint}...", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "tcpErrorHostRequired": "IP address is required.", + "tcpErrorPortInvalid": "Port must be between 1 and 65535.", + "tcpErrorUnsupported": "TCP transport is not supported on this platform.", + "tcpErrorTimedOut": "TCP connection timed out.", + "tcpConnectionFailed": "TCP connection failed: {error}", + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, "usbScreenTitle": "Connect over USB", "usbScreenSubtitle": "Choose a detected serial device and connect directly to your MeshCore node.", "usbScreenStatus": "Select a USB device", @@ -1898,4 +1926,4 @@ "discoveredContacts_deleteContact": "Delete Discovered Contact", "discoveredContacts_deleteContactAll": "Delete All Discovered Contacts", "discoveredContacts_deleteContactAllContent": "Are you sure you want to delete all discovered contacts?" -} \ No newline at end of file +} diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9b791d3..dcad901 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1887,5 +1887,33 @@ "usbStatus_searching": "Buscando dispositivos USB...", "usbStatus_notConnected": "Seleccione un dispositivo USB", "usbConnectionFailed": "Error al conectar mediante USB: {error}", - "usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion." + "usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpScreenTitle": "Establecer conexión a través de TCP", + "connectionChoiceTcpLabel": "TCP", + "tcpHostHint": "192.168.40.10", + "tcpHostLabel": "Dirección IP", + "tcpPortLabel": "Puerto", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Ingrese la dirección final y conecte.", + "tcpStatus_connecting": "Conectándose al punto final TCP...", + "tcpStatus_connectingTo": "Conectándose a {endpoint}...", + "tcpErrorHostRequired": "Se requiere la dirección IP.", + "tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.", + "tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.", + "tcpErrorTimedOut": "La conexión TCP ha caducado.", + "tcpConnectionFailed": "Error en la conexión TCP: {error}" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index a7bedc9..e934b7e 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1859,5 +1859,33 @@ "usbConnectionFailed": "Échec de la connexion USB : {error}", "usbStatus_connecting": "Connexion au périphérique USB...", "usbStatus_searching": "Recherche de périphériques USB...", - "usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion." + "usbErrorConnectTimedOut": "La connexion a expiré. Assurez-vous que l'appareil dispose du firmware USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostLabel": "Adresse IP", + "connectionChoiceTcpLabel": "TCP", + "tcpScreenTitle": "Établir une connexion via TCP", + "tcpHostHint": "192.168.40.10", + "tcpPortLabel": "Port", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Entrez l'adresse de destination et connectez-vous.", + "tcpStatus_connecting": "Connexion au point de terminaison TCP...", + "tcpStatus_connectingTo": "Connexion à {endpoint}...", + "tcpErrorHostRequired": "Une adresse IP est obligatoire.", + "tcpErrorPortInvalid": "La taille du port doit être comprise entre 1 et 65535.", + "tcpErrorUnsupported": "Le protocole TCP n'est pas pris en charge sur cette plateforme.", + "tcpErrorTimedOut": "La connexion TCP a expiré.", + "tcpConnectionFailed": "Échec de la connexion TCP : {error}" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 423ff40..30b19b0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1859,5 +1859,33 @@ "usbConnectionFailed": "Errore nella connessione USB: {error}", "usbStatus_notConnected": "Seleziona un dispositivo USB", "usbStatus_connecting": "Connessione al dispositivo USB...", - "usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion." + "usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostLabel": "Indirizzo IP", + "tcpHostHint": "192.168.40.10", + "connectionChoiceTcpLabel": "TCP", + "tcpScreenTitle": "Stabilire una connessione tramite TCP", + "tcpPortLabel": "Porto", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Inserisci l'endpoint e connettiti.", + "tcpStatus_connecting": "Connessione al punto finale TCP...", + "tcpStatus_connectingTo": "Connessione a {endpoint}...", + "tcpErrorHostRequired": "È necessario fornire un indirizzo IP.", + "tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.", + "tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.", + "tcpErrorTimedOut": "La connessione TCP è scaduta.", + "tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8d3f86b..536d7fb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -334,6 +334,90 @@ abstract class AppLocalizations { /// **'Bluetooth'** String get connectionChoiceBluetoothLabel; + /// No description provided for @connectionChoiceTcpLabel. + /// + /// In en, this message translates to: + /// **'TCP'** + String get connectionChoiceTcpLabel; + + /// No description provided for @tcpScreenTitle. + /// + /// In en, this message translates to: + /// **'Connect over TCP'** + String get tcpScreenTitle; + + /// No description provided for @tcpHostLabel. + /// + /// In en, this message translates to: + /// **'IP Address'** + String get tcpHostLabel; + + /// No description provided for @tcpHostHint. + /// + /// In en, this message translates to: + /// **'192.168.40.10'** + String get tcpHostHint; + + /// No description provided for @tcpPortLabel. + /// + /// In en, this message translates to: + /// **'Port'** + String get tcpPortLabel; + + /// No description provided for @tcpPortHint. + /// + /// In en, this message translates to: + /// **'5000'** + String get tcpPortHint; + + /// No description provided for @tcpStatus_notConnected. + /// + /// In en, this message translates to: + /// **'Enter endpoint and connect'** + String get tcpStatus_notConnected; + + /// No description provided for @tcpStatus_connecting. + /// + /// In en, this message translates to: + /// **'Connecting to TCP endpoint...'** + String get tcpStatus_connecting; + + /// No description provided for @tcpStatus_connectingTo. + /// + /// In en, this message translates to: + /// **'Connecting to {endpoint}...'** + String tcpStatus_connectingTo(String endpoint); + + /// No description provided for @tcpErrorHostRequired. + /// + /// In en, this message translates to: + /// **'IP address is required.'** + String get tcpErrorHostRequired; + + /// No description provided for @tcpErrorPortInvalid. + /// + /// In en, this message translates to: + /// **'Port must be between 1 and 65535.'** + String get tcpErrorPortInvalid; + + /// No description provided for @tcpErrorUnsupported. + /// + /// In en, this message translates to: + /// **'TCP transport is not supported on this platform.'** + String get tcpErrorUnsupported; + + /// No description provided for @tcpErrorTimedOut. + /// + /// In en, this message translates to: + /// **'TCP connection timed out.'** + String get tcpErrorTimedOut; + + /// No description provided for @tcpConnectionFailed. + /// + /// In en, this message translates to: + /// **'TCP connection failed: {error}'** + String tcpConnectionFailed(String error); + /// No description provided for @usbScreenTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 356106e..2ea0e0f 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -117,6 +117,53 @@ class AppLocalizationsBg extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Свържете се чрез TCP'; + + @override + String get tcpHostLabel => 'IP адрес'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Пристанище'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Въведете крайната точка и свържете се.'; + + @override + String get tcpStatus_connecting => 'Свързване към TCP крайния пункт...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Свързване към $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Необходим е IP адрес.'; + + @override + String get tcpErrorPortInvalid => 'Портът трябва да бъде между 1 и 65535.'; + + @override + String get tcpErrorUnsupported => + 'Транспортът чрез TCP не се поддържа на тази платформа.'; + + @override + String get tcpErrorTimedOut => 'Връзката TCP изтекла.'; + + @override + String tcpConnectionFailed(String error) { + return 'Неуспешно е установено TCP връзката: $error'; + } + @override String get usbScreenTitle => 'Свържете се чрез USB'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6353f35..495b37b 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -117,6 +117,56 @@ class AppLocalizationsDe extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Verbinden über TCP'; + + @override + String get tcpHostLabel => 'IP-Adresse'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Hafen'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => + 'Geben Sie den Endpunkt ein und verbinden Sie sich.'; + + @override + String get tcpStatus_connecting => + 'Verbindung zum TCP-Endpunkt hergestellt...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Verbindung zu $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Eine IP-Adresse ist erforderlich.'; + + @override + String get tcpErrorPortInvalid => + 'Die Portnummer muss zwischen 1 und 65535 liegen.'; + + @override + String get tcpErrorUnsupported => + 'Die TCP-Übertragung wird auf dieser Plattform nicht unterstützt.'; + + @override + String get tcpErrorTimedOut => 'Die TCP-Verbindung ist abgelaufen.'; + + @override + String tcpConnectionFailed(String error) { + return 'Fehler beim TCP-Verbindungsaufbau: $error'; + } + @override String get usbScreenTitle => 'Verbinden über USB'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9c20df7..c20a6df 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -117,6 +117,53 @@ class AppLocalizationsEn extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Connect over TCP'; + + @override + String get tcpHostLabel => 'IP Address'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Port'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Enter endpoint and connect'; + + @override + String get tcpStatus_connecting => 'Connecting to TCP endpoint...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Connecting to $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'IP address is required.'; + + @override + String get tcpErrorPortInvalid => 'Port must be between 1 and 65535.'; + + @override + String get tcpErrorUnsupported => + 'TCP transport is not supported on this platform.'; + + @override + String get tcpErrorTimedOut => 'TCP connection timed out.'; + + @override + String tcpConnectionFailed(String error) { + return 'TCP connection failed: $error'; + } + @override String get usbScreenTitle => 'Connect over USB'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index eecbd48..56bba0e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -117,6 +117,53 @@ class AppLocalizationsEs extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Establecer conexión a través de TCP'; + + @override + String get tcpHostLabel => 'Dirección IP'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Puerto'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Ingrese la dirección final y conecte.'; + + @override + String get tcpStatus_connecting => 'Conectándose al punto final TCP...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Conectándose a $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Se requiere la dirección IP.'; + + @override + String get tcpErrorPortInvalid => 'El puerto debe estar entre 1 y 65535.'; + + @override + String get tcpErrorUnsupported => + 'El protocolo de transporte TCP no está soportado en esta plataforma.'; + + @override + String get tcpErrorTimedOut => 'La conexión TCP ha caducado.'; + + @override + String tcpConnectionFailed(String error) { + return 'Error en la conexión TCP: $error'; + } + @override String get usbScreenTitle => 'Conecte mediante USB'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 5cabc86..0dd88f7 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -117,6 +117,55 @@ class AppLocalizationsFr extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Établir une connexion via TCP'; + + @override + String get tcpHostLabel => 'Adresse IP'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Port'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => + 'Entrez l\'adresse de destination et connectez-vous.'; + + @override + String get tcpStatus_connecting => 'Connexion au point de terminaison TCP...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Connexion à $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Une adresse IP est obligatoire.'; + + @override + String get tcpErrorPortInvalid => + 'La taille du port doit être comprise entre 1 et 65535.'; + + @override + String get tcpErrorUnsupported => + 'Le protocole TCP n\'est pas pris en charge sur cette plateforme.'; + + @override + String get tcpErrorTimedOut => 'La connexion TCP a expiré.'; + + @override + String tcpConnectionFailed(String error) { + return 'Échec de la connexion TCP : $error'; + } + @override String get usbScreenTitle => 'Connectez via USB'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index d170540..ce7b658 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -117,6 +117,54 @@ class AppLocalizationsIt extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Stabilire una connessione tramite TCP'; + + @override + String get tcpHostLabel => 'Indirizzo IP'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Porto'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Inserisci l\'endpoint e connettiti.'; + + @override + String get tcpStatus_connecting => 'Connessione al punto finale TCP...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Connessione a $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'È necessario fornire un indirizzo IP.'; + + @override + String get tcpErrorPortInvalid => + 'La dimensione della porta deve essere compresa tra 1 e 65535.'; + + @override + String get tcpErrorUnsupported => + 'Il protocollo TCP non è supportato su questa piattaforma.'; + + @override + String get tcpErrorTimedOut => 'La connessione TCP è scaduta.'; + + @override + String tcpConnectionFailed(String error) { + return 'Impossibile stabilire la connessione TCP: $error'; + } + @override String get usbScreenTitle => 'Connessione tramite USB'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 323ba34..70dbfe7 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -117,6 +117,54 @@ class AppLocalizationsNl extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Verbind via TCP'; + + @override + String get tcpHostLabel => 'IP-adres'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Haven'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Voer het eindpunt in en verbind'; + + @override + String get tcpStatus_connecting => 'Verbinding maken met TCP-eindpunt...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Verbinding maken met $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Een IP-adres is vereist.'; + + @override + String get tcpErrorPortInvalid => + 'De poortwaarde moet tussen 1 en 65535 liggen.'; + + @override + String get tcpErrorUnsupported => + 'TCP-transport wordt niet ondersteund op deze platform.'; + + @override + String get tcpErrorTimedOut => 'De TCP-verbinding is verlopen.'; + + @override + String tcpConnectionFailed(String error) { + return 'Verbinding met TCP mislukt: $error'; + } + @override String get usbScreenTitle => 'Verbind via USB'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 9175c3e..121c5fe 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -117,6 +117,55 @@ class AppLocalizationsPl extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Połącz się za pomocą protokołu TCP'; + + @override + String get tcpHostLabel => 'Adres IP'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Port'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Wprowadź adres URL i połącz'; + + @override + String get tcpStatus_connecting => 'Połączenie z punktem TCP...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Połączenie z $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Wymagana jest adresa IP.'; + + @override + String get tcpErrorPortInvalid => + 'Numer portu musi mieścić się w zakresie od 1 do 65535.'; + + @override + String get tcpErrorUnsupported => + 'Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.'; + + @override + String get tcpErrorTimedOut => + 'Połączenie TCP zakończyło się bez powodzenia.'; + + @override + String tcpConnectionFailed(String error) { + return 'Błąd połączenia TCP: $error'; + } + @override String get usbScreenTitle => 'Połącz przez USB'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ff09213..9545143 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -117,6 +117,55 @@ class AppLocalizationsPt extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Estabelecer conexão via TCP'; + + @override + String get tcpHostLabel => 'Endereço IP'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Porto'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Insira o endereço final e conecte-se.'; + + @override + String get tcpStatus_connecting => + 'Conectando ao ponto de extremidade TCP...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Conectando a $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'É necessário fornecer um endereço IP.'; + + @override + String get tcpErrorPortInvalid => + 'O valor do porto deve estar entre 1 e 65535.'; + + @override + String get tcpErrorUnsupported => + 'O protocolo TCP não é suportado nesta plataforma.'; + + @override + String get tcpErrorTimedOut => 'A conexão TCP expirou.'; + + @override + String tcpConnectionFailed(String error) { + return 'Falha na conexão TCP: $error'; + } + @override String get usbScreenTitle => 'Conecte via USB'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 69a5891..a0af1b3 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -117,6 +117,54 @@ class AppLocalizationsRu extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Установить соединение по протоколу TCP'; + + @override + String get tcpHostLabel => 'IP-адрес'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Порт'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Введите адрес и подключитесь.'; + + @override + String get tcpStatus_connecting => 'Установление соединения с TCP-портом...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Подключение к $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Необходимо указать IP-адрес.'; + + @override + String get tcpErrorPortInvalid => + 'Порт должен находиться в диапазоне от 1 до 65535.'; + + @override + String get tcpErrorUnsupported => + 'Протокол TCP не поддерживается на этой платформе.'; + + @override + String get tcpErrorTimedOut => 'Соединение TCP не удалось установить.'; + + @override + String tcpConnectionFailed(String error) { + return 'Не удалось установить соединение TCP: $error'; + } + @override String get usbScreenTitle => 'Подключение через USB'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index d0e75b0..f29c1e6 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -117,6 +117,53 @@ class AppLocalizationsSk extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Spojte sa pomocou protokolu TCP'; + + @override + String get tcpHostLabel => 'IP adresa'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Pri항'; + + @override + String get tcpPortHint => '5 000'; + + @override + String get tcpStatus_notConnected => 'Zadajte cieľovú adresu a pripojte sa.'; + + @override + String get tcpStatus_connecting => 'Pripojenie k TCP endpointu...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Pripojenie k $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Je potrebné zadať IP adresu.'; + + @override + String get tcpErrorPortInvalid => 'Číslo portu musí byť medzi 1 a 65535.'; + + @override + String get tcpErrorUnsupported => + 'Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.'; + + @override + String get tcpErrorTimedOut => 'Pripojenie TCP vypršalo.'; + + @override + String tcpConnectionFailed(String error) { + return 'Neúspešné vytvorenie TCP spojenia: $error'; + } + @override String get usbScreenTitle => 'Pripojte cez USB'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 21e3d9d..f4c6df0 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -117,6 +117,53 @@ class AppLocalizationsSl extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Komunicirajte preko protokola TCP'; + + @override + String get tcpHostLabel => 'IP naslov'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Pril'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Vnesite končni naslov in se povežite'; + + @override + String get tcpStatus_connecting => 'Povezava z TCP koncem...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Povezava z $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Potrebna je IP-naslov.'; + + @override + String get tcpErrorPortInvalid => 'Port mora biti med 1 in 65535.'; + + @override + String get tcpErrorUnsupported => + 'Transport preko protokola TCP ni podprt na tej platformi.'; + + @override + String get tcpErrorTimedOut => 'Povezava TCP je presegla časovno obdobje.'; + + @override + String tcpConnectionFailed(String error) { + return 'Napaka pri povezavi TCP: $error'; + } + @override String get usbScreenTitle => 'Povežite preko USB'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 5951fae..140e3eb 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -117,6 +117,53 @@ class AppLocalizationsSv extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'Anslut via TCP'; + + @override + String get tcpHostLabel => 'IP-adress'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Hamn'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Ange slutpunkt och anslut'; + + @override + String get tcpStatus_connecting => 'Anslutning till TCP-slutpunkt...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Anslutning till $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'IP-adress krävs.'; + + @override + String get tcpErrorPortInvalid => 'Porten måste vara mellan 1 och 65535.'; + + @override + String get tcpErrorUnsupported => + 'TCP-transport fungerar inte på denna plattform.'; + + @override + String get tcpErrorTimedOut => 'TCP-anslutningen har tidsut gått.'; + + @override + String tcpConnectionFailed(String error) { + return 'Fel vid TCP-anslutning: $error'; + } + @override String get usbScreenTitle => 'Anslut via USB'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index b8fd60a..5b28e57 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -117,6 +117,54 @@ class AppLocalizationsUk extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => 'Bluetooth'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => 'З\'єднатися через протокол TCP'; + + @override + String get tcpHostLabel => 'IP-адреса'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => 'Порт'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => 'Введіть кінцеву точку та підключіться'; + + @override + String get tcpStatus_connecting => 'Підключення до TCP-кінцевої точки...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return 'Підключення до $endpoint...'; + } + + @override + String get tcpErrorHostRequired => 'Необхідно вказати IP-адресу.'; + + @override + String get tcpErrorPortInvalid => 'Порт повинен бути в межах від 1 до 65535.'; + + @override + String get tcpErrorUnsupported => + 'Транспорт TCP не підтримується на цій платформі.'; + + @override + String get tcpErrorTimedOut => + 'З\'єднання TCP завершилося через закінчення часу очікування.'; + + @override + String tcpConnectionFailed(String error) { + return 'Не вдалося встановити з\'єднання TCP: $error'; + } + @override String get usbScreenTitle => 'Підключити через USB'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 27e6c21..2c0034c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -117,6 +117,52 @@ class AppLocalizationsZh extends AppLocalizations { @override String get connectionChoiceBluetoothLabel => '蓝牙'; + @override + String get connectionChoiceTcpLabel => 'TCP'; + + @override + String get tcpScreenTitle => '通过 TCP 连接'; + + @override + String get tcpHostLabel => 'IP地址'; + + @override + String get tcpHostHint => '192.168.40.10'; + + @override + String get tcpPortLabel => '港'; + + @override + String get tcpPortHint => '5000'; + + @override + String get tcpStatus_notConnected => '输入目标地址,然后连接'; + + @override + String get tcpStatus_connecting => '连接到 TCP 终点...'; + + @override + String tcpStatus_connectingTo(String endpoint) { + return '连接到 $endpoint...'; + } + + @override + String get tcpErrorHostRequired => '需要提供IP地址。'; + + @override + String get tcpErrorPortInvalid => '端口号必须在 1 到 65535 之间。'; + + @override + String get tcpErrorUnsupported => '此平台不支持 TCP 传输。'; + + @override + String get tcpErrorTimedOut => 'TCP 连接超时。'; + + @override + String tcpConnectionFailed(String error) { + return 'TCP 连接失败:$error'; + } + @override String get usbScreenTitle => '通过USB连接'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 94df130..29b9eca 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1859,5 +1859,33 @@ "usbStatus_notConnected": "Selecteer een USB-apparaat", "usbStatus_connecting": "Verbinding maken met USB-apparaat...", "usbStatus_searching": "Zoeken naar USB-apparaten...", - "usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft." + "usbErrorConnectTimedOut": "Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpScreenTitle": "Verbind via TCP", + "tcpHostLabel": "IP-adres", + "tcpHostHint": "192.168.40.10", + "connectionChoiceTcpLabel": "TCP", + "tcpPortLabel": "Haven", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Voer het eindpunt in en verbind", + "tcpStatus_connecting": "Verbinding maken met TCP-eindpunt...", + "tcpStatus_connectingTo": "Verbinding maken met {endpoint}...", + "tcpErrorHostRequired": "Een IP-adres is vereist.", + "tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.", + "tcpErrorUnsupported": "TCP-transport wordt niet ondersteund op deze platform.", + "tcpErrorTimedOut": "De TCP-verbinding is verlopen.", + "tcpConnectionFailed": "Verbinding met TCP mislukt: {error}" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index d020e0e..7576cf7 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1859,5 +1859,33 @@ "usbStatus_connecting": "Połączenie z urządzeniem USB...", "usbStatus_notConnected": "Wybierz urządzenie USB", "usbConnectionFailed": "Błąd połączenia USB: {error}", - "usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\"." + "usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "connectionChoiceTcpLabel": "TCP", + "tcpHostHint": "192.168.40.10", + "tcpScreenTitle": "Połącz się za pomocą protokołu TCP", + "tcpHostLabel": "Adres IP", + "tcpPortLabel": "Port", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Wprowadź adres URL i połącz", + "tcpStatus_connecting": "Połączenie z punktem TCP...", + "tcpStatus_connectingTo": "Połączenie z {endpoint}...", + "tcpErrorHostRequired": "Wymagana jest adresa IP.", + "tcpErrorPortInvalid": "Numer portu musi mieścić się w zakresie od 1 do 65535.", + "tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.", + "tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.", + "tcpConnectionFailed": "Błąd połączenia TCP: {error}" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index d52cb41..428772c 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1859,5 +1859,33 @@ "usbStatus_notConnected": "Selecione um dispositivo USB", "usbConnectionFailed": "Falha na conexão USB: {error}", "usbStatus_connecting": "Conectando ao dispositivo USB...", - "usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion." + "usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostLabel": "Endereço IP", + "connectionChoiceTcpLabel": "TCP", + "tcpScreenTitle": "Estabelecer conexão via TCP", + "tcpHostHint": "192.168.40.10", + "tcpPortLabel": "Porto", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Insira o endereço final e conecte-se.", + "tcpStatus_connecting": "Conectando ao ponto de extremidade TCP...", + "tcpStatus_connectingTo": "Conectando a {endpoint}...", + "tcpErrorHostRequired": "É necessário fornecer um endereço IP.", + "tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.", + "tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.", + "tcpErrorTimedOut": "A conexão TCP expirou.", + "tcpConnectionFailed": "Falha na conexão TCP: {error}" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 92fd55e..f42e959 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1099,5 +1099,33 @@ "usbStatus_connecting": "Подключение к USB-устройству...", "usbConnectionFailed": "Не удалось установить соединение через USB: {error}", "usbStatus_notConnected": "Выберите USB-устройство", - "usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion." + "usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostHint": "192.168.40.10", + "connectionChoiceTcpLabel": "TCP", + "tcpHostLabel": "IP-адрес", + "tcpScreenTitle": "Установить соединение по протоколу TCP", + "tcpPortLabel": "Порт", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Введите адрес и подключитесь.", + "tcpStatus_connecting": "Установление соединения с TCP-портом...", + "tcpStatus_connectingTo": "Подключение к {endpoint}...", + "tcpErrorHostRequired": "Необходимо указать IP-адрес.", + "tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.", + "tcpErrorUnsupported": "Протокол TCP не поддерживается на этой платформе.", + "tcpErrorTimedOut": "Соединение TCP не удалось установить.", + "tcpConnectionFailed": "Не удалось установить соединение TCP: {error}" } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 141147c..57f95ab 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1859,5 +1859,33 @@ "usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}", "usbStatus_notConnected": "Vyberte USB zariadenie", "usbStatus_connecting": "Pripojenie k USB zariadeniu...", - "usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion." + "usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostHint": "192.168.40.10", + "tcpHostLabel": "IP adresa", + "tcpScreenTitle": "Spojte sa pomocou protokolu TCP", + "connectionChoiceTcpLabel": "TCP", + "tcpPortLabel": "Pri항", + "tcpPortHint": "5 000", + "tcpStatus_notConnected": "Zadajte cieľovú adresu a pripojte sa.", + "tcpStatus_connecting": "Pripojenie k TCP endpointu...", + "tcpStatus_connectingTo": "Pripojenie k {endpoint}...", + "tcpErrorHostRequired": "Je potrebné zadať IP adresu.", + "tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.", + "tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.", + "tcpErrorTimedOut": "Pripojenie TCP vypršalo.", + "tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}" } diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 12529d6..252c7d7 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1859,5 +1859,33 @@ "usbStatus_connecting": "Povezava z USB napravo...", "usbStatus_searching": "Iskanje USB naprav...", "usbConnectionFailed": "Napaka pri povezavi preko USB: {error}", - "usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion." + "usbErrorConnectTimedOut": "Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "connectionChoiceTcpLabel": "TCP", + "tcpHostLabel": "IP naslov", + "tcpHostHint": "192.168.40.10", + "tcpScreenTitle": "Komunicirajte preko protokola TCP", + "tcpPortLabel": "Pril", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Vnesite končni naslov in se povežite", + "tcpStatus_connecting": "Povezava z TCP koncem...", + "tcpStatus_connectingTo": "Povezava z {endpoint}...", + "tcpErrorHostRequired": "Potrebna je IP-naslov.", + "tcpErrorPortInvalid": "Port mora biti med 1 in 65535.", + "tcpErrorUnsupported": "Transport preko protokola TCP ni podprt na tej platformi.", + "tcpErrorTimedOut": "Povezava TCP je presegla časovno obdobje.", + "tcpConnectionFailed": "Napaka pri povezavi TCP: {error}" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index f7615df..349a4d3 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1859,5 +1859,33 @@ "usbStatus_notConnected": "Välj en USB-enhet", "usbConnectionFailed": "Fel vid USB-anslutning: {error}", "usbStatus_searching": "Söker efter USB-enheter...", - "usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware." + "usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostHint": "192.168.40.10", + "tcpHostLabel": "IP-adress", + "tcpScreenTitle": "Anslut via TCP", + "connectionChoiceTcpLabel": "TCP", + "tcpPortLabel": "Hamn", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Ange slutpunkt och anslut", + "tcpStatus_connecting": "Anslutning till TCP-slutpunkt...", + "tcpStatus_connectingTo": "Anslutning till {endpoint}...", + "tcpErrorHostRequired": "IP-adress krävs.", + "tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.", + "tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.", + "tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.", + "tcpConnectionFailed": "Fel vid TCP-anslutning: {error}" } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 7794098..8d1ea80 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1859,5 +1859,33 @@ "usbStatus_notConnected": "Виберіть пристрій USB", "usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}", "usbStatus_connecting": "Підключення до USB-пристрою...", - "usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion." + "usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "connectionChoiceTcpLabel": "TCP", + "tcpHostHint": "192.168.40.10", + "tcpHostLabel": "IP-адреса", + "tcpScreenTitle": "З'єднатися через протокол TCP", + "tcpPortLabel": "Порт", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "Введіть кінцеву точку та підключіться", + "tcpStatus_connecting": "Підключення до TCP-кінцевої точки...", + "tcpStatus_connectingTo": "Підключення до {endpoint}...", + "tcpErrorHostRequired": "Необхідно вказати IP-адресу.", + "tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.", + "tcpErrorUnsupported": "Транспорт TCP не підтримується на цій платформі.", + "tcpErrorTimedOut": "З'єднання TCP завершилося через закінчення часу очікування.", + "tcpConnectionFailed": "Не вдалося встановити з'єднання TCP: {error}" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index dfc8e64..1df60a7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1864,5 +1864,33 @@ "usbStatus_connecting": "连接USB设备...", "usbStatus_notConnected": "选择一个 USB 设备", "usbConnectionFailed": "USB 连接失败:{error}", - "usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。" + "usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。", + "@tcpStatus_connectingTo": { + "placeholders": { + "endpoint": { + "type": "String" + } + } + }, + "@tcpConnectionFailed": { + "placeholders": { + "error": { + "type": "String" + } + } + }, + "tcpHostLabel": "IP地址", + "tcpHostHint": "192.168.40.10", + "tcpScreenTitle": "通过 TCP 连接", + "connectionChoiceTcpLabel": "TCP", + "tcpPortLabel": "港", + "tcpPortHint": "5000", + "tcpStatus_notConnected": "输入目标地址,然后连接", + "tcpStatus_connecting": "连接到 TCP 终点...", + "tcpStatus_connectingTo": "连接到 {endpoint}...", + "tcpErrorHostRequired": "需要提供IP地址。", + "tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。", + "tcpErrorUnsupported": "此平台不支持 TCP 传输。", + "tcpErrorTimedOut": "TCP 连接超时。", + "tcpConnectionFailed": "TCP 连接失败:{error}" } diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index 45fd3fb..986a598 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -10,6 +10,7 @@ import '../utils/app_logger.dart'; import '../widgets/adaptive_app_bar_title.dart'; import '../widgets/device_tile.dart'; import 'contacts_screen.dart'; +import 'tcp_screen.dart'; import 'usb_screen.dart'; /// Screen for scanning and connecting to MeshCore devices @@ -125,61 +126,78 @@ class _ScannerScreenState extends State { connector.state == MeshCoreConnectionState.scanning; final isBluetoothOff = _bluetoothState == BluetoothAdapterState.off; final usbSupported = PlatformInfo.supportsUsbSerial; + final tcpSupported = !PlatformInfo.isWeb; return SafeArea( top: false, minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (usbSupported) + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (usbSupported) + FloatingActionButton.extended( + onPressed: () { + appLogger.info( + 'USB selected, opening UsbScreen', + tag: 'ScannerScreen', + ); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UsbScreen()), + ); + }, + heroTag: 'scanner_usb_action', + icon: const Icon(Icons.usb), + label: Text(context.l10n.connectionChoiceUsbLabel), + ), + if (usbSupported) const SizedBox(width: 12), + if (tcpSupported) + FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const TcpScreen()), + ); + }, + heroTag: 'scanner_tcp_action', + icon: const Icon(Icons.lan), + label: Text(context.l10n.connectionChoiceTcpLabel), + ), + if (tcpSupported) const SizedBox(width: 12), FloatingActionButton.extended( - onPressed: () { - appLogger.info( - 'USB selected, opening UsbScreen', - tag: 'ScannerScreen', - ); - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const UsbScreen()), - ); - }, - heroTag: 'scanner_usb_action', - icon: const Icon(Icons.usb), - label: Text(context.l10n.connectionChoiceUsbLabel), + heroTag: 'scanner_ble_action', + onPressed: isBluetoothOff + ? null + : () { + if (isScanning) { + connector.stopScan(); + } else { + unawaited( + connector.startScan().catchError((e) { + appLogger.warn( + 'startScan error: $e', + tag: 'ScannerScreen', + ); + }), + ); + } + }, + icon: isScanning + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.bluetooth_searching), + label: Text( + isScanning + ? context.l10n.scanner_stop + : context.l10n.scanner_scan, + ), ), - if (usbSupported) const SizedBox(width: 12), - FloatingActionButton.extended( - heroTag: 'scanner_ble_action', - onPressed: isBluetoothOff - ? null - : () { - if (isScanning) { - connector.stopScan(); - } else { - unawaited( - connector.startScan().catchError((e) { - appLogger.warn( - 'startScan error: $e', - tag: 'ScannerScreen', - ); - }), - ); - } - }, - icon: isScanning - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.bluetooth_searching), - label: Text( - isScanning - ? context.l10n.scanner_stop - : context.l10n.scanner_scan, - ), - ), - ], + ], + ), ), ); }, diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart new file mode 100644 index 0000000..f3702c3 --- /dev/null +++ b/lib/screens/tcp_screen.dart @@ -0,0 +1,272 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../connector/meshcore_connector.dart'; +import '../l10n/l10n.dart'; +import '../utils/platform_info.dart'; +import '../widgets/adaptive_app_bar_title.dart'; +import 'contacts_screen.dart'; +import 'usb_screen.dart'; + +class TcpScreen extends StatefulWidget { + const TcpScreen({super.key}); + + @override + State createState() => _TcpScreenState(); +} + +class _TcpScreenState extends State { + late final TextEditingController _hostController; + late final TextEditingController _portController; + late final MeshCoreConnector _connector; + late final VoidCallback _connectionListener; + bool _navigatedToContacts = false; + + @override + void initState() { + super.initState(); + _hostController = TextEditingController(text: '192.168.40.10'); + _portController = TextEditingController(text: '5000'); + _connector = context.read(); + + _connectionListener = () { + if (!mounted) return; + if (_connector.state == MeshCoreConnectionState.disconnected) { + _navigatedToContacts = false; + } + if (_connector.state == MeshCoreConnectionState.connected && + _connector.isTcpTransportConnected && + !_navigatedToContacts) { + _navigatedToContacts = true; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const ContactsScreen()), + ); + } + }; + _connector.addListener(_connectionListener); + } + + @override + void dispose() { + _hostController.dispose(); + _portController.dispose(); + _connector.removeListener(_connectionListener); + if (!_navigatedToContacts && + _connector.activeTransport == MeshCoreTransportType.tcp && + _connector.state != MeshCoreConnectionState.disconnected) { + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_connector.disconnect(manual: true)); + }); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: AdaptiveAppBarTitle(context.l10n.tcpScreenTitle), + centerTitle: true, + ), + body: SafeArea( + top: false, + child: Consumer( + builder: (context, connector, child) { + final isConnecting = + connector.state == MeshCoreConnectionState.connecting && + connector.activeTransport == MeshCoreTransportType.tcp; + return Column( + children: [ + _buildStatusBar(context, connector), + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _hostController, + decoration: InputDecoration( + labelText: context.l10n.tcpHostLabel, + hintText: context.l10n.tcpHostHint, + border: const OutlineInputBorder(), + ), + enabled: !isConnecting, + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + TextField( + controller: _portController, + decoration: InputDecoration( + labelText: context.l10n.tcpPortLabel, + hintText: context.l10n.tcpPortHint, + border: const OutlineInputBorder(), + ), + enabled: !isConnecting, + keyboardType: TextInputType.number, + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isConnecting ? null : _connectTcp, + icon: isConnecting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.lan), + label: Text( + isConnecting + ? context.l10n.scanner_connecting + : context.l10n.common_connect, + ), + ), + ], + ), + ), + ], + ); + }, + ), + ), + bottomNavigationBar: SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (PlatformInfo.supportsUsbSerial) + FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const UsbScreen()), + ); + }, + heroTag: 'tcp_usb_action', + icon: const Icon(Icons.usb), + label: Text(context.l10n.connectionChoiceUsbLabel), + ), + if (PlatformInfo.supportsUsbSerial) const SizedBox(width: 12), + FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).maybePop(); + }, + heroTag: 'tcp_ble_action', + icon: const Icon(Icons.bluetooth), + label: Text(context.l10n.connectionChoiceBluetoothLabel), + ), + ], + ), + ), + ), + ); + } + + Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) { + final l10n = context.l10n; + String statusText; + Color statusColor; + + if (connector.isTcpTransportConnected) { + statusText = l10n.scanner_connectedTo( + connector.activeTcpEndpoint ?? 'TCP', + ); + statusColor = Colors.green; + } else if (connector.state == MeshCoreConnectionState.connecting && + connector.activeTransport == MeshCoreTransportType.tcp) { + statusText = l10n.tcpStatus_connectingTo( + '${_hostController.text}:${_portController.text}', + ); + statusColor = Colors.orange; + } else if (connector.state == MeshCoreConnectionState.disconnecting && + connector.activeTransport == MeshCoreTransportType.tcp) { + statusText = l10n.scanner_disconnecting; + statusColor = Colors.orange; + } else { + statusText = l10n.tcpStatus_notConnected; + statusColor = Colors.grey; + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + color: statusColor.withValues(alpha: 0.1), + child: Row( + children: [ + Icon(Icons.circle, size: 12, color: statusColor), + const SizedBox(width: 8), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + statusText, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ); + } + + Future _connectTcp() async { + if (_connector.state != MeshCoreConnectionState.disconnected) return; + + final host = _hostController.text.trim(); + final parsedPort = int.tryParse(_portController.text.trim()); + if (host.isEmpty) { + _showError(context.l10n.tcpErrorHostRequired); + return; + } + if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) { + _showError(context.l10n.tcpErrorPortInvalid); + return; + } + + try { + await _connector.connectTcp(host: host, port: parsedPort); + } catch (error) { + if (!mounted) return; + _showError(_friendlyErrorMessage(error)); + } + } + + void _showError(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), backgroundColor: Colors.red), + ); + } + + String _friendlyErrorMessage(Object error) { + if (error is UnsupportedError) { + return context.l10n.tcpErrorUnsupported; + } + if (error is TimeoutException) { + return context.l10n.tcpErrorTimedOut; + } + if (error is StateError) { + return context.l10n.tcpConnectionFailed(error.message); + } + if (error is ArgumentError) { + return context.l10n.tcpConnectionFailed( + error.message?.toString() ?? error.toString(), + ); + } + return context.l10n.tcpConnectionFailed(error.toString()); + } +} diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart index e230a54..0cc9007 100644 --- a/lib/screens/usb_screen.dart +++ b/lib/screens/usb_screen.dart @@ -12,6 +12,7 @@ import '../utils/usb_port_labels.dart'; import '../widgets/adaptive_app_bar_title.dart'; import 'contacts_screen.dart'; import 'scanner_screen.dart'; +import 'tcp_screen.dart'; class UsbScreen extends StatefulWidget { const UsbScreen({super.key}); @@ -111,41 +112,68 @@ class _UsbScreenState extends State { PlatformInfo.isWeb || PlatformInfo.isAndroid || PlatformInfo.isIOS; + final showTcp = !PlatformInfo.isWeb; return SafeArea( top: false, minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (showBle) - FloatingActionButton.extended( - onPressed: () { - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => const ScannerScreen(), - ), - ); - }, - heroTag: 'usb_ble_action', - icon: const Icon(Icons.bluetooth), - label: Text(context.l10n.connectionChoiceBluetoothLabel), - ), - if (showBle) const SizedBox(width: 12), - if (!_supportsHotPlug) - FloatingActionButton.extended( - onPressed: isLoading ? null : _loadPorts, - heroTag: 'usb_refresh_action', - icon: isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh), - label: Text(context.l10n.repeater_refresh), - ), - ], + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (showTcp) + FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const TcpScreen()), + ); + }, + heroTag: 'usb_tcp_action', + extendedPadding: const EdgeInsets.symmetric( + horizontal: 12, + ), + icon: const Icon(Icons.lan), + label: Text(context.l10n.connectionChoiceTcpLabel), + ), + if (showTcp && showBle) const SizedBox(width: 12), + if (showBle) + FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => const ScannerScreen(), + ), + ); + }, + heroTag: 'usb_ble_action', + extendedPadding: const EdgeInsets.symmetric( + horizontal: 12, + ), + icon: const Icon(Icons.bluetooth), + label: Text(context.l10n.connectionChoiceBluetoothLabel), + ), + if ((showTcp || showBle) && !_supportsHotPlug) + const SizedBox(width: 12), + if (!_supportsHotPlug) + FloatingActionButton.extended( + onPressed: isLoading ? null : _loadPorts, + heroTag: 'usb_refresh_action', + extendedPadding: const EdgeInsets.symmetric( + horizontal: 12, + ), + icon: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.usb), + label: Text(context.l10n.scanner_scan), + ), + ], + ), ), ); }, @@ -192,9 +220,18 @@ class _UsbScreenState extends State { children: [ Icon(Icons.circle, size: 12, color: statusColor), const SizedBox(width: 8), - Text( - statusText, - style: TextStyle(color: statusColor, fontWeight: FontWeight.w500), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + statusText, + style: TextStyle( + color: statusColor, + fontWeight: FontWeight.w500, + ), + ), + ), ), ], ), diff --git a/lib/services/tcp_transport_service.dart b/lib/services/tcp_transport_service.dart new file mode 100644 index 0000000..0e6f7a1 --- /dev/null +++ b/lib/services/tcp_transport_service.dart @@ -0,0 +1,2 @@ +export 'tcp_transport_service_native.dart' + if (dart.library.js_interop) 'tcp_transport_service_web.dart'; diff --git a/lib/services/tcp_transport_service_native.dart b/lib/services/tcp_transport_service_native.dart new file mode 100644 index 0000000..d8a3ab3 --- /dev/null +++ b/lib/services/tcp_transport_service_native.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'app_debug_log_service.dart'; +import 'usb_serial_frame_codec.dart'; + +class TcpTransportService { + final StreamController _frameController = + StreamController.broadcast(); + final UsbSerialFrameDecoder _frameDecoder = UsbSerialFrameDecoder(); + + StreamSubscription? _socketSubscription; + Socket? _socket; + AppDebugLogService? _debugLogService; + TcpTransportStatus _status = TcpTransportStatus.disconnected; + String? _activeHost; + int? _activePort; + Future _pendingWrite = Future.value(); + int _connectGeneration = 0; + + TcpTransportStatus get status => _status; + Stream get frameStream => _frameController.stream; + bool get isConnected => _status == TcpTransportStatus.connected; + String? get activeEndpoint => _activeHost == null || _activePort == null + ? null + : '$_activeHost:$_activePort'; + + void setDebugLogService(AppDebugLogService? service) { + _debugLogService = service; + } + + Future connect({ + required String host, + required int port, + Duration timeout = const Duration(seconds: 10), + }) async { + if (_status == TcpTransportStatus.connected || + _status == TcpTransportStatus.connecting) { + throw StateError('TCP transport is already active'); + } + final trimmedHost = host.trim(); + if (trimmedHost.isEmpty) { + throw ArgumentError.value(host, 'host', 'Host cannot be empty'); + } + if (port < 1 || port > 65535) { + throw ArgumentError.value(port, 'port', 'Port must be in 1..65535'); + } + + _status = TcpTransportStatus.connecting; + final generation = ++_connectGeneration; + _frameDecoder.reset(); + + try { + final socket = await Socket.connect(trimmedHost, port, timeout: timeout); + if (generation != _connectGeneration || + _status != TcpTransportStatus.connecting) { + try { + await socket.close(); + } catch (_) {} + try { + socket.destroy(); + } catch (_) {} + return; + } + socket.setOption(SocketOption.tcpNoDelay, true); + _socket = socket; + _activeHost = trimmedHost; + _activePort = port; + _socketSubscription = socket.listen( + _handleSocketData, + onError: _handleSocketError, + onDone: _handleSocketDone, + ); + _status = TcpTransportStatus.connected; + _debugLogService?.info( + 'TCP transport opened endpoint=$activeEndpoint', + tag: 'TCP', + ); + } catch (error) { + await _cleanupFailedConnect(); + _status = TcpTransportStatus.disconnected; + rethrow; + } + } + + Future write(Uint8List data) async { + if (!isConnected || _socket == null) { + throw StateError('TCP transport is not connected'); + } + + final packet = wrapUsbSerialTxFrame(data); + _logFrameSummary('TCP TX frame', data); + + final writeTask = _pendingWrite.then((_) async { + final socket = _socket; + if (!isConnected || socket == null) { + throw StateError('TCP transport is not connected'); + } + socket.add(packet); + await socket.flush(); + }); + + _pendingWrite = writeTask.catchError((_) {}); + await writeTask; + } + + Future disconnect() async { + _connectGeneration += 1; + if (_status == TcpTransportStatus.disconnected) return; + + final endpoint = activeEndpoint; + _status = TcpTransportStatus.disconnecting; + _frameDecoder.reset(); + _activeHost = null; + _activePort = null; + + final subscription = _socketSubscription; + _socketSubscription = null; + await subscription?.cancel(); + + final socket = _socket; + _socket = null; + try { + await socket?.close(); + } catch (_) {} + try { + socket?.destroy(); + } catch (_) {} + + _status = TcpTransportStatus.disconnected; + _debugLogService?.info( + 'TCP transport closed endpoint=${endpoint ?? 'unknown'}', + tag: 'TCP', + ); + } + + void dispose() { + unawaited(disconnect().whenComplete(_closeFrameController)); + } + + Future _cleanupFailedConnect() async { + final subscription = _socketSubscription; + _socketSubscription = null; + await subscription?.cancel(); + final socket = _socket; + _socket = null; + try { + await socket?.close(); + } catch (_) {} + try { + socket?.destroy(); + } catch (_) {} + _activeHost = null; + _activePort = null; + _frameDecoder.reset(); + } + + void _handleSocketData(Uint8List bytes) { + for (final packet in _frameDecoder.ingest(bytes)) { + if (!packet.isRxFrame) { + _debugLogService?.info( + 'TCP ignored packet start=0x${packet.frameStart.toRadixString(16).padLeft(2, '0')} len=${packet.payload.length}', + tag: 'TCP', + ); + continue; + } + _addFrame(packet.payload); + } + } + + void _handleSocketError(Object error, [StackTrace? stackTrace]) { + _addFrameError(error, stackTrace); + unawaited(disconnect()); + } + + void _handleSocketDone() { + unawaited(disconnect()); + } + + void _addFrame(Uint8List payload) { + if (_frameController.isClosed) return; + _frameController.add(payload); + } + + void _addFrameError(Object error, [StackTrace? stackTrace]) { + if (_frameController.isClosed) return; + _frameController.addError(error, stackTrace); + } + + void _logFrameSummary(String prefix, Uint8List payload) { + final code = payload.isNotEmpty ? payload.first : -1; + _debugLogService?.info( + '$prefix code=$code len=${payload.length}', + tag: 'TCP', + ); + } + + Future _closeFrameController() async { + if (_frameController.isClosed) return; + await _frameController.close(); + } +} + +enum TcpTransportStatus { disconnected, connecting, connected, disconnecting } diff --git a/lib/services/tcp_transport_service_web.dart b/lib/services/tcp_transport_service_web.dart new file mode 100644 index 0000000..c23179b --- /dev/null +++ b/lib/services/tcp_transport_service_web.dart @@ -0,0 +1,35 @@ +import 'dart:typed_data'; + +import 'app_debug_log_service.dart'; + +class TcpTransportService { + AppDebugLogService? _debugLogService; + + Stream get frameStream => const Stream.empty(); + bool get isConnected => false; + String? get activeEndpoint => null; + + void setDebugLogService(AppDebugLogService? service) { + _debugLogService = service; + } + + Future connect({ + required String host, + required int port, + Duration timeout = const Duration(seconds: 10), + }) async { + _debugLogService?.warn( + 'TCP transport requested on web for $host:$port', + tag: 'TCP', + ); + throw UnsupportedError('TCP transport is not supported on web.'); + } + + Future write(Uint8List data) async { + throw UnsupportedError('TCP transport is not supported on web.'); + } + + Future disconnect() async {} + + void dispose() {} +} diff --git a/test/screens/tcp_flow_test.dart b/test/screens/tcp_flow_test.dart new file mode 100644 index 0000000..501046d --- /dev/null +++ b/test/screens/tcp_flow_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/screens/scanner_screen.dart'; +import 'package:meshcore_open/screens/tcp_screen.dart'; + +class _FakeMeshCoreConnector extends MeshCoreConnector { + _FakeMeshCoreConnector(); + + MeshCoreConnectionState initialState = MeshCoreConnectionState.disconnected; + MeshCoreTransportType initialTransport = MeshCoreTransportType.bluetooth; + String? initialEndpoint; + int connectTcpCalls = 0; + String? lastHost; + int? lastPort; + + @override + MeshCoreConnectionState get state => initialState; + + @override + MeshCoreTransportType get activeTransport => initialTransport; + + @override + bool get isTcpTransportConnected => + initialState == MeshCoreConnectionState.connected && + initialTransport == MeshCoreTransportType.tcp; + + @override + String? get activeTcpEndpoint => initialEndpoint; + + @override + Future connectTcp({required String host, required int port}) async { + connectTcpCalls += 1; + lastHost = host; + lastPort = port; + } +} + +Widget _buildTestApp({ + required MeshCoreConnector connector, + required Widget child, + Locale? locale, +}) { + return ChangeNotifierProvider.value( + value: connector, + child: MaterialApp( + locale: locale, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: child, + ), + ); +} + +void main() { + testWidgets('TcpScreen uses localized TCP copy', (tester) async { + final connector = _FakeMeshCoreConnector(); + + await tester.pumpWidget( + _buildTestApp( + connector: connector, + child: const TcpScreen(), + locale: const Locale('en'), + ), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(TcpScreen)); + final l10n = AppLocalizations.of(context); + + expect(find.text(l10n.tcpScreenTitle), findsOneWidget); + expect(find.text(l10n.tcpHostLabel), findsOneWidget); + expect(find.text(l10n.tcpPortLabel), findsOneWidget); + expect(find.text(l10n.tcpStatus_notConnected), findsOneWidget); + }); + + testWidgets('TcpScreen validation errors are localized', (tester) async { + final connector = _FakeMeshCoreConnector(); + + await tester.pumpWidget( + _buildTestApp( + connector: connector, + child: const TcpScreen(), + locale: const Locale('en'), + ), + ); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(TcpScreen)); + final l10n = AppLocalizations.of(context); + + await tester.enterText(find.byType(TextField).first, ''); + await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.pumpAndSettle(); + + expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget); + expect(connector.connectTcpCalls, 0); + + await tester.enterText(find.byType(TextField).first, '192.168.1.50'); + await tester.enterText(find.byType(TextField).at(1), '99999'); + await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.pumpAndSettle(); + + expect(connector.connectTcpCalls, 0); + }); + + testWidgets('TCP Bluetooth action returns to existing scanner route', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector(); + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: const ScannerScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FloatingActionButton, 'TCP')); + await tester.pumpAndSettle(); + expect(find.byType(TcpScreen), findsOneWidget); + + await tester.tap(find.widgetWithText(FloatingActionButton, 'Bluetooth')); + await tester.pumpAndSettle(); + + expect(find.byType(TcpScreen), findsNothing); + expect(find.byType(ScannerScreen), findsOneWidget); + final navigatorState = tester.state(find.byType(Navigator)); + expect(navigatorState.canPop(), isFalse); + + // ScannerScreen.dispose() schedules disconnect work that debounces notify. + // Drain that debounce timer before test teardown. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 60)); + }); + + testWidgets('TcpScreen narrow width long status text does not overflow', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final connector = _FakeMeshCoreConnector() + ..initialState = MeshCoreConnectionState.connected + ..initialTransport = MeshCoreTransportType.tcp + ..initialEndpoint = 'meshcore-room-server-very-long-hostname.local:5000'; + + await tester.pumpWidget( + _buildTestApp( + connector: connector, + child: const TcpScreen(), + locale: const Locale('en'), + ), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + final context = tester.element(find.byType(TcpScreen)); + final l10n = AppLocalizations.of(context); + expect( + find.text(l10n.scanner_connectedTo(connector.initialEndpoint!)), + findsOneWidget, + ); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 60)); + }); +} diff --git a/test/screens/usb_flow_test.dart b/test/screens/usb_flow_test.dart index 436d230..16e5a95 100644 --- a/test/screens/usb_flow_test.dart +++ b/test/screens/usb_flow_test.dart @@ -116,12 +116,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap( - find.ancestor( - of: find.text('Connect'), - matching: find.bySubtype(), - ), - ); + await tester.tap(find.byType(ListTile).first); await tester.pump(); expect(connector.connectUsbCalls, 0); @@ -145,12 +140,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap( - find.ancestor( - of: find.text('Connect'), - matching: find.bySubtype(), - ), - ); + await tester.tap(find.byType(ListTile).first); await tester.pump(); expect(connector.connectUsbCalls, 1); @@ -179,6 +169,68 @@ void main() { await tester.pump(const Duration(milliseconds: 60)); }); + testWidgets('ScannerScreen narrow width keeps actions without overflow', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final connector = _FakeMeshCoreConnector(); + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: const ScannerScreen()), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + final context = tester.element(find.byType(ScannerScreen)); + final l10n = AppLocalizations.of(context); + expect(find.text(l10n.scanner_scan), findsOneWidget); + + if (PlatformInfo.supportsUsbSerial) { + expect(find.text(l10n.connectionChoiceUsbLabel), findsOneWidget); + } + if (!PlatformInfo.isWeb) { + expect(find.text(l10n.connectionChoiceTcpLabel), findsOneWidget); + } + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 60)); + }); + + testWidgets('UsbScreen narrow width long status text does not overflow', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(320, 700)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final connector = + _FakeMeshCoreConnector(initialState: MeshCoreConnectionState.connected) + ..fakeUsbTransportConnected = true + ..fakeActiveUsbPortDisplayLabel = + '/dev/bus/usb/001/002 - KD3CGK mesh-utility.org very long label'; + + await tester.pumpWidget( + _buildTestApp(connector: connector, child: const UsbScreen()), + ); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + + final context = tester.element(find.byType(UsbScreen)); + final l10n = AppLocalizations.of(context); + expect( + find.text( + l10n.scanner_connectedTo(connector.fakeActiveUsbPortDisplayLabel!), + ), + findsOneWidget, + ); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(const Duration(milliseconds: 60)); + }); + group('Error Handling', () { testWidgets('shows error SnackBar when listing ports fails', ( tester, @@ -212,12 +264,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap( - find.ancestor( - of: find.text('Connect'), - matching: find.bySubtype(), - ), - ); + await tester.tap(find.byType(ListTile).first); await tester.pumpAndSettle(); expect(connectAttempted, isTrue); diff --git a/test/services/tcp_transport_service_native_test.dart b/test/services/tcp_transport_service_native_test.dart new file mode 100644 index 0000000..24c62ad --- /dev/null +++ b/test/services/tcp_transport_service_native_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/services/tcp_transport_service_native.dart'; +import 'package:meshcore_open/services/usb_serial_frame_codec.dart'; + +final class _DelayedConnectOverrides extends IOOverrides { + _DelayedConnectOverrides(this.delay); + + final Duration delay; + + @override + Future socketConnect( + host, + int port, { + sourceAddress, + int sourcePort = 0, + Duration? timeout, + }) async { + await Future.delayed(delay); + return super.socketConnect( + host, + port, + sourceAddress: sourceAddress, + sourcePort: sourcePort, + timeout: timeout, + ); + } +} + +void main() { + test('connect/disconnect updates TCP transport state', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final service = TcpTransportService(); + + try { + await service.connect( + host: InternetAddress.loopbackIPv4.address, + port: server.port, + ); + + expect(service.isConnected, isTrue); + expect( + service.activeEndpoint, + '${InternetAddress.loopbackIPv4.address}:${server.port}', + ); + + await service.disconnect(); + + expect(service.isConnected, isFalse); + expect(service.activeEndpoint, isNull); + } finally { + await service.disconnect(); + await server.close(); + } + }); + + test('disconnect is safe when already disconnected', () async { + final service = TcpTransportService(); + + await service.disconnect(); + await service.disconnect(); + + expect(service.isConnected, isFalse); + expect(service.activeEndpoint, isNull); + }); + + test('emits only RX frames from socket stream', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final acceptedSocket = Completer(); + final service = TcpTransportService(); + final receivedFrames = []; + + final serverSub = server.listen((socket) { + if (!acceptedSocket.isCompleted) { + acceptedSocket.complete(socket); + } else { + socket.destroy(); + } + }); + final frameSub = service.frameStream.listen(receivedFrames.add); + + try { + await service.connect( + host: InternetAddress.loopbackIPv4.address, + port: server.port, + ); + + final socket = await acceptedSocket.future.timeout( + const Duration(seconds: 2), + ); + + socket.add([usbSerialTxFrameStart, 0x01, 0x00, 0x11]); + socket.add([usbSerialRxFrameStart, 0x02, 0x00, 0x33, 0x44]); + await socket.flush(); + + await Future.delayed(const Duration(milliseconds: 20)); + + expect(receivedFrames, hasLength(1)); + expect(receivedFrames.single, orderedEquals([0x33, 0x44])); + } finally { + await service.disconnect(); + await frameSub.cancel(); + await serverSub.cancel(); + await server.close(); + } + }); + + test( + 'disconnect during in-flight connect keeps transport disconnected', + () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final service = TcpTransportService(); + final host = InternetAddress.loopbackIPv4.address; + + try { + await IOOverrides.runWithIOOverrides(() async { + final connectFuture = service.connect(host: host, port: server.port); + + await Future.delayed(const Duration(milliseconds: 10)); + await service.disconnect(); + await connectFuture; + + expect(service.isConnected, isFalse); + expect(service.status, TcpTransportStatus.disconnected); + expect(service.activeEndpoint, isNull); + }, _DelayedConnectOverrides(const Duration(milliseconds: 120))); + } finally { + await service.disconnect(); + await server.close(); + } + }, + ); +} From 929c1c3d286664d1d65301b1f9cb985a3edaafd1 Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Mon, 9 Mar 2026 20:39:17 -0400 Subject: [PATCH 2/6] `fix(tcp): cancel pending connects on disconnect and propagate remote close` --- lib/connector/meshcore_connector_tcp.dart | 3 --- lib/services/tcp_transport_service_native.dart | 5 +++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/connector/meshcore_connector_tcp.dart b/lib/connector/meshcore_connector_tcp.dart index 01c2b92..92b98d7 100644 --- a/lib/connector/meshcore_connector_tcp.dart +++ b/lib/connector/meshcore_connector_tcp.dart @@ -22,9 +22,6 @@ class MeshCoreTcpManager { } Future disconnect() async { - if (!_service.isConnected && _service.activeEndpoint == null) { - return; - } _debugLog?.info('TcpManager.disconnect', tag: 'TCP'); await _service.disconnect(); } diff --git a/lib/services/tcp_transport_service_native.dart b/lib/services/tcp_transport_service_native.dart index d8a3ab3..a6db99b 100644 --- a/lib/services/tcp_transport_service_native.dart +++ b/lib/services/tcp_transport_service_native.dart @@ -175,6 +175,11 @@ class TcpTransportService { } void _handleSocketDone() { + if (_status == TcpTransportStatus.disconnecting || + _status == TcpTransportStatus.disconnected) { + return; + } + _addFrameError(StateError('TCP socket closed by remote endpoint')); unawaited(disconnect()); } From 1913a5aa114ad57e2f0fcaf2af3b36f040f20d60 Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Tue, 10 Mar 2026 19:27:39 -0400 Subject: [PATCH 3/6] fix(tcp): guard connect cancellation race and align USB screen actions - add connectTcp cancellation guards after socket connect and connect delay so handshake does not proceed when transport/state changed - ignore late TCP connect errors after manual cancel or transport switch to avoid spurious second disconnect paths - keep TCP action hidden only on web and show Bluetooth action on USB screen across platforms for navigation consistency --- lib/connector/meshcore_connector.dart | 38 +++++++++++++++++++++++++++ lib/screens/usb_screen.dart | 5 +--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 135299e..aad2c71 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -995,9 +995,37 @@ class MeshCoreConnector extends ChangeNotifier { await _tcpFrameSubscription?.cancel(); _tcpFrameSubscription = null; await _tcpManager.connect(host: host, port: port); + final isTcpConnectCancelled = + _activeTransport != MeshCoreTransportType.tcp || + _state != MeshCoreConnectionState.connecting || + !_tcpManager.isConnected; + if (isTcpConnectCancelled) { + _appDebugLogService?.warn( + 'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', + tag: 'TCP', + ); + if (_tcpManager.isConnected) { + await _tcpManager.disconnect(); + } + return; + } notifyListeners(); await Future.delayed(const Duration(milliseconds: 200)); + final isTcpConnectCancelledAfterDelay = + _activeTransport != MeshCoreTransportType.tcp || + _state != MeshCoreConnectionState.connecting || + !_tcpManager.isConnected; + if (isTcpConnectCancelledAfterDelay) { + _appDebugLogService?.warn( + 'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', + tag: 'TCP', + ); + if (_tcpManager.isConnected) { + await _tcpManager.disconnect(); + } + return; + } _tcpFrameSubscription = _tcpManager.frameStream.listen( _handleFrame, onError: (error, stackTrace) { @@ -1031,6 +1059,16 @@ class MeshCoreConnector extends ChangeNotifier { await syncTime(); } catch (error) { _appDebugLogService?.error('TCP connection error: $error', tag: 'TCP'); + final tcpConnectNoLongerActive = + _activeTransport != MeshCoreTransportType.tcp || + _state != MeshCoreConnectionState.connecting; + if (tcpConnectNoLongerActive) { + _appDebugLogService?.info( + 'Ignoring late TCP connect error after cancellation/switch: state=$_state transport=$_activeTransport', + tag: 'TCP', + ); + return; + } await disconnect(manual: false); rethrow; } diff --git a/lib/screens/usb_screen.dart b/lib/screens/usb_screen.dart index 0cc9007..2f2713a 100644 --- a/lib/screens/usb_screen.dart +++ b/lib/screens/usb_screen.dart @@ -108,10 +108,7 @@ class _UsbScreenState extends State { bottomNavigationBar: Consumer( builder: (context, connector, child) { final isLoading = _isLoadingPorts; - final showBle = - PlatformInfo.isWeb || - PlatformInfo.isAndroid || - PlatformInfo.isIOS; + final showBle = true; final showTcp = !PlatformInfo.isWeb; return SafeArea( From 9db79e9d4034e6b07aa0fe5f826e2637a64fcf2b Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Tue, 10 Mar 2026 20:06:05 -0400 Subject: [PATCH 4/6] test(tcp): harden cancel-race handling and add coverage - tighten late TCP connect error suppression to manual-cancel disconnecting/disconnected windows - keep TCP handshake failures surfaced outside explicit cancel flow - allow TcpScreen connect action when connector is scanning - add connector-level tests for late-error suppression classifier - add TcpScreen test covering connect from scanning state --- lib/connector/meshcore_connector.dart | 25 ++++++-- lib/screens/tcp_screen.dart | 6 +- ...hcore_connector_tcp_error_filter_test.dart | 64 +++++++++++++++++++ test/screens/tcp_flow_test.dart | 23 +++++++ 4 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 test/connector/meshcore_connector_tcp_error_filter_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index aad2c71..0de1a90 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -1059,10 +1059,14 @@ class MeshCoreConnector extends ChangeNotifier { await syncTime(); } catch (error) { _appDebugLogService?.error('TCP connection error: $error', tag: 'TCP'); - final tcpConnectNoLongerActive = - _activeTransport != MeshCoreTransportType.tcp || - _state != MeshCoreConnectionState.connecting; - if (tcpConnectNoLongerActive) { + final tcpConnectCancelledBeforeHandshake = + shouldIgnoreLateTcpConnectError( + manualDisconnect: _manualDisconnect, + state: _state, + activeTransport: _activeTransport, + tcpManagerConnected: _tcpManager.isConnected, + ); + if (tcpConnectCancelledBeforeHandshake) { _appDebugLogService?.info( 'Ignoring late TCP connect error after cancellation/switch: state=$_state transport=$_activeTransport', tag: 'TCP', @@ -1074,6 +1078,19 @@ class MeshCoreConnector extends ChangeNotifier { } } + @visibleForTesting + static bool shouldIgnoreLateTcpConnectError({ + required bool manualDisconnect, + required MeshCoreConnectionState state, + required MeshCoreTransportType activeTransport, + required bool tcpManagerConnected, + }) { + return manualDisconnect && + (state == MeshCoreConnectionState.disconnected || + state == MeshCoreConnectionState.disconnecting) && + (activeTransport != MeshCoreTransportType.tcp || !tcpManagerConnected); + } + Future connect(BluetoothDevice device, {String? displayName}) async { if (_state == MeshCoreConnectionState.connecting || _state == MeshCoreConnectionState.connected) { diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart index f3702c3..55bec20 100644 --- a/lib/screens/tcp_screen.dart +++ b/lib/screens/tcp_screen.dart @@ -224,7 +224,11 @@ class _TcpScreenState extends State { } Future _connectTcp() async { - if (_connector.state != MeshCoreConnectionState.disconnected) return; + if (_connector.state == MeshCoreConnectionState.connecting || + _connector.state == MeshCoreConnectionState.connected || + _connector.state == MeshCoreConnectionState.disconnecting) { + return; + } final host = _hostController.text.trim(); final parsedPort = int.tryParse(_portController.text.trim()); diff --git a/test/connector/meshcore_connector_tcp_error_filter_test.dart b/test/connector/meshcore_connector_tcp_error_filter_test.dart new file mode 100644 index 0000000..ee6a382 --- /dev/null +++ b/test/connector/meshcore_connector_tcp_error_filter_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; + +void main() { + group('shouldIgnoreLateTcpConnectError', () { + test('returns true for manual cancel during disconnecting state', () { + final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError( + manualDisconnect: true, + state: MeshCoreConnectionState.disconnecting, + activeTransport: MeshCoreTransportType.bluetooth, + tcpManagerConnected: false, + ); + + expect(result, isTrue); + }); + + test( + 'returns true for manual cancel after reaching disconnected state', + () { + final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError( + manualDisconnect: true, + state: MeshCoreConnectionState.disconnected, + activeTransport: MeshCoreTransportType.bluetooth, + tcpManagerConnected: false, + ); + + expect(result, isTrue); + }, + ); + + test('returns false when not a manual disconnect', () { + final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError( + manualDisconnect: false, + state: MeshCoreConnectionState.disconnecting, + activeTransport: MeshCoreTransportType.bluetooth, + tcpManagerConnected: false, + ); + + expect(result, isFalse); + }); + + test('returns false for connected state handshake failures', () { + final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError( + manualDisconnect: true, + state: MeshCoreConnectionState.connected, + activeTransport: MeshCoreTransportType.tcp, + tcpManagerConnected: true, + ); + + expect(result, isFalse); + }); + + test('returns false when TCP is still active while disconnecting', () { + final result = MeshCoreConnector.shouldIgnoreLateTcpConnectError( + manualDisconnect: true, + state: MeshCoreConnectionState.disconnecting, + activeTransport: MeshCoreTransportType.tcp, + tcpManagerConnected: true, + ); + + expect(result, isFalse); + }); + }); +} diff --git a/test/screens/tcp_flow_test.dart b/test/screens/tcp_flow_test.dart index 501046d..5c240f4 100644 --- a/test/screens/tcp_flow_test.dart +++ b/test/screens/tcp_flow_test.dart @@ -135,6 +135,29 @@ void main() { await tester.pump(const Duration(milliseconds: 60)); }); + testWidgets('TcpScreen allows connect while connector is scanning', ( + tester, + ) async { + final connector = _FakeMeshCoreConnector() + ..initialState = MeshCoreConnectionState.scanning; + + await tester.pumpWidget( + _buildTestApp( + connector: connector, + child: const TcpScreen(), + locale: const Locale('en'), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.pumpAndSettle(); + + expect(connector.connectTcpCalls, 1); + expect(connector.lastHost, '192.168.40.10'); + expect(connector.lastPort, 5000); + }); + testWidgets('TcpScreen narrow width long status text does not overflow', ( tester, ) async { From 2f770bbd532cb80044a8128682e5940fe639c632 Mon Sep 17 00:00:00 2001 From: just-stuff-tm Date: Tue, 10 Mar 2026 21:38:35 -0400 Subject: [PATCH 5/6] fix(tcp): reset state on aborted pre-handshake connect --- lib/connector/meshcore_connector.dart | 42 +++++++++++++------ ...hcore_connector_tcp_error_filter_test.dart | 29 +++++++++++++ 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 0de1a90..c89e37f 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -992,6 +992,21 @@ class MeshCoreConnector extends ChangeNotifier { _setState(MeshCoreConnectionState.connecting); try { + Future handleTcpConnectAbort({required String message}) async { + _appDebugLogService?.warn(message, tag: 'TCP'); + final shouldResetState = shouldResetStateAfterTcpConnectAbort( + state: _state, + activeTransport: _activeTransport, + ); + if (shouldResetState) { + await disconnect(manual: false); + return; + } + if (_tcpManager.isConnected) { + await _tcpManager.disconnect(); + } + } + await _tcpFrameSubscription?.cancel(); _tcpFrameSubscription = null; await _tcpManager.connect(host: host, port: port); @@ -1000,13 +1015,10 @@ class MeshCoreConnector extends ChangeNotifier { _state != MeshCoreConnectionState.connecting || !_tcpManager.isConnected; if (isTcpConnectCancelled) { - _appDebugLogService?.warn( - 'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', - tag: 'TCP', + await handleTcpConnectAbort( + message: + 'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', ); - if (_tcpManager.isConnected) { - await _tcpManager.disconnect(); - } return; } notifyListeners(); @@ -1017,13 +1029,10 @@ class MeshCoreConnector extends ChangeNotifier { _state != MeshCoreConnectionState.connecting || !_tcpManager.isConnected; if (isTcpConnectCancelledAfterDelay) { - _appDebugLogService?.warn( - 'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', - tag: 'TCP', + await handleTcpConnectAbort( + message: + 'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', ); - if (_tcpManager.isConnected) { - await _tcpManager.disconnect(); - } return; } _tcpFrameSubscription = _tcpManager.frameStream.listen( @@ -1091,6 +1100,15 @@ class MeshCoreConnector extends ChangeNotifier { (activeTransport != MeshCoreTransportType.tcp || !tcpManagerConnected); } + @visibleForTesting + static bool shouldResetStateAfterTcpConnectAbort({ + required MeshCoreConnectionState state, + required MeshCoreTransportType activeTransport, + }) { + return state == MeshCoreConnectionState.connecting && + activeTransport == MeshCoreTransportType.tcp; + } + Future connect(BluetoothDevice device, {String? displayName}) async { if (_state == MeshCoreConnectionState.connecting || _state == MeshCoreConnectionState.connected) { diff --git a/test/connector/meshcore_connector_tcp_error_filter_test.dart b/test/connector/meshcore_connector_tcp_error_filter_test.dart index ee6a382..c363b97 100644 --- a/test/connector/meshcore_connector_tcp_error_filter_test.dart +++ b/test/connector/meshcore_connector_tcp_error_filter_test.dart @@ -61,4 +61,33 @@ void main() { expect(result, isFalse); }); }); + + group('shouldResetStateAfterTcpConnectAbort', () { + test('returns true when TCP connect is still in connecting state', () { + final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort( + state: MeshCoreConnectionState.connecting, + activeTransport: MeshCoreTransportType.tcp, + ); + + expect(result, isTrue); + }); + + test('returns false when state is already disconnected', () { + final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort( + state: MeshCoreConnectionState.disconnected, + activeTransport: MeshCoreTransportType.tcp, + ); + + expect(result, isFalse); + }); + + test('returns false when transport switched away from TCP', () { + final result = MeshCoreConnector.shouldResetStateAfterTcpConnectAbort( + state: MeshCoreConnectionState.connecting, + activeTransport: MeshCoreTransportType.bluetooth, + ); + + expect(result, isFalse); + }); + }); } From db935a745433367dffb46228c621a81ba8982376 Mon Sep 17 00:00:00 2001 From: Zach Date: Fri, 13 Mar 2026 10:58:52 -0700 Subject: [PATCH 6/6] refactor(tcp): promote MeshCoreTcpConnector, fix translations, harden UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace thin MeshCoreTcpManager facade with a proper MeshCoreTcpConnector that owns TcpTransportService and the frame subscription, mirroring MeshCoreUsbManager. The connector no longer holds a raw TcpTransportService or a _tcpFrameSubscription field. - Remove hardcoded default host IP from TcpScreen (keep port 5000 hint). - Disable connect button during scanning state, not just connecting state. - Fix tcpPortLabel mistranslated as nautical "port/harbor" in de, it, pt, nl, sv, sk, sl, zh; fix corrupted Slovak tcpPortHint ("5 000" → "5000"). - Remove unused tcpStatus_connecting string from all 15 locale arb files and all generated app_localizations_*.dart files. - Add extendedPadding to TCP screen FABs to match USB screen. - Add Key to connect button; update tests to use byKey and assert onPressed == null when button is disabled during scanning. --- lib/connector/meshcore_connector.dart | 39 +++++++++----------- lib/connector/meshcore_connector_tcp.dart | 44 ++++++++++++++++++++--- lib/l10n/app_bg.arb | 1 - lib/l10n/app_de.arb | 3 +- lib/l10n/app_en.arb | 1 - lib/l10n/app_es.arb | 1 - lib/l10n/app_fr.arb | 1 - lib/l10n/app_it.arb | 3 +- lib/l10n/app_localizations.dart | 6 ---- lib/l10n/app_localizations_bg.dart | 3 -- lib/l10n/app_localizations_de.dart | 6 +--- lib/l10n/app_localizations_en.dart | 3 -- lib/l10n/app_localizations_es.dart | 3 -- lib/l10n/app_localizations_fr.dart | 3 -- lib/l10n/app_localizations_it.dart | 5 +-- lib/l10n/app_localizations_nl.dart | 5 +-- lib/l10n/app_localizations_pl.dart | 3 -- lib/l10n/app_localizations_pt.dart | 6 +--- lib/l10n/app_localizations_ru.dart | 3 -- lib/l10n/app_localizations_sk.dart | 7 ++-- lib/l10n/app_localizations_sl.dart | 5 +-- lib/l10n/app_localizations_sv.dart | 5 +-- lib/l10n/app_localizations_uk.dart | 3 -- lib/l10n/app_localizations_zh.dart | 5 +-- lib/l10n/app_nl.arb | 3 +- lib/l10n/app_pl.arb | 1 - lib/l10n/app_pt.arb | 3 +- lib/l10n/app_ru.arb | 1 - lib/l10n/app_sk.arb | 5 ++- lib/l10n/app_sl.arb | 3 +- lib/l10n/app_sv.arb | 3 +- lib/l10n/app_uk.arb | 1 - lib/l10n/app_zh.arb | 3 +- lib/screens/tcp_screen.dart | 10 ++++-- test/screens/tcp_flow_test.dart | 17 +++++---- 35 files changed, 91 insertions(+), 123 deletions(-) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index b800cfa..d974b7b 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -116,8 +116,7 @@ class MeshCoreConnector extends ChangeNotifier { bool _manualDisconnect = false; final MeshCoreUsbManager _usbManager = MeshCoreUsbManager(); StreamSubscription? _usbFrameSubscription; - final MeshCoreTcpManager _tcpManager = MeshCoreTcpManager(); - StreamSubscription? _tcpFrameSubscription; + final MeshCoreTcpConnector _tcpConnector = MeshCoreTcpConnector(); MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth; final List _scanResults = []; @@ -257,7 +256,7 @@ class MeshCoreConnector extends ChangeNotifier { bool get isUsbTransportConnected => _state == MeshCoreConnectionState.connected && _activeTransport == MeshCoreTransportType.usb; - String? get activeTcpEndpoint => _tcpManager.activeEndpoint; + String? get activeTcpEndpoint => _tcpConnector.activeEndpoint; bool get isTcpTransportConnected => _state == MeshCoreConnectionState.connected && _activeTransport == MeshCoreTransportType.tcp; @@ -666,7 +665,7 @@ class MeshCoreConnector extends ChangeNotifier { _appDebugLogService = appDebugLogService; _backgroundService = backgroundService; _usbManager.setDebugLogService(_appDebugLogService); - _tcpManager.setDebugLogService(_appDebugLogService); + _tcpConnector.setDebugLogService(_appDebugLogService); // Initialize notification service _notificationService.initialize(); @@ -1002,22 +1001,21 @@ class MeshCoreConnector extends ChangeNotifier { await disconnect(manual: false); return; } - if (_tcpManager.isConnected) { - await _tcpManager.disconnect(); + if (_tcpConnector.isConnected) { + await _tcpConnector.disconnect(); } } - await _tcpFrameSubscription?.cancel(); - _tcpFrameSubscription = null; - await _tcpManager.connect(host: host, port: port); + await _tcpConnector.cancelFrameSubscription(); + await _tcpConnector.connect(host: host, port: port); final isTcpConnectCancelled = _activeTransport != MeshCoreTransportType.tcp || _state != MeshCoreConnectionState.connecting || - !_tcpManager.isConnected; + !_tcpConnector.isConnected; if (isTcpConnectCancelled) { await handleTcpConnectAbort( message: - 'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', + 'connectTcp aborted before handshake: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}', ); return; } @@ -1027,16 +1025,16 @@ class MeshCoreConnector extends ChangeNotifier { final isTcpConnectCancelledAfterDelay = _activeTransport != MeshCoreTransportType.tcp || _state != MeshCoreConnectionState.connecting || - !_tcpManager.isConnected; + !_tcpConnector.isConnected; if (isTcpConnectCancelledAfterDelay) { await handleTcpConnectAbort( message: - 'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpManager.isConnected}', + 'connectTcp aborted after connect delay: state=$_state transport=$_activeTransport connected=${_tcpConnector.isConnected}', ); return; } - _tcpFrameSubscription = _tcpManager.frameStream.listen( - _handleFrame, + _tcpConnector.listenFrames( + onFrame: _handleFrame, onError: (error, stackTrace) { _appDebugLogService?.error('TCP transport error: $error', tag: 'TCP'); unawaited(disconnect(manual: false)); @@ -1073,7 +1071,7 @@ class MeshCoreConnector extends ChangeNotifier { manualDisconnect: _manualDisconnect, state: _state, activeTransport: _activeTransport, - tcpManagerConnected: _tcpManager.isConnected, + tcpManagerConnected: _tcpConnector.isConnected, ); if (tcpConnectCancelledBeforeHandshake) { _appDebugLogService?.info( @@ -1445,9 +1443,7 @@ class MeshCoreConnector extends ChangeNotifier { await _usbFrameSubscription?.cancel(); _usbFrameSubscription = null; await _usbManager.disconnect(); - await _tcpFrameSubscription?.cancel(); - _tcpFrameSubscription = null; - await _tcpManager.disconnect(); + await _tcpConnector.disconnect(); await _notifySubscription?.cancel(); _notifySubscription = null; @@ -1530,7 +1526,7 @@ class MeshCoreConnector extends ChangeNotifier { if (_activeTransport == MeshCoreTransportType.usb) { await _usbManager.write(data); } else if (_activeTransport == MeshCoreTransportType.tcp) { - await _tcpManager.write(data); + await _tcpConnector.write(data); } else { if (_rxCharacteristic == null) { throw Exception("MeshCore RX characteristic not available"); @@ -4484,14 +4480,13 @@ class MeshCoreConnector extends ChangeNotifier { _scanSubscription?.cancel(); _connectionSubscription?.cancel(); _usbFrameSubscription?.cancel(); - _tcpFrameSubscription?.cancel(); _notifySubscription?.cancel(); _notifyListenersTimer?.cancel(); _reconnectTimer?.cancel(); _batteryPollTimer?.cancel(); _receivedFramesController.close(); _usbManager.dispose(); - _tcpManager.dispose(); + _tcpConnector.dispose(); // Flush pending unread writes before disposal _unreadStore.flush(); diff --git a/lib/connector/meshcore_connector_tcp.dart b/lib/connector/meshcore_connector_tcp.dart index 92b98d7..7c93d9f 100644 --- a/lib/connector/meshcore_connector_tcp.dart +++ b/lib/connector/meshcore_connector_tcp.dart @@ -1,34 +1,70 @@ +import 'dart:async'; import 'dart:typed_data'; import '../services/app_debug_log_service.dart'; import '../services/tcp_transport_service.dart'; -class MeshCoreTcpManager { +/// Manages TCP transport for MeshCore devices. +/// +/// Owns the [TcpTransportService] and TCP-specific connection state. +/// The main [MeshCoreConnector] delegates all TCP operations here. +class MeshCoreTcpConnector { final TcpTransportService _service = TcpTransportService(); AppDebugLogService? _debugLog; + StreamSubscription? _frameSubscription; + // --- Getters --- String? get activeEndpoint => _service.activeEndpoint; bool get isConnected => _service.isConnected; - Stream get frameStream => _service.frameStream; + // --- Configuration --- void setDebugLogService(AppDebugLogService? service) { _debugLog = service; _service.setDebugLogService(service); } + // --- Connection lifecycle --- Future connect({required String host, required int port}) async { - _debugLog?.info('TcpManager.connect endpoint=$host:$port', tag: 'TCP'); + _debugLog?.info('TcpConnector.connect endpoint=$host:$port', tag: 'TCP'); + await _frameSubscription?.cancel(); + _frameSubscription = null; await _service.connect(host: host, port: port); + _debugLog?.info( + 'TcpConnector.connect done, endpoint=${_service.activeEndpoint}', + tag: 'TCP', + ); + } + + StreamSubscription listenFrames({ + required void Function(Uint8List) onFrame, + required void Function(Object, StackTrace?) onError, + required void Function() onDone, + }) { + _frameSubscription = _service.frameStream.listen( + onFrame, + onError: onError, + onDone: onDone, + ); + return _frameSubscription!; + } + + Future cancelFrameSubscription() async { + await _frameSubscription?.cancel(); + _frameSubscription = null; } Future disconnect() async { - _debugLog?.info('TcpManager.disconnect', tag: 'TCP'); + if (!_service.isConnected && _frameSubscription == null) return; + _debugLog?.info('TcpConnector.disconnect', tag: 'TCP'); + await _frameSubscription?.cancel(); + _frameSubscription = null; await _service.disconnect(); } Future write(Uint8List data) => _service.write(data); void dispose() { + _frameSubscription?.cancel(); _service.dispose(); } } diff --git a/lib/l10n/app_bg.arb b/lib/l10n/app_bg.arb index fa84e50..c5acba9 100644 --- a/lib/l10n/app_bg.arb +++ b/lib/l10n/app_bg.arb @@ -1881,7 +1881,6 @@ "tcpPortLabel": "Пристанище", "tcpPortHint": "5000", "tcpStatus_notConnected": "Въведете крайната точка и свържете се.", - "tcpStatus_connecting": "Свързване към TCP крайния пункт...", "tcpStatus_connectingTo": "Свързване към {endpoint}...", "tcpErrorHostRequired": "Необходим е IP адрес.", "tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index eae33e5..2eed922 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1906,10 +1906,9 @@ "connectionChoiceTcpLabel": "TCP", "tcpHostHint": "192.168.40.10", "tcpScreenTitle": "Verbinden über TCP", - "tcpPortLabel": "Hafen", + "tcpPortLabel": "Port", "tcpPortHint": "5000", "tcpStatus_notConnected": "Geben Sie den Endpunkt ein und verbinden Sie sich.", - "tcpStatus_connecting": "Verbindung zum TCP-Endpunkt hergestellt...", "tcpStatus_connectingTo": "Verbindung zu {endpoint}...", "tcpErrorHostRequired": "Eine IP-Adresse ist erforderlich.", "tcpErrorPortInvalid": "Die Portnummer muss zwischen 1 und 65535 liegen.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0acf9a5..58569e4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -56,7 +56,6 @@ "tcpPortLabel": "Port", "tcpPortHint": "5000", "tcpStatus_notConnected": "Enter endpoint and connect", - "tcpStatus_connecting": "Connecting to TCP endpoint...", "tcpStatus_connectingTo": "Connecting to {endpoint}...", "@tcpStatus_connectingTo": { "placeholders": { diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 974e2c3..25e0345 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1909,7 +1909,6 @@ "tcpPortLabel": "Puerto", "tcpPortHint": "5000", "tcpStatus_notConnected": "Ingrese la dirección final y conecte.", - "tcpStatus_connecting": "Conectándose al punto final TCP...", "tcpStatus_connectingTo": "Conectándose a {endpoint}...", "tcpErrorHostRequired": "Se requiere la dirección IP.", "tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 3c36a96..5d586f4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1881,7 +1881,6 @@ "tcpPortLabel": "Port", "tcpPortHint": "5000", "tcpStatus_notConnected": "Entrez l'adresse de destination et connectez-vous.", - "tcpStatus_connecting": "Connexion au point de terminaison TCP...", "tcpStatus_connectingTo": "Connexion à {endpoint}...", "tcpErrorHostRequired": "Une adresse IP est obligatoire.", "tcpErrorPortInvalid": "La taille du port doit être comprise entre 1 et 65535.", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 8a1c29d..4de9e9d 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1878,10 +1878,9 @@ "tcpHostHint": "192.168.40.10", "connectionChoiceTcpLabel": "TCP", "tcpScreenTitle": "Stabilire una connessione tramite TCP", - "tcpPortLabel": "Porto", + "tcpPortLabel": "Porta", "tcpPortHint": "5000", "tcpStatus_notConnected": "Inserisci l'endpoint e connettiti.", - "tcpStatus_connecting": "Connessione al punto finale TCP...", "tcpStatus_connectingTo": "Connessione a {endpoint}...", "tcpErrorHostRequired": "È necessario fornire un indirizzo IP.", "tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 36be955..3690bf2 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -376,12 +376,6 @@ abstract class AppLocalizations { /// **'Enter endpoint and connect'** String get tcpStatus_notConnected; - /// No description provided for @tcpStatus_connecting. - /// - /// In en, this message translates to: - /// **'Connecting to TCP endpoint...'** - String get tcpStatus_connecting; - /// No description provided for @tcpStatus_connectingTo. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index fd61be0..785f373 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -138,9 +138,6 @@ class AppLocalizationsBg extends AppLocalizations { @override String get tcpStatus_notConnected => 'Въведете крайната точка и свържете се.'; - @override - String get tcpStatus_connecting => 'Свързване към TCP крайния пункт...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Свързване към $endpoint...'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 0400237..708e0ab 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -130,7 +130,7 @@ class AppLocalizationsDe extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Hafen'; + String get tcpPortLabel => 'Port'; @override String get tcpPortHint => '5000'; @@ -139,10 +139,6 @@ class AppLocalizationsDe extends AppLocalizations { String get tcpStatus_notConnected => 'Geben Sie den Endpunkt ein und verbinden Sie sich.'; - @override - String get tcpStatus_connecting => - 'Verbindung zum TCP-Endpunkt hergestellt...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Verbindung zu $endpoint...'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d01dc36..4938dd4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -138,9 +138,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tcpStatus_notConnected => 'Enter endpoint and connect'; - @override - String get tcpStatus_connecting => 'Connecting to TCP endpoint...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Connecting to $endpoint...'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 7e5f806..2d4e2fb 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -138,9 +138,6 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tcpStatus_notConnected => 'Ingrese la dirección final y conecte.'; - @override - String get tcpStatus_connecting => 'Conectándose al punto final TCP...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Conectándose a $endpoint...'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index c7a225b..28bbab3 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -139,9 +139,6 @@ class AppLocalizationsFr extends AppLocalizations { String get tcpStatus_notConnected => 'Entrez l\'adresse de destination et connectez-vous.'; - @override - String get tcpStatus_connecting => 'Connexion au point de terminaison TCP...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Connexion à $endpoint...'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 121c2f8..b510bc1 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -130,7 +130,7 @@ class AppLocalizationsIt extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Porto'; + String get tcpPortLabel => 'Porta'; @override String get tcpPortHint => '5000'; @@ -138,9 +138,6 @@ class AppLocalizationsIt extends AppLocalizations { @override String get tcpStatus_notConnected => 'Inserisci l\'endpoint e connettiti.'; - @override - String get tcpStatus_connecting => 'Connessione al punto finale TCP...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Connessione a $endpoint...'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 48fe379..7c054dd 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -130,7 +130,7 @@ class AppLocalizationsNl extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Haven'; + String get tcpPortLabel => 'Poort'; @override String get tcpPortHint => '5000'; @@ -138,9 +138,6 @@ class AppLocalizationsNl extends AppLocalizations { @override String get tcpStatus_notConnected => 'Voer het eindpunt in en verbind'; - @override - String get tcpStatus_connecting => 'Verbinding maken met TCP-eindpunt...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Verbinding maken met $endpoint...'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 97681be..dec6583 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -138,9 +138,6 @@ class AppLocalizationsPl extends AppLocalizations { @override String get tcpStatus_notConnected => 'Wprowadź adres URL i połącz'; - @override - String get tcpStatus_connecting => 'Połączenie z punktem TCP...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Połączenie z $endpoint...'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index eb2fc7f..4d8d20e 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -130,7 +130,7 @@ class AppLocalizationsPt extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Porto'; + String get tcpPortLabel => 'Porta'; @override String get tcpPortHint => '5000'; @@ -138,10 +138,6 @@ class AppLocalizationsPt extends AppLocalizations { @override String get tcpStatus_notConnected => 'Insira o endereço final e conecte-se.'; - @override - String get tcpStatus_connecting => - 'Conectando ao ponto de extremidade TCP...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Conectando a $endpoint...'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 1a28e8d..60aa486 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -138,9 +138,6 @@ class AppLocalizationsRu extends AppLocalizations { @override String get tcpStatus_notConnected => 'Введите адрес и подключитесь.'; - @override - String get tcpStatus_connecting => 'Установление соединения с TCP-портом...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Подключение к $endpoint...'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index eb53c40..4e11719 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -130,17 +130,14 @@ class AppLocalizationsSk extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Pri항'; + String get tcpPortLabel => 'Port'; @override - String get tcpPortHint => '5 000'; + String get tcpPortHint => '5000'; @override String get tcpStatus_notConnected => 'Zadajte cieľovú adresu a pripojte sa.'; - @override - String get tcpStatus_connecting => 'Pripojenie k TCP endpointu...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Pripojenie k $endpoint...'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index be87248..f967db4 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -130,7 +130,7 @@ class AppLocalizationsSl extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Pril'; + String get tcpPortLabel => 'Vrata'; @override String get tcpPortHint => '5000'; @@ -138,9 +138,6 @@ class AppLocalizationsSl extends AppLocalizations { @override String get tcpStatus_notConnected => 'Vnesite končni naslov in se povežite'; - @override - String get tcpStatus_connecting => 'Povezava z TCP koncem...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Povezava z $endpoint...'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 55ac5c6..200bdbe 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -130,7 +130,7 @@ class AppLocalizationsSv extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => 'Hamn'; + String get tcpPortLabel => 'Port'; @override String get tcpPortHint => '5000'; @@ -138,9 +138,6 @@ class AppLocalizationsSv extends AppLocalizations { @override String get tcpStatus_notConnected => 'Ange slutpunkt och anslut'; - @override - String get tcpStatus_connecting => 'Anslutning till TCP-slutpunkt...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Anslutning till $endpoint...'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 8466adf..8dfe123 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -138,9 +138,6 @@ class AppLocalizationsUk extends AppLocalizations { @override String get tcpStatus_notConnected => 'Введіть кінцеву точку та підключіться'; - @override - String get tcpStatus_connecting => 'Підключення до TCP-кінцевої точки...'; - @override String tcpStatus_connectingTo(String endpoint) { return 'Підключення до $endpoint...'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 85026c2..ecd6813 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -130,7 +130,7 @@ class AppLocalizationsZh extends AppLocalizations { String get tcpHostHint => '192.168.40.10'; @override - String get tcpPortLabel => '港'; + String get tcpPortLabel => '端口'; @override String get tcpPortHint => '5000'; @@ -138,9 +138,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tcpStatus_notConnected => '输入目标地址,然后连接'; - @override - String get tcpStatus_connecting => '连接到 TCP 终点...'; - @override String tcpStatus_connectingTo(String endpoint) { return '连接到 $endpoint...'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 51c045c..d38fb4c 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1878,10 +1878,9 @@ "tcpHostLabel": "IP-adres", "tcpHostHint": "192.168.40.10", "connectionChoiceTcpLabel": "TCP", - "tcpPortLabel": "Haven", + "tcpPortLabel": "Poort", "tcpPortHint": "5000", "tcpStatus_notConnected": "Voer het eindpunt in en verbind", - "tcpStatus_connecting": "Verbinding maken met TCP-eindpunt...", "tcpStatus_connectingTo": "Verbinding maken met {endpoint}...", "tcpErrorHostRequired": "Een IP-adres is vereist.", "tcpErrorPortInvalid": "De poortwaarde moet tussen 1 en 65535 liggen.", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index e711a99..9dc3b33 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1881,7 +1881,6 @@ "tcpPortLabel": "Port", "tcpPortHint": "5000", "tcpStatus_notConnected": "Wprowadź adres URL i połącz", - "tcpStatus_connecting": "Połączenie z punktem TCP...", "tcpStatus_connectingTo": "Połączenie z {endpoint}...", "tcpErrorHostRequired": "Wymagana jest adresa IP.", "tcpErrorPortInvalid": "Numer portu musi mieścić się w zakresie od 1 do 65535.", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 6c1ad9e..cded31f 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1878,10 +1878,9 @@ "connectionChoiceTcpLabel": "TCP", "tcpScreenTitle": "Estabelecer conexão via TCP", "tcpHostHint": "192.168.40.10", - "tcpPortLabel": "Porto", + "tcpPortLabel": "Porta", "tcpPortHint": "5000", "tcpStatus_notConnected": "Insira o endereço final e conecte-se.", - "tcpStatus_connecting": "Conectando ao ponto de extremidade TCP...", "tcpStatus_connectingTo": "Conectando a {endpoint}...", "tcpErrorHostRequired": "É necessário fornecer um endereço IP.", "tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index d90c387..43e1b9a 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1121,7 +1121,6 @@ "tcpPortLabel": "Порт", "tcpPortHint": "5000", "tcpStatus_notConnected": "Введите адрес и подключитесь.", - "tcpStatus_connecting": "Установление соединения с TCP-портом...", "tcpStatus_connectingTo": "Подключение к {endpoint}...", "tcpErrorHostRequired": "Необходимо указать IP-адрес.", "tcpErrorPortInvalid": "Порт должен находиться в диапазоне от 1 до 65535.", diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index ae55843..f03d276 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1878,10 +1878,9 @@ "tcpHostLabel": "IP adresa", "tcpScreenTitle": "Spojte sa pomocou protokolu TCP", "connectionChoiceTcpLabel": "TCP", - "tcpPortLabel": "Pri항", - "tcpPortHint": "5 000", + "tcpPortLabel": "Port", + "tcpPortHint": "5000", "tcpStatus_notConnected": "Zadajte cieľovú adresu a pripojte sa.", - "tcpStatus_connecting": "Pripojenie k TCP endpointu...", "tcpStatus_connectingTo": "Pripojenie k {endpoint}...", "tcpErrorHostRequired": "Je potrebné zadať IP adresu.", "tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.", diff --git a/lib/l10n/app_sl.arb b/lib/l10n/app_sl.arb index 6ad2449..4a4b5cb 100644 --- a/lib/l10n/app_sl.arb +++ b/lib/l10n/app_sl.arb @@ -1878,10 +1878,9 @@ "tcpHostLabel": "IP naslov", "tcpHostHint": "192.168.40.10", "tcpScreenTitle": "Komunicirajte preko protokola TCP", - "tcpPortLabel": "Pril", + "tcpPortLabel": "Vrata", "tcpPortHint": "5000", "tcpStatus_notConnected": "Vnesite končni naslov in se povežite", - "tcpStatus_connecting": "Povezava z TCP koncem...", "tcpStatus_connectingTo": "Povezava z {endpoint}...", "tcpErrorHostRequired": "Potrebna je IP-naslov.", "tcpErrorPortInvalid": "Port mora biti med 1 in 65535.", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index b70049f..6a33e11 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1878,10 +1878,9 @@ "tcpHostLabel": "IP-adress", "tcpScreenTitle": "Anslut via TCP", "connectionChoiceTcpLabel": "TCP", - "tcpPortLabel": "Hamn", + "tcpPortLabel": "Port", "tcpPortHint": "5000", "tcpStatus_notConnected": "Ange slutpunkt och anslut", - "tcpStatus_connecting": "Anslutning till TCP-slutpunkt...", "tcpStatus_connectingTo": "Anslutning till {endpoint}...", "tcpErrorHostRequired": "IP-adress krävs.", "tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.", diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 9dcf88f..c179ca3 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1881,7 +1881,6 @@ "tcpPortLabel": "Порт", "tcpPortHint": "5000", "tcpStatus_notConnected": "Введіть кінцеву точку та підключіться", - "tcpStatus_connecting": "Підключення до TCP-кінцевої точки...", "tcpStatus_connectingTo": "Підключення до {endpoint}...", "tcpErrorHostRequired": "Необхідно вказати IP-адресу.", "tcpErrorPortInvalid": "Порт повинен бути в межах від 1 до 65535.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 199f85c..cac4b79 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1883,10 +1883,9 @@ "tcpHostHint": "192.168.40.10", "tcpScreenTitle": "通过 TCP 连接", "connectionChoiceTcpLabel": "TCP", - "tcpPortLabel": "港", + "tcpPortLabel": "端口", "tcpPortHint": "5000", "tcpStatus_notConnected": "输入目标地址,然后连接", - "tcpStatus_connecting": "连接到 TCP 终点...", "tcpStatus_connectingTo": "连接到 {endpoint}...", "tcpErrorHostRequired": "需要提供IP地址。", "tcpErrorPortInvalid": "端口号必须在 1 到 65535 之间。", diff --git a/lib/screens/tcp_screen.dart b/lib/screens/tcp_screen.dart index 55bec20..cf87382 100644 --- a/lib/screens/tcp_screen.dart +++ b/lib/screens/tcp_screen.dart @@ -27,7 +27,7 @@ class _TcpScreenState extends State { @override void initState() { super.initState(); - _hostController = TextEditingController(text: '192.168.40.10'); + _hostController = TextEditingController(); _portController = TextEditingController(text: '5000'); _connector = context.read(); @@ -81,6 +81,9 @@ class _TcpScreenState extends State { final isConnecting = connector.state == MeshCoreConnectionState.connecting && connector.activeTransport == MeshCoreTransportType.tcp; + final isButtonDisabled = + isConnecting || + connector.state == MeshCoreConnectionState.scanning; return Column( children: [ _buildStatusBar(context, connector), @@ -112,7 +115,8 @@ class _TcpScreenState extends State { ), const SizedBox(height: 16), FilledButton.icon( - onPressed: isConnecting ? null : _connectTcp, + key: const Key('tcp_connect_button'), + onPressed: isButtonDisabled ? null : _connectTcp, icon: isConnecting ? const SizedBox( width: 18, @@ -153,6 +157,7 @@ class _TcpScreenState extends State { ); }, heroTag: 'tcp_usb_action', + extendedPadding: const EdgeInsets.symmetric(horizontal: 12), icon: const Icon(Icons.usb), label: Text(context.l10n.connectionChoiceUsbLabel), ), @@ -162,6 +167,7 @@ class _TcpScreenState extends State { Navigator.of(context).maybePop(); }, heroTag: 'tcp_ble_action', + extendedPadding: const EdgeInsets.symmetric(horizontal: 12), icon: const Icon(Icons.bluetooth), label: Text(context.l10n.connectionChoiceBluetoothLabel), ), diff --git a/test/screens/tcp_flow_test.dart b/test/screens/tcp_flow_test.dart index 5c240f4..725388a 100644 --- a/test/screens/tcp_flow_test.dart +++ b/test/screens/tcp_flow_test.dart @@ -93,7 +93,7 @@ void main() { final l10n = AppLocalizations.of(context); await tester.enterText(find.byType(TextField).first, ''); - await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.tap(find.byKey(const Key('tcp_connect_button'))); await tester.pumpAndSettle(); expect(find.text(l10n.tcpErrorHostRequired), findsOneWidget); @@ -101,7 +101,7 @@ void main() { await tester.enterText(find.byType(TextField).first, '192.168.1.50'); await tester.enterText(find.byType(TextField).at(1), '99999'); - await tester.tap(find.widgetWithText(FilledButton, 'Connect')); + await tester.tap(find.byKey(const Key('tcp_connect_button'))); await tester.pumpAndSettle(); expect(connector.connectTcpCalls, 0); @@ -135,7 +135,7 @@ void main() { await tester.pump(const Duration(milliseconds: 60)); }); - testWidgets('TcpScreen allows connect while connector is scanning', ( + testWidgets('TcpScreen disables connect button while connector is scanning', ( tester, ) async { final connector = _FakeMeshCoreConnector() @@ -150,12 +150,11 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(FilledButton, 'Connect')); - await tester.pumpAndSettle(); - - expect(connector.connectTcpCalls, 1); - expect(connector.lastHost, '192.168.40.10'); - expect(connector.lastPort, 5000); + final button = tester.widget( + find.byKey(const Key('tcp_connect_button')), + ); + expect(button.onPressed, isNull); + expect(connector.connectTcpCalls, 0); }); testWidgets('TcpScreen narrow width long status text does not overflow', (