Refactor USB screen, add debug logging, fix UI issues

- Rewrite UsbScreen to mirror ScannerScreen patterns (status bar,
  tap-to-connect port list, bottom FABs, SnackBar errors)
- Extract MeshCoreUsbManager from MeshCoreConnector for cleaner
  USB transport ownership
- Add debug logging throughout USB connection flow (connector,
  manager, web/native services)
- Print debug logs to console in debug mode even when app debug
  log setting is disabled
- Localize remaining hardcoded strings (Web Serial Device fallback
  label, USB status bar keys, companion firmware timeout hint)
- Fix Swedish misspelling in translations (stöderliga → stödda)
- Guard Linux notification init against missing D-Bus session bus
- Fix SNRIndicator hit-test error by adding minimum size constraints
- Update USB flow tests for new UI patterns
This commit is contained in:
zjs81 2026-03-07 12:38:28 -07:00
parent 8238b6197f
commit fef73b7b62
42 changed files with 981 additions and 553 deletions

View file

@ -40,15 +40,15 @@ class MeshcoreUsbFunctions(
private val mainHandler = Handler(Looper.getMainLooper())
private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private var eventSink: EventChannel.EventSink? = null
private var usbConnection: UsbDeviceConnection? = null
private var usbInEndpoint: UsbEndpoint? = null
private var usbOutEndpoint: UsbEndpoint? = null
private var controlInterface: UsbInterface? = null
private var dataInterface: UsbInterface? = null
@Volatile private var eventSink: EventChannel.EventSink? = null
@Volatile private var usbConnection: UsbDeviceConnection? = null
@Volatile private var usbInEndpoint: UsbEndpoint? = null
@Volatile private var usbOutEndpoint: UsbEndpoint? = null
@Volatile private var controlInterface: UsbInterface? = null
@Volatile private var dataInterface: UsbInterface? = null
private var readThread: Thread? = null
@Volatile private var isReading = false
private var connectedDeviceName: String? = null
@Volatile private var connectedDeviceName: String? = null
private var pendingConnectResult: MethodChannel.Result? = null
private var pendingConnectPortName: String? = null
@ -86,7 +86,7 @@ class MeshcoreUsbFunctions(
if (device == null) {
result.error(
"usb_device_missing",
"USB device no longer available for $portName",
null,
null,
)
return
@ -95,7 +95,7 @@ class MeshcoreUsbFunctions(
val granted =
intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (!granted || !usbManager.hasPermission(device)) {
result.error("usb_permission_denied", "USB permission denied", null)
result.error("usb_permission_denied", null, null)
return
}
@ -176,13 +176,13 @@ class MeshcoreUsbFunctions(
val portName = call.argument<String>("portName")
val baudRate = call.argument<Int>("baudRate") ?: 115200
if (portName.isNullOrBlank()) {
result.error("usb_invalid_port", "Port name is required", null)
result.error("usb_invalid_port", null, null)
return
}
val device = findUsbDevice(portName)
if (device == null) {
result.error("usb_device_missing", "USB device not found for $portName", null)
result.error("usb_device_missing", null, null)
return
}
@ -192,7 +192,7 @@ class MeshcoreUsbFunctions(
}
if (pendingConnectResult != null) {
result.error("usb_busy", "Another USB permission request is already pending", null)
result.error("usb_busy", null, null)
return
}
@ -214,11 +214,11 @@ class MeshcoreUsbFunctions(
val connection = usbConnection
val endpoint = usbOutEndpoint
if (data == null) {
result.error("usb_invalid_data", "Data is required", null)
result.error("usb_invalid_data", null, null)
return
}
if (connection == null || endpoint == null) {
result.error("usb_not_connected", "USB serial port is not connected", null)
result.error("usb_not_connected", null, null)
return
}
@ -259,7 +259,7 @@ class MeshcoreUsbFunctions(
mainHandler.post {
result.error(
"usb_driver_missing",
"No compatible USB serial interface for ${device.deviceName}",
null,
null,
)
}
@ -271,7 +271,7 @@ class MeshcoreUsbFunctions(
mainHandler.post {
result.error(
"usb_open_failed",
"UsbManager could not open ${device.deviceName}",
null,
null,
)
}
@ -283,7 +283,7 @@ class MeshcoreUsbFunctions(
mainHandler.post {
result.error(
"usb_open_failed",
"Could not claim USB data interface for ${device.deviceName}",
null,
null,
)
}
@ -299,20 +299,21 @@ class MeshcoreUsbFunctions(
mainHandler.post {
result.error(
"usb_open_failed",
"Could not claim USB control interface for ${device.deviceName}",
null,
null,
)
}
return@execute
}
configureDevice(connection, config, baudRate)
usbConnection = connection
usbInEndpoint = config.inEndpoint
usbOutEndpoint = config.outEndpoint
controlInterface = config.controlInterface
dataInterface = config.dataInterface
configureDevice(connection, config, baudRate)
connectedDeviceName = device.deviceName
startReadLoop()

View file

@ -21,7 +21,7 @@ import '../services/path_history_service.dart';
import '../services/app_settings_service.dart';
import '../services/background_service.dart';
import '../services/notification_service.dart';
import '../services/usb_serial_service.dart';
import 'meshcore_connector_usb.dart';
import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart';
@ -114,11 +114,9 @@ class MeshCoreConnector extends ChangeNotifier {
String? _lastDeviceId;
String? _lastDeviceDisplayName;
bool _manualDisconnect = false;
final UsbSerialService _usbSerialService = UsbSerialService();
final MeshCoreUsbManager _usbManager = MeshCoreUsbManager();
StreamSubscription<Uint8List>? _usbFrameSubscription;
MeshCoreTransportType _activeTransport = MeshCoreTransportType.bluetooth;
String? _activeUsbPortKey;
String? _activeUsbPortLabel;
final List<ScanResult> _scanResults = [];
final List<Contact> _contacts = [];
@ -252,9 +250,8 @@ class MeshCoreConnector extends ChangeNotifier {
String get deviceIdLabel => _deviceId ?? 'Unknown';
MeshCoreTransportType get activeTransport => _activeTransport;
String? get activeUsbPort => _activeUsbPortKey;
String? get activeUsbPortDisplayLabel =>
_activeUsbPortLabel ?? _activeUsbPortKey;
String? get activeUsbPort => _usbManager.activePortKey;
String? get activeUsbPortDisplayLabel => _usbManager.activePortDisplayLabel;
bool get isUsbTransportConnected =>
_state == MeshCoreConnectionState.connected &&
_activeTransport == MeshCoreTransportType.usb;
@ -661,7 +658,7 @@ class MeshCoreConnector extends ChangeNotifier {
_bleDebugLogService = bleDebugLogService;
_appDebugLogService = appDebugLogService;
_backgroundService = backgroundService;
_usbSerialService.setDebugLogService(_appDebugLogService);
_usbManager.setDebugLogService(_appDebugLogService);
// Initialize notification service
_notificationService.initialize();
@ -871,10 +868,14 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
Future<List<String>> listUsbPorts() => _usbSerialService.listPorts();
Future<List<String>> listUsbPorts() => _usbManager.listPorts();
void setUsbRequestPortLabel(String label) {
_usbSerialService.setRequestPortLabel(label);
_usbManager.setRequestPortLabel(label);
}
void setUsbFallbackDeviceName(String label) {
_usbManager.setFallbackDeviceName(label);
}
Future<void> connectUsb({
@ -883,53 +884,70 @@ class MeshCoreConnector extends ChangeNotifier {
}) async {
if (_state == MeshCoreConnectionState.connecting ||
_state == MeshCoreConnectionState.connected) {
_appDebugLogService?.warn(
'connectUsb ignored: already $_state',
tag: 'USB',
);
return;
}
_activeTransport = MeshCoreTransportType.bluetooth;
_activeUsbPortKey = null;
_activeUsbPortLabel = null;
_appDebugLogService?.info(
'connectUsb: port=$portName baud=$baudRate',
tag: 'USB',
);
await stopScan();
_cancelReconnectTimer();
_manualDisconnect = false;
_resetConnectionHandshakeState();
_activeTransport = MeshCoreTransportType.usb;
_activeUsbPortKey = portName;
_activeUsbPortLabel = portName;
_setState(MeshCoreConnectionState.connecting);
try {
await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null;
await _usbSerialService.connect(portName: portName, baudRate: baudRate);
_activeUsbPortKey = _usbSerialService.activePortKey ?? _activeUsbPortKey;
_activeUsbPortLabel =
_usbSerialService.activePortDisplayLabel ?? _activeUsbPortLabel;
_appDebugLogService?.info(
'connectUsb: opening serial port…',
tag: 'USB',
);
await _usbManager.connect(portName: portName, baudRate: baudRate);
_appDebugLogService?.info(
'connectUsb: serial port opened, label=${_usbManager.activePortDisplayLabel}',
tag: 'USB',
);
notifyListeners();
if (PlatformInfo.isWeb) {
await stopScan();
}
await Future<void>.delayed(const Duration(milliseconds: 200));
_usbFrameSubscription = _usbSerialService.frameStream.listen(
_usbFrameSubscription = _usbManager.frameStream.listen(
_handleFrame,
onError: (error, stackTrace) {
_appDebugLogService?.error('USB transport error: $error', tag: 'USB');
unawaited(disconnect(manual: false));
},
onDone: () {
_appDebugLogService?.warn('USB frame stream ended', tag: 'USB');
unawaited(disconnect(manual: false));
},
);
_setState(MeshCoreConnectionState.connected);
_pendingInitialChannelSync = true;
_appDebugLogService?.info(
'connectUsb: requesting device info…',
tag: 'USB',
);
await _requestDeviceInfo();
_startBatteryPolling();
var gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
);
if (!gotSelfInfo) {
_appDebugLogService?.warn(
'connectUsb: SELF_INFO timeout, retrying…',
tag: 'USB',
);
await refreshDeviceInfo();
gotSelfInfo = await _waitForSelfInfo(
timeout: const Duration(seconds: 3),
@ -939,7 +957,9 @@ class MeshCoreConnector extends ChangeNotifier {
throw StateError('Timed out waiting for SELF_INFO during connect');
}
_appDebugLogService?.info('connectUsb: syncing time…', tag: 'USB');
await syncTime();
_appDebugLogService?.info('connectUsb: complete', tag: 'USB');
} catch (error) {
_appDebugLogService?.error('USB connection error: $error', tag: 'USB');
await disconnect(manual: false);
@ -954,8 +974,6 @@ class MeshCoreConnector extends ChangeNotifier {
}
_activeTransport = MeshCoreTransportType.bluetooth;
_activeUsbPortKey = null;
_activeUsbPortLabel = null;
await stopScan();
_setState(MeshCoreConnectionState.connecting);
@ -1282,7 +1300,7 @@ class MeshCoreConnector extends ChangeNotifier {
await _usbFrameSubscription?.cancel();
_usbFrameSubscription = null;
await _usbSerialService.disconnect();
await _usbManager.disconnect();
await _notifySubscription?.cancel();
_notifySubscription = null;
@ -1341,8 +1359,6 @@ class MeshCoreConnector extends ChangeNotifier {
_reactionSendQueueSequence = 0;
_activeTransport = MeshCoreTransportType.bluetooth;
_activeUsbPortKey = null;
_activeUsbPortLabel = null;
_setState(MeshCoreConnectionState.disconnected);
_appDebugLogService?.info(
@ -1365,7 +1381,7 @@ class MeshCoreConnector extends ChangeNotifier {
_bleDebugLogService?.logFrame(data, outgoing: true);
if (_activeTransport == MeshCoreTransportType.usb) {
await _usbSerialService.write(data);
await _usbManager.write(data);
} else {
if (_rxCharacteristic == null) {
throw Exception("MeshCore RX characteristic not available");
@ -2464,9 +2480,7 @@ class MeshCoreConnector extends ChangeNotifier {
if (_activeTransport == MeshCoreTransportType.usb &&
selfName != null &&
selfName.isNotEmpty) {
_usbSerialService.updateConnectedLabel(selfName);
_activeUsbPortLabel =
_usbSerialService.activePortDisplayLabel ?? _activeUsbPortLabel;
_usbManager.updateConnectedLabel(selfName);
}
_awaitingSelfInfo = false;
_selfInfoRetryTimer?.cancel();
@ -4246,7 +4260,7 @@ class MeshCoreConnector extends ChangeNotifier {
_reconnectTimer?.cancel();
_batteryPollTimer?.cancel();
_receivedFramesController.close();
_usbSerialService.dispose();
_usbManager.dispose();
// Flush pending unread writes before disposal
_unreadStore.flush();
@ -4269,6 +4283,10 @@ class MeshCoreConnector extends ChangeNotifier {
final header = packet.readByte();
routeType = header & 0x03;
payloadType = (header >> 2) & 0x0F;
if (routeType == _routeTransportFlood ||
routeType == _routeTransportDirect) {
packet.skipBytes(4); // Skip transport-specific bytes
}
//final payloadVer = (header >> 6) & 0x03;
final pathLen = packet.readByte();
pathBytes = packet.readBytes(pathLen);
@ -4301,7 +4319,12 @@ class MeshCoreConnector extends ChangeNotifier {
packet.skipBytes(1); // Skip SNR byte
packet.skipBytes(1); // Skip RSSI byte
final header = packet.readByte();
final routeType = header & 0x03;
payloadType = (header >> 2) & 0x0F;
if (routeType == _routeTransportFlood ||
routeType == _routeTransportDirect) {
packet.skipBytes(4); // Skip transport-specific bytes
}
//final payloadVer = (header >> 6) & 0x03;
final pathLen = packet.readByte();
pathBytes = packet.readBytes(pathLen);

View file

@ -1,32 +1,71 @@
import 'package:flutter/foundation.dart';
import 'dart:typed_data';
import 'meshcore_connector.dart';
import '../services/app_debug_log_service.dart';
import '../services/usb_serial_service.dart';
class MeshCoreConnectorUsb {
const MeshCoreConnectorUsb(this.connector);
/// Manages USB serial transport for MeshCore devices.
///
/// Owns the [UsbSerialService] and USB-specific connection state.
/// The main [MeshCoreConnector] delegates all USB operations here.
class MeshCoreUsbManager {
MeshCoreUsbManager();
final MeshCoreConnector connector;
final UsbSerialService _service = UsbSerialService();
AppDebugLogService? _debugLog;
String? _activePortKey;
String? _activePortLabel;
MeshCoreConnectionState get state => connector.state;
MeshCoreTransportType get activeTransport => connector.activeTransport;
String? get activeUsbPortDisplayLabel => connector.activeUsbPortDisplayLabel;
bool get isUsbTransportConnected => connector.isUsbTransportConnected;
// --- Getters ---
String? get activePortKey => _activePortKey;
String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey;
bool get isConnected => _service.isConnected;
Stream<Uint8List> get frameStream => _service.frameStream;
void addListener(VoidCallback listener) => connector.addListener(listener);
void removeListener(VoidCallback listener) =>
connector.removeListener(listener);
// --- Configuration ---
Future<List<String>> listPorts() => _service.listPorts();
Future<List<String>> listPorts() => connector.listUsbPorts();
void setRequestPortLabel(String label) =>
_service.setRequestPortLabel(label);
void setRequestPortLabel(String label) {
connector.setUsbRequestPortLabel(label);
void setFallbackDeviceName(String label) =>
_service.setFallbackDeviceName(label);
void setDebugLogService(AppDebugLogService? service) {
_debugLog = service;
_service.setDebugLogService(service);
}
Future<void> connect({required String portName, int baudRate = 115200}) {
return connector.connectUsb(portName: portName, baudRate: baudRate);
// --- Connection lifecycle ---
Future<void> connect({required String portName, int baudRate = 115200}) async {
_debugLog?.info(
'UsbManager.connect: portName=$portName baud=$baudRate',
tag: 'USB',
);
await _service.connect(portName: portName, baudRate: baudRate);
_activePortKey = _service.activePortKey ?? portName;
_activePortLabel = _service.activePortDisplayLabel ?? portName;
_debugLog?.info(
'UsbManager.connect: done, key=$_activePortKey label=$_activePortLabel',
tag: 'USB',
);
}
Future<void> disconnect({bool manual = true}) {
return connector.disconnect(manual: manual);
Future<void> disconnect() async {
_debugLog?.info('UsbManager.disconnect', tag: 'USB');
await _service.disconnect();
_activePortKey = null;
_activePortLabel = null;
}
Future<void> write(Uint8List data) => _service.write(data);
// --- Label management ---
void updateConnectedLabel(String selfName) {
_service.updateConnectedLabel(selfName);
_activePortLabel = _service.activePortDisplayLabel ?? _activePortLabel;
}
void dispose() {
_service.dispose();
}
}

View file

@ -1847,5 +1847,17 @@
"usbErrorAlreadyActive": "USB връзката вече е активирана.",
"usbErrorNoDeviceSelected": "Няма избран USB устройство.",
"usbErrorPortClosed": "USB връзката не е активна.",
"usbErrorConnectTimedOut": "Изчаква се, но устройството не отговаря в рамките на зададения време."
"usbFallbackDeviceName": "Устройство за четене на уеб серийни данни",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_connecting": "Свързване към USB устройство...",
"usbConnectionFailed": "Неуспешно свързване през USB: {error}",
"usbStatus_notConnected": "Изберете USB устройство",
"usbStatus_searching": "Търсене на USB устройства...",
"usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка."
}

View file

@ -1875,5 +1875,17 @@
"usbErrorAlreadyActive": "Eine USB-Verbindung ist bereits hergestellt.",
"usbErrorNoDeviceSelected": "Kein USB-Gerät wurde ausgewählt.",
"usbErrorPortClosed": "Die USB-Verbindung ist nicht aktiv.",
"usbErrorConnectTimedOut": "Wartezeit abgelaufen, da keine Antwort vom Gerät empfangen wurde."
"usbFallbackDeviceName": "Web-Serielle Geräte",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_searching": "Suche nach USB-Geräten...",
"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."
}

View file

@ -65,7 +65,19 @@
"usbErrorAlreadyActive": "A USB connection is already active.",
"usbErrorNoDeviceSelected": "No USB device was selected.",
"usbErrorPortClosed": "The USB connection is not open.",
"usbErrorConnectTimedOut": "Timed out waiting for the device to respond.",
"usbErrorConnectTimedOut": "Connection timed out. Make sure the device has USB Companion firmware.",
"usbFallbackDeviceName": "Web Serial Device",
"usbStatus_notConnected": "Select a USB device",
"usbStatus_connecting": "Connecting to USB device...",
"usbStatus_searching": "Searching for USB devices...",
"usbConnectionFailed": "USB connection failed: {error}",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"scanner_scanning": "Scanning for devices...",
"scanner_connecting": "Connecting...",
"scanner_disconnecting": "Disconnecting...",

View file

@ -1875,5 +1875,17 @@
"usbErrorAlreadyActive": "La conexión USB ya está activa.",
"usbErrorNoDeviceSelected": "No se ha seleccionado ningún dispositivo USB.",
"usbErrorPortClosed": "La conexión USB no está activa.",
"usbErrorConnectTimedOut": "Se ha agotado el tiempo de espera mientras se esperaba la respuesta del dispositivo."
"usbFallbackDeviceName": "Dispositivo de serie web",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_connecting": "Conectándose al dispositivo USB...",
"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."
}

View file

@ -1847,5 +1847,17 @@
"usbErrorAlreadyActive": "Une connexion USB est déjà établie.",
"usbErrorNoDeviceSelected": "Aucun appareil USB n'a été sélectionné.",
"usbErrorPortClosed": "La connexion USB n'est pas établie.",
"usbErrorConnectTimedOut": "Attente avec délai, en attendant une réponse de l'appareil."
"usbFallbackDeviceName": "Dispositif de communication série sur le Web",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_notConnected": "Sélectionnez un périphérique USB",
"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."
}

View file

@ -1847,5 +1847,17 @@
"usbErrorAlreadyActive": "La connessione USB è già attiva.",
"usbErrorNoDeviceSelected": "Non è stato selezionato alcun dispositivo USB.",
"usbErrorPortClosed": "La connessione USB non è attiva.",
"usbErrorConnectTimedOut": "Attesa superata, in attesa di una risposta dal dispositivo."
"usbFallbackDeviceName": "Dispositivo per comunicazione seriale su rete",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_searching": "Ricerca di dispositivi USB...",
"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."
}

View file

@ -433,9 +433,39 @@ abstract class AppLocalizations {
/// No description provided for @usbErrorConnectTimedOut.
///
/// In en, this message translates to:
/// **'Timed out waiting for the device to respond.'**
/// **'Connection timed out. Make sure the device has USB Companion firmware.'**
String get usbErrorConnectTimedOut;
/// No description provided for @usbFallbackDeviceName.
///
/// In en, this message translates to:
/// **'Web Serial Device'**
String get usbFallbackDeviceName;
/// No description provided for @usbStatus_notConnected.
///
/// In en, this message translates to:
/// **'Select a USB device'**
String get usbStatus_notConnected;
/// No description provided for @usbStatus_connecting.
///
/// In en, this message translates to:
/// **'Connecting to USB device...'**
String get usbStatus_connecting;
/// No description provided for @usbStatus_searching.
///
/// In en, this message translates to:
/// **'Searching for USB devices...'**
String get usbStatus_searching;
/// No description provided for @usbConnectionFailed.
///
/// In en, this message translates to:
/// **'USB connection failed: {error}'**
String usbConnectionFailed(String error);
/// No description provided for @scanner_scanning.
///
/// In en, this message translates to:

View file

@ -175,7 +175,25 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Изчаква се, но устройството не отговаря в рамките на зададения време.';
'Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.';
@override
String get usbFallbackDeviceName =>
'Устройство за четене на уеб серийни данни';
@override
String get usbStatus_notConnected => 'Изберете USB устройство';
@override
String get usbStatus_connecting => 'Свързване към USB устройство...';
@override
String get usbStatus_searching => 'Търсене на USB устройства...';
@override
String usbConnectionFailed(String error) {
return 'Неуспешно свързване през USB: $error';
}
@override
String get scanner_scanning => 'Сканиране за устройства...';

View file

@ -177,7 +177,24 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Wartezeit abgelaufen, da keine Antwort vom Gerät empfangen wurde.';
'Verbindung konnte nicht hergestellt werden. Stellen Sie sicher, dass das Gerät die entsprechende USB-Firmware enthält.';
@override
String get usbFallbackDeviceName => 'Web-Serielle Geräte';
@override
String get usbStatus_notConnected => 'Wählen Sie ein USB-Gerät aus';
@override
String get usbStatus_connecting => 'Verbindung zum USB-Gerät...';
@override
String get usbStatus_searching => 'Suche nach USB-Geräten...';
@override
String usbConnectionFailed(String error) {
return 'Fehler beim USB-Verbindungsaufbau: $error';
}
@override
String get scanner_scanning => 'Scannen nach Geräten...';

View file

@ -174,7 +174,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Timed out waiting for the device to respond.';
'Connection timed out. Make sure the device has USB Companion firmware.';
@override
String get usbFallbackDeviceName => 'Web Serial Device';
@override
String get usbStatus_notConnected => 'Select a USB device';
@override
String get usbStatus_connecting => 'Connecting to USB device...';
@override
String get usbStatus_searching => 'Searching for USB devices...';
@override
String usbConnectionFailed(String error) {
return 'USB connection failed: $error';
}
@override
String get scanner_scanning => 'Scanning for devices...';

View file

@ -177,7 +177,24 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Se ha agotado el tiempo de espera mientras se esperaba la respuesta del dispositivo.';
'La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.';
@override
String get usbFallbackDeviceName => 'Dispositivo de serie web';
@override
String get usbStatus_notConnected => 'Seleccione un dispositivo USB';
@override
String get usbStatus_connecting => 'Conectándose al dispositivo USB...';
@override
String get usbStatus_searching => 'Buscando dispositivos USB...';
@override
String usbConnectionFailed(String error) {
return 'Error al conectar mediante USB: $error';
}
@override
String get scanner_scanning => 'Escaneando dispositivos...';

View file

@ -176,7 +176,25 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Attente avec délai, en attendant une réponse de l\'appareil.';
'La connexion a expiré. Assurez-vous que l\'appareil dispose du firmware USB Companion.';
@override
String get usbFallbackDeviceName =>
'Dispositif de communication série sur le Web';
@override
String get usbStatus_notConnected => 'Sélectionnez un périphérique USB';
@override
String get usbStatus_connecting => 'Connexion au périphérique USB...';
@override
String get usbStatus_searching => 'Recherche de périphériques USB...';
@override
String usbConnectionFailed(String error) {
return 'Échec de la connexion USB : $error';
}
@override
String get scanner_scanning => 'Recherche de périphériques...';

View file

@ -177,7 +177,25 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Attesa superata, in attesa di una risposta dal dispositivo.';
'La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.';
@override
String get usbFallbackDeviceName =>
'Dispositivo per comunicazione seriale su rete';
@override
String get usbStatus_notConnected => 'Seleziona un dispositivo USB';
@override
String get usbStatus_connecting => 'Connessione al dispositivo USB...';
@override
String get usbStatus_searching => 'Ricerca di dispositivi USB...';
@override
String usbConnectionFailed(String error) {
return 'Errore nella connessione USB: $error';
}
@override
String get scanner_scanning => 'Scansione in corso per i dispositivi...';

View file

@ -175,7 +175,24 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Wachtperiode is verlopen, aangezien het apparaat niet reageerde.';
'Verbinding is verbroken. Zorg ervoor dat het apparaat de juiste USB-firmware heeft.';
@override
String get usbFallbackDeviceName => 'Web-serieapparaat';
@override
String get usbStatus_notConnected => 'Selecteer een USB-apparaat';
@override
String get usbStatus_connecting => 'Verbinding maken met USB-apparaat...';
@override
String get usbStatus_searching => 'Zoeken naar USB-apparaten...';
@override
String usbConnectionFailed(String error) {
return 'Fout bij de USB-verbinding: $error';
}
@override
String get scanner_scanning => 'Scannen naar apparaten...';

View file

@ -177,7 +177,25 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Czekanie na odpowiedź urządzenia zakończyło się z powodu braku reakcji.';
'Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".';
@override
String get usbFallbackDeviceName =>
'Urządzenie do komunikacji przez sieć (seria)';
@override
String get usbStatus_notConnected => 'Wybierz urządzenie USB';
@override
String get usbStatus_connecting => 'Połączenie z urządzeniem USB...';
@override
String get usbStatus_searching => 'Wyszukiwanie urządzeń USB...';
@override
String usbConnectionFailed(String error) {
return 'Błąd połączenia USB: $error';
}
@override
String get scanner_scanning => 'Skanowanie urządzeń...';

View file

@ -177,7 +177,24 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Tempo limite aguardando a resposta do dispositivo.';
'A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.';
@override
String get usbFallbackDeviceName => 'Dispositivo de Serial para a Web';
@override
String get usbStatus_notConnected => 'Selecione um dispositivo USB';
@override
String get usbStatus_connecting => 'Conectando ao dispositivo USB...';
@override
String get usbStatus_searching => 'Procurando por dispositivos USB...';
@override
String usbConnectionFailed(String error) {
return 'Falha na conexão USB: $error';
}
@override
String get scanner_scanning => 'Procurando por dispositivos...';

View file

@ -177,7 +177,25 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Ожидание ответа от устройства превысило установленное время.';
'Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion.';
@override
String get usbFallbackDeviceName =>
'Устройство для последовательного подключения к сети';
@override
String get usbStatus_notConnected => 'Выберите USB-устройство';
@override
String get usbStatus_connecting => 'Подключение к USB-устройству...';
@override
String get usbStatus_searching => 'Поиск USB-устройств...';
@override
String usbConnectionFailed(String error) {
return 'Не удалось установить соединение через USB: $error';
}
@override
String get scanner_scanning => 'Поиск устройств...';

View file

@ -177,7 +177,24 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Čakal som, kým sa zariadenie neozvými, ale časový limit sa dobehol.';
'Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.';
@override
String get usbFallbackDeviceName => 'Webový sériový zariadenie';
@override
String get usbStatus_notConnected => 'Vyberte USB zariadenie';
@override
String get usbStatus_connecting => 'Pripojenie k USB zariadeniu...';
@override
String get usbStatus_searching => 'Hľadanie USB zariadení...';
@override
String usbConnectionFailed(String error) {
return 'Neúspešné pripojenie cez USB: $error';
}
@override
String get scanner_scanning => 'Skrívania zariadení...';

View file

@ -174,7 +174,25 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Čakanje je preseglo določeno časovno obdobo, ker se naprave ni odzval.';
'Vzpostavitve ni bilo mogo. Prosimo, da se prepričate, da ima naprave trenutno nameštan firmware USB Companion.';
@override
String get usbFallbackDeviceName =>
'Naprave za serijsko komunikacijo preko spleta';
@override
String get usbStatus_notConnected => 'Izberite USB naprave.';
@override
String get usbStatus_connecting => 'Povezava z USB napravo...';
@override
String get usbStatus_searching => 'Iskanje USB naprav...';
@override
String usbConnectionFailed(String error) {
return 'Napaka pri povezavi preko USB: $error';
}
@override
String get scanner_scanning => 'Skeniram za naprave...';

View file

@ -129,7 +129,7 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get usbScreenNote =>
'USB-seriell kommunikation är aktiv på stöderliga Android-enheter och på skrivbordsplattformar.';
'USB-seriell kommunikation är aktiv på stödda Android-enheter och på skrivbordsplattformar.';
@override
String get usbScreenEmptyState =>
@ -175,7 +175,24 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Tiden har löpt ut medan vi väntade på att enheten skulle svara.';
'Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.';
@override
String get usbFallbackDeviceName => 'Web-serieenhet';
@override
String get usbStatus_notConnected => 'Välj en USB-enhet';
@override
String get usbStatus_connecting => 'Anslutning till USB-enhet...';
@override
String get usbStatus_searching => 'Söker efter USB-enheter...';
@override
String usbConnectionFailed(String error) {
return 'Fel vid USB-anslutning: $error';
}
@override
String get scanner_scanning => 'Söker efter enheter...';

View file

@ -175,7 +175,25 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get usbErrorConnectTimedOut =>
'Час очікування закінчився, оскільки пристрій не відповів.';
'З\'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion.';
@override
String get usbFallbackDeviceName =>
'Пристрій для передачі даних по веб-серіалах';
@override
String get usbStatus_notConnected => 'Виберіть пристрій USB';
@override
String get usbStatus_connecting => 'Підключення до USB-пристрою...';
@override
String get usbStatus_searching => 'Пошук пристроїв USB...';
@override
String usbConnectionFailed(String error) {
return 'Не вдалося встановити з\'єднання через USB: $error';
}
@override
String get scanner_scanning => 'Пошук пристроїв...';

View file

@ -166,7 +166,24 @@ class AppLocalizationsZh extends AppLocalizations {
String get usbErrorPortClosed => 'USB 连接未建立。';
@override
String get usbErrorConnectTimedOut => '等待设备响应超时。';
String get usbErrorConnectTimedOut => '连接超时。请确保设备已安装 USB 伴侣固件。';
@override
String get usbFallbackDeviceName => 'Web 串流设备';
@override
String get usbStatus_notConnected => '选择一个 USB 设备';
@override
String get usbStatus_connecting => '连接USB设备...';
@override
String get usbStatus_searching => '正在搜索 USB 设备...';
@override
String usbConnectionFailed(String error) {
return 'USB 连接失败:$error';
}
@override
String get scanner_scanning => '正在搜索设备...';

View file

@ -1847,5 +1847,17 @@
"usbErrorAlreadyActive": "Een USB-verbinding is al actief.",
"usbErrorNoDeviceSelected": "Geen USB-apparaat is geselecteerd.",
"usbErrorPortClosed": "De USB-verbinding is niet actief.",
"usbErrorConnectTimedOut": "Wachtperiode is verlopen, aangezien het apparaat niet reageerde."
"usbFallbackDeviceName": "Web-serieapparaat",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbConnectionFailed": "Fout bij de USB-verbinding: {error}",
"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."
}

View file

@ -1847,5 +1847,17 @@
"usbErrorAlreadyActive": "Połączenie USB jest już aktywne.",
"usbErrorNoDeviceSelected": "Nie został wybrany żaden urządzenie USB.",
"usbErrorPortClosed": "Połączenie USB nie jest aktywne.",
"usbErrorConnectTimedOut": "Czekanie na odpowiedź urządzenia zakończyło się z powodu braku reakcji."
"usbFallbackDeviceName": "Urządzenie do komunikacji przez sieć (seria)",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_searching": "Wyszukiwanie urządzeń USB...",
"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\"."
}

View file

@ -1847,5 +1847,17 @@
"usbErrorAlreadyActive": "A conexão USB já está ativa.",
"usbErrorNoDeviceSelected": "Nenhum dispositivo USB foi selecionado.",
"usbErrorPortClosed": "A conexão USB não está ativa.",
"usbErrorConnectTimedOut": "Tempo limite aguardando a resposta do dispositivo."
"usbFallbackDeviceName": "Dispositivo de Serial para a Web",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_searching": "Procurando por dispositivos USB...",
"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."
}

View file

@ -1087,5 +1087,17 @@
"usbErrorAlreadyActive": "USB-соединение уже установлено.",
"usbErrorNoDeviceSelected": "Не было выбрано ни одно устройство USB.",
"usbErrorPortClosed": "USB-соединение не установлено.",
"usbErrorConnectTimedOut": "Ожидание ответа от устройства превысило установленное время."
"usbFallbackDeviceName": "Устройство для последовательного подключения к сети",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_searching": "Поиск USB-устройств...",
"usbStatus_connecting": "Подключение к USB-устройству...",
"usbConnectionFailed": "Не удалось установить соединение через USB: {error}",
"usbStatus_notConnected": "Выберите USB-устройство",
"usbErrorConnectTimedOut": "Соединение не установлено. Убедитесь, что устройство имеет установленное программное обеспечение USB Companion."
}

View file

@ -1847,5 +1847,17 @@
"usbErrorAlreadyActive": "Pripojenie cez USB je už aktivované.",
"usbErrorNoDeviceSelected": "Nebolo vybrané žiadne USB zariadenie.",
"usbErrorPortClosed": "Pripojenie cez USB nie je aktivované.",
"usbErrorConnectTimedOut": "Čakal som, kým sa zariadenie neozvými, ale časový limit sa dobehol."
"usbFallbackDeviceName": "Webový sériový zariadenie",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_searching": "Hľadanie USB zariadení...",
"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."
}

View file

@ -1847,5 +1847,17 @@
"usbErrorAlreadyActive": "USB povezava je že aktivirana.",
"usbErrorNoDeviceSelected": "Ni bilo izbranega USB naprave.",
"usbErrorPortClosed": "USB povezava ni aktivirana.",
"usbErrorConnectTimedOut": "Čakanje je preseglo določeno časovno obdobo, ker se naprave ni odzval."
"usbFallbackDeviceName": "Naprave za serijsko komunikacijo preko spleta",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_notConnected": "Izberite USB naprave.",
"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."
}

View file

@ -1834,7 +1834,7 @@
"usbScreenSubtitle": "Välj en detekterad seriell enhet och anslut direkt till din MeshCore-nod.",
"usbScreenTitle": "Anslut via USB",
"usbScreenStatus": "Välj en USB-enhet",
"usbScreenNote": "USB-seriell kommunikation är aktiv på stöderliga Android-enheter och på skrivbordsplattformar.",
"usbScreenNote": "USB-seriell kommunikation är aktiv på stödda Android-enheter och på skrivbordsplattformar.",
"usbScreenEmptyState": "Inga USB-enheter hittades. Anslut en och uppdatera.",
"usbErrorPermissionDenied": "Tillgången via USB nekas.",
"usbErrorDeviceMissing": "Den valda USB-enheten är inte längre tillgänglig.",
@ -1847,5 +1847,17 @@
"usbErrorAlreadyActive": "En USB-anslutning är redan aktiv.",
"usbErrorNoDeviceSelected": "Ingen USB-enhet valdes.",
"usbErrorPortClosed": "USB-anslutningen är inte aktiv.",
"usbErrorConnectTimedOut": "Tiden har löpt ut medan vi väntade på att enheten skulle svara."
"usbFallbackDeviceName": "Web-serieenhet",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_connecting": "Anslutning till USB-enhet...",
"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."
}

View file

@ -1847,5 +1847,17 @@
"usbErrorAlreadyActive": "USB-з'єднання вже встановлено.",
"usbErrorNoDeviceSelected": "Не було вибрано жодного пристрою USB.",
"usbErrorPortClosed": "З'єднання USB не встановлено.",
"usbErrorConnectTimedOut": "Час очікування закінчився, оскільки пристрій не відповів."
"usbFallbackDeviceName": "Пристрій для передачі даних по веб-серіалах",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_searching": "Пошук пристроїв USB...",
"usbStatus_notConnected": "Виберіть пристрій USB",
"usbConnectionFailed": "Не вдалося встановити з'єднання через USB: {error}",
"usbStatus_connecting": "Підключення до USB-пристрою...",
"usbErrorConnectTimedOut": "З'єднання не вдалося встановити. Переконайтеся, що пристрій має встановлене програмне забезпечення USB Companion."
}

View file

@ -1852,5 +1852,17 @@
"usbErrorAlreadyActive": "USB 连接已建立。",
"usbErrorNoDeviceSelected": "未选择任何 USB 设备。",
"usbErrorPortClosed": "USB 连接未建立。",
"usbErrorConnectTimedOut": "等待设备响应超时。"
"usbFallbackDeviceName": "Web 串流设备",
"@usbConnectionFailed": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"usbStatus_searching": "正在搜索 USB 设备...",
"usbStatus_connecting": "连接USB设备...",
"usbStatus_notConnected": "选择一个 USB 设备",
"usbConnectionFailed": "USB 连接失败:{error}",
"usbErrorConnectTimedOut": "连接超时。请确保设备已安装 USB 伴侣固件。"
}

View file

@ -1,16 +1,15 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_connector_usb.dart';
import '../l10n/l10n.dart';
import '../utils/app_logger.dart';
import '../utils/platform_info.dart';
import '../utils/usb_port_labels.dart';
import '../widgets/adaptive_app_bar_title.dart';
import 'contacts_screen.dart';
import 'scanner_screen.dart';
@ -24,20 +23,12 @@ class UsbScreen extends StatefulWidget {
class _UsbScreenState extends State<UsbScreen> {
final List<String> _ports = <String>[];
bool _isLoadingPorts = true;
bool _isConnecting = false;
bool _navigatedToContacts = false;
bool _didScheduleInitialLoad = false;
String? _selectedPort;
String? _connectedPortDisplayLabel;
String? _errorText;
Timer? _hotPlugTimer;
late final MeshCoreConnector _connector;
late final MeshCoreConnectorUsb _usbConnector;
late final VoidCallback _connectionListener;
/// Whether the current platform supports dynamic hot-plug polling.
/// On desktop (macOS, Windows, Linux) we poll continuously so the user
/// never needs to hit Refresh manually.
bool get _supportsHotPlug =>
PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS;
@ -45,25 +36,13 @@ class _UsbScreenState extends State<UsbScreen> {
void initState() {
super.initState();
_connector = context.read<MeshCoreConnector>();
_usbConnector = MeshCoreConnectorUsb(_connector);
_connectionListener = () {
if (!mounted) return;
final activeUsbPortDisplayLabel = _usbConnector.activeUsbPortDisplayLabel;
final shouldUpdateDisplayLabel =
activeUsbPortDisplayLabel != _connectedPortDisplayLabel;
if (_usbConnector.state == MeshCoreConnectionState.disconnected) {
if (_connector.state == MeshCoreConnectionState.disconnected) {
_navigatedToContacts = false;
setState(() {
_isConnecting = false;
_connectedPortDisplayLabel = activeUsbPortDisplayLabel;
});
} else if (shouldUpdateDisplayLabel) {
setState(() {
_connectedPortDisplayLabel = activeUsbPortDisplayLabel;
});
}
if (_usbConnector.state == MeshCoreConnectionState.connected &&
_usbConnector.isUsbTransportConnected &&
if (_connector.state == MeshCoreConnectionState.connected &&
_connector.isUsbTransportConnected &&
!_navigatedToContacts) {
_navigatedToContacts = true;
Navigator.of(context).pushReplacement(
@ -71,14 +50,15 @@ class _UsbScreenState extends State<UsbScreen> {
);
}
};
_usbConnector.addListener(_connectionListener);
_connector.addListener(_connectionListener);
_startHotPlugTimer();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_usbConnector.setRequestPortLabel(context.l10n.usbScreenStatus);
_connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
_connector.setUsbFallbackDeviceName(context.l10n.usbFallbackDeviceName);
if (!_didScheduleInitialLoad) {
_didScheduleInitialLoad = true;
unawaited(_loadPorts());
@ -89,12 +69,12 @@ class _UsbScreenState extends State<UsbScreen> {
void dispose() {
_hotPlugTimer?.cancel();
_hotPlugTimer = null;
_usbConnector.removeListener(_connectionListener);
_connector.removeListener(_connectionListener);
if (!_navigatedToContacts &&
_usbConnector.activeTransport == MeshCoreTransportType.usb &&
_usbConnector.state != MeshCoreConnectionState.disconnected) {
_connector.activeTransport == MeshCoreTransportType.usb &&
_connector.state != MeshCoreConnectionState.disconnected) {
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_usbConnector.disconnect(manual: true));
unawaited(_connector.disconnect(manual: true));
});
}
super.dispose();
@ -102,234 +82,192 @@ class _UsbScreenState extends State<UsbScreen> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
appLogger.info('Back button pressed', tag: 'UsbScreen');
Navigator.of(context).maybePop();
},
),
title: Text(
l10n.connectionChoiceUsbLabel,
style: theme.textTheme.titleLarge,
onPressed: () => Navigator.of(context).maybePop(),
),
title: AdaptiveAppBarTitle(context.l10n.usbScreenTitle),
centerTitle: true,
actions: [
if (PlatformInfo.isWeb ||
PlatformInfo.isAndroid ||
PlatformInfo.isIOS)
TextButton.icon(
onPressed: () {
appLogger.info(
'Bluetooth selected, opening ScannerScreen',
tag: 'UsbScreen',
);
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => const ScannerScreen()),
);
},
icon: const Icon(Icons.bluetooth),
label: Text(l10n.connectionChoiceBluetoothLabel),
),
],
),
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final availableHeight = constraints.maxHeight.isFinite
? constraints.maxHeight
: 600.0;
final availableWidth = constraints.maxWidth.isFinite
? constraints.maxWidth
: 800.0;
final gap = math.max(8.0, math.min(16.0, availableHeight * 0.025));
final iconSize = math.max(
28.0,
math.min(72.0, availableHeight * 0.12),
);
final isNarrow = availableWidth < 460.0;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Compact header
Row(
children: [
Icon(
Icons.usb,
size: iconSize.clamp(24.0, 40.0),
color: theme.colorScheme.primary,
),
SizedBox(width: gap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
l10n.usbScreenTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
Text(
l10n.usbScreenSubtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
SizedBox(height: gap),
// Port list takes all remaining space
Expanded(child: _buildPortList(context)),
if (_errorText != null) ...[
SizedBox(height: gap * 0.5),
Text(
_errorText!,
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
SizedBox(height: gap),
// Action buttons
if (isNarrow)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!_supportsHotPlug) ...[
OutlinedButton.icon(
onPressed: _isLoadingPorts || _isConnecting
? null
: () {
appLogger.info(
'Refresh ports pressed',
tag: 'UsbScreen',
);
_loadPorts();
},
icon: const Icon(Icons.refresh),
label: Text(l10n.repeater_refresh),
),
SizedBox(height: gap),
],
FilledButton.icon(
onPressed: _canConnect
? () {
final rawPortName = normalizeUsbPortName(
_selectedPort!,
);
appLogger.info(
'Connect pressed for $_selectedPort (raw: $rawPortName)',
tag: 'UsbScreen',
);
_connectSelectedPort();
}
: null,
icon: _isConnecting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.usb),
label: Text(l10n.common_connect),
),
],
)
else
Row(
children: [
if (!_supportsHotPlug) ...[
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoadingPorts || _isConnecting
? null
: () {
appLogger.info(
'Refresh ports pressed',
tag: 'UsbScreen',
);
_loadPorts();
},
icon: const Icon(Icons.refresh),
label: Text(l10n.repeater_refresh),
),
),
SizedBox(width: gap),
],
Expanded(
child: FilledButton.icon(
onPressed: _canConnect
? () {
final rawPortName = normalizeUsbPortName(
_selectedPort!,
);
appLogger.info(
'Connect pressed for $_selectedPort (raw: $rawPortName)',
tag: 'UsbScreen',
);
_connectSelectedPort();
}
: null,
icon: _isConnecting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.usb),
label: Text(l10n.common_connect),
),
),
],
),
SizedBox(height: math.max(4.0, gap * 0.5)),
Text(
l10n.usbScreenNote,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
top: false,
child: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
return Column(
children: [
_buildStatusBar(context, connector),
Expanded(child: _buildPortList(context, connector)),
],
);
},
),
),
bottomNavigationBar: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final isLoading = _isLoadingPorts;
final showBle = PlatformInfo.isWeb ||
PlatformInfo.isAndroid ||
PlatformInfo.isIOS;
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),
),
],
),
);
},
),
);
}
bool get _canConnect =>
!_isLoadingPorts &&
!_isConnecting &&
_selectedPort != null &&
_selectedPort!.isNotEmpty;
Widget _buildStatusBar(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
String statusText;
Color statusColor;
if (_isLoadingPorts) {
statusText = l10n.usbStatus_searching;
statusColor = Colors.blue;
} else if (connector.isUsbTransportConnected) {
switch (connector.state) {
case MeshCoreConnectionState.connected:
statusText = l10n.scanner_connectedTo(
connector.activeUsbPortDisplayLabel ?? 'USB',
);
statusColor = Colors.green;
case MeshCoreConnectionState.disconnecting:
statusText = l10n.scanner_disconnecting;
statusColor = Colors.orange;
default:
statusText = l10n.usbStatus_notConnected;
statusColor = Colors.grey;
}
} else if (connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.usb) {
statusText = l10n.usbStatus_connecting;
statusColor = Colors.orange;
} else {
statusText = l10n.usbStatus_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),
Text(
statusText,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w500),
),
],
),
);
}
Widget _buildPortList(BuildContext context, MeshCoreConnector connector) {
final l10n = context.l10n;
if (_isLoadingPorts) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.usb, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
l10n.usbStatus_searching,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
);
}
if (_ports.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.usb, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
l10n.usbScreenEmptyState,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
);
}
final isConnecting =
connector.state == MeshCoreConnectionState.connecting &&
connector.activeTransport == MeshCoreTransportType.usb;
return ListView.separated(
padding: const EdgeInsets.all(8),
itemCount: _ports.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final port = _ports[index];
final displayName = friendlyUsbPortName(port);
final rawName = normalizeUsbPortName(port);
final showRawName =
rawName != displayName && !rawName.startsWith('web:');
return ListTile(
leading: const Icon(Icons.usb),
title: Text(
displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: showRawName ? Text(rawName) : null,
trailing: ElevatedButton(
onPressed:
isConnecting ? null : () => _connectPort(port),
child: Text(l10n.common_connect),
),
onTap: isConnecting ? null : () => _connectPort(port),
);
},
);
}
void _startHotPlugTimer() {
if (!_supportsHotPlug) return;
@ -340,9 +278,10 @@ class _UsbScreenState extends State<UsbScreen> {
}
Future<void> _pollHotPlug() async {
// Don't interfere with an active connection attempt or initial load.
if (_isConnecting || _isLoadingPorts) return;
if (_isLoadingPorts) return;
if (!mounted) return;
// Don't poll while connecting or connected.
if (_connector.state != MeshCoreConnectionState.disconnected) return;
try {
final ports = await _connector.listUsbPorts();
if (!mounted) return;
@ -353,186 +292,72 @@ class _UsbScreenState extends State<UsbScreen> {
_ports
..clear()
..addAll(ports);
if (_ports.isEmpty) {
_selectedPort = null;
} else if (added.isNotEmpty) {
// Auto-select the newly-connected device.
_selectedPort = added.first;
} else if (_selectedPort != null && !_ports.contains(_selectedPort)) {
// Previously-selected device was unplugged.
_selectedPort = _ports.isNotEmpty ? _ports.first : null;
}
});
} catch (_) {
// Silent hot-plug failures are non-critical.
}
}
Widget _buildPortList(BuildContext context) {
final theme = Theme.of(context);
final l10n = context.l10n;
if (_isLoadingPorts) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 12),
Text(l10n.common_loading),
],
),
);
}
if (_ports.isEmpty) {
return Center(
child: Text(
l10n.usbScreenEmptyState,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
return ListView.separated(
itemCount: _ports.length,
itemBuilder: (context, index) {
final port = _ports[index];
final isSelected = port == _selectedPort;
final displayName = _friendlyPortName(port);
final rawName = normalizeUsbPortName(port);
final showRawName =
rawName != displayName && !rawName.startsWith('web:');
return Material(
color: isSelected
? theme.colorScheme.primaryContainer
: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
child: ListTile(
onTap: _isConnecting
? null
: () {
setState(() {
_selectedPort = port;
_errorText = null;
});
appLogger.info('Selected port $port', tag: 'UsbScreen');
},
leading: Icon(
Icons.usb,
color: isSelected
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant,
),
title: Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
color: isSelected ? theme.colorScheme.onPrimaryContainer : null,
),
),
subtitle: showRawName
? Text(
rawName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: isSelected
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurfaceVariant,
),
)
: null,
trailing: isSelected
? Icon(
Icons.check_circle,
color: theme.colorScheme.onPrimaryContainer,
)
: null,
),
);
},
separatorBuilder: (context, index) => const SizedBox(height: 10),
);
}
Future<void> _loadPorts() async {
if (!mounted) return;
_usbConnector.setRequestPortLabel(context.l10n.usbScreenStatus);
_connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
setState(() {
_isLoadingPorts = true;
_errorText = null;
});
try {
final ports = await _usbConnector.listPorts();
final ports = await _connector.listUsbPorts();
if (!mounted) return;
setState(() {
_ports
..clear()
..addAll(ports);
if (_ports.isEmpty) {
_selectedPort = null;
} else if (!_ports.contains(_selectedPort)) {
_selectedPort = _ports.first;
}
_isLoadingPorts = false;
});
} catch (error) {
if (!mounted) return;
setState(() {
_ports.clear();
_selectedPort = null;
_errorText = _friendlyErrorMessage(error);
_isLoadingPorts = false;
});
_showError(error);
}
}
Future<void> _connectSelectedPort() async {
final selectedPort = _selectedPort;
if (selectedPort == null || selectedPort.isEmpty) {
return;
}
_usbConnector.setRequestPortLabel(context.l10n.usbScreenStatus);
if (_usbConnector.state != MeshCoreConnectionState.disconnected) {
setState(() {
_isConnecting = false;
_errorText = null;
});
return;
}
final rawPortName = normalizeUsbPortName(selectedPort);
Future<void> _connectPort(String port) async {
if (_connector.state != MeshCoreConnectionState.disconnected) return;
setState(() {
_isConnecting = true;
_errorText = null;
});
final rawPortName = normalizeUsbPortName(port);
appLogger.info('Connect tapped for $port (raw: $rawPortName)',
tag: 'UsbScreen');
try {
await _usbConnector.connect(portName: rawPortName);
await _connector.connectUsb(portName: rawPortName);
} catch (error, stackTrace) {
appLogger.error(
'Connect failed for $rawPortName: $error\n$stackTrace',
tag: 'UsbScreen',
);
if (!mounted) return;
setState(() {
_isConnecting = false;
_errorText = _friendlyErrorMessage(error);
});
// Re-scan so stale or renamed port entries are cleared from the list.
_showError(error);
unawaited(_loadPorts());
}
}
void _showError(Object error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_friendlyErrorMessage(error)),
backgroundColor: Colors.red,
),
);
}
String _friendlyErrorMessage(Object error) {
final l10n = context.l10n;
if (error is PlatformException) {
switch (error.code) {
case 'usb_permission_denied':
@ -546,43 +371,35 @@ class _UsbScreenState extends State<UsbScreen> {
return l10n.usbErrorBusy;
case 'usb_not_connected':
return l10n.usbErrorNotConnected;
case 'usb_driver_missing':
case 'usb_open_failed':
case 'usb_driver_missing':
return l10n.usbErrorOpenFailed;
case 'usb_connect_failed':
case 'usb_write_failed':
case 'usb_io_error':
return l10n.usbErrorConnectFailed;
}
}
var msg = error.toString();
if (msg.startsWith('Bad state: ')) {
msg = msg.substring('Bad state: '.length);
} else if (msg.startsWith('Exception: ')) {
msg = msg.substring('Exception: '.length);
if (error is UnsupportedError) {
return l10n.usbErrorUnsupported;
}
switch (msg) {
case 'USB serial transport is already active':
return l10n.usbErrorAlreadyActive;
case 'No USB serial device selected':
if (error is StateError) {
final msg = error.message;
if (msg.contains('already active')) return l10n.usbErrorAlreadyActive;
if (msg.contains('No USB serial device selected')) {
return l10n.usbErrorNoDeviceSelected;
case 'USB serial port is not open':
}
if (msg.contains('not open') || msg.contains('closed')) {
return l10n.usbErrorPortClosed;
case 'USB serial is not supported on this platform.':
case 'Web Serial is not supported by this browser.':
return l10n.usbErrorUnsupported;
case 'Timed out waiting for SELF_INFO during connect':
return l10n.usbErrorConnectTimedOut;
}
if (msg.contains('Timed out')) return l10n.usbErrorConnectTimedOut;
if (msg.contains('Failed to open')) return l10n.usbErrorOpenFailed;
}
if (msg.startsWith('Failed to open USB port ')) {
return l10n.usbErrorOpenFailed;
if (error is TimeoutException) {
return l10n.usbErrorConnectTimedOut;
}
return msg;
return error.toString();
}
String _friendlyPortName(String portLabel) => friendlyUsbPortName(portLabel);
}

View file

@ -52,7 +52,12 @@ class AppDebugLogService extends ChangeNotifier {
String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info,
}) {
if (!_enabled) return;
if (!_enabled && !kDebugMode) return;
if (!_enabled) {
// In debug mode, still print to console but don't store entries.
debugPrint('[$tag] $message');
return;
}
_entries.add(
AppDebugLogEntry(

View file

@ -1,9 +1,11 @@
import 'dart:io' show Platform, File;
import 'dart:ui';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
import '../l10n/app_localizations.dart';
import '../utils/platform_info.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
@ -75,6 +77,15 @@ class NotificationService {
linux: linuxSettings,
);
// On Linux, the notifications plugin opens a D-Bus session bus
// connection whose async subscription can throw an unhandled
// SocketException when the bus socket is missing (e.g. running as
// root or inside a container without a session bus).
if (PlatformInfo.isLinux && !_isDbusSessionAvailable()) {
debugPrint('Skipping notification init: D-Bus session bus unavailable');
return;
}
try {
await _notifications.initialize(
settings: initSettings,
@ -86,6 +97,16 @@ class NotificationService {
}
}
static bool _isDbusSessionAvailable() {
final addr = Platform.environment['DBUS_SESSION_BUS_ADDRESS'];
if (addr != null && addr.isNotEmpty) return true;
// Fallback: check the default socket for the current user.
final uid = Platform.environment['UID'] ??
Platform.environment['EUID'];
final path = '/run/user/${uid ?? '1000'}/bus';
return File(path).existsSync();
}
Future<bool> _ensureInitialized() async {
if (!_isInitialized) {
await initialize();

View file

@ -325,6 +325,10 @@ class UsbSerialService {
// Native implementations do not use a synthetic chooser row.
}
void setFallbackDeviceName(String label) {
// Native implementations use OS-provided device names.
}
void updateConnectedLabel(String label) {
final trimmed = label.trim();
if (trimmed.isEmpty) {

View file

@ -32,6 +32,7 @@ class UsbSerialService {
String? _connectedPortName;
String? _connectedPortKey;
String _requestPortLabel = 'Choose USB Device';
String _fallbackDeviceName = 'Web Serial Device';
AppDebugLogService? _debugLogService;
UsbSerialStatus get status => _status;
@ -77,11 +78,19 @@ class UsbSerialService {
try {
final requestedPortName = normalizeUsbPortName(portName);
_debugLogService?.info(
'Web connect: requested=$requestedPortName baud=$baudRate',
tag: 'USB Serial',
);
final selectedPortKey = requestedPortName.startsWith('web:port:')
? requestedPortName
: null;
_port = _authorizedPortsByKey[requestedPortName];
final authorizedPorts = await _getAuthorizedPorts();
_debugLogService?.info(
'Web connect: ${authorizedPorts.length} authorized port(s), cached=${_port != null}',
tag: 'USB Serial',
);
_port ??= _selectPort(authorizedPorts, requestedPortName);
_port ??= await _requestPort();
@ -89,6 +98,10 @@ class UsbSerialService {
throw StateError('No USB serial device selected');
}
_debugLogService?.info(
'Web connect: opening port at $baudRate baud…',
tag: 'USB Serial',
);
await _openPort(_port!, baudRate);
_connectedPortKey = _cachePort(_port!, preferredKey: selectedPortKey);
_connectedPortName = _displayLabelForPort(
@ -105,6 +118,10 @@ class UsbSerialService {
tag: 'USB Serial',
);
} catch (error) {
_debugLogService?.error(
'Web connect failed: $error',
tag: 'USB Serial',
);
await _cleanupFailedConnect();
_status = UsbSerialStatus.disconnected;
_connectedPortName = null;
@ -194,6 +211,14 @@ class UsbSerialService {
_requestPortLabel = trimmed;
}
void setFallbackDeviceName(String label) {
final trimmed = label.trim();
if (trimmed.isEmpty) {
return;
}
_fallbackDeviceName = trimmed;
}
void setDebugLogService(AppDebugLogService? service) {
_debugLogService = service;
}
@ -403,6 +428,7 @@ class UsbSerialService {
vendorId: hasVendor ? vendorId : null,
productId: hasProduct ? productId : null,
requestPortLabel: _requestPortLabel,
fallbackDeviceName: _fallbackDeviceName,
knownUsbNames: _knownUsbNames,
);
}

View file

@ -31,6 +31,7 @@ String describeWebUsbPort({
required int? vendorId,
required int? productId,
String requestPortLabel = 'Choose USB Device',
String fallbackDeviceName = 'Web Serial Device',
Map<String, String> knownUsbNames = const <String, String>{},
}) {
if (vendorId == null && productId == null) {
@ -43,7 +44,7 @@ String describeWebUsbPort({
? knownUsbNames['${vendorHex.toLowerCase()}:${productHex.toLowerCase()}']
: null;
final parts = <String>[knownName ?? 'Web Serial Device'];
final parts = <String>[knownName ?? fallbackDeviceName];
if (vendorHex != null) {
parts.add('VID:$vendorHex');
}

View file

@ -78,40 +78,36 @@ class _SNRIndicatorState extends State<SNRIndicator> {
widget.connector.currentSf,
);
return InkWell(
onTap: () {
if (directRepeater != null) {
_showFullPathDialog(context, directBestRepeaters);
}
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(snrUi.icon, size: 18, color: snrUi.color),
Text(
snrUi.text,
style: TextStyle(fontSize: 12, color: snrUi.color),
),
],
),
if (directRepeater != null)
return ConstrainedBox(
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
child: InkWell(
onTap: directRepeater != null
? () => _showFullPathDialog(context, directBestRepeaters)
: null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(snrUi.icon, size: 18, color: snrUi.color),
Text(
'${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
snrUi.text,
style: TextStyle(fontSize: 12, color: snrUi.color),
),
],
if (directRepeater != null)
Text(
'${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
);
@ -148,8 +144,10 @@ class _SNRIndicatorState extends State<SNRIndicator> {
builder: (context) => AlertDialog(
title: Text(l10n.snrIndicator_nearByRepeaters),
content: SizedBox(
width: double.maxFinite,
child: Scrollbar(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: directBestRepeaters.length,
separatorBuilder: (_, _) => const Divider(height: 1),

View file

@ -19,6 +19,7 @@ class _FakeMeshCoreConnector extends MeshCoreConnector {
final List<String> _ports;
String? requestPortLabel;
String? fallbackDeviceName;
int connectUsbCalls = 0;
String? lastConnectPortName;
String? fakeActiveUsbPort;
@ -30,6 +31,9 @@ class _FakeMeshCoreConnector extends MeshCoreConnector {
@override
MeshCoreConnectionState get state => initialState;
@override
MeshCoreTransportType get activeTransport => MeshCoreTransportType.usb;
@override
String? get activeUsbPort => fakeActiveUsbPort;
@ -64,6 +68,11 @@ class _FakeMeshCoreConnector extends MeshCoreConnector {
void setUsbRequestPortLabel(String label) {
requestPortLabel = label;
}
@override
void setUsbFallbackDeviceName(String label) {
fallbackDeviceName = label;
}
}
Widget _buildTestApp({
@ -107,16 +116,23 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
await tester.tap(find.ancestor(
of: find.text('Connect'),
matching: find.bySubtype<ElevatedButton>(),
));
await tester.pump();
expect(connector.connectUsbCalls, 0);
expect(find.byType(CircularProgressIndicator), findsNothing);
// UsbScreen.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(
'UsbScreen keeps raw selection when connector USB display label changes',
'UsbScreen sends raw port name when tapping Connect',
(tester) async {
final connector = _FakeMeshCoreConnector(
ports: <String>['COM6 - USB Serial Device (COM6)'],
@ -127,12 +143,10 @@ void main() {
);
await tester.pumpAndSettle();
connector.fakeActiveUsbPortDisplayLabel =
'COM6 - KD3CGK mesh-utility.org';
connector.notifyListeners();
await tester.pump(const Duration(milliseconds: 60));
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
await tester.tap(find.ancestor(
of: find.text('Connect'),
matching: find.bySubtype<ElevatedButton>(),
));
await tester.pump();
expect(connector.connectUsbCalls, 1);
@ -163,7 +177,8 @@ void main() {
});
group('Error Handling', () {
testWidgets('shows error message when listing ports fails', (tester) async {
testWidgets('shows error SnackBar when listing ports fails',
(tester) async {
final connector = _FakeMeshCoreConnector();
connector.listUsbPortsImpl = () async {
throw PlatformException(
@ -180,7 +195,7 @@ void main() {
expect(find.text('USB permission was denied.'), findsOneWidget);
});
testWidgets('connection failure completes without leaving loading state', (
testWidgets('connection failure shows SnackBar error', (
tester,
) async {
final connector = _FakeMeshCoreConnector(ports: <String>['COM1']);
@ -195,11 +210,17 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Connect'));
await tester.tap(find.ancestor(
of: find.text('Connect'),
matching: find.bySubtype<ElevatedButton>(),
));
await tester.pumpAndSettle();
expect(connectAttempted, isTrue);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(
find.text('Another USB connection request is already in progress.'),
findsOneWidget,
);
});
});
}