Compare commits

...

8 Commits

Author SHA1 Message Date
Janez T
f286b0afd1 fix: Show signal chips on double tap 2026-03-18 16:59:02 +01:00
Janez T
68ec72534d fix: Sensor tiles — move channel label to bottom-right, smaller
Channel chip (ch2) was inline with the label, stealing horizontal
space and causing label truncation ('Temper...'). Now positioned as
a tiny 9px label anchored bottom-right of the tile, giving the full
width to the metric label and value.
2026-03-18 16:58:48 +01:00
Janez T
6af8b62d9b fix: Show message signal chips by default #123 2026-03-18 16:38:19 +01:00
Janez T
01fbb7de6c fix: Profile duplication on device connect
DeviceKey resolver was falling back to deviceId when publicKey wasn't
yet available during early connection. This created two different keys
(id:xxx then pk:yyy) for the same device, causing a new profile to be
created each time.

Now returns null until publicKey is available, so profile sync waits
for the stable identifier.
2026-03-18 14:45:55 +01:00
Janez T
9303d24b69 fix: Remove duplicate sensor cards for temperature, humidity, pressure
Core telemetry fields (temperature, humidity, pressure, voltage) were
shown twice: once from the ContactTelemetry top-level fields and again
from extraSensorData channel-keyed entries (e.g. temperature_2).

Now skips extraSensorData keys that are already represented by core
telemetry cards.
2026-03-18 14:37:06 +01:00
Janez T
140088fe2f fix: Improve message signal chip behavior 2026-03-18 14:29:33 +01:00
Janez T
67d8e1c906 fix: Deduplicate telemetry from 0x8B + 0x8C double delivery
When firmware sends telemetry via both pushTelemetryResponse (0x8B)
and pushBinaryResponse (0x8C), the same LPP data was parsed and
applied twice, causing duplicate sensor entries.

Added hash-based dedup: same payload for same contact within 2s is
skipped. Also added minimum length check for binary responses.
2026-03-18 14:24:38 +01:00
Janez T
29b75a3155 feat: Save profile defaults per device 2026-03-18 13:34:50 +01:00
19 changed files with 1794 additions and 723 deletions

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00024"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.00103">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.554978"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.372199">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="100.17557"> <testcase classname="fastlane.lanes" name="2: build_app" time="121.394124">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="264.49426"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="365.414242">
</testcase> </testcase>

View File

@@ -1338,11 +1338,14 @@ class AppProvider with ChangeNotifier {
return; return;
} }
debugPrint( debugPrint(
'📊 [AppProvider] Binary response (0x8C) received - updating contact telemetry', '📊 [AppProvider] Binary response (0x8C tag=$tag) received',
); );
// Binary response tag 0 = telemetry data (Cayenne LPP format) // Binary responses carry Cayenne LPP telemetry data.
// Other tags may be used for different data types in the future // The data starts with a channel byte — valid LPP always has at least
// 3 bytes (channel + type + value). Skip clearly non-telemetry payloads.
if (responseData.length >= 3) {
contactsProvider.updateTelemetry(publicKeyPrefix, responseData); contactsProvider.updateTelemetry(publicKeyPrefix, responseData);
}
}; };
// When raw binary data is received (PUSH_CODE_RAW_DATA 0x84) // When raw binary data is received (PUSH_CODE_RAW_DATA 0x84)

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

@@ -926,6 +926,10 @@ class ContactsProvider with ChangeNotifier {
} }
/// Update contact telemetry /// Update contact telemetry
// Dedup: track last telemetry data hash per contact to avoid processing
// the same payload twice (0x8B + 0x8C can both fire for the same data).
final Map<String, (int, DateTime)> _lastTelemetryHash = {};
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) { void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
debugPrint('📊 [ContactsProvider] updateTelemetry() called'); debugPrint('📊 [ContactsProvider] updateTelemetry() called');
debugPrint( debugPrint(
@@ -933,6 +937,21 @@ class ContactsProvider with ChangeNotifier {
); );
debugPrint(' LPP data size: ${lppData.length} bytes'); debugPrint(' LPP data size: ${lppData.length} bytes');
// Deduplicate: same data for same contact within 2 seconds = skip
final prefixHex = publicKeyPrefix
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final dataHash = lppData.fold<int>(0, (h, b) => h * 31 + b);
final now = DateTime.now();
final last = _lastTelemetryHash[prefixHex];
if (last != null &&
last.$1 == dataHash &&
now.difference(last.$2).inSeconds < 2) {
debugPrint(' ⏭️ Duplicate telemetry payload, skipping');
return;
}
_lastTelemetryHash[prefixHex] = (dataHash, now);
// Find contact by public key prefix // Find contact by public key prefix
final contact = _findContactByPrefix(publicKeyPrefix); final contact = _findContactByPrefix(publicKeyPrefix);
if (contact == null) { if (contact == null) {

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -29,7 +31,9 @@ import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart'; import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart'; import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart'; import '../services/mesh_map_nodes_service.dart';
import '../services/profile_device_key_resolver.dart';
import '../services/profile_manager.dart'; import '../services/profile_manager.dart';
import '../services/profile_workspace_coordinator.dart';
import '../services/profiles_feature_service.dart'; import '../services/profiles_feature_service.dart';
enum _HomeTab { messages, contacts, sensors, map } enum _HomeTab { messages, contacts, sensors, map }
@@ -60,6 +64,7 @@ class _HomeScreenState extends State<HomeScreen>
with TickerProviderStateMixin, WidgetsBindingObserver { with TickerProviderStateMixin, WidgetsBindingObserver {
late TabController _tabController; late TabController _tabController;
late final AppProvider _appProvider; late final AppProvider _appProvider;
late final ConnectionProvider _connectionProvider;
int _currentIndex = 0; int _currentIndex = 0;
bool _isMapFullscreen = false; bool _isMapFullscreen = false;
bool _showRxTxIndicators = true; bool _showRxTxIndicators = true;
@@ -68,6 +73,7 @@ class _HomeScreenState extends State<HomeScreen>
bool _isContactsEnabled = true; bool _isContactsEnabled = true;
bool _isSensorsEnabled = false; bool _isSensorsEnabled = false;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed; AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _lastProfileDeviceKey;
List<_HomeTab> get _enabledTabs { List<_HomeTab> get _enabledTabs {
return [ return [
@@ -91,6 +97,8 @@ class _HomeScreenState extends State<HomeScreen>
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_appProvider = context.read<AppProvider>(); _appProvider = context.read<AppProvider>();
_connectionProvider = context.read<ConnectionProvider>();
_connectionProvider.addListener(_handleConnectionProviderChanged);
_isMapEnabled = _appProvider.isMapEnabled; _isMapEnabled = _appProvider.isMapEnabled;
_isContactsEnabled = _appProvider.isContactsEnabled; _isContactsEnabled = _appProvider.isContactsEnabled;
_isSensorsEnabled = _appProvider.isSensorsEnabled; _isSensorsEnabled = _appProvider.isSensorsEnabled;
@@ -108,6 +116,10 @@ class _HomeScreenState extends State<HomeScreen>
_showPermissionDialog(); _showPermissionDialog();
}); });
} }
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleConnectionProviderChanged();
});
} }
void _initTabController() { void _initTabController() {
@@ -223,6 +235,36 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
void _handleConnectionProviderChanged() {
final deviceKey = ProfileDeviceKeyResolver.resolve(
deviceInfo: _connectionProvider.deviceInfo,
connectionMode: _connectionProvider.connectionMode,
);
if (_lastProfileDeviceKey == deviceKey) {
return;
}
_lastProfileDeviceKey = deviceKey;
if (deviceKey == null || !mounted) {
return;
}
unawaited(
context
.read<ProfileWorkspaceCoordinator>()
.syncActiveProfileForCurrentDevice(),
);
}
@override
void dispose() {
_connectionProvider.removeListener(_handleConnectionProviderChanged);
WidgetsBinding.instance.removeObserver(this);
_appProvider.setFastLocationUiActive(false);
_appProvider.removeListener(_handleAppProviderChanged);
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
super.dispose();
}
void _openLiveTraffic(ConnectionProvider provider) { void _openLiveTraffic(ConnectionProvider provider) {
openLiveTrafficScreen(context, provider); openLiveTrafficScreen(context, provider);
} }
@@ -257,16 +299,6 @@ class _HomeScreenState extends State<HomeScreen>
}); });
} }
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_appProvider.setFastLocationUiActive(false);
_appProvider.removeListener(_handleAppProviderChanged);
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
super.dispose();
}
void _showPermissionDialog() { void _showPermissionDialog() {
if (!mounted) return; if (!mounted) return;
@@ -402,10 +434,7 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
Widget _buildMiniSignalBars({ Widget _buildMiniSignalBars({required int activeBars, required Color color}) {
required int activeBars,
required Color color,
}) {
final inactive = Colors.grey.withValues(alpha: 0.3); final inactive = Colors.grey.withValues(alpha: 0.3);
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -914,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(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
AppLocalizations.of(context)!.appTitle, AppLocalizations.of(context)!.appTitle,
style: const TextStyle( style: const TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, 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),
@@ -937,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),
), ),
), ),
), ),
@@ -1033,7 +1075,8 @@ class _HomeScreenState extends State<HomeScreen>
deviceInfo.signalRssi != null) ...[ deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
_buildMiniSignalBars( _buildMiniSignalBars(
activeBars: BatteryDisplayHelper.getSignalBars( activeBars:
BatteryDisplayHelper.getSignalBars(
deviceInfo.signalRssi!, deviceInfo.signalRssi!,
), ),
color: signalColor, color: signalColor,

View File

@@ -0,0 +1,21 @@
import '../models/device_info.dart';
class ProfileDeviceKeyResolver {
static String? resolve({
required DeviceInfo deviceInfo,
required ConnectionMode connectionMode,
}) {
// Always prefer the public key — it's the only truly stable identifier.
// Don't fall back to deviceId to avoid creating duplicate profiles when
// publicKey arrives late (after initial connection but before DeviceInfo).
final publicKey = deviceInfo.publicKey;
if (publicKey == null || publicKey.isEmpty) {
return null; // Wait until publicKey is available
}
final hex = publicKey
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return hex.isNotEmpty ? 'pk:$hex' : null;
}
}

View File

@@ -9,11 +9,14 @@ import 'profiles_feature_service.dart';
class ProfileManager with ChangeNotifier { class ProfileManager with ChangeNotifier {
static const String _profilesKey = 'profiles_library'; static const String _profilesKey = 'profiles_library';
static const String activeProfileIdKey = 'profiles_active_profile_id'; static const String activeProfileIdKey = 'profiles_active_profile_id';
static const String _deviceProfileDefaultsKey =
'profiles_device_active_profile_ids';
static const String _transferHistoryKey = 'profiles_transfer_history'; static const String _transferHistoryKey = 'profiles_transfer_history';
final List<ConfigProfile> _customProfiles = <ConfigProfile>[]; final List<ConfigProfile> _customProfiles = <ConfigProfile>[];
final List<ProfileTransferRecord> _transferHistory = final List<ProfileTransferRecord> _transferHistory =
<ProfileTransferRecord>[]; <ProfileTransferRecord>[];
final Map<String, String> _deviceProfileDefaults = <String, String>{};
bool _isInitialized = false; bool _isInitialized = false;
bool _profilesEnabled = false; bool _profilesEnabled = false;
String _activeProfileId = ConfigProfile.defaultProfileId; String _activeProfileId = ConfigProfile.defaultProfileId;
@@ -37,6 +40,19 @@ class ProfileManager with ChangeNotifier {
_activeProfileId = _activeProfileId =
prefs.getString(activeProfileIdKey) ?? ConfigProfile.defaultProfileId; prefs.getString(activeProfileIdKey) ?? ConfigProfile.defaultProfileId;
final deviceDefaultsJson = prefs.getString(_deviceProfileDefaultsKey);
if (deviceDefaultsJson != null && deviceDefaultsJson.isNotEmpty) {
final decoded = jsonDecode(deviceDefaultsJson);
if (decoded is Map<String, dynamic>) {
_deviceProfileDefaults
..clear()
..addAll(
decoded.map((key, value) => MapEntry(key, value?.toString() ?? ''))
..removeWhere((key, value) => value.isEmpty),
);
}
}
final profilesJson = prefs.getString(_profilesKey); final profilesJson = prefs.getString(_profilesKey);
if (profilesJson != null && profilesJson.isNotEmpty) { if (profilesJson != null && profilesJson.isNotEmpty) {
final decoded = jsonDecode(profilesJson) as List<dynamic>; final decoded = jsonDecode(profilesJson) as List<dynamic>;
@@ -90,9 +106,37 @@ class ProfileManager with ChangeNotifier {
} }
Future<void> setActiveProfileId(String id) async { Future<void> setActiveProfileId(String id) async {
await setActiveProfileIdForDevice(id);
}
String profileIdForDevice(String? deviceKey) {
if (deviceKey == null || deviceKey.isEmpty) {
return _activeProfileId;
}
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,
}) async {
_activeProfileId = id; _activeProfileId = id;
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(activeProfileIdKey, id); await prefs.setString(activeProfileIdKey, id);
if (deviceKey != null && deviceKey.isNotEmpty) {
_deviceProfileDefaults[deviceKey] = id;
await prefs.setString(
_deviceProfileDefaultsKey,
jsonEncode(_deviceProfileDefaults),
);
}
ProfileStorageScope.setScope( ProfileStorageScope.setScope(
profilesEnabled: _profilesEnabled, profilesEnabled: _profilesEnabled,
activeProfileId: _activeProfileId, activeProfileId: _activeProfileId,

View File

@@ -19,6 +19,7 @@ import 'contact_storage_service.dart';
import 'device_config_applicator.dart'; import 'device_config_applicator.dart';
import 'message_storage_service.dart'; import 'message_storage_service.dart';
import 'profile_manager.dart'; import 'profile_manager.dart';
import 'profile_device_key_resolver.dart';
import 'profiles_feature_service.dart'; import 'profiles_feature_service.dart';
import 'map_workspace_snapshot_service.dart'; import 'map_workspace_snapshot_service.dart';
@@ -63,6 +64,7 @@ class ProfileWorkspaceCoordinator {
final DeviceConfigApplicator _deviceConfigApplicator; final DeviceConfigApplicator _deviceConfigApplicator;
final MessageStorageService _messageStorageService; final MessageStorageService _messageStorageService;
final ContactStorageService _contactStorageService; final ContactStorageService _contactStorageService;
bool _isSyncingDeviceProfile = false;
Future<void> setProfilesEnabled(bool enabled) async { Future<void> setProfilesEnabled(bool enabled) async {
final wasEnabled = profileManager.profilesEnabled; final wasEnabled = profileManager.profilesEnabled;
@@ -77,6 +79,7 @@ class ProfileWorkspaceCoordinator {
activeProfileId: enabled ? profileManager.activeProfileId : 'default', activeProfileId: enabled ? profileManager.activeProfileId : 'default',
); );
if (enabled) { if (enabled) {
await _ensureProfileForCurrentDevice();
if (wasEnabled) { if (wasEnabled) {
await openProfile(profileManager.activeProfileId); await openProfile(profileManager.activeProfileId);
} else { } else {
@@ -181,9 +184,13 @@ class ProfileWorkspaceCoordinator {
} }
Future<void> openProfile(String profileId) async { Future<void> openProfile(String profileId) async {
final deviceKey = _currentDeviceProfileKey;
await _saveActiveCustomProfileSnapshot(); await _saveActiveCustomProfileSnapshot();
await connectionProvider.disconnect(); await connectionProvider.disconnect();
await profileManager.setActiveProfileId(profileId); await profileManager.setActiveProfileIdForDevice(
profileId,
deviceKey: deviceKey,
);
await _switchRuntimeScope(profileId); await _switchRuntimeScope(profileId);
final profile = await resolveProfile(profileId); final profile = await resolveProfile(profileId);
@@ -271,6 +278,99 @@ class ProfileWorkspaceCoordinator {
return imported; return imported;
} }
Future<void> syncActiveProfileForCurrentDevice() async {
if (!profileManager.profilesEnabled || _isSyncingDeviceProfile) {
return;
}
final deviceKey = _currentDeviceProfileKey;
if (deviceKey == null) {
return;
}
_isSyncingDeviceProfile = true;
try {
final profile = await _ensureProfileForCurrentDevice();
final targetProfileId = profile.id;
if (targetProfileId == profileManager.activeProfileId) {
return;
}
await _persistCurrentState();
await profileManager.setActiveProfileIdForDevice(
targetProfileId,
deviceKey: deviceKey,
);
await _switchRuntimeScope(targetProfileId);
await _appConfigSnapshotService.apply(
profile.sections.appSettings,
appProvider,
);
await _mapWorkspaceSnapshotService.apply(
profile.sections.mapWorkspace,
mapProvider: mapProvider,
drawingProvider: drawingProvider,
);
} finally {
_isSyncingDeviceProfile = false;
}
}
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,
);
Future<void> _switchRuntimeScope(String profileId) async { Future<void> _switchRuntimeScope(String profileId) async {
final runtimeProfilesEnabled = profileManager.profilesEnabled; final runtimeProfilesEnabled = profileManager.profilesEnabled;
ProfileStorageScope.setScope( ProfileStorageScope.setScope(

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

View File

@@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.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:meshcore_client/meshcore_client.dart' hide Contact;
import 'package:provider/provider.dart';
import 'package:usb_serial/usb_serial.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 '../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 /// Connection Dialog with tabs for BLE devices and Network servers
class ConnectionDialog extends StatefulWidget { class ConnectionDialog extends StatefulWidget {
@@ -25,10 +26,9 @@ class _ConnectionDialogState extends State<ConnectionDialog>
int _scannedCount = 0; int _scannedCount = 0;
int _totalToScan = 0; int _totalToScan = 0;
int _lastTabIndex = 0; int _lastTabIndex = 0;
String? String? _connectingToServerKey;
_connectingToServerKey; // Track which server is being connected to (ip:port) String? _connectingBleDeviceId;
// Named listener method for proper cleanup
void _onTabChanged() { void _onTabChanged() {
if (_tabController.index == _lastTabIndex) return; if (_tabController.index == _lastTabIndex) return;
_lastTabIndex = _tabController.index; _lastTabIndex = _tabController.index;
@@ -38,18 +38,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
if (_tabController.index == 1) { if (_tabController.index == 1) {
// Switched to network tab
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) { if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
// Load cached results
setState(() { setState(() {
_discoveredServers.addAll(_networkScanner.cachedServers); _discoveredServers.addAll(_networkScanner.cachedServers);
}); });
debugPrint(
'📦 [NetworkScanner] Loaded ${_discoveredServers.length} servers from cache',
);
} else if (!_networkScanner.isScanning && } else if (!_networkScanner.isScanning &&
!_networkScanner.hasCachedResults) { !_networkScanner.hasCachedResults) {
// No cache, start initial scan
_startNetworkScan(); _startNetworkScan();
} }
} }
@@ -64,35 +58,28 @@ class _ConnectionDialogState extends State<ConnectionDialog>
listen: false, 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((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
_refreshBleDevices(); _refreshBleDevices();
}); });
// Set up network scanner callbacks
_networkScanner.onServerDiscovered = (server) { _networkScanner.onServerDiscovered = (server) {
if (mounted) { if (!mounted) return;
setState(() { setState(() {
// Only add if not already in the list (deduplicate)
if (!_discoveredServers.contains(server)) { if (!_discoveredServers.contains(server)) {
_discoveredServers.add(server); _discoveredServers.add(server);
} }
}); });
}
}; };
_networkScanner.onProgressUpdate = (scanned, total) { _networkScanner.onProgressUpdate = (scanned, total) {
if (mounted) { if (!mounted) return;
setState(() { setState(() {
_scannedCount = scanned; _scannedCount = scanned;
_totalToScan = total; _totalToScan = total;
}); });
}
}; };
// Listen to tab changes using named method for proper cleanup
_tabController.addListener(_onTabChanged); _tabController.addListener(_onTabChanged);
} }
@@ -100,7 +87,6 @@ class _ConnectionDialogState extends State<ConnectionDialog>
void dispose() { void dispose() {
_connectionProvider.stopScan(); _connectionProvider.stopScan();
_networkScanner.stopScan(); _networkScanner.stopScan();
// Remove listener before disposing to prevent memory leaks
_tabController.removeListener(_onTabChanged); _tabController.removeListener(_onTabChanged);
_tabController.dispose(); _tabController.dispose();
super.dispose(); super.dispose();
@@ -112,7 +98,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
_scannedCount = 0; _scannedCount = 0;
_totalToScan = 0; _totalToScan = 0;
}); });
_networkScanner.clearCache(); // Clear cache before starting new scan _networkScanner.clearCache();
_networkScanner.scan(); _networkScanner.scan();
} }
@@ -131,78 +117,94 @@ class _ConnectionDialogState extends State<ConnectionDialog>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final connectionProvider = context.watch<ConnectionProvider>(); final connectionProvider = context.watch<ConnectionProvider>();
final theme = Theme.of(context);
return Container( return Container(
height: MediaQuery.of(context).size.height * 0.9, height: MediaQuery.of(context).size.height * 0.9,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
), ),
child: Column( child: Column(
children: [ children: [
// Header
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest, color: theme.colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
top: Radius.circular(20), top: Radius.circular(24),
), ),
), ),
child: Column( child: Column(
children: [ 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( Row(
children: [ children: [
IconButton( IconButton(
icon: Icon( icon: const Icon(Icons.close_rounded),
Icons.arrow_back, onPressed: () => Navigator.pop(context),
color: Theme.of(context).colorScheme.onSurface,
),
onPressed: () {
Navigator.pop(context);
},
), ),
Expanded( Expanded(
child: Text( child: Column(
AppLocalizations.of(context)!.appTitle, children: [
Text(
'Connect Device',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: theme.textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w700,
fontSize: 18,
fontWeight: FontWeight.bold,
), ),
), ),
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(height: 8), ),
// Tab Bar const SizedBox(width: 48),
],
),
const SizedBox(height: 12),
TabBar( TabBar(
controller: _tabController, 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 [ tabs: const [
Tab(text: 'BLE', icon: Icon(Icons.bluetooth)), Tab(text: 'BLE', icon: Icon(Icons.bluetooth_rounded)),
Tab(text: 'Network', icon: Icon(Icons.wifi)), Tab(text: 'Network', icon: Icon(Icons.wifi_rounded)),
Tab(text: 'USB', icon: Icon(Icons.usb)), Tab(text: 'USB', icon: Icon(Icons.usb_rounded)),
], ],
), ),
], ],
), ),
), ),
// Tab Content
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
// BLE Devices Tab
_buildBleDevicesTab(connectionProvider), _buildBleDevicesTab(connectionProvider),
// Network Servers Tab
_buildNetworkServersTab(), _buildNetworkServersTab(),
_buildUsbTab(),
// USB Serial Tab
_buildUsbTab(connectionProvider),
], ],
), ),
), ),
@@ -211,78 +213,175 @@ class _ConnectionDialogState extends State<ConnectionDialog>
); );
} }
Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) { Widget _buildSectionBanner({
return Column( required IconData icon,
children: [ required String message,
// Info banner required VoidCallback onRefresh,
Container( }) {
margin: const EdgeInsets.all(16), final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 12),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer, color: theme.colorScheme.primaryContainer.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(16),
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(icon, color: theme.colorScheme.onPrimaryContainer),
Icons.info_outline, const SizedBox(width: 12),
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
AppLocalizations.of(context)!.defaultPinInfo, message,
style: TextStyle( style: theme.textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer, color: theme.colorScheme.onPrimaryContainer,
fontSize: 13,
), ),
), ),
), ),
IconButton( IconButton(
icon: Icon( icon: Icon(
Icons.refresh, Icons.refresh_rounded,
color: Theme.of(context).colorScheme.onPrimaryContainer, color: theme.colorScheme.onPrimaryContainer,
), ),
onPressed: _refreshBleDevices, 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),
), ),
], ],
), ),
), ),
);
}
// Device list 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: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: iconColor),
),
const SizedBox(width: 14),
Expanded(
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,
),
),
],
),
),
const SizedBox(width: 12),
trailing,
],
),
),
),
);
}
Widget _buildBleDevicesTab(ConnectionProvider connectionProvider) {
return Column(
children: [
_buildSectionBanner(
icon: Icons.bluetooth_searching_rounded,
message: AppLocalizations.of(context)!.defaultPinInfo,
onRefresh: _refreshBleDevices,
),
Expanded( Expanded(
child: child:
connectionProvider.isScanning && connectionProvider.isScanning &&
connectionProvider.scannedDevices.isEmpty connectionProvider.scannedDevices.isEmpty
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: connectionProvider.scannedDevices.isEmpty : connectionProvider.scannedDevices.isEmpty
? Center( ? _buildEmptyState(
child: Column( icon: Icons.bluetooth_searching_rounded,
mainAxisAlignment: MainAxisAlignment.center, title: AppLocalizations.of(context)!.noDevicesFound,
children: [ actionLabel: AppLocalizations.of(context)!.scanAgain,
Icon( onAction: _refreshBleDevices,
Icons.bluetooth_searching,
size: 64,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
SizedBox(height: 16),
Text(
AppLocalizations.of(context)!.noDevicesFound,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 16,
),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _refreshBleDevices,
icon: Icon(Icons.refresh),
label: Text(AppLocalizations.of(context)!.scanAgain),
),
],
),
) )
: ListView.builder( : ListView.builder(
itemCount: connectionProvider.scannedDevices.length, itemCount: connectionProvider.scannedDevices.length,
@@ -292,74 +391,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final device = scannedDevice.device; final device = scannedDevice.device;
final rssi = scannedDevice.rssi; final rssi = scannedDevice.rssi;
final signalColor = _getSignalColor(rssi); final signalColor = _getSignalColor(rssi);
final deviceId = device.remoteId.toString();
final isConnecting = _connectingBleDeviceId == deviceId;
return Container( Future<void> connectBle() async {
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading: Icon(
Icons.bluetooth,
color: signalColor,
size: 32,
),
title: Text(
device.platformName.isNotEmpty
? device.platformName
: 'Unknown Device',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
subtitle: Row(
children: [
Text(
AppLocalizations.of(context)!.tapToConnect,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
const SizedBox(width: 8),
Text(
'$rssi dBm',
style: TextStyle(
color: signalColor,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
onTap: () async {
final appProvider = context.read<AppProvider>(); final appProvider = context.read<AppProvider>();
setState(() {
_connectingBleDeviceId = deviceId;
});
try {
Navigator.pop(context); Navigator.pop(context);
final success = await connectionProvider.connect( final success = await connectionProvider.connect(
device, device,
); );
@@ -367,8 +408,36 @@ class _ConnectionDialogState extends State<ConnectionDialog>
connectionProvider.deviceInfo.isConnected) { connectionProvider.deviceInfo.isConnected) {
await appProvider.initialize(); await appProvider.initialize();
} }
}, } finally {
if (mounted) {
setState(() {
_connectingBleDeviceId = null;
});
}
}
}
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<ConnectionDialog>
return Column( return Column(
children: [ children: [
// Info banner _buildSectionBanner(
Container( icon: showingCachedResults
margin: const EdgeInsets.fromLTRB(16, 16, 16, 16), ? Icons.cached_rounded
padding: const EdgeInsets.all(16), : Icons.wifi_find_rounded,
decoration: BoxDecoration( message: showingCachedResults
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.' ? 'Showing cached results. Tap refresh to rescan.'
: 'Scanning local network for MeshCore WiFi devices on port 5000', : 'Scanning local network for MeshCore WiFi devices on port 5000',
style: TextStyle( onRefresh: _startNetworkScan,
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontSize: 13,
), ),
),
),
IconButton(
icon: Icon(
Icons.refresh,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
onPressed: _startNetworkScan,
),
],
),
),
// Scan progress
if (_networkScanner.isScanning) if (_networkScanner.isScanning)
Container( Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -439,39 +479,15 @@ class _ConnectionDialogState extends State<ConnectionDialog>
], ],
), ),
), ),
// Server list
Expanded( Expanded(
child: _networkScanner.isScanning && _discoveredServers.isEmpty child: _networkScanner.isScanning && _discoveredServers.isEmpty
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _discoveredServers.isEmpty : _discoveredServers.isEmpty
? Center( ? _buildEmptyState(
child: Column( icon: Icons.wifi_off_rounded,
mainAxisAlignment: MainAxisAlignment.center, title: 'No servers found',
children: [ actionLabel: 'Scan Again',
Icon( onAction: _startNetworkScan,
Icons.wifi_off,
size: 64,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
Text(
'No servers found',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 16,
),
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: _startNetworkScan,
icon: const Icon(Icons.refresh),
label: const Text('Scan Again'),
),
],
),
) )
: ListView.builder( : ListView.builder(
itemCount: _discoveredServers.length, itemCount: _discoveredServers.length,
@@ -483,100 +499,24 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final isAnyConnectionInProgress = final isAnyConnectionInProgress =
_connectingToServerKey != null; _connectingToServerKey != null;
return Container( Future<void> connectServer() async {
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isConnectingToThisServer
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.2),
width: isConnectingToThisServer ? 2 : 1,
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading: isConnectingToThisServer
? SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 3,
color: Theme.of(context).colorScheme.primary,
),
)
: const Icon(
Icons.wifi,
color: Colors.green,
size: 32,
),
title: Text(
server.ipAddress,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
isConnectingToThisServer
? 'Connecting...'
: 'Port ${server.port}${server.responseTime}ms',
style: TextStyle(
color: isConnectingToThisServer
? Theme.of(context).colorScheme.primary
: Theme.of(
context,
).colorScheme.onSurfaceVariant,
fontSize: 14,
fontWeight: isConnectingToThisServer
? FontWeight.w500
: FontWeight.normal,
),
),
trailing: isConnectingToThisServer
? null
: Icon(
Icons.chevron_right,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
enabled: !isAnyConnectionInProgress,
onTap: isAnyConnectionInProgress
? null
: () async {
// Capture context-dependent objects before async operations
final connectionProvider = context final connectionProvider = context
.read<ConnectionProvider>(); .read<ConnectionProvider>();
final appProvider = context.read<AppProvider>(); final appProvider = context.read<AppProvider>();
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
// Mark this server as connecting
setState(() { setState(() {
_connectingToServerKey = serverKey; _connectingToServerKey = serverKey;
}); });
try { try {
// Pre-verify server is still available final isAvailable = await _networkScanner.verifyServer(
final isAvailable = await _networkScanner server,
.verifyServer(server); );
if (!isAvailable) { if (!isAvailable) {
throw Exception( throw Exception(
'Server at ${server.ipAddress}:${server.port} is no longer available. ' 'Server at ${server.ipAddress}:${server.port} is no longer available. Please scan again to find active servers.',
'Please scan again to find active servers.',
); );
} }
@@ -590,17 +530,13 @@ class _ConnectionDialogState extends State<ConnectionDialog>
navigator.pop(); navigator.pop();
} }
} catch (e) { } catch (e) {
// Clear connecting state on error if (!mounted) return;
if (mounted) {
setState(() { setState(() {
_connectingToServerKey = null; _connectingToServerKey = null;
}); });
// Clean up error message (remove "Exception: " prefix) var errorMessage = e.toString();
String errorMessage = e.toString(); if (errorMessage.startsWith('Exception: ')) {
if (errorMessage.startsWith(
'Exception: ',
)) {
errorMessage = errorMessage.substring( errorMessage = errorMessage.substring(
'Exception: '.length, 'Exception: '.length,
); );
@@ -628,8 +564,30 @@ class _ConnectionDialogState extends State<ConnectionDialog>
); );
} }
} }
},
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,
), ),
)
: FilledButton.tonal(
onPressed: isAnyConnectionInProgress
? null
: connectServer,
child: const Text('Connect'),
),
enabled: !isAnyConnectionInProgress,
onTap: isAnyConnectionInProgress ? null : connectServer,
); );
}, },
), ),
@@ -638,8 +596,38 @@ class _ConnectionDialogState extends State<ConnectionDialog>
); );
} }
Widget _buildUsbTab(ConnectionProvider connectionProvider) { Widget _buildUsbTab() {
return _UsbDeviceList( 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: () { onConnected: () {
if (mounted) Navigator.of(context).pop(); if (mounted) Navigator.of(context).pop();
}, },
@@ -647,10 +635,35 @@ class _ConnectionDialogState extends State<ConnectionDialog>
} }
} }
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 { class _UsbDeviceList extends StatefulWidget {
final VoidCallback onConnected; 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 @override
State<_UsbDeviceList> createState() => _UsbDeviceListState(); State<_UsbDeviceList> createState() => _UsbDeviceListState();
@@ -678,7 +691,7 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
_devices = devices; _devices = devices;
_isScanning = false; _isScanning = false;
}); });
} catch (e) { } catch (_) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_devices = []; _devices = [];
@@ -767,9 +780,9 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _isConnecting = false); setState(() => _isConnecting = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('USB error: $e')), context,
); ).showSnackBar(SnackBar(content: Text('USB error: $e')));
} }
} }
@@ -796,20 +809,21 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
return Column( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: OutlinedButton.icon( child: FilledButton.tonalIcon(
onPressed: _isConnecting ? null : _scanDevices, onPressed: _isConnecting ? null : _scanDevices,
icon: const Icon(Icons.refresh), icon: const Icon(Icons.usb_rounded),
label: const Text('Scan USB devices'), label: const Text('Scan USB devices'),
), ),
), ),
if (_devices.isEmpty) if (_devices.isEmpty)
const Expanded( Expanded(
child: Center( child: widget.buildEmptyState(
child: Text( icon: Icons.usb_off_rounded,
title:
'No USB serial devices found.\nConnect a MeshCore device via OTG cable.', 'No USB serial devices found.\nConnect a MeshCore device via OTG cable.',
textAlign: TextAlign.center, actionLabel: 'Scan USB devices',
), onAction: _scanDevices,
), ),
) )
else else
@@ -818,17 +832,26 @@ class _UsbDeviceListState extends State<_UsbDeviceList> {
itemCount: _devices.length, itemCount: _devices.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final device = _devices[index]; final device = _devices[index];
return ListTile( return widget.buildTransportCard(
leading: const Icon(Icons.usb), icon: Icons.usb_rounded,
title: Text(device.productName ?? 'USB Device'), iconColor: Theme.of(context).colorScheme.primary,
subtitle: Text(device.manufacturerName ?? ''), title: device.productName ?? 'USB Device',
subtitle: (device.manufacturerName?.isNotEmpty ?? false)
? device.manufacturerName!
: 'Ready over OTG serial',
trailing: _isConnecting trailing: _isConnecting
? const SizedBox( ? const SizedBox(
width: 20, width: 24,
height: 20, height: 24,
child: CircularProgressIndicator(strokeWidth: 2), 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), onTap: _isConnecting ? null : () => _connectToDevice(device),
); );
}, },

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

@@ -174,14 +174,21 @@ class _MessageBubbleState extends State<MessageBubble> {
!widget.message.isSystemMessage) { !widget.message.isSystemMessage) {
context.read<MessagesProvider>().markAsRead(widget.message.id); context.read<MessagesProvider>().markAsRead(widget.message.id);
} }
widget.onTap?.call();
}
void _handleBubbleDoubleTap({
required bool isSarMarker,
required bool isDrawing,
}) {
if (widget.isCompact || isSarMarker || isDrawing) {
return;
}
if (!widget.isCompact && !isSarMarker && !isDrawing) {
setState(() { setState(() {
_showReceivedStats = !_showReceivedStats; _showReceivedStats = !_showReceivedStats;
}); });
} }
widget.onTap?.call();
}
Future<void> _retryFailedMessage( Future<void> _retryFailedMessage(
BuildContext context, BuildContext context,
@@ -684,7 +691,10 @@ class _MessageBubbleState extends State<MessageBubble> {
void copyField(String value) { void copyField(String value) {
Clipboard.setData(ClipboardData(text: value)); Clipboard.setData(ClipboardData(text: value));
ToastLogger.success(context, AppLocalizations.of(context)!.textCopiedToClipboard); ToastLogger.success(
context,
AppLocalizations.of(context)!.textCopiedToClipboard,
);
} }
showModalBottomSheet( showModalBottomSheet(
@@ -812,7 +822,9 @@ class _MessageBubbleState extends State<MessageBubble> {
), ),
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.receivedRfc3339, label: AppLocalizations.of(
context,
)!.receivedRfc3339,
value: _formatRfc3339(widget.message.receivedAt), value: _formatRfc3339(widget.message.receivedAt),
onCopy: () => copyField( onCopy: () => copyField(
_formatRfc3339(widget.message.receivedAt), _formatRfc3339(widget.message.receivedAt),
@@ -821,14 +833,18 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.message.expectedAckTag != null) if (widget.message.expectedAckTag != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.expectedAckTag, label: AppLocalizations.of(
context,
)!.expectedAckTag,
value: widget.message.expectedAckTag! value: widget.message.expectedAckTag!
.toString(), .toString(),
), ),
if (receptionDetails?.senderToReceiptMs != null) if (receptionDetails?.senderToReceiptMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.senderToReceipt, label: AppLocalizations.of(
context,
)!.senderToReceipt,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.senderToReceiptMs!, receptionDetails!.senderToReceiptMs!,
), ),
@@ -836,7 +852,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (receptionDetails?.estimatedTransmitMs != null) if (receptionDetails?.estimatedTransmitMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.estimatedTx, label: AppLocalizations.of(
context,
)!.estimatedTx,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.estimatedTransmitMs!, receptionDetails!.estimatedTransmitMs!,
), ),
@@ -844,7 +862,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (receptionDetails?.postTransmitDelayMs != null) if (receptionDetails?.postTransmitDelayMs != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.posttxDelay, label: AppLocalizations.of(
context,
)!.posttxDelay,
value: _formatDurationMs( value: _formatDurationMs(
receptionDetails!.postTransmitDelayMs!, receptionDetails!.postTransmitDelayMs!,
), ),
@@ -852,7 +872,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.receivedCopies > 1) if (widget.receivedCopies > 1)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.receivedCopies, label: AppLocalizations.of(
context,
)!.receivedCopies,
value: '${widget.receivedCopies}', value: '${widget.receivedCopies}',
), ),
if (widget.message.suggestedTimeoutMs != null) if (widget.message.suggestedTimeoutMs != null)
@@ -883,7 +905,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.message.retryAttempt > 0) if (widget.message.retryAttempt > 0)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.retryAttempt, label: AppLocalizations.of(
context,
)!.retryAttempt,
value: '${widget.message.retryAttempt}/4', value: '${widget.message.retryAttempt}/4',
), ),
if (widget.message.lastRetryAt != null) if (widget.message.lastRetryAt != null)
@@ -900,21 +924,27 @@ class _MessageBubbleState extends State<MessageBubble> {
if (widget.message.usedFloodFallback) if (widget.message.usedFloodFallback)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.floodFallback, label: AppLocalizations.of(
context,
)!.floodFallback,
value: AppLocalizations.of(context)!.yes, value: AppLocalizations.of(context)!.yes,
), ),
if (routeMetadata?.canonicalPath if (routeMetadata?.canonicalPath
case final routePath?) case final routePath?)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.selectedPath, label: AppLocalizations.of(
context,
)!.selectedPath,
value: routePath, value: routePath,
onCopy: () => copyField(routePath), onCopy: () => copyField(routePath),
), ),
if (retryResult != null) if (retryResult != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.retryResult, label: AppLocalizations.of(
context,
)!.retryResult,
value: retryResult, value: retryResult,
), ),
if (packetPathHex != null) if (packetPathHex != null)
@@ -964,7 +994,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (recipientPrefixHex != null) if (recipientPrefixHex != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.recipientKey, label: AppLocalizations.of(
context,
)!.recipientKey,
value: recipientPrefixHex, value: recipientPrefixHex,
onCopy: () => copyField(recipientPrefixHex), onCopy: () => copyField(recipientPrefixHex),
), ),
@@ -994,7 +1026,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (voiceSession != null) if (voiceSession != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.sessionProgress, label: AppLocalizations.of(
context,
)!.sessionProgress,
value: value:
'${voiceSession.receivedCount}/${voiceSession.total} segments', '${voiceSession.receivedCount}/${voiceSession.total} segments',
), ),
@@ -1009,14 +1043,18 @@ class _MessageBubbleState extends State<MessageBubble> {
if (transferDetails != null) if (transferDetails != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.transfers, label: AppLocalizations.of(
context,
)!.transfers,
value: '${transferDetails.totalTransfers}', value: '${transferDetails.totalTransfers}',
), ),
if (transferDetails != null && if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty) transferDetails.downloaders.isNotEmpty)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.downloadedBy, label: AppLocalizations.of(
context,
)!.downloadedBy,
value: _formatDownloaderSummary( value: _formatDownloaderSummary(
transferDetails, transferDetails,
), ),
@@ -1024,7 +1062,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (voiceTxEstimate > Duration.zero) if (voiceTxEstimate > Duration.zero)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.estimatedTx, label: AppLocalizations.of(
context,
)!.estimatedTx,
value: voiceTxEstimate.inSeconds < 60 value: voiceTxEstimate.inSeconds < 60
? '~${voiceTxEstimate.inSeconds}s' ? '~${voiceTxEstimate.inSeconds}s'
: '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s', : '~${voiceTxEstimate.inMinutes}m ${voiceTxEstimate.inSeconds % 60}s',
@@ -1075,14 +1115,18 @@ class _MessageBubbleState extends State<MessageBubble> {
if (transferDetails != null) if (transferDetails != null)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.transfers, label: AppLocalizations.of(
context,
)!.transfers,
value: '${transferDetails.totalTransfers}', value: '${transferDetails.totalTransfers}',
), ),
if (transferDetails != null && if (transferDetails != null &&
transferDetails.downloaders.isNotEmpty) transferDetails.downloaders.isNotEmpty)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.downloadedBy, label: AppLocalizations.of(
context,
)!.downloadedBy,
value: _formatDownloaderSummary( value: _formatDownloaderSummary(
transferDetails, transferDetails,
), ),
@@ -1090,7 +1134,9 @@ class _MessageBubbleState extends State<MessageBubble> {
if (imageTxEstimate > Duration.zero) if (imageTxEstimate > Duration.zero)
_detailRow( _detailRow(
sheetContext, sheetContext,
label: AppLocalizations.of(context)!.estimatedTx, label: AppLocalizations.of(
context,
)!.estimatedTx,
value: imageTxEstimate.inSeconds < 60 value: imageTxEstimate.inSeconds < 60
? '~${imageTxEstimate.inSeconds}s' ? '~${imageTxEstimate.inSeconds}s'
: '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s', : '~${imageTxEstimate.inMinutes}m ${imageTxEstimate.inSeconds % 60}s',
@@ -1438,7 +1484,10 @@ class _MessageBubbleState extends State<MessageBubble> {
// Share the location // Share the location
SharePlus.instance.share( SharePlus.instance.share(
ShareParams(text: shareText, subject: AppLocalizations.of(context)!.sarLocationShare), ShareParams(
text: shareText,
subject: AppLocalizations.of(context)!.sarLocationShare,
),
); );
} }
@@ -1890,6 +1939,10 @@ class _MessageBubbleState extends State<MessageBubble> {
isSarMarker: isSarMarker, isSarMarker: isSarMarker,
isDrawing: message.isDrawing, isDrawing: message.isDrawing,
), ),
onDoubleTap: () => _handleBubbleDoubleTap(
isSarMarker: isSarMarker,
isDrawing: message.isDrawing,
),
onLongPress: widget.isCompact onLongPress: widget.isCompact
? null ? null
: () => _showMessageOptions(context), : () => _showMessageOptions(context),
@@ -2779,10 +2832,8 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
}, },
), ),
if (shouldShowSentChannelStats( if (_showReceivedStats &&
message, shouldShowSentChannelStats(message)) ...[
showReceivedStats: _showReceivedStats,
)) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
buildChannelEchoStatus(context, message), buildChannelEchoStatus(context, message),
], ],

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) {
@@ -91,10 +92,7 @@ Widget buildChannelEchoStatus(BuildContext context, Message message) {
); );
} }
bool shouldShowSentChannelStats( bool shouldShowSentChannelStats(Message message) {
Message message, {
required bool showReceivedStats,
}) {
if (!message.isSentMessage || !message.isChannelMessage) { if (!message.isSentMessage || !message.isChannelMessage) {
return false; return false;
} }
@@ -104,7 +102,7 @@ bool shouldShowSentChannelStats(
message.lastEchoRssiDbm != null || message.lastEchoRssiDbm != null ||
message.lastEchoSnrRaw != null || message.lastEchoSnrRaw != null ||
message.expectedAckTag != null; message.expectedAckTag != null;
return showReceivedStats && hasSignalData; return hasSignalData;
} }
Widget buildReceivedSignalStatus( Widget buildReceivedSignalStatus(
@@ -382,30 +380,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

@@ -207,9 +207,23 @@ List<SensorMetricOption> sensorMetricOptionsFor(
), ),
]; ];
// Keys that are already shown as core telemetry fields above.
// Skip them from extraSensorData to avoid duplicates.
final coreFieldKeys = <String>{
if (batteryMilliVolts != null || batteryPercentage != null)
...extraSensorData?.keys.where((k) =>
k.startsWith('voltage_') || k.startsWith('analog_input_')) ?? [],
if (temperature != null)
...extraSensorData?.keys.where((k) => k.startsWith('temperature_')) ?? [],
if (humidity != null)
...extraSensorData?.keys.where((k) => k.startsWith('humidity_')) ?? [],
if (pressure != null)
...extraSensorData?.keys.where((k) => k.startsWith('pressure_')) ?? [],
};
if (extraSensorData != null) { if (extraSensorData != null) {
for (final key in extraSensorData.keys) { for (final key in extraSensorData.keys) {
if (_isTelemetryMetadataKey(key)) { if (_isTelemetryMetadataKey(key) || coreFieldKeys.contains(key)) {
continue; continue;
} }
final metricKey = _parseMetricKey(key); final metricKey = _parseMetricKey(key);
@@ -2237,7 +2251,9 @@ class SensorMetricTile extends StatelessWidget {
border: Border.all(color: data.accent.withValues(alpha: 0.14)), border: Border.all(color: data.accent.withValues(alpha: 0.14)),
), ),
child: data.mapLocation == null || !allowMapPreview child: data.mapLocation == null || !allowMapPreview
? Row( ? Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_MetricIcon(accent: data.accent, icon: data.icon), _MetricIcon(accent: data.accent, icon: data.icon),
@@ -2246,6 +2262,21 @@ class SensorMetricTile extends StatelessWidget {
child: _MetricText(data: data, keyPrefix: keyPrefix), child: _MetricText(data: data, keyPrefix: keyPrefix),
), ),
], ],
),
if (data.channel != null)
Positioned(
right: 0,
bottom: 0,
child: Text(
'ch${data.channel}',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: data.accent.withValues(alpha: 0.5),
),
),
),
],
) )
: Column( : Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -2381,11 +2412,7 @@ class _MetricText extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Text(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
data.label, data.label,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -2394,27 +2421,6 @@ class _MetricText extends StatelessWidget {
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
),
if (data.channel != null) ...[
const SizedBox(width: 8),
Container(
key: ValueKey('${keyPrefix}_channel_${data.fieldKey}'),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: data.accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'ch${data.channel}',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: data.accent,
fontWeight: FontWeight.w800,
),
),
),
],
],
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
data.value, data.value,

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',
() async {
final manager = ProfileManager(); 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.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.profilesEnabled, isTrue);
expect(ProfileStorageScope.effectiveNamespace, isNull); expect(ProfileStorageScope.effectiveNamespace, isNull);
}, expect(manager.visibleProfiles.single.name, 'Default');
); expect(ProfileStorageScope.effectiveNamespace, isNull);
});
test( test(
'persists custom profiles and restores scoped active profile state', 'persists custom profiles and restores scoped active profile state',
@@ -82,5 +70,44 @@ void main() {
expect(reloaded.activeProfileId, profile.id); expect(reloaded.activeProfileId, profile.id);
}, },
); );
test('stores per-device default profiles independently', () async {
final manager = ProfileManager();
await manager.initialize();
await manager.setProfilesEnabled(true);
final profile = ConfigProfile(
id: 'profile-alpha',
name: 'Alpha',
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
sections: const ConfigProfileSections(),
);
await manager.upsertProfile(profile);
await manager.setActiveProfileIdForDevice(
profile.id,
deviceKey: 'pk:device-a',
);
await manager.setActiveProfileIdForDevice(
ConfigProfile.defaultProfileId,
deviceKey: 'pk:device-b',
);
final reloaded = ProfileManager();
await reloaded.initialize();
expect(reloaded.profileIdForDevice('pk:device-a'), profile.id);
expect(
reloaded.profileIdForDevice('pk:device-b'),
ConfigProfile.defaultProfileId,
);
expect(
reloaded.profileIdForDevice('pk:device-c'),
ConfigProfile.defaultProfileId,
);
expect(reloaded.hasProfileForDevice('pk:device-a'), isTrue);
expect(reloaded.hasProfileForDevice('pk:device-c'), isFalse);
});
}); });
} }

View File

@@ -184,6 +184,73 @@ void main() {
expect(manager.activeProfileId, target.id); expect(manager.activeProfileId, target.id);
}, },
); );
test('syncActiveProfileForCurrentDevice uses per-device default', () async {
final manager = ProfileManager();
await manager.initialize();
await manager.setProfilesEnabled(true);
final alpha = ConfigProfile(
id: 'profile-alpha',
name: 'Alpha',
createdAt: DateTime.parse('2026-03-16T12:00:00Z'),
updatedAt: DateTime.parse('2026-03-16T12:00:00Z'),
sections: const ConfigProfileSections(),
);
await manager.upsertProfile(alpha);
await manager.setActiveProfileIdForDevice(
alpha.id,
deviceKey: 'pk:01020304',
);
await manager.setActiveProfileId(ConfigProfile.defaultProfileId);
final connectionProvider = _FakeConnectionProvider(
deviceInfo: DeviceInfo(publicKey: Uint8List.fromList([1, 2, 3, 4])),
);
final coordinator = _buildCoordinator(
profileManager: manager,
connectionProvider: connectionProvider,
);
await coordinator.syncActiveProfileForCurrentDevice();
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,
);
},
);
}); });
} }
@@ -265,10 +332,17 @@ class _FakeDeviceConfigApplicator extends DeviceConfigApplicator {
} }
class _FakeConnectionProvider implements ConnectionProvider { class _FakeConnectionProvider implements ConnectionProvider {
_FakeConnectionProvider({
DeviceInfo? deviceInfo,
}) : deviceInfo = deviceInfo ?? DeviceInfo();
int disconnectCallCount = 0; int disconnectCallCount = 0;
@override @override
DeviceInfo get deviceInfo => DeviceInfo(); final DeviceInfo deviceInfo;
@override
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');
});
});
}

View File

@@ -0,0 +1,244 @@
import 'dart:typed_data';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/app_provider.dart';
import 'package:meshcore_sar_app/providers/channels_provider.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/drawing_provider.dart';
import 'package:meshcore_sar_app/providers/image_provider.dart' as ip;
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/providers/voice_provider.dart';
import 'package:meshcore_sar_app/services/voice_codec_service.dart';
import 'package:meshcore_sar_app/services/voice_player_service.dart';
import 'package:meshcore_sar_app/widgets/messages/message_bubble.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('received bubbles show signal chips on double tap', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'received-signal',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(1),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Inbound message',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.received,
lastEchoRssiDbm: -84,
lastEchoSnrRaw: 24,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text('1 hop'), findsNothing);
expect(find.text('Fair'), findsNothing);
expect(find.text('-84'), findsNothing);
expect(find.text('6.0'), findsNothing);
await tester.tap(find.text('Inbound message'));
await tester.pump(kDoubleTapTimeout);
expect(find.text('1 hop'), findsNothing);
expect(find.text('-84'), findsNothing);
await _doubleTap(tester, find.text('Inbound message'));
expect(find.text('1 hop'), findsOneWidget);
expect(find.text('Fair'), findsOneWidget);
expect(find.text('-84'), findsOneWidget);
expect(find.text('6.0'), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('delivered direct bubbles show timing chips on double tap', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'sent-direct-signal',
messageType: MessageType.contact,
senderPublicKeyPrefix: _prefix(11),
recipientPublicKey: _key(77),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Outbound message',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.delivered,
roundTripTimeMs: 320,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text('Direct'), findsNothing);
expect(find.text('320ms'), findsNothing);
await tester.tap(find.text('Outbound message'));
await tester.pump(kDoubleTapTimeout);
expect(find.text('Direct'), findsNothing);
expect(find.text('320ms'), findsNothing);
await _doubleTap(tester, find.text('Outbound message'));
expect(find.text('Direct'), findsOneWidget);
expect(find.text('320ms'), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
testWidgets('sent channel bubbles show echo chips on double tap', (
tester,
) async {
final harness = await _TestHarness.create();
try {
final message = Message(
id: 'sent-channel-signal',
messageType: MessageType.channel,
senderPublicKeyPrefix: _prefix(21),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'Broadcast message',
receivedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
deliveryStatus: MessageDeliveryStatus.sent,
echoCount: 2,
lastEchoRssiDbm: -76,
lastEchoSnrRaw: 20,
);
await tester.pumpWidget(_buildApp(harness, message));
await tester.pump(const Duration(milliseconds: 60));
expect(find.text('x2'), findsNothing);
expect(find.text('-76'), findsNothing);
expect(find.text('5.0'), findsNothing);
await tester.tap(find.text('Broadcast message'));
await tester.pump(kDoubleTapTimeout);
expect(find.text('x2'), findsNothing);
expect(find.text('-76'), findsNothing);
await _doubleTap(tester, find.text('Broadcast message'));
expect(find.text('x2'), findsOneWidget);
expect(find.text('-76'), findsOneWidget);
expect(find.text('5.0'), findsOneWidget);
} finally {
await _disposeHarness(tester, harness);
}
});
}
Widget _buildApp(_TestHarness harness, Message message) {
return MultiProvider(
providers: [
ChangeNotifierProvider.value(value: harness.connectionProvider),
ChangeNotifierProvider.value(value: harness.contactsProvider),
ChangeNotifierProvider.value(value: harness.messagesProvider),
ChangeNotifierProvider.value(value: harness.drawingProvider),
ChangeNotifierProvider.value(value: harness.channelsProvider),
ChangeNotifierProvider.value(value: harness.voiceProvider),
ChangeNotifierProvider.value(value: harness.imageProvider),
ChangeNotifierProvider.value(value: harness.appProvider),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: MessageBubble(message: message)),
),
);
}
class _TestHarness {
final connectionProvider = ConnectionProvider();
final contactsProvider = ContactsProvider();
final messagesProvider = MessagesProvider();
final drawingProvider = DrawingProvider();
final channelsProvider = ChannelsProvider()..initializePublicChannel();
final voiceProvider = VoiceProvider(
codec: VoiceCodecService(),
player: VoicePlayerService(),
);
final imageProvider = ip.ImageProvider();
late final AppProvider appProvider;
static Future<_TestHarness> create() async {
final harness = _TestHarness();
await harness.messagesProvider.initialize();
await harness.drawingProvider.initialize();
harness.appProvider = AppProvider(
connectionProvider: harness.connectionProvider,
contactsProvider: harness.contactsProvider,
messagesProvider: harness.messagesProvider,
drawingProvider: harness.drawingProvider,
channelsProvider: harness.channelsProvider,
voiceProvider: harness.voiceProvider,
imageProvider: harness.imageProvider,
);
return harness;
}
bool _isDisposed = false;
void dispose() {
if (_isDisposed) {
return;
}
_isDisposed = true;
appProvider.dispose();
voiceProvider.dispose();
imageProvider.dispose();
drawingProvider.dispose();
messagesProvider.dispose();
contactsProvider.dispose();
connectionProvider.dispose();
channelsProvider.dispose();
}
}
Uint8List _prefix(int seed) =>
Uint8List.fromList(List<int>.generate(6, (index) => seed + index));
Uint8List _key(int seed) =>
Uint8List.fromList(List<int>.generate(32, (index) => seed + index));
Future<void> _disposeHarness(WidgetTester tester, _TestHarness harness) async {
await tester.pump(kDoubleTapTimeout);
await tester.pumpWidget(const SizedBox.shrink());
harness.dispose();
await tester.pump();
}
Future<void> _doubleTap(WidgetTester tester, Finder finder) async {
await tester.tap(finder);
await tester.pump(kDoubleTapMinTime);
await tester.tap(finder);
await tester.pump();
}