mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-14 22:55:12 +10:00
458 lines
14 KiB
Dart
458 lines
14 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../l10n/l10n.dart';
|
|
import '../theme/mesh_theme.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 '../widgets/empty_state.dart';
|
|
import '../widgets/mesh_ui.dart';
|
|
import '../helpers/snack_bar_builder.dart';
|
|
import 'channels_screen.dart';
|
|
import 'tcp_screen.dart';
|
|
|
|
class UsbScreen extends StatefulWidget {
|
|
const UsbScreen({super.key});
|
|
|
|
@override
|
|
State<UsbScreen> createState() => _UsbScreenState();
|
|
}
|
|
|
|
class _UsbScreenState extends State<UsbScreen> {
|
|
final List<String> _ports = <String>[];
|
|
bool _isLoadingPorts = true;
|
|
bool _navigatedToChannels = false;
|
|
bool _didScheduleInitialLoad = false;
|
|
Timer? _hotPlugTimer;
|
|
late final MeshCoreConnector _connector;
|
|
late final VoidCallback _connectionListener;
|
|
|
|
bool get _supportsHotPlug =>
|
|
PlatformInfo.isWindows || PlatformInfo.isLinux || PlatformInfo.isMacOS;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_connector = context.read<MeshCoreConnector>();
|
|
_connectionListener = () {
|
|
if (!mounted) return;
|
|
if (_connector.state == MeshCoreConnectionState.disconnected) {
|
|
_navigatedToChannels = false;
|
|
}
|
|
if (_connector.state == MeshCoreConnectionState.connected &&
|
|
_connector.isUsbTransportConnected &&
|
|
!_navigatedToChannels) {
|
|
_navigatedToChannels = true;
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (_) => const ChannelsScreen()),
|
|
);
|
|
}
|
|
};
|
|
_connector.addListener(_connectionListener);
|
|
_startHotPlugTimer();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
|
|
_connector.setUsbFallbackDeviceName(context.l10n.usbFallbackDeviceName);
|
|
if (!_didScheduleInitialLoad) {
|
|
_didScheduleInitialLoad = true;
|
|
unawaited(_loadPorts());
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_hotPlugTimer?.cancel();
|
|
_hotPlugTimer = null;
|
|
_connector.removeListener(_connectionListener);
|
|
if (!_navigatedToChannels &&
|
|
_connector.activeTransport == MeshCoreTransportType.usb &&
|
|
_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.usbScreenTitle),
|
|
centerTitle: true,
|
|
),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: Consumer<MeshCoreConnector>(
|
|
builder: (context, connector, child) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Status header
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 300),
|
|
child: Align(
|
|
key: ValueKey(
|
|
'${connector.state}_$_isLoadingPorts',
|
|
),
|
|
alignment: Alignment.centerLeft,
|
|
child: _buildStatusChip(context, connector),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Transport switcher
|
|
_buildTransportLinks(context),
|
|
|
|
// Port list
|
|
Expanded(child: _buildPortList(context, connector)),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
bottomNavigationBar: _supportsHotPlug
|
|
? null
|
|
: SafeArea(
|
|
top: false,
|
|
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
FloatingActionButton.extended(
|
|
onPressed: _isLoadingPorts ? null : _loadPorts,
|
|
heroTag: 'usb_refresh_action',
|
|
icon: _isLoadingPorts
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.usb),
|
|
label: Text(context.l10n.scanner_scan),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusChip(BuildContext context, MeshCoreConnector connector) {
|
|
final l10n = context.l10n;
|
|
final scheme = Theme.of(context).colorScheme;
|
|
|
|
if (_isLoadingPorts) {
|
|
return StatusChip(
|
|
label: l10n.usbStatus_searching,
|
|
color: scheme.primary,
|
|
pulse: true,
|
|
);
|
|
} else if (connector.isUsbTransportConnected) {
|
|
switch (connector.state) {
|
|
case MeshCoreConnectionState.connected:
|
|
return StatusChip(
|
|
label: l10n.scanner_connectedTo(
|
|
connector.activeUsbPortDisplayLabel ?? 'USB',
|
|
),
|
|
color: MeshPalette.signal,
|
|
);
|
|
case MeshCoreConnectionState.disconnecting:
|
|
return StatusChip(
|
|
label: l10n.scanner_disconnecting,
|
|
color: MeshPalette.warn,
|
|
pulse: true,
|
|
);
|
|
default:
|
|
return StatusChip(
|
|
label: l10n.usbStatus_notConnected,
|
|
color: scheme.onSurfaceVariant,
|
|
);
|
|
}
|
|
} else if (connector.state == MeshCoreConnectionState.connecting &&
|
|
connector.activeTransport == MeshCoreTransportType.usb) {
|
|
return StatusChip(
|
|
label: l10n.usbStatus_connecting,
|
|
color: MeshPalette.warn,
|
|
pulse: true,
|
|
);
|
|
} else {
|
|
return StatusChip(
|
|
label: l10n.usbStatus_notConnected,
|
|
color: scheme.onSurfaceVariant,
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildTransportLinks(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Wrap(
|
|
spacing: 12,
|
|
runSpacing: 8,
|
|
children: [
|
|
if (!PlatformInfo.isWeb)
|
|
OutlinedButton.icon(
|
|
onPressed: () {
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(builder: (_) => const TcpScreen()),
|
|
);
|
|
},
|
|
icon: const Icon(Icons.lan),
|
|
label: Text(context.l10n.connectionChoiceTcpLabel),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () => Navigator.of(context).maybePop(),
|
|
icon: const Icon(Icons.bluetooth),
|
|
label: Text(context.l10n.connectionChoiceBluetoothLabel),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPortList(BuildContext context, MeshCoreConnector connector) {
|
|
final l10n = context.l10n;
|
|
|
|
if (_isLoadingPorts) {
|
|
return EmptyState(
|
|
icon: Icons.usb,
|
|
title: l10n.usbStatus_searching,
|
|
);
|
|
}
|
|
|
|
if (_ports.isEmpty) {
|
|
return EmptyState(
|
|
icon: Icons.usb,
|
|
title: l10n.usbScreenEmptyState,
|
|
);
|
|
}
|
|
|
|
final isConnecting =
|
|
connector.state == MeshCoreConnectionState.connecting &&
|
|
connector.activeTransport == MeshCoreTransportType.usb;
|
|
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.only(bottom: 32),
|
|
itemCount: _ports.length,
|
|
itemBuilder: (context, index) {
|
|
final port = _ports[index];
|
|
final displayName = friendlyUsbPortName(port);
|
|
final rawName = normalizeUsbPortName(port);
|
|
final showRawName =
|
|
rawName != displayName && !rawName.startsWith('web:');
|
|
|
|
return ListEntrance(
|
|
index: index,
|
|
child: MeshCard(
|
|
padding: EdgeInsets.zero,
|
|
child: ListTile(
|
|
onTap: isConnecting
|
|
? null
|
|
: () {
|
|
HapticFeedback.selectionClick();
|
|
_connectPort(port);
|
|
},
|
|
leading: AvatarCircle(
|
|
name: displayName,
|
|
size: 40,
|
|
icon: Icons.usb,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
title: Text(
|
|
displayName,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: showRawName
|
|
? Text(
|
|
rawName,
|
|
style: MeshTheme.mono(
|
|
fontSize: 11,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
)
|
|
: null,
|
|
trailing: Icon(
|
|
Icons.chevron_right,
|
|
size: 18,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _startHotPlugTimer() {
|
|
if (!_supportsHotPlug) return;
|
|
_hotPlugTimer?.cancel();
|
|
_hotPlugTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
|
_pollHotPlug();
|
|
});
|
|
}
|
|
|
|
Future<void> _pollHotPlug() async {
|
|
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;
|
|
final added = ports.where((p) => !_ports.contains(p)).toList();
|
|
final removed = _ports.where((p) => !ports.contains(p)).toList();
|
|
if (added.isEmpty && removed.isEmpty) return;
|
|
setState(() {
|
|
_ports
|
|
..clear()
|
|
..addAll(ports);
|
|
});
|
|
} catch (_) {
|
|
// Silent — hot-plug failures are non-critical.
|
|
}
|
|
}
|
|
|
|
Future<void> _loadPorts() async {
|
|
if (!mounted) return;
|
|
_connector.setUsbRequestPortLabel(context.l10n.usbScreenStatus);
|
|
|
|
setState(() {
|
|
_isLoadingPorts = true;
|
|
});
|
|
|
|
try {
|
|
final ports = await _connector.listUsbPorts();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_ports
|
|
..clear()
|
|
..addAll(ports);
|
|
_isLoadingPorts = false;
|
|
});
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_ports.clear();
|
|
_isLoadingPorts = false;
|
|
});
|
|
_showError(error);
|
|
}
|
|
}
|
|
|
|
Future<void> _connectPort(String port) async {
|
|
if (_connector.state != MeshCoreConnectionState.disconnected) return;
|
|
|
|
final rawPortName = normalizeUsbPortName(port);
|
|
appLogger.info(
|
|
'Connect tapped for $port (raw: $rawPortName)',
|
|
tag: 'UsbScreen',
|
|
);
|
|
|
|
try {
|
|
await _connector.connectUsb(portName: rawPortName);
|
|
} catch (error, stackTrace) {
|
|
appLogger.error(
|
|
'Connect failed for $rawPortName: $error\n$stackTrace',
|
|
tag: 'UsbScreen',
|
|
);
|
|
if (!mounted) return;
|
|
_showError(error);
|
|
unawaited(_loadPorts());
|
|
}
|
|
}
|
|
|
|
void _showError(Object error) {
|
|
if (!mounted) return;
|
|
// Cancelling the browser's serial port picker is a normal user action, not
|
|
// an error — don't show a scary red toast (and never leak the raw
|
|
// DOMException text).
|
|
if (_isUserCancelledPortPicker(error)) return;
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(_friendlyErrorMessage(error)),
|
|
backgroundColor: Theme.of(context).colorScheme.error,
|
|
);
|
|
}
|
|
|
|
bool _isUserCancelledPortPicker(Object error) {
|
|
if (error is StateError &&
|
|
error.message.contains('No USB serial device selected')) {
|
|
return true;
|
|
}
|
|
final text = error.toString();
|
|
return text.contains('No port selected by the user') ||
|
|
text.contains("Failed to execute 'requestPort'");
|
|
}
|
|
|
|
String _friendlyErrorMessage(Object error) {
|
|
final l10n = context.l10n;
|
|
|
|
if (error is PlatformException) {
|
|
switch (error.code) {
|
|
case 'usb_permission_denied':
|
|
return l10n.usbErrorPermissionDenied;
|
|
case 'usb_device_missing':
|
|
case 'usb_device_detached':
|
|
return l10n.usbErrorDeviceMissing;
|
|
case 'usb_invalid_port':
|
|
return l10n.usbErrorInvalidPort;
|
|
case 'usb_busy':
|
|
return l10n.usbErrorBusy;
|
|
case 'usb_not_connected':
|
|
return l10n.usbErrorNotConnected;
|
|
case 'usb_open_failed':
|
|
case 'usb_driver_missing':
|
|
return l10n.usbErrorOpenFailed;
|
|
case 'usb_connect_failed':
|
|
return l10n.usbErrorConnectFailed;
|
|
}
|
|
}
|
|
|
|
if (error is UnsupportedError) {
|
|
return l10n.usbErrorUnsupported;
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (msg.contains('not open') || msg.contains('closed')) {
|
|
return l10n.usbErrorPortClosed;
|
|
}
|
|
if (msg.contains('Timed out')) return l10n.usbErrorConnectTimedOut;
|
|
if (msg.contains('Failed to open')) return l10n.usbErrorOpenFailed;
|
|
}
|
|
|
|
if (error is TimeoutException) {
|
|
return l10n.usbErrorConnectTimedOut;
|
|
}
|
|
|
|
return error.toString();
|
|
}
|
|
}
|