mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 08:20:36 +00:00
fix: Improve message signal chip behavior
This commit is contained in:
@@ -224,6 +224,77 @@ class ConnectionProvider with ChangeNotifier {
|
||||
_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].
|
||||
/// 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<bool> 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<void> 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<bool> 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<void> 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');
|
||||
|
||||
@@ -943,19 +943,35 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
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<HomeScreen>
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<void> setActiveProfileIdForDevice(
|
||||
String id, {
|
||||
String? deviceKey,
|
||||
|
||||
@@ -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<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(
|
||||
deviceInfo: connectionProvider.deviceInfo,
|
||||
connectionMode: connectionProvider.connectionMode,
|
||||
|
||||
@@ -5,7 +5,7 @@ class ProfilesFeatureService {
|
||||
|
||||
static Future<bool> isEnabled() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(enabledKey) ?? false;
|
||||
return prefs.getBool(enabledKey) ?? true;
|
||||
}
|
||||
|
||||
static Future<void> 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<void> bootstrap({
|
||||
|
||||
54
lib/utils/link_quality.dart
Normal file
54
lib/utils/link_quality.dart
Normal 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
@@ -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<void> _fetchNeighbours() async {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
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<ContactsProvider>();
|
||||
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 = <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
|
||||
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 = <String>[
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> disconnect() async {
|
||||
|
||||
25
test/utils/link_quality_test.dart
Normal file
25
test/utils/link_quality_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user