mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
Replace hardcoded English strings in 32 Dart source files with l10n references. Covers settings labels, UI actions, map/SAR features, device config, notifications, profiles, spectrum scan, and status messages for sl, de, hr, pl, es, fr, it, el, ru, tr, uk, zh, pt.
634 lines
24 KiB
Dart
634 lines
24 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.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<ConnectionDialog> createState() => _ConnectionDialogState();
|
|
}
|
|
|
|
class _ConnectionDialogState extends State<ConnectionDialog>
|
|
with SingleTickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
late final ConnectionProvider _connectionProvider;
|
|
final NetworkScannerService _networkScanner = NetworkScannerService();
|
|
final List<DiscoveredServer> _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: 2, vsync: this);
|
|
_connectionProvider = Provider.of<ConnectionProvider>(
|
|
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<void> _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<ConnectionProvider>();
|
|
|
|
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 Devices', icon: Icon(Icons.bluetooth)),
|
|
Tab(text: 'Network Servers', icon: Icon(Icons.wifi)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Tab Content
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
// BLE Devices Tab
|
|
_buildBleDevicesTab(connectionProvider),
|
|
|
|
// Network Servers Tab
|
|
_buildNetworkServersTab(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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<AppProvider>();
|
|
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<ConnectionProvider>();
|
|
final appProvider = context.read<AppProvider>();
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|