diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index f4df5f3..1ddf199 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -224,6 +224,77 @@ class ConnectionProvider with ChangeNotifier { _wireServiceCallbacks(_bleService); } + Future _prepareForConnectionSwitch(ConnectionMode nextMode) async { + if (_isScanning) { + debugPrint('🔵 [Provider] Stopping active scan before connect()'); + await stopScan(); + } + + await _disconnectInactiveTransports(nextMode); + _error = null; + _supportsAutoaddConfig = null; + _resetSyncState(); + } + + Future _disconnectInactiveTransports(ConnectionMode activeMode) async { + if (activeMode != ConnectionMode.ble && _bleService.isConnected) { + await _bleService.disconnect(); + } + + if (activeMode != ConnectionMode.tcp && _tcpService != null) { + await _disposeTcpService(); + } + + if (activeMode != ConnectionMode.usb && _serialService != null) { + _disposeSerialService(); + } + } + + Future _disposeTcpService() async { + final service = _tcpService; + if (service == null) { + return; + } + _tcpService = null; + await service.disconnect(); + service.dispose(); + } + + void _disposeSerialService() { + _serialService?.markDisconnected(); + _serialService?.dispose(); + _serialService = null; + } + + void _beginConnectionAttempt({ + required ConnectionMode mode, + required String deviceId, + required String deviceName, + String? tcpHost, + }) { + _connectionMode = mode; + _tcpHost = mode == ConnectionMode.tcp ? tcpHost : null; + _deviceInfo = _deviceInfo.copyWith( + deviceId: deviceId, + deviceName: deviceName, + connectionState: ConnectionState.connecting, + ); + notifyListeners(); + } + + void _resetConnectionSession({ConnectionMode nextMode = ConnectionMode.ble}) { + _tcpHost = nextMode == ConnectionMode.tcp ? _tcpHost : null; + _connectionMode = nextMode; + _supportsAutoaddConfig = null; + _resetSyncState(); + _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); + _roomLoginManager.clearRoomLoginStates(); + _pingTracker.clearAll(); + _pendingSendOperations.clear(); + _messageDeliveryTracker.clearTracking(); + notifyListeners(); + } + /// Wire all shared event callbacks onto [service]. /// Called for both BLE and TCP services so the provider handles events /// identically regardless of transport. @@ -629,27 +700,15 @@ class ConnectionProvider with ChangeNotifier { debugPrint( '🔵 [Provider] connect() called for device: ${device.platformName}', ); - - if (_isScanning) { - debugPrint('🔵 [Provider] Stopping active scan before connect()'); - await stopScan(); - } - - // Ensure we route commands to BLE, not a stale TCP service. - _connectionMode = ConnectionMode.ble; - - _deviceInfo = _deviceInfo.copyWith( + await _prepareForConnectionSwitch(ConnectionMode.ble); + _beginConnectionAttempt( + mode: ConnectionMode.ble, deviceId: device.remoteId.toString(), deviceName: device.platformName.isNotEmpty ? device.platformName : 'Unknown', - connectionState: ConnectionState.connecting, ); - _error = null; - _supportsAutoaddConfig = null; - _resetSyncState(); debugPrint('✅ [Provider] Device info updated to connecting state'); - notifyListeners(); debugPrint('🔵 [Provider] Calling BLE service connect()...'); final success = await _bleService.connect(device); @@ -669,23 +728,18 @@ class ConnectionProvider with ChangeNotifier { /// Connect to a MeshCore device over TCP/WiFi (port 5000) Future connectTcp(String host, int port) async { debugPrint('🌐 [Provider] connectTcp() $host:$port'); - - _tcpHost = host; - _deviceInfo = _deviceInfo.copyWith( - deviceId: '$host:$port', - deviceName: host, - connectionState: ConnectionState.connecting, - ); - _error = null; - _supportsAutoaddConfig = null; - notifyListeners(); + await _prepareForConnectionSwitch(ConnectionMode.tcp); // Create fresh TCP service and wire its callbacks - _tcpService?.dispose(); + await _disposeTcpService(); _tcpService = MeshCoreTcpService(); _wireServiceCallbacks(_tcpService!); - - _connectionMode = ConnectionMode.tcp; + _beginConnectionAttempt( + mode: ConnectionMode.tcp, + deviceId: '$host:$port', + deviceName: host, + tcpHost: host, + ); final success = await _tcpService!.connect(host, port); if (!success) { @@ -699,21 +753,8 @@ class ConnectionProvider with ChangeNotifier { /// Disconnect from TCP/WiFi device Future disconnectTcp() async { - if (_tcpService != null) { - await _tcpService!.disconnect(); - _tcpService!.dispose(); - _tcpService = null; - } - _tcpHost = null; - _connectionMode = ConnectionMode.ble; - _supportsAutoaddConfig = null; - _resetSyncState(); - _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); - _roomLoginManager.clearRoomLoginStates(); - _pingTracker.clearAll(); - _pendingSendOperations.clear(); - _messageDeliveryTracker.clearTracking(); - notifyListeners(); + await _disposeTcpService(); + _resetConnectionSession(); } /// Connect via USB serial using a pre-configured [MeshCoreSerialService]. @@ -723,20 +764,15 @@ class ConnectionProvider with ChangeNotifier { /// After this call succeeds, [service.markConnected()] has already run. Future connectSerial(MeshCoreSerialService service) async { debugPrint('🔌 [Provider] connectSerial()'); - - _deviceInfo = _deviceInfo.copyWith( - deviceId: 'usb', - deviceName: 'USB Companion', - connectionState: ConnectionState.connecting, - ); - _error = null; - _supportsAutoaddConfig = null; - notifyListeners(); - - _serialService?.dispose(); + await _prepareForConnectionSwitch(ConnectionMode.usb); + _disposeSerialService(); _serialService = service; _wireServiceCallbacks(_serialService!); - _connectionMode = ConnectionMode.usb; + _beginConnectionAttempt( + mode: ConnectionMode.usb, + deviceId: 'usb', + deviceName: 'USB Companion', + ); // markConnected() should already have been called by the transport. // If it hasn't, the service won't be connected yet. @@ -752,18 +788,8 @@ class ConnectionProvider with ChangeNotifier { /// Disconnect from USB serial device. Future disconnectSerial() async { - _serialService?.markDisconnected(); - _serialService?.dispose(); - _serialService = null; - _connectionMode = ConnectionMode.ble; - _supportsAutoaddConfig = null; - _resetSyncState(); - _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); - _roomLoginManager.clearRoomLoginStates(); - _pingTracker.clearAll(); - _pendingSendOperations.clear(); - _messageDeliveryTracker.clearTracking(); - notifyListeners(); + _disposeSerialService(); + _resetConnectionSession(); } /// Disconnect from device @@ -783,15 +809,7 @@ class ConnectionProvider with ChangeNotifier { } await _bleService.disconnect(); - - _supportsAutoaddConfig = null; - _resetSyncState(); - _deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected); - _roomLoginManager.clearRoomLoginStates(); - _pingTracker.clearAll(); - _pendingSendOperations.clear(); - _messageDeliveryTracker.clearTracking(); - notifyListeners(); + _resetConnectionSession(); } /// Reset message sync state so the next connect/reconnect can sync cleanly. @@ -2267,9 +2285,7 @@ class ConnectionProvider with ChangeNotifier { if (!_activeService.isConnected) return null; try { final frame = await _activeService.exportContact(publicKey); - final hex = frame - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); + final hex = frame.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); return 'meshcore://$hex'; } catch (e) { debugPrint('⚠️ [Provider] exportContact failed: $e'); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7a3f2ef..30cea8a 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -943,19 +943,35 @@ class _HomeScreenState extends State final isTcpConnected = provider.connectionMode == ConnectionMode.tcp; if (!isConnected) { - // Disconnected state: show connect button + final buttonLabel = provider.isReconnecting + ? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}' + : AppLocalizations.of(context)!.connect; return Row( children: [ Expanded( - child: Text( - AppLocalizations.of(context)!.appTitle, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppLocalizations.of(context)!.appTitle, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Text( + provider.isReconnecting + ? 'Restoring previous link' + : 'No device connected', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], ), ), - ElevatedButton.icon( + FilledButton.icon( onPressed: provider.isReconnecting ? null : () => _showConnectionDialog(context), @@ -966,22 +982,19 @@ class _HomeScreenState extends State child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( - Colors.black54, + Colors.white70, ), ), ) - : Icon(Icons.bluetooth, size: 18), - label: Text( - provider.isReconnecting - ? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}' - : AppLocalizations.of(context)!.connect, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black87, - elevation: 0, + : const Icon(Icons.add_link_rounded, size: 18), + label: Text(buttonLabel), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(16), ), ), ), diff --git a/lib/services/profile_manager.dart b/lib/services/profile_manager.dart index fdecd49..099bc4d 100644 --- a/lib/services/profile_manager.dart +++ b/lib/services/profile_manager.dart @@ -116,6 +116,13 @@ class ProfileManager with ChangeNotifier { return _deviceProfileDefaults[deviceKey] ?? ConfigProfile.defaultProfileId; } + bool hasProfileForDevice(String? deviceKey) { + if (deviceKey == null || deviceKey.isEmpty) { + return false; + } + return _deviceProfileDefaults.containsKey(deviceKey); + } + Future setActiveProfileIdForDevice( String id, { String? deviceKey, diff --git a/lib/services/profile_workspace_coordinator.dart b/lib/services/profile_workspace_coordinator.dart index 413bb0e..e6d1bad 100644 --- a/lib/services/profile_workspace_coordinator.dart +++ b/lib/services/profile_workspace_coordinator.dart @@ -79,14 +79,7 @@ class ProfileWorkspaceCoordinator { activeProfileId: enabled ? profileManager.activeProfileId : 'default', ); if (enabled) { - final deviceKey = _currentDeviceProfileKey; - final targetProfileId = profileManager.profileIdForDevice(deviceKey); - if (targetProfileId != profileManager.activeProfileId) { - await profileManager.setActiveProfileIdForDevice( - targetProfileId, - deviceKey: deviceKey, - ); - } + await _ensureProfileForCurrentDevice(); if (wasEnabled) { await openProfile(profileManager.activeProfileId); } else { @@ -294,13 +287,14 @@ class ProfileWorkspaceCoordinator { return; } - final targetProfileId = profileManager.profileIdForDevice(deviceKey); - if (targetProfileId == profileManager.activeProfileId) { - return; - } - _isSyncingDeviceProfile = true; try { + final profile = await _ensureProfileForCurrentDevice(); + final targetProfileId = profile.id; + if (targetProfileId == profileManager.activeProfileId) { + return; + } + await _persistCurrentState(); await profileManager.setActiveProfileIdForDevice( targetProfileId, @@ -308,7 +302,6 @@ class ProfileWorkspaceCoordinator { ); await _switchRuntimeScope(targetProfileId); - final profile = await resolveProfile(targetProfileId); await _appConfigSnapshotService.apply( profile.sections.appSettings, appProvider, @@ -323,6 +316,56 @@ class ProfileWorkspaceCoordinator { } } + Future _ensureProfileForCurrentDevice() async { + final deviceKey = _currentDeviceProfileKey; + final targetProfileId = profileManager.profileIdForDevice(deviceKey); + final existingProfile = profileManager.getProfile(targetProfileId); + if (profileManager.hasProfileForDevice(deviceKey) && + existingProfile != null) { + return existingProfile; + } + if (profileManager.hasProfileForDevice(deviceKey) && + targetProfileId == ConfigProfile.defaultProfileId) { + return ConfigProfile.defaultProfile(); + } + if (deviceKey == null) { + return await resolveProfile(profileManager.activeProfileId); + } + + final profile = await createProfileFromCurrent( + name: _buildDeviceProfileName(), + ); + await profileManager.setActiveProfileIdForDevice( + profile.id, + deviceKey: deviceKey, + ); + return profile; + } + + String _buildDeviceProfileName() { + final deviceInfo = connectionProvider.deviceInfo; + final name = deviceInfo.selfName?.trim(); + if (name != null && name.isNotEmpty) { + return 'Device ${_sanitizeProfileLabel(name)}'; + } + + final displayName = deviceInfo.displayName?.trim(); + if (displayName != null && displayName.isNotEmpty) { + return 'Device ${_sanitizeProfileLabel(displayName)}'; + } + + final deviceId = deviceInfo.deviceId?.trim(); + if (deviceId != null && deviceId.isNotEmpty) { + return 'Device ${_sanitizeProfileLabel(deviceId)}'; + } + + return 'Device Profile'; + } + + String _sanitizeProfileLabel(String value) { + return value.replaceAll(RegExp(r'\s+'), ' ').trim(); + } + String? get _currentDeviceProfileKey => ProfileDeviceKeyResolver.resolve( deviceInfo: connectionProvider.deviceInfo, connectionMode: connectionProvider.connectionMode, diff --git a/lib/services/profiles_feature_service.dart b/lib/services/profiles_feature_service.dart index 231cc8b..d0d44f9 100644 --- a/lib/services/profiles_feature_service.dart +++ b/lib/services/profiles_feature_service.dart @@ -5,7 +5,7 @@ class ProfilesFeatureService { static Future isEnabled() async { final prefs = await SharedPreferences.getInstance(); - return prefs.getBool(enabledKey) ?? false; + return prefs.getBool(enabledKey) ?? true; } static Future setEnabled(bool enabled) async { @@ -15,7 +15,7 @@ class ProfilesFeatureService { } class ProfileStorageScope { - static bool _profilesEnabled = false; + static bool _profilesEnabled = true; static String _activeProfileId = 'default'; static Future bootstrap({ diff --git a/lib/utils/link_quality.dart b/lib/utils/link_quality.dart new file mode 100644 index 0000000..efda7ec --- /dev/null +++ b/lib/utils/link_quality.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +int rssiScore(int rssiDbm) { + if (rssiDbm >= -60) return 5; + if (rssiDbm >= -70) return 4; + if (rssiDbm >= -80) return 3; + if (rssiDbm >= -90) return 2; + if (rssiDbm >= -100) return 1; + return 0; +} + +int snrScore(double snrDb) { + if (snrDb >= 10) return 5; + if (snrDb >= 5) return 4; + if (snrDb >= 0) return 3; + if (snrDb >= -5) return 2; + if (snrDb >= -10) return 1; + return 0; +} + +String linkQualityLabel(int? rssiDbm, double? snrDb) { + var totalScore = 0; + var metricCount = 0; + + if (rssiDbm != null) { + totalScore += rssiScore(rssiDbm); + metricCount += 1; + } + if (snrDb != null) { + totalScore += snrScore(snrDb); + metricCount += 1; + } + + if (metricCount == 0) return 'Weak'; + + final averageScore = totalScore / metricCount; + if (averageScore >= 4.5) return 'Excellent'; + if (averageScore >= 3.5) return 'Good'; + if (averageScore >= 2.5) return 'Fair'; + return 'Weak'; +} + +Color linkQualityColor(String quality) { + switch (quality) { + case 'Excellent': + return Colors.green; + case 'Good': + return Colors.lightGreen; + case 'Fair': + return Colors.orange; + default: + return Colors.redAccent; + } +} diff --git a/lib/widgets/connection_dialog.dart b/lib/widgets/connection_dialog.dart index 307d7a4..e1acd98 100644 --- a/lib/widgets/connection_dialog.dart +++ b/lib/widgets/connection_dialog.dart @@ -1,12 +1,13 @@ -import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter/material.dart'; import 'package:meshcore_client/meshcore_client.dart' hide Contact; +import 'package:provider/provider.dart'; 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'; +import '../providers/app_provider.dart'; +import '../providers/connection_provider.dart'; +import '../services/network_scanner_service.dart'; /// Connection Dialog with tabs for BLE devices and Network servers class ConnectionDialog extends StatefulWidget { @@ -25,10 +26,9 @@ class _ConnectionDialogState extends State int _scannedCount = 0; int _totalToScan = 0; int _lastTabIndex = 0; - String? - _connectingToServerKey; // Track which server is being connected to (ip:port) + String? _connectingToServerKey; + String? _connectingBleDeviceId; - // Named listener method for proper cleanup void _onTabChanged() { if (_tabController.index == _lastTabIndex) return; _lastTabIndex = _tabController.index; @@ -38,18 +38,12 @@ class _ConnectionDialogState extends State } 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(); } } @@ -64,35 +58,28 @@ class _ConnectionDialogState extends State 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); - } - }); - } + if (!mounted) return; + setState(() { + if (!_discoveredServers.contains(server)) { + _discoveredServers.add(server); + } + }); }; _networkScanner.onProgressUpdate = (scanned, total) { - if (mounted) { - setState(() { - _scannedCount = scanned; - _totalToScan = total; - }); - } + if (!mounted) return; + setState(() { + _scannedCount = scanned; + _totalToScan = total; + }); }; - // Listen to tab changes using named method for proper cleanup _tabController.addListener(_onTabChanged); } @@ -100,7 +87,6 @@ class _ConnectionDialogState extends State void dispose() { _connectionProvider.stopScan(); _networkScanner.stopScan(); - // Remove listener before disposing to prevent memory leaks _tabController.removeListener(_onTabChanged); _tabController.dispose(); super.dispose(); @@ -112,7 +98,7 @@ class _ConnectionDialogState extends State _scannedCount = 0; _totalToScan = 0; }); - _networkScanner.clearCache(); // Clear cache before starting new scan + _networkScanner.clearCache(); _networkScanner.scan(); } @@ -131,78 +117,94 @@ class _ConnectionDialogState extends State @override Widget build(BuildContext context) { final connectionProvider = context.watch(); + final theme = Theme.of(context); 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)), + color: theme.colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), ), child: Column( children: [ - // Header Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, + color: theme.colorScheme.surfaceContainerHighest, borderRadius: const BorderRadius.vertical( - top: Radius.circular(20), + top: Radius.circular(24), ), ), child: Column( children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.35, + ), + borderRadius: BorderRadius.circular(999), + ), + ), + const SizedBox(height: 12), Row( children: [ IconButton( - icon: Icon( - Icons.arrow_back, - color: Theme.of(context).colorScheme.onSurface, - ), - onPressed: () { - Navigator.pop(context); - }, + icon: const Icon(Icons.close_rounded), + 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, - ), + child: Column( + children: [ + Text( + 'Connect Device', + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Choose Bluetooth, WiFi, or USB transport', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ), ), - const SizedBox(width: 48), // Balance the back button + const SizedBox(width: 48), ], ), - const SizedBox(height: 8), - // Tab Bar + const SizedBox(height: 12), TabBar( controller: _tabController, + dividerColor: Colors.transparent, + indicator: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(14), + ), + indicatorSize: TabBarIndicatorSize.tab, + labelColor: theme.colorScheme.onPrimaryContainer, + unselectedLabelColor: theme.colorScheme.onSurfaceVariant, tabs: const [ - Tab(text: 'BLE', icon: Icon(Icons.bluetooth)), - Tab(text: 'Network', icon: Icon(Icons.wifi)), - Tab(text: 'USB', icon: Icon(Icons.usb)), + Tab(text: 'BLE', icon: Icon(Icons.bluetooth_rounded)), + Tab(text: 'Network', icon: Icon(Icons.wifi_rounded)), + Tab(text: 'USB', icon: Icon(Icons.usb_rounded)), ], ), ], ), ), - - // Tab Content Expanded( child: TabBarView( controller: _tabController, children: [ - // BLE Devices Tab _buildBleDevicesTab(connectionProvider), - - // Network Servers Tab _buildNetworkServersTab(), - - // USB Serial Tab - _buildUsbTab(connectionProvider), + _buildUsbTab(), ], ), ), @@ -211,78 +213,175 @@ class _ConnectionDialogState extends State ); } - 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), + Widget _buildSectionBanner({ + required IconData icon, + required String message, + required VoidCallback onRefresh, + }) { + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon(icon, color: theme.colorScheme.onPrimaryContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onPrimaryContainer, + ), + ), ), + IconButton( + icon: Icon( + Icons.refresh_rounded, + color: theme.colorScheme.onPrimaryContainer, + ), + onPressed: onRefresh, + ), + ], + ), + ); + } + + Widget _buildEmptyState({ + required IconData icon, + required String title, + required String actionLabel, + required VoidCallback onAction, + }) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 36, + color: theme.colorScheme.onSurfaceVariant.withValues( + alpha: 0.8, + ), + ), + ), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + FilledButton.tonalIcon( + onPressed: onAction, + icon: const Icon(Icons.refresh_rounded), + label: Text(actionLabel), + ), + ], + ), + ), + ); + } + + Widget _buildTransportCard({ + required IconData icon, + required Color iconColor, + required String title, + required String subtitle, + required Widget trailing, + VoidCallback? onTap, + bool enabled = true, + }) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.7), + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: enabled ? onTap : null, + child: Padding( + padding: const EdgeInsets.all(16), child: Row( children: [ - Icon( - Icons.info_outline, - color: Theme.of(context).colorScheme.onPrimaryContainer, + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, color: iconColor), ), - SizedBox(width: 12), + const SizedBox(width: 14), Expanded( - child: Text( - AppLocalizations.of(context)!.defaultPinInfo, - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimaryContainer, - fontSize: 13, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ), ), - IconButton( - icon: Icon( - Icons.refresh, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - onPressed: _refreshBleDevices, - ), + const SizedBox(width: 12), + trailing, ], ), ), + ), + ); + } - // Device list + Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) { + return Column( + children: [ + _buildSectionBanner( + icon: Icons.bluetooth_searching_rounded, + message: AppLocalizations.of(context)!.defaultPinInfo, + onRefresh: _refreshBleDevices, + ), 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), - ), - ], - ), + ? _buildEmptyState( + icon: Icons.bluetooth_searching_rounded, + title: AppLocalizations.of(context)!.noDevicesFound, + actionLabel: AppLocalizations.of(context)!.scanAgain, + onAction: _refreshBleDevices, ) : ListView.builder( itemCount: connectionProvider.scannedDevices.length, @@ -292,83 +391,53 @@ class _ConnectionDialogState extends State final device = scannedDevice.device; final rssi = scannedDevice.rssi; final signalColor = _getSignalColor(rssi); + final deviceId = device.remoteId.toString(); + final isConnecting = _connectingBleDeviceId == deviceId; - 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); + Future connectBle() async { + final appProvider = context.read(); + setState(() { + _connectingBleDeviceId = deviceId; + }); + try { + Navigator.pop(context); + final success = await connectionProvider.connect( + device, + ); + if (success && + connectionProvider.deviceInfo.isConnected) { + await appProvider.initialize(); + } + } finally { + if (mounted) { + setState(() { + _connectingBleDeviceId = null; + }); + } + } + } - final success = await connectionProvider.connect( - device, - ); - if (success && - connectionProvider.deviceInfo.isConnected) { - await appProvider.initialize(); - } - }, - ), + return _buildTransportCard( + icon: Icons.bluetooth_rounded, + iconColor: signalColor, + title: device.platformName.isNotEmpty + ? device.platformName + : 'Unknown Device', + subtitle: 'Signal $rssi dBm', + trailing: isConnecting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + ), + ) + : FilledButton.tonal( + onPressed: connectBle, + child: const Text('Connect'), + ), + onTap: isConnecting ? null : connectBle, + enabled: !isConnecting, ); }, ), @@ -385,44 +454,15 @@ class _ConnectionDialogState extends State 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, - ), - ], - ), + _buildSectionBanner( + icon: showingCachedResults + ? Icons.cached_rounded + : Icons.wifi_find_rounded, + message: showingCachedResults + ? 'Showing cached results. Tap refresh to rescan.' + : 'Scanning local network for MeshCore WiFi devices on port 5000', + onRefresh: _startNetworkScan, ), - - // Scan progress if (_networkScanner.isScanning) Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -439,39 +479,15 @@ class _ConnectionDialogState extends State ], ), ), - - // 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'), - ), - ], - ), + ? _buildEmptyState( + icon: Icons.wifi_off_rounded, + title: 'No servers found', + actionLabel: 'Scan Again', + onAction: _startNetworkScan, ) : ListView.builder( itemCount: _discoveredServers.length, @@ -483,153 +499,95 @@ class _ConnectionDialogState extends State 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( + Future connectServer() async { + final connectionProvider = context + .read(); + final appProvider = context.read(); + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + + setState(() { + _connectingToServerKey = serverKey; + }); + + try { + 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, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - fontWeight: FontWeight.w500, + server.port, + ); + await appProvider.initialize(); + + if (mounted) { + navigator.pop(); + } + } catch (e) { + if (!mounted) return; + setState(() { + _connectingToServerKey = null; + }); + + var 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), ), - ), - 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, + ); + } + } + + return _buildTransportCard( + icon: Icons.wifi_rounded, + iconColor: Colors.green, + title: server.ipAddress, + subtitle: isConnectingToThisServer + ? 'Connecting...' + : 'Port ${server.port} • ${server.responseTime}ms', + trailing: isConnectingToThisServer + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, ), - 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), - ), - ); - } - } - }, - ), + ) + : FilledButton.tonal( + onPressed: isAnyConnectionInProgress + ? null + : connectServer, + child: const Text('Connect'), + ), + enabled: !isAnyConnectionInProgress, + onTap: isAnyConnectionInProgress ? null : connectServer, ); }, ), @@ -638,8 +596,38 @@ class _ConnectionDialogState extends State ); } - Widget _buildUsbTab(ConnectionProvider connectionProvider) { + Widget _buildUsbTab() { return _UsbDeviceList( + buildTransportCard: + ({ + required icon, + required iconColor, + required title, + required subtitle, + required trailing, + onTap, + enabled = true, + }) => _buildTransportCard( + icon: icon, + iconColor: iconColor, + title: title, + subtitle: subtitle, + trailing: trailing, + onTap: onTap, + enabled: enabled, + ), + buildEmptyState: + ({ + required icon, + required title, + required actionLabel, + required onAction, + }) => _buildEmptyState( + icon: icon, + title: title, + actionLabel: actionLabel, + onAction: onAction, + ), onConnected: () { if (mounted) Navigator.of(context).pop(); }, @@ -647,10 +635,35 @@ class _ConnectionDialogState extends State } } +typedef _TransportCardBuilder = + Widget Function({ + required IconData icon, + required Color iconColor, + required String title, + required String subtitle, + required Widget trailing, + VoidCallback? onTap, + bool enabled, + }); + +typedef _EmptyStateBuilder = + Widget Function({ + required IconData icon, + required String title, + required String actionLabel, + required VoidCallback onAction, + }); + class _UsbDeviceList extends StatefulWidget { final VoidCallback onConnected; + final _TransportCardBuilder buildTransportCard; + final _EmptyStateBuilder buildEmptyState; - const _UsbDeviceList({required this.onConnected}); + const _UsbDeviceList({ + required this.onConnected, + required this.buildTransportCard, + required this.buildEmptyState, + }); @override State<_UsbDeviceList> createState() => _UsbDeviceListState(); @@ -678,7 +691,7 @@ class _UsbDeviceListState extends State<_UsbDeviceList> { _devices = devices; _isScanning = false; }); - } catch (e) { + } catch (_) { if (!mounted) return; setState(() { _devices = []; @@ -767,9 +780,9 @@ class _UsbDeviceListState extends State<_UsbDeviceList> { } catch (e) { if (!mounted) return; setState(() => _isConnecting = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('USB error: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('USB error: $e'))); } } @@ -796,20 +809,21 @@ class _UsbDeviceListState extends State<_UsbDeviceList> { return Column( children: [ Padding( - padding: const EdgeInsets.all(12), - child: OutlinedButton.icon( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: FilledButton.tonalIcon( onPressed: _isConnecting ? null : _scanDevices, - icon: const Icon(Icons.refresh), + icon: const Icon(Icons.usb_rounded), 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, - ), + Expanded( + child: widget.buildEmptyState( + icon: Icons.usb_off_rounded, + title: + 'No USB serial devices found.\nConnect a MeshCore device via OTG cable.', + actionLabel: 'Scan USB devices', + onAction: _scanDevices, ), ) else @@ -818,17 +832,26 @@ class _UsbDeviceListState extends State<_UsbDeviceList> { 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 ?? ''), + return widget.buildTransportCard( + icon: Icons.usb_rounded, + iconColor: Theme.of(context).colorScheme.primary, + title: device.productName ?? 'USB Device', + subtitle: (device.manufacturerName?.isNotEmpty ?? false) + ? device.manufacturerName! + : 'Ready over OTG serial', trailing: _isConnecting ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2.5), ) - : const Icon(Icons.chevron_right), + : FilledButton.tonal( + onPressed: _isConnecting + ? null + : () => _connectToDevice(device), + child: const Text('Connect'), + ), + enabled: !_isConnecting, onTap: _isConnecting ? null : () => _connectToDevice(device), ); }, diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 8208904..0a54e4f 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:geolocator/geolocator.dart'; @@ -16,6 +17,8 @@ import 'contact_trace_sheet.dart'; import 'room_login_sheet.dart'; import '../common/contact_avatar.dart'; import '../sensors/sensor_telemetry_card.dart'; +import '../../utils/link_quality.dart'; +import '../../utils/time_ago_extensions.dart'; import '../../utils/toast_logger.dart'; import '../../l10n/app_localizations.dart'; @@ -1259,6 +1262,7 @@ class _NeighboursSheetState extends State<_NeighboursSheet> { Future _fetchNeighbours() async { final connectionProvider = context.read(); + final previousOnMessageReceived = connectionProvider.onMessageReceived; String? responseText; void onMessage(message) { @@ -1270,9 +1274,12 @@ class _NeighboursSheetState extends State<_NeighboursSheet> { } } - connectionProvider.onMessageReceived = (message) { + void sheetListener(message) { + previousOnMessageReceived?.call(message); onMessage(message); - }; + } + + connectionProvider.onMessageReceived = sheetListener; try { await connectionProvider.sendTextMessage( @@ -1343,32 +1350,321 @@ class _NeighboursSheetState extends State<_NeighboursSheet> { _loading = false; _error = 'Failed: $e'; }); + } finally { + if (identical(connectionProvider.onMessageReceived, sheetListener)) { + connectionProvider.onMessageReceived = previousOnMessageReceived; + } } } - String _resolveNeighbourName(String keyHex) { + Contact? _resolveNeighbourContact(String keyHex) { final contactsProvider = context.read(); for (final contact in contactsProvider.contacts) { if (contact.publicKeyHex.toLowerCase().startsWith(keyHex.toLowerCase())) { - return contact.displayName; + return contact; } } + return null; + } + + String _resolveNeighbourName(String keyHex) { + final contact = _resolveNeighbourContact(keyHex); + if (contact != null) { + return contact.displayName; + } return keyHex.length > 12 ? '${keyHex.substring(0, 12)}...' : keyHex; } - String _formatAge(DateTime when) { - final diff = DateTime.now().difference(when); - if (diff.inMinutes < 1) return 'just now'; - if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; - if (diff.inHours < 24) return '${diff.inHours}h ago'; - return '${diff.inDays}d ago'; + String _formatAge(BuildContext context, _Neighbour neighbour) { + if (neighbour.lastSeenAt != null) { + return DateTime.now() + .difference(neighbour.lastSeenAt!) + .toLocalizedTimeAgoWithSeconds(context); + } + if (neighbour.lastSeenMs != null) { + if (neighbour.lastSeenMs! < 1000) { + return AppLocalizations.of(context)!.justNow; + } + return Duration( + milliseconds: neighbour.lastSeenMs!, + ).toLocalizedTimeAgoWithSeconds(context); + } + return AppLocalizations.of(context)!.justNow; + } + + List<_MappedNeighbour> _mappedNeighbours() { + return _neighbours + .map((neighbour) { + final contact = _resolveNeighbourContact(neighbour.publicKeyHex); + final location = contact?.displayLocation; + if (contact == null || location == null) { + return null; + } + return _MappedNeighbour( + neighbour: neighbour, + contact: contact, + location: LatLng(location.latitude, location.longitude), + ); + }) + .whereType<_MappedNeighbour>() + .toList(); + } + + Widget _buildSummaryChip( + BuildContext context, { + required IconData icon, + required String label, + }) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } + + Widget _buildRepeaterMarker(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 132), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.78), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + widget.contact.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 11, + ), + ), + ), + const SizedBox(height: 4), + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon(Icons.hub_outlined, color: Colors.white, size: 18), + ), + ], + ); + } + + Widget _buildNeighbourMarker(BuildContext context, _MappedNeighbour mapped) { + final quality = linkQualityLabel(null, mapped.neighbour.snrDb); + final qualityColor = linkQualityColor(quality); + final ageLabel = _formatAge(context, mapped.neighbour); + final signalLabel = mapped.neighbour.snrDb == null + ? quality + : '$quality • ${mapped.neighbour.snrDb!.toStringAsFixed(1)} dB'; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 146), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: qualityColor.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(999), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + signalLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 10, + ), + ), + ), + const SizedBox(height: 4), + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all(color: qualityColor, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + mapped.contact.isRepeater + ? Icons.router_outlined + : Icons.location_on_outlined, + color: qualityColor, + size: 16, + ), + ), + const SizedBox(height: 4), + Container( + constraints: const BoxConstraints(maxWidth: 148), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.78), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + mapped.contact.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 11, + ), + ), + const SizedBox(height: 2), + Text( + ageLabel, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white70, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildRouteMap( + BuildContext context, { + required LatLng repeaterLocation, + required List<_MappedNeighbour> mappedNeighbours, + }) { + final points = [ + repeaterLocation, + ...mappedNeighbours.map((mapped) => mapped.location), + ]; + final colorScheme = Theme.of(context).colorScheme; + + return ClipRRect( + borderRadius: BorderRadius.circular(18), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: colorScheme.outlineVariant), + ), + child: flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCameraFit: flutter_map.CameraFit.bounds( + bounds: flutter_map.LatLngBounds.fromPoints(points), + padding: const EdgeInsets.all(42), + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.meshcore.sar', + ), + flutter_map.PolylineLayer( + polylines: mappedNeighbours.map((mapped) { + final quality = linkQualityLabel(null, mapped.neighbour.snrDb); + final qualityColor = linkQualityColor(quality); + return flutter_map.Polyline( + points: [repeaterLocation, mapped.location], + color: qualityColor.withValues(alpha: 0.9), + strokeWidth: 4, + borderColor: Colors.white.withValues(alpha: 0.7), + borderStrokeWidth: 1.5, + ); + }).toList(), + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: repeaterLocation, + width: 150, + height: 74, + child: _buildRepeaterMarker(context), + ), + ...mappedNeighbours.map( + (mapped) => flutter_map.Marker( + point: mapped.location, + width: 164, + height: 112, + child: _buildNeighbourMarker(context, mapped), + ), + ), + ], + ), + ], + ), + ), + ); } @override Widget build(BuildContext context) { + final repeaterDisplayLocation = widget.contact.displayLocation; + final repeaterLocation = repeaterDisplayLocation == null + ? null + : LatLng( + repeaterDisplayLocation.latitude, + repeaterDisplayLocation.longitude, + ); + final mappedNeighbours = _mappedNeighbours(); + final missingLocations = _neighbours.length - mappedNeighbours.length; + return SafeArea( child: SizedBox( - height: MediaQuery.of(context).size.height * 0.55, + height: MediaQuery.of(context).size.height * 0.72, child: Column( children: [ const SizedBox(height: 12), @@ -1410,26 +1706,68 @@ class _NeighboursSheetState extends State<_NeighboursSheet> { ) : _neighbours.isEmpty ? const Center(child: Text('No neighbours found')) - : ListView.builder( - padding: const EdgeInsets.only(bottom: 16), - itemCount: _neighbours.length, - itemBuilder: (context, index) { - final n = _neighbours[index]; - final name = _resolveNeighbourName(n.publicKeyHex); - final parts = [ - if (n.snrDb != null) - 'SNR ${n.snrDb!.toStringAsFixed(1)} dB', - if (n.lastSeenAt != null) _formatAge(n.lastSeenAt!), - if (n.lastSeenMs != null) '${n.lastSeenMs}ms ago', - ]; - return ListTile( - leading: const Icon(Icons.router_outlined), - title: Text(name), - subtitle: parts.isNotEmpty - ? Text(parts.join(' • ')) - : null, - ); - }, + : Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildSummaryChip( + context, + icon: Icons.route_outlined, + label: + '${_neighbours.length} neighbour${_neighbours.length == 1 ? '' : 's'}', + ), + _buildSummaryChip( + context, + icon: Icons.map_outlined, + label: '${mappedNeighbours.length} on map', + ), + if (missingLocations > 0) + _buildSummaryChip( + context, + icon: Icons.location_off_outlined, + label: '$missingLocations without GPS', + ), + ], + ), + const SizedBox(height: 12), + Expanded( + child: repeaterLocation == null + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + ), + child: Text( + '${widget.contact.displayName} has no saved location, so neighbour routes cannot be drawn on the map yet.', + textAlign: TextAlign.center, + ), + ), + ) + : mappedNeighbours.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24, + ), + child: Text( + 'Neighbours responded, but no geolocated contacts matched saved nodes to plot. Recent neighbour: ${_resolveNeighbourName(_neighbours.first.publicKeyHex)} • ${_formatAge(context, _neighbours.first)}', + textAlign: TextAlign.center, + ), + ), + ) + : _buildRouteMap( + context, + repeaterLocation: repeaterLocation, + mappedNeighbours: mappedNeighbours, + ), + ), + ], + ), ), ), ], @@ -1452,3 +1790,15 @@ class _Neighbour { this.snrDb, }); } + +class _MappedNeighbour { + final _Neighbour neighbour; + final Contact contact; + final LatLng location; + + const _MappedNeighbour({ + required this.neighbour, + required this.contact, + required this.location, + }); +} diff --git a/lib/widgets/messages/message_bubble_signal.dart b/lib/widgets/messages/message_bubble_signal.dart index 168a42c..364de6c 100644 --- a/lib/widgets/messages/message_bubble_signal.dart +++ b/lib/widgets/messages/message_bubble_signal.dart @@ -6,6 +6,7 @@ import '../../models/message_route_metadata.dart'; import '../../models/path_selection.dart'; import '../../models/message_reception_details.dart'; import '../../providers/messages_provider.dart'; +import '../../utils/link_quality.dart'; IconData getDeliveryStatusIcon(MessageDeliveryStatus status) { switch (status) { @@ -382,30 +383,3 @@ Widget _signalCapsule( ), ); } - -int rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5); - -int snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5); - -String linkQualityLabel(int? rssiDbm, double? snrDb) { - var score = 0; - if (rssiDbm != null) score += rssiScore(rssiDbm); - if (snrDb != null) score += snrScore(snrDb); - if (score >= 8) return 'Excellent'; - if (score >= 6) return 'Good'; - if (score >= 4) return 'Fair'; - return 'Weak'; -} - -Color linkQualityColor(String quality) { - switch (quality) { - case 'Excellent': - return Colors.green; - case 'Good': - return Colors.lightGreen; - case 'Fair': - return Colors.orange; - default: - return Colors.redAccent; - } -} diff --git a/test/services/profile_manager_test.dart b/test/services/profile_manager_test.dart index e34ad29..cd648ec 100644 --- a/test/services/profile_manager_test.dart +++ b/test/services/profile_manager_test.dart @@ -10,40 +10,28 @@ void main() { setUp(() { SharedPreferences.setMockInitialValues({}); ProfileStorageScope.setScope( - profilesEnabled: false, + profilesEnabled: true, activeProfileId: ConfigProfile.defaultProfileId, ); }); group('ProfileManager', () { - test( - 'hides the built-in default profile until profiles are enabled', - () async { - final manager = ProfileManager(); + test('shows the built-in default profile on a fresh install', () async { + final manager = ProfileManager(); - await manager.initialize(); + await manager.initialize(); - expect(manager.activeProfileId, ConfigProfile.defaultProfileId); - expect(manager.visibleProfiles, isEmpty); - expect( - manager.getProfile(ConfigProfile.defaultProfileId)?.isDefault, - isTrue, - ); - expect(ProfileStorageScope.profilesEnabled, isFalse); - expect(ProfileStorageScope.effectiveNamespace, isNull); - - await manager.setProfilesEnabled(true); - - expect(manager.visibleProfiles, hasLength(1)); - expect( - manager.visibleProfiles.single.id, - ConfigProfile.defaultProfileId, - ); - expect(manager.visibleProfiles.single.name, 'Default'); - expect(ProfileStorageScope.profilesEnabled, isTrue); - expect(ProfileStorageScope.effectiveNamespace, isNull); - }, - ); + expect(manager.activeProfileId, ConfigProfile.defaultProfileId); + expect(manager.visibleProfiles, hasLength(1)); + expect( + manager.getProfile(ConfigProfile.defaultProfileId)?.isDefault, + isTrue, + ); + expect(ProfileStorageScope.profilesEnabled, isTrue); + expect(ProfileStorageScope.effectiveNamespace, isNull); + expect(manager.visibleProfiles.single.name, 'Default'); + expect(ProfileStorageScope.effectiveNamespace, isNull); + }); test( 'persists custom profiles and restores scoped active profile state', @@ -118,6 +106,8 @@ void main() { reloaded.profileIdForDevice('pk:device-c'), ConfigProfile.defaultProfileId, ); + expect(reloaded.hasProfileForDevice('pk:device-a'), isTrue); + expect(reloaded.hasProfileForDevice('pk:device-c'), isFalse); }); }); } diff --git a/test/services/profile_workspace_coordinator_test.dart b/test/services/profile_workspace_coordinator_test.dart index 701eb0e..d8ba9eb 100644 --- a/test/services/profile_workspace_coordinator_test.dart +++ b/test/services/profile_workspace_coordinator_test.dart @@ -217,6 +217,40 @@ void main() { expect(manager.activeProfileId, alpha.id); expect(connectionProvider.disconnectCallCount, 0); }); + + test( + 'syncActiveProfileForCurrentDevice creates a profile for a new device', + () async { + final manager = ProfileManager(); + await manager.initialize(); + await manager.setProfilesEnabled(true); + + final connectionProvider = _FakeConnectionProvider( + deviceInfo: DeviceInfo( + deviceId: 'ble-77', + deviceName: 'MeshCore-Field Unit', + selfName: 'Field Unit', + publicKey: Uint8List.fromList([7, 7, 7, 7]), + ), + ); + final coordinator = _buildCoordinator( + profileManager: manager, + connectionProvider: connectionProvider, + ); + + await coordinator.syncActiveProfileForCurrentDevice(); + + expect(manager.activeProfileId, isNot(ConfigProfile.defaultProfileId)); + final profile = manager.getProfile(manager.activeProfileId); + expect(profile, isNotNull); + expect(profile!.name, 'Device Field Unit'); + expect(manager.hasProfileForDevice('pk:07070707'), isTrue); + expect( + manager.profileIdForDevice('pk:07070707'), + manager.activeProfileId, + ); + }, + ); }); } @@ -300,7 +334,6 @@ class _FakeDeviceConfigApplicator extends DeviceConfigApplicator { class _FakeConnectionProvider implements ConnectionProvider { _FakeConnectionProvider({ DeviceInfo? deviceInfo, - this.connectionMode = ConnectionMode.ble, }) : deviceInfo = deviceInfo ?? DeviceInfo(); int disconnectCallCount = 0; @@ -309,7 +342,7 @@ class _FakeConnectionProvider implements ConnectionProvider { final DeviceInfo deviceInfo; @override - final ConnectionMode connectionMode; + ConnectionMode get connectionMode => ConnectionMode.ble; @override Future disconnect() async { diff --git a/test/utils/link_quality_test.dart b/test/utils/link_quality_test.dart new file mode 100644 index 0000000..6858a73 --- /dev/null +++ b/test/utils/link_quality_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/utils/link_quality.dart'; + +void main() { + group('linkQualityLabel', () { + test('classifies SNR-only values without forcing them to weak', () { + expect(linkQualityLabel(null, 12.0), 'Excellent'); + expect(linkQualityLabel(null, 6.0), 'Good'); + expect(linkQualityLabel(null, 1.0), 'Fair'); + expect(linkQualityLabel(null, -6.0), 'Weak'); + }); + + test('classifies RSSI-only values using direct thresholds', () { + expect(linkQualityLabel(-58, null), 'Excellent'); + expect(linkQualityLabel(-68, null), 'Good'); + expect(linkQualityLabel(-78, null), 'Fair'); + expect(linkQualityLabel(-92, null), 'Weak'); + }); + + test('averages mixed metrics when both are available', () { + expect(linkQualityLabel(-72, 11.0), 'Good'); + expect(linkQualityLabel(-85, 7.0), 'Fair'); + }); + }); +}