import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; import 'package:meshcore_client/meshcore_client.dart' hide Contact; import 'package:usb_serial/usb_serial.dart'; import '../providers/connection_provider.dart'; import '../providers/app_provider.dart'; import '../services/network_scanner_service.dart'; import '../l10n/app_localizations.dart'; /// Connection Dialog with tabs for BLE devices and Network servers class ConnectionDialog extends StatefulWidget { const ConnectionDialog({super.key}); @override State createState() => _ConnectionDialogState(); } class _ConnectionDialogState extends State with SingleTickerProviderStateMixin { late TabController _tabController; late final ConnectionProvider _connectionProvider; final NetworkScannerService _networkScanner = NetworkScannerService(); final List _discoveredServers = []; int _scannedCount = 0; int _totalToScan = 0; int _lastTabIndex = 0; String? _connectingToServerKey; // Track which server is being connected to (ip:port) // Named listener method for proper cleanup void _onTabChanged() { if (_tabController.index == _lastTabIndex) return; _lastTabIndex = _tabController.index; if (_tabController.index == 0) { _refreshBleDevices(); } if (_tabController.index == 1) { // Switched to network tab if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) { // Load cached results setState(() { _discoveredServers.addAll(_networkScanner.cachedServers); }); debugPrint( '📦 [NetworkScanner] Loaded ${_discoveredServers.length} servers from cache', ); } else if (!_networkScanner.isScanning && !_networkScanner.hasCachedResults) { // No cache, start initial scan _startNetworkScan(); } } } @override void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); _connectionProvider = Provider.of( context, listen: false, ); // Defer scan startup until after the first frame so Provider listeners // are not notified while this dialog is still being built. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _refreshBleDevices(); }); // Set up network scanner callbacks _networkScanner.onServerDiscovered = (server) { if (mounted) { setState(() { // Only add if not already in the list (deduplicate) if (!_discoveredServers.contains(server)) { _discoveredServers.add(server); } }); } }; _networkScanner.onProgressUpdate = (scanned, total) { if (mounted) { setState(() { _scannedCount = scanned; _totalToScan = total; }); } }; // Listen to tab changes using named method for proper cleanup _tabController.addListener(_onTabChanged); } @override void dispose() { _connectionProvider.stopScan(); _networkScanner.stopScan(); // Remove listener before disposing to prevent memory leaks _tabController.removeListener(_onTabChanged); _tabController.dispose(); super.dispose(); } void _startNetworkScan() { setState(() { _discoveredServers.clear(); _scannedCount = 0; _totalToScan = 0; }); _networkScanner.clearCache(); // Clear cache before starting new scan _networkScanner.scan(); } Future _refreshBleDevices() async { await _connectionProvider.stopScan(); if (!mounted) return; await _connectionProvider.startScan(); } Color _getSignalColor(int rssi) { if (rssi >= -60) return Colors.green; if (rssi >= -75) return Colors.orange; return Colors.red; } @override Widget build(BuildContext context) { final connectionProvider = context.watch(); return Container( height: MediaQuery.of(context).size.height * 0.9, decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( children: [ // Header Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.vertical( top: Radius.circular(20), ), ), child: Column( children: [ Row( children: [ IconButton( icon: Icon( Icons.arrow_back, color: Theme.of(context).colorScheme.onSurface, ), onPressed: () { Navigator.pop(context); }, ), Expanded( child: Text( AppLocalizations.of(context)!.appTitle, textAlign: TextAlign.center, style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontSize: 18, fontWeight: FontWeight.bold, ), ), ), const SizedBox(width: 48), // Balance the back button ], ), const SizedBox(height: 8), // Tab Bar TabBar( controller: _tabController, tabs: const [ Tab(text: 'BLE', icon: Icon(Icons.bluetooth)), Tab(text: 'Network', icon: Icon(Icons.wifi)), Tab(text: 'USB', icon: Icon(Icons.usb)), ], ), ], ), ), // Tab Content Expanded( child: TabBarView( controller: _tabController, children: [ // BLE Devices Tab _buildBleDevicesTab(connectionProvider), // Network Servers Tab _buildNetworkServersTab(), // USB Serial Tab _buildUsbTab(connectionProvider), ], ), ), ], ), ); } Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) { return Column( children: [ // Info banner Container( margin: const EdgeInsets.all(16), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Icon( Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer, ), SizedBox(width: 12), Expanded( child: Text( AppLocalizations.of(context)!.defaultPinInfo, style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer, fontSize: 13, ), ), ), IconButton( icon: Icon( Icons.refresh, color: Theme.of(context).colorScheme.onPrimaryContainer, ), onPressed: _refreshBleDevices, ), ], ), ), // Device list Expanded( child: connectionProvider.isScanning && connectionProvider.scannedDevices.isEmpty ? const Center(child: CircularProgressIndicator()) : connectionProvider.scannedDevices.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.bluetooth_searching, size: 64, color: Theme.of( context, ).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), SizedBox(height: 16), Text( AppLocalizations.of(context)!.noDevicesFound, style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 16, ), ), const SizedBox(height: 8), TextButton.icon( onPressed: _refreshBleDevices, icon: Icon(Icons.refresh), label: Text(AppLocalizations.of(context)!.scanAgain), ), ], ), ) : ListView.builder( itemCount: connectionProvider.scannedDevices.length, itemBuilder: (context, index) { final scannedDevice = connectionProvider.scannedDevices[index]; final device = scannedDevice.device; final rssi = scannedDevice.rssi; final signalColor = _getSignalColor(rssi); return Container( margin: const EdgeInsets.symmetric( horizontal: 16, vertical: 4, ), decoration: BoxDecoration( color: Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: Border.all( color: Theme.of( context, ).colorScheme.outline.withValues(alpha: 0.2), width: 1, ), ), child: ListTile( contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), leading: Icon( Icons.bluetooth, color: signalColor, size: 32, ), title: Text( device.platformName.isNotEmpty ? device.platformName : 'Unknown Device', style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontSize: 16, fontWeight: FontWeight.w500, ), ), subtitle: Row( children: [ Text( AppLocalizations.of(context)!.tapToConnect, style: TextStyle( color: Theme.of( context, ).colorScheme.onSurfaceVariant, fontSize: 14, ), ), const SizedBox(width: 8), Text( '$rssi dBm', style: TextStyle( color: signalColor, fontSize: 12, fontWeight: FontWeight.w500, ), ), ], ), trailing: Icon( Icons.chevron_right, color: Theme.of(context).colorScheme.onSurfaceVariant, ), onTap: () async { final appProvider = context.read(); Navigator.pop(context); final success = await connectionProvider.connect( device, ); if (success && connectionProvider.deviceInfo.isConnected) { await appProvider.initialize(); } }, ), ); }, ), ), ], ); } Widget _buildNetworkServersTab() { final bool showingCachedResults = !_networkScanner.isScanning && _networkScanner.hasCachedResults && _discoveredServers.isNotEmpty; return Column( children: [ // Info banner Container( margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Icon( showingCachedResults ? Icons.cached : Icons.info_outline, color: Theme.of(context).colorScheme.onPrimaryContainer, ), const SizedBox(width: 12), Expanded( child: Text( showingCachedResults ? 'Showing cached results. Tap refresh to rescan.' : 'Scanning local network for MeshCore WiFi devices on port 5000', style: TextStyle( color: Theme.of(context).colorScheme.onPrimaryContainer, fontSize: 13, ), ), ), IconButton( icon: Icon( Icons.refresh, color: Theme.of(context).colorScheme.onPrimaryContainer, ), onPressed: _startNetworkScan, ), ], ), ), // Scan progress if (_networkScanner.isScanning) Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Column( children: [ LinearProgressIndicator( value: _totalToScan > 0 ? _scannedCount / _totalToScan : null, ), const SizedBox(height: 8), Text( 'Scanning... $_scannedCount/${_totalToScan > 0 ? _totalToScan : "?"} IPs', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), // Server list Expanded( child: _networkScanner.isScanning && _discoveredServers.isEmpty ? const Center(child: CircularProgressIndicator()) : _discoveredServers.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.wifi_off, size: 64, color: Theme.of( context, ).colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), const SizedBox(height: 16), Text( 'No servers found', style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 16, ), ), const SizedBox(height: 8), TextButton.icon( onPressed: _startNetworkScan, icon: const Icon(Icons.refresh), label: const Text('Scan Again'), ), ], ), ) : ListView.builder( itemCount: _discoveredServers.length, itemBuilder: (context, index) { final server = _discoveredServers[index]; final serverKey = '${server.ipAddress}:${server.port}'; final isConnectingToThisServer = _connectingToServerKey == serverKey; final isAnyConnectionInProgress = _connectingToServerKey != null; return Container( margin: const EdgeInsets.symmetric( horizontal: 16, vertical: 4, ), decoration: BoxDecoration( color: Theme.of( context, ).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: Border.all( color: isConnectingToThisServer ? Theme.of(context).colorScheme.primary : Theme.of( context, ).colorScheme.outline.withValues(alpha: 0.2), width: isConnectingToThisServer ? 2 : 1, ), ), child: ListTile( contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, ), leading: isConnectingToThisServer ? SizedBox( width: 32, height: 32, child: CircularProgressIndicator( strokeWidth: 3, color: Theme.of(context).colorScheme.primary, ), ) : const Icon( Icons.wifi, color: Colors.green, size: 32, ), title: Text( server.ipAddress, style: TextStyle( color: Theme.of(context).colorScheme.onSurface, fontSize: 16, fontWeight: FontWeight.w500, ), ), subtitle: Text( isConnectingToThisServer ? 'Connecting...' : 'Port ${server.port} • ${server.responseTime}ms', style: TextStyle( color: isConnectingToThisServer ? Theme.of(context).colorScheme.primary : Theme.of( context, ).colorScheme.onSurfaceVariant, fontSize: 14, fontWeight: isConnectingToThisServer ? FontWeight.w500 : FontWeight.normal, ), ), trailing: isConnectingToThisServer ? null : Icon( Icons.chevron_right, color: Theme.of( context, ).colorScheme.onSurfaceVariant, ), enabled: !isAnyConnectionInProgress, onTap: isAnyConnectionInProgress ? null : () async { // Capture context-dependent objects before async operations final connectionProvider = context .read(); final appProvider = context.read(); final navigator = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); // Mark this server as connecting setState(() { _connectingToServerKey = serverKey; }); try { // Pre-verify server is still available final isAvailable = await _networkScanner .verifyServer(server); if (!isAvailable) { throw Exception( 'Server at ${server.ipAddress}:${server.port} is no longer available. ' 'Please scan again to find active servers.', ); } await connectionProvider.connectTcp( server.ipAddress, server.port, ); await appProvider.initialize(); if (mounted) { navigator.pop(); } } catch (e) { // Clear connecting state on error if (mounted) { setState(() { _connectingToServerKey = null; }); // Clean up error message (remove "Exception: " prefix) String errorMessage = e.toString(); if (errorMessage.startsWith( 'Exception: ', )) { errorMessage = errorMessage.substring( 'Exception: '.length, ); } if (errorMessage.startsWith( 'Connection failed: Exception: ', )) { errorMessage = errorMessage.substring( 'Connection failed: Exception: '.length, ); } else if (errorMessage.startsWith( 'Connection failed: ', )) { errorMessage = errorMessage.substring( 'Connection failed: '.length, ); } messenger.showSnackBar( SnackBar( content: Text(errorMessage), backgroundColor: Colors.red, duration: const Duration(seconds: 5), ), ); } } }, ), ); }, ), ), ], ); } Widget _buildUsbTab(ConnectionProvider connectionProvider) { return _UsbDeviceList( onConnected: () { if (mounted) Navigator.of(context).pop(); }, ); } } class _UsbDeviceList extends StatefulWidget { final VoidCallback onConnected; const _UsbDeviceList({required this.onConnected}); @override State<_UsbDeviceList> createState() => _UsbDeviceListState(); } class _UsbDeviceListState extends State<_UsbDeviceList> { List _devices = []; bool _isScanning = false; bool _isConnecting = false; @override void initState() { super.initState(); if (defaultTargetPlatform == TargetPlatform.android) { _scanDevices(); } } Future _scanDevices() async { setState(() => _isScanning = true); try { final devices = await UsbSerial.listDevices(); if (!mounted) return; setState(() { _devices = devices; _isScanning = false; }); } catch (e) { if (!mounted) return; setState(() { _devices = []; _isScanning = false; }); } } Future _connectToDevice(UsbDevice device) async { setState(() => _isConnecting = true); try { final connectionProvider = context.read(); final service = MeshCoreSerialService(appName: 'MeshCore SAR'); final port = await device.create(); if (port == null) { if (mounted) { setState(() => _isConnecting = false); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Failed to create USB port')), ); } return; } final opened = await port.open(); if (!opened) { if (mounted) { setState(() => _isConnecting = false); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Failed to open USB port')), ); } return; } await port.setDTR(true); await port.setRTS(true); await port.setPortParameters( 115200, UsbPort.DATABITS_8, UsbPort.STOPBITS_1, UsbPort.PARITY_NONE, ); service.writeRaw = (data) async { await port.write(data); }; port.inputStream?.listen( (data) => service.feedRawBytes(data), onError: (_) { service.markDisconnected(); port.close(); }, onDone: () { service.markDisconnected(); }, ); final sessionOk = await service.markConnected(); if (!sessionOk) { await port.close(); if (mounted) { setState(() => _isConnecting = false); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('USB session initialization failed')), ); } return; } final success = await connectionProvider.connectSerial(service); if (!mounted) return; if (success) { widget.onConnected(); } else { await port.close(); setState(() => _isConnecting = false); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Failed to connect via USB')), ); } } catch (e) { if (!mounted) return; setState(() => _isConnecting = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('USB error: $e')), ); } } @override Widget build(BuildContext context) { if (defaultTargetPlatform != TargetPlatform.android) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Text( kIsWeb ? 'Web Serial is not yet supported.\nUse BLE or Network instead.' : 'USB serial is available on Android only.\nConnect via OTG cable.', textAlign: TextAlign.center, ), ), ); } if (_isScanning) { return const Center(child: CircularProgressIndicator()); } return Column( children: [ Padding( padding: const EdgeInsets.all(12), child: OutlinedButton.icon( onPressed: _isConnecting ? null : _scanDevices, icon: const Icon(Icons.refresh), label: const Text('Scan USB devices'), ), ), if (_devices.isEmpty) const Expanded( child: Center( child: Text( 'No USB serial devices found.\nConnect a MeshCore device via OTG cable.', textAlign: TextAlign.center, ), ), ) else Expanded( child: ListView.builder( itemCount: _devices.length, itemBuilder: (context, index) { final device = _devices[index]; return ListTile( leading: const Icon(Icons.usb), title: Text(device.productName ?? 'USB Device'), subtitle: Text(device.manufacturerName ?? ''), trailing: _isConnecting ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.chevron_right), onTap: _isConnecting ? null : () => _connectToDevice(device), ); }, ), ), ], ); } }