fix: Improve message signal chip behavior

This commit is contained in:
Janez T
2026-03-18 14:29:33 +01:00
parent 67d8e1c906
commit 140088fe2f
12 changed files with 1162 additions and 634 deletions

View File

@@ -224,6 +224,77 @@ class ConnectionProvider with ChangeNotifier {
_wireServiceCallbacks(_bleService); _wireServiceCallbacks(_bleService);
} }
Future<void> _prepareForConnectionSwitch(ConnectionMode nextMode) async {
if (_isScanning) {
debugPrint('🔵 [Provider] Stopping active scan before connect()');
await stopScan();
}
await _disconnectInactiveTransports(nextMode);
_error = null;
_supportsAutoaddConfig = null;
_resetSyncState();
}
Future<void> _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<void> _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]. /// Wire all shared event callbacks onto [service].
/// Called for both BLE and TCP services so the provider handles events /// Called for both BLE and TCP services so the provider handles events
/// identically regardless of transport. /// identically regardless of transport.
@@ -629,27 +700,15 @@ class ConnectionProvider with ChangeNotifier {
debugPrint( debugPrint(
'🔵 [Provider] connect() called for device: ${device.platformName}', '🔵 [Provider] connect() called for device: ${device.platformName}',
); );
await _prepareForConnectionSwitch(ConnectionMode.ble);
if (_isScanning) { _beginConnectionAttempt(
debugPrint('🔵 [Provider] Stopping active scan before connect()'); mode: ConnectionMode.ble,
await stopScan();
}
// Ensure we route commands to BLE, not a stale TCP service.
_connectionMode = ConnectionMode.ble;
_deviceInfo = _deviceInfo.copyWith(
deviceId: device.remoteId.toString(), deviceId: device.remoteId.toString(),
deviceName: device.platformName.isNotEmpty deviceName: device.platformName.isNotEmpty
? device.platformName ? device.platformName
: 'Unknown', : 'Unknown',
connectionState: ConnectionState.connecting,
); );
_error = null;
_supportsAutoaddConfig = null;
_resetSyncState();
debugPrint('✅ [Provider] Device info updated to connecting state'); debugPrint('✅ [Provider] Device info updated to connecting state');
notifyListeners();
debugPrint('🔵 [Provider] Calling BLE service connect()...'); debugPrint('🔵 [Provider] Calling BLE service connect()...');
final success = await _bleService.connect(device); final success = await _bleService.connect(device);
@@ -669,23 +728,18 @@ class ConnectionProvider with ChangeNotifier {
/// Connect to a MeshCore device over TCP/WiFi (port 5000) /// Connect to a MeshCore device over TCP/WiFi (port 5000)
Future<bool> connectTcp(String host, int port) async { Future<bool> connectTcp(String host, int port) async {
debugPrint('🌐 [Provider] connectTcp() $host:$port'); debugPrint('🌐 [Provider] connectTcp() $host:$port');
await _prepareForConnectionSwitch(ConnectionMode.tcp);
_tcpHost = host;
_deviceInfo = _deviceInfo.copyWith(
deviceId: '$host:$port',
deviceName: host,
connectionState: ConnectionState.connecting,
);
_error = null;
_supportsAutoaddConfig = null;
notifyListeners();
// Create fresh TCP service and wire its callbacks // Create fresh TCP service and wire its callbacks
_tcpService?.dispose(); await _disposeTcpService();
_tcpService = MeshCoreTcpService(); _tcpService = MeshCoreTcpService();
_wireServiceCallbacks(_tcpService!); _wireServiceCallbacks(_tcpService!);
_beginConnectionAttempt(
_connectionMode = ConnectionMode.tcp; mode: ConnectionMode.tcp,
deviceId: '$host:$port',
deviceName: host,
tcpHost: host,
);
final success = await _tcpService!.connect(host, port); final success = await _tcpService!.connect(host, port);
if (!success) { if (!success) {
@@ -699,21 +753,8 @@ class ConnectionProvider with ChangeNotifier {
/// Disconnect from TCP/WiFi device /// Disconnect from TCP/WiFi device
Future<void> disconnectTcp() async { Future<void> disconnectTcp() async {
if (_tcpService != null) { await _disposeTcpService();
await _tcpService!.disconnect(); _resetConnectionSession();
_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();
} }
/// Connect via USB serial using a pre-configured [MeshCoreSerialService]. /// 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. /// After this call succeeds, [service.markConnected()] has already run.
Future<bool> connectSerial(MeshCoreSerialService service) async { Future<bool> connectSerial(MeshCoreSerialService service) async {
debugPrint('🔌 [Provider] connectSerial()'); debugPrint('🔌 [Provider] connectSerial()');
await _prepareForConnectionSwitch(ConnectionMode.usb);
_deviceInfo = _deviceInfo.copyWith( _disposeSerialService();
deviceId: 'usb',
deviceName: 'USB Companion',
connectionState: ConnectionState.connecting,
);
_error = null;
_supportsAutoaddConfig = null;
notifyListeners();
_serialService?.dispose();
_serialService = service; _serialService = service;
_wireServiceCallbacks(_serialService!); _wireServiceCallbacks(_serialService!);
_connectionMode = ConnectionMode.usb; _beginConnectionAttempt(
mode: ConnectionMode.usb,
deviceId: 'usb',
deviceName: 'USB Companion',
);
// markConnected() should already have been called by the transport. // markConnected() should already have been called by the transport.
// If it hasn't, the service won't be connected yet. // If it hasn't, the service won't be connected yet.
@@ -752,18 +788,8 @@ class ConnectionProvider with ChangeNotifier {
/// Disconnect from USB serial device. /// Disconnect from USB serial device.
Future<void> disconnectSerial() async { Future<void> disconnectSerial() async {
_serialService?.markDisconnected(); _disposeSerialService();
_serialService?.dispose(); _resetConnectionSession();
_serialService = null;
_connectionMode = ConnectionMode.ble;
_supportsAutoaddConfig = null;
_resetSyncState();
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
_roomLoginManager.clearRoomLoginStates();
_pingTracker.clearAll();
_pendingSendOperations.clear();
_messageDeliveryTracker.clearTracking();
notifyListeners();
} }
/// Disconnect from device /// Disconnect from device
@@ -783,15 +809,7 @@ class ConnectionProvider with ChangeNotifier {
} }
await _bleService.disconnect(); await _bleService.disconnect();
_resetConnectionSession();
_supportsAutoaddConfig = null;
_resetSyncState();
_deviceInfo = DeviceInfo(connectionState: ConnectionState.disconnected);
_roomLoginManager.clearRoomLoginStates();
_pingTracker.clearAll();
_pendingSendOperations.clear();
_messageDeliveryTracker.clearTracking();
notifyListeners();
} }
/// Reset message sync state so the next connect/reconnect can sync cleanly. /// 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; if (!_activeService.isConnected) return null;
try { try {
final frame = await _activeService.exportContact(publicKey); final frame = await _activeService.exportContact(publicKey);
final hex = frame final hex = frame.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
return 'meshcore://$hex'; return 'meshcore://$hex';
} catch (e) { } catch (e) {
debugPrint('⚠️ [Provider] exportContact failed: $e'); debugPrint('⚠️ [Provider] exportContact failed: $e');

View File

@@ -943,19 +943,35 @@ class _HomeScreenState extends State<HomeScreen>
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp; final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
if (!isConnected) { if (!isConnected) {
// Disconnected state: show connect button final buttonLabel = provider.isReconnecting
? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}'
: AppLocalizations.of(context)!.connect;
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Column(
AppLocalizations.of(context)!.appTitle, crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle( mainAxisSize: MainAxisSize.min,
fontSize: 20, children: [
fontWeight: FontWeight.bold, 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 onPressed: provider.isReconnecting
? null ? null
: () => _showConnectionDialog(context), : () => _showConnectionDialog(context),
@@ -966,22 +982,19 @@ class _HomeScreenState extends State<HomeScreen>
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
Colors.black54, Colors.white70,
), ),
), ),
) )
: Icon(Icons.bluetooth, size: 18), : const Icon(Icons.add_link_rounded, size: 18),
label: Text( label: Text(buttonLabel),
provider.isReconnecting style: FilledButton.styleFrom(
? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}' padding: const EdgeInsets.symmetric(
: AppLocalizations.of(context)!.connect, horizontal: 16,
), vertical: 12,
style: ElevatedButton.styleFrom( ),
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(16),
), ),
), ),
), ),

View File

@@ -116,6 +116,13 @@ class ProfileManager with ChangeNotifier {
return _deviceProfileDefaults[deviceKey] ?? ConfigProfile.defaultProfileId; return _deviceProfileDefaults[deviceKey] ?? ConfigProfile.defaultProfileId;
} }
bool hasProfileForDevice(String? deviceKey) {
if (deviceKey == null || deviceKey.isEmpty) {
return false;
}
return _deviceProfileDefaults.containsKey(deviceKey);
}
Future<void> setActiveProfileIdForDevice( Future<void> setActiveProfileIdForDevice(
String id, { String id, {
String? deviceKey, String? deviceKey,

View File

@@ -79,14 +79,7 @@ class ProfileWorkspaceCoordinator {
activeProfileId: enabled ? profileManager.activeProfileId : 'default', activeProfileId: enabled ? profileManager.activeProfileId : 'default',
); );
if (enabled) { if (enabled) {
final deviceKey = _currentDeviceProfileKey; await _ensureProfileForCurrentDevice();
final targetProfileId = profileManager.profileIdForDevice(deviceKey);
if (targetProfileId != profileManager.activeProfileId) {
await profileManager.setActiveProfileIdForDevice(
targetProfileId,
deviceKey: deviceKey,
);
}
if (wasEnabled) { if (wasEnabled) {
await openProfile(profileManager.activeProfileId); await openProfile(profileManager.activeProfileId);
} else { } else {
@@ -294,13 +287,14 @@ class ProfileWorkspaceCoordinator {
return; return;
} }
final targetProfileId = profileManager.profileIdForDevice(deviceKey);
if (targetProfileId == profileManager.activeProfileId) {
return;
}
_isSyncingDeviceProfile = true; _isSyncingDeviceProfile = true;
try { try {
final profile = await _ensureProfileForCurrentDevice();
final targetProfileId = profile.id;
if (targetProfileId == profileManager.activeProfileId) {
return;
}
await _persistCurrentState(); await _persistCurrentState();
await profileManager.setActiveProfileIdForDevice( await profileManager.setActiveProfileIdForDevice(
targetProfileId, targetProfileId,
@@ -308,7 +302,6 @@ class ProfileWorkspaceCoordinator {
); );
await _switchRuntimeScope(targetProfileId); await _switchRuntimeScope(targetProfileId);
final profile = await resolveProfile(targetProfileId);
await _appConfigSnapshotService.apply( await _appConfigSnapshotService.apply(
profile.sections.appSettings, profile.sections.appSettings,
appProvider, appProvider,
@@ -323,6 +316,56 @@ class ProfileWorkspaceCoordinator {
} }
} }
Future<ConfigProfile> _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( String? get _currentDeviceProfileKey => ProfileDeviceKeyResolver.resolve(
deviceInfo: connectionProvider.deviceInfo, deviceInfo: connectionProvider.deviceInfo,
connectionMode: connectionProvider.connectionMode, connectionMode: connectionProvider.connectionMode,

View File

@@ -5,7 +5,7 @@ class ProfilesFeatureService {
static Future<bool> isEnabled() async { static Future<bool> isEnabled() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getBool(enabledKey) ?? false; return prefs.getBool(enabledKey) ?? true;
} }
static Future<void> setEnabled(bool enabled) async { static Future<void> setEnabled(bool enabled) async {
@@ -15,7 +15,7 @@ class ProfilesFeatureService {
} }
class ProfileStorageScope { class ProfileStorageScope {
static bool _profilesEnabled = false; static bool _profilesEnabled = true;
static String _activeProfileId = 'default'; static String _activeProfileId = 'default';
static Future<void> bootstrap({ static Future<void> bootstrap({

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
@@ -16,6 +17,8 @@ import 'contact_trace_sheet.dart';
import 'room_login_sheet.dart'; import 'room_login_sheet.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
import '../sensors/sensor_telemetry_card.dart'; import '../sensors/sensor_telemetry_card.dart';
import '../../utils/link_quality.dart';
import '../../utils/time_ago_extensions.dart';
import '../../utils/toast_logger.dart'; import '../../utils/toast_logger.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -1259,6 +1262,7 @@ class _NeighboursSheetState extends State<_NeighboursSheet> {
Future<void> _fetchNeighbours() async { Future<void> _fetchNeighbours() async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final previousOnMessageReceived = connectionProvider.onMessageReceived;
String? responseText; String? responseText;
void onMessage(message) { void onMessage(message) {
@@ -1270,9 +1274,12 @@ class _NeighboursSheetState extends State<_NeighboursSheet> {
} }
} }
connectionProvider.onMessageReceived = (message) { void sheetListener(message) {
previousOnMessageReceived?.call(message);
onMessage(message); onMessage(message);
}; }
connectionProvider.onMessageReceived = sheetListener;
try { try {
await connectionProvider.sendTextMessage( await connectionProvider.sendTextMessage(
@@ -1343,32 +1350,321 @@ class _NeighboursSheetState extends State<_NeighboursSheet> {
_loading = false; _loading = false;
_error = 'Failed: $e'; _error = 'Failed: $e';
}); });
} finally {
if (identical(connectionProvider.onMessageReceived, sheetListener)) {
connectionProvider.onMessageReceived = previousOnMessageReceived;
}
} }
} }
String _resolveNeighbourName(String keyHex) { Contact? _resolveNeighbourContact(String keyHex) {
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
for (final contact in contactsProvider.contacts) { for (final contact in contactsProvider.contacts) {
if (contact.publicKeyHex.toLowerCase().startsWith(keyHex.toLowerCase())) { 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; return keyHex.length > 12 ? '${keyHex.substring(0, 12)}...' : keyHex;
} }
String _formatAge(DateTime when) { String _formatAge(BuildContext context, _Neighbour neighbour) {
final diff = DateTime.now().difference(when); if (neighbour.lastSeenAt != null) {
if (diff.inMinutes < 1) return 'just now'; return DateTime.now()
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; .difference(neighbour.lastSeenAt!)
if (diff.inHours < 24) return '${diff.inHours}h ago'; .toLocalizedTimeAgoWithSeconds(context);
return '${diff.inDays}d ago'; }
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 = <LatLng>[
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 @override
Widget build(BuildContext context) { 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( return SafeArea(
child: SizedBox( child: SizedBox(
height: MediaQuery.of(context).size.height * 0.55, height: MediaQuery.of(context).size.height * 0.72,
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -1410,26 +1706,68 @@ class _NeighboursSheetState extends State<_NeighboursSheet> {
) )
: _neighbours.isEmpty : _neighbours.isEmpty
? const Center(child: Text('No neighbours found')) ? const Center(child: Text('No neighbours found'))
: ListView.builder( : Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
itemCount: _neighbours.length, child: Column(
itemBuilder: (context, index) { crossAxisAlignment: CrossAxisAlignment.start,
final n = _neighbours[index]; children: [
final name = _resolveNeighbourName(n.publicKeyHex); Wrap(
final parts = <String>[ spacing: 8,
if (n.snrDb != null) runSpacing: 8,
'SNR ${n.snrDb!.toStringAsFixed(1)} dB', children: [
if (n.lastSeenAt != null) _formatAge(n.lastSeenAt!), _buildSummaryChip(
if (n.lastSeenMs != null) '${n.lastSeenMs}ms ago', context,
]; icon: Icons.route_outlined,
return ListTile( label:
leading: const Icon(Icons.router_outlined), '${_neighbours.length} neighbour${_neighbours.length == 1 ? '' : 's'}',
title: Text(name), ),
subtitle: parts.isNotEmpty _buildSummaryChip(
? Text(parts.join('')) context,
: null, 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, this.snrDb,
}); });
} }
class _MappedNeighbour {
final _Neighbour neighbour;
final Contact contact;
final LatLng location;
const _MappedNeighbour({
required this.neighbour,
required this.contact,
required this.location,
});
}

View File

@@ -6,6 +6,7 @@ import '../../models/message_route_metadata.dart';
import '../../models/path_selection.dart'; import '../../models/path_selection.dart';
import '../../models/message_reception_details.dart'; import '../../models/message_reception_details.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
import '../../utils/link_quality.dart';
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) { IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
switch (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;
}
}

View File

@@ -10,40 +10,28 @@ void main() {
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
ProfileStorageScope.setScope( ProfileStorageScope.setScope(
profilesEnabled: false, profilesEnabled: true,
activeProfileId: ConfigProfile.defaultProfileId, activeProfileId: ConfigProfile.defaultProfileId,
); );
}); });
group('ProfileManager', () { group('ProfileManager', () {
test( test('shows the built-in default profile on a fresh install', () async {
'hides the built-in default profile until profiles are enabled', final manager = ProfileManager();
() async {
final manager = ProfileManager();
await manager.initialize(); await manager.initialize();
expect(manager.activeProfileId, ConfigProfile.defaultProfileId); expect(manager.activeProfileId, ConfigProfile.defaultProfileId);
expect(manager.visibleProfiles, isEmpty); expect(manager.visibleProfiles, hasLength(1));
expect( expect(
manager.getProfile(ConfigProfile.defaultProfileId)?.isDefault, manager.getProfile(ConfigProfile.defaultProfileId)?.isDefault,
isTrue, isTrue,
); );
expect(ProfileStorageScope.profilesEnabled, isFalse); expect(ProfileStorageScope.profilesEnabled, isTrue);
expect(ProfileStorageScope.effectiveNamespace, isNull); expect(ProfileStorageScope.effectiveNamespace, isNull);
expect(manager.visibleProfiles.single.name, 'Default');
await manager.setProfilesEnabled(true); expect(ProfileStorageScope.effectiveNamespace, isNull);
});
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);
},
);
test( test(
'persists custom profiles and restores scoped active profile state', 'persists custom profiles and restores scoped active profile state',
@@ -118,6 +106,8 @@ void main() {
reloaded.profileIdForDevice('pk:device-c'), reloaded.profileIdForDevice('pk:device-c'),
ConfigProfile.defaultProfileId, ConfigProfile.defaultProfileId,
); );
expect(reloaded.hasProfileForDevice('pk:device-a'), isTrue);
expect(reloaded.hasProfileForDevice('pk:device-c'), isFalse);
}); });
}); });
} }

View File

@@ -217,6 +217,40 @@ void main() {
expect(manager.activeProfileId, alpha.id); expect(manager.activeProfileId, alpha.id);
expect(connectionProvider.disconnectCallCount, 0); 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 { class _FakeConnectionProvider implements ConnectionProvider {
_FakeConnectionProvider({ _FakeConnectionProvider({
DeviceInfo? deviceInfo, DeviceInfo? deviceInfo,
this.connectionMode = ConnectionMode.ble,
}) : deviceInfo = deviceInfo ?? DeviceInfo(); }) : deviceInfo = deviceInfo ?? DeviceInfo();
int disconnectCallCount = 0; int disconnectCallCount = 0;
@@ -309,7 +342,7 @@ class _FakeConnectionProvider implements ConnectionProvider {
final DeviceInfo deviceInfo; final DeviceInfo deviceInfo;
@override @override
final ConnectionMode connectionMode; ConnectionMode get connectionMode => ConnectionMode.ble;
@override @override
Future<void> disconnect() async { Future<void> disconnect() async {

View File

@@ -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');
});
});
}