From dc297b0b9faa0d3411eb1df8ef053bd255cef572 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sat, 21 Mar 2026 20:44:29 +0100 Subject: [PATCH] fix: Tighten device settings layout --- lib/main.dart | 2 + lib/models/device_info.dart | 8 + lib/providers/app_provider.dart | 13 +- lib/providers/channels_provider.dart | 14 +- lib/providers/connection_provider.dart | 92 +- lib/providers/contacts_provider.dart | 112 ++- lib/providers/image_provider.dart | 25 +- lib/providers/sensors_provider.dart | 40 +- lib/providers/voice_provider.dart | 31 +- lib/screens/device_config_screen.dart | 565 ++++++++++-- lib/screens/home_screen.dart | 327 ++++--- lib/screens/sensors_tab.dart | 108 ++- .../profile_workspace_coordinator.dart | 77 +- lib/widgets/connection_dialog.dart | 32 +- lib/widgets/contacts/contact_tile.dart | 805 ++++++++++++++---- .../sensors/sensor_telemetry_card.dart | 255 +++--- test/providers/channels_provider_test.dart | 25 + .../contacts_provider_profile_scope_test.dart | 58 +- test/providers/contacts_provider_test.dart | 259 +++--- .../media_provider_profile_scope_test.dart | 134 +++ test/providers/sensors_provider_test.dart | 81 +- .../profile_workspace_coordinator_test.dart | 41 +- test/widgets/sensor_telemetry_card_test.dart | 32 + 23 files changed, 2358 insertions(+), 778 deletions(-) create mode 100644 test/providers/channels_provider_test.dart create mode 100644 test/providers/media_provider_profile_scope_test.dart diff --git a/lib/main.dart b/lib/main.dart index 8b180bc..9e9e164 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -372,6 +372,8 @@ class _MeshCoreSarAppState extends State { mapProvider: mapProvider, drawingProvider: context.read(), channelsProvider: context.read(), + voiceProvider: context.read(), + imageProvider: context.read(), appProvider: appProvider, ), ), diff --git a/lib/models/device_info.dart b/lib/models/device_info.dart index d4d6588..071eb08 100644 --- a/lib/models/device_info.dart +++ b/lib/models/device_info.dart @@ -71,6 +71,7 @@ class DeviceInfo { final bool? autoAddRoomServers; final bool? autoAddSensors; final bool? autoAddOverwriteOldest; + final int? autoAddMaxHops; final int? radioFreq; final int? radioBw; final int? radioSf; @@ -95,6 +96,7 @@ class DeviceInfo { // Repeat mode (firmware v9+) final bool? clientRepeat; + final int? pathHashMode; final bool? supportsSpectrumScan; final int? spectrumScanMinKhz; final int? spectrumScanMaxKhz; @@ -123,6 +125,7 @@ class DeviceInfo { this.autoAddRoomServers, this.autoAddSensors, this.autoAddOverwriteOldest, + this.autoAddMaxHops, this.radioFreq, this.radioBw, this.radioSf, @@ -139,6 +142,7 @@ class DeviceInfo { this.manufacturerModel, this.semanticVersion, this.clientRepeat, + this.pathHashMode, this.supportsSpectrumScan, this.spectrumScanMinKhz, this.spectrumScanMaxKhz, @@ -254,6 +258,7 @@ class DeviceInfo { bool? autoAddRoomServers, bool? autoAddSensors, bool? autoAddOverwriteOldest, + int? autoAddMaxHops, int? radioFreq, int? radioBw, int? radioSf, @@ -270,6 +275,7 @@ class DeviceInfo { String? manufacturerModel, String? semanticVersion, bool? clientRepeat, + int? pathHashMode, bool? supportsSpectrumScan, int? spectrumScanMinKhz, int? spectrumScanMaxKhz, @@ -299,6 +305,7 @@ class DeviceInfo { autoAddSensors: autoAddSensors ?? this.autoAddSensors, autoAddOverwriteOldest: autoAddOverwriteOldest ?? this.autoAddOverwriteOldest, + autoAddMaxHops: autoAddMaxHops ?? this.autoAddMaxHops, radioFreq: radioFreq ?? this.radioFreq, radioBw: radioBw ?? this.radioBw, radioSf: radioSf ?? this.radioSf, @@ -315,6 +322,7 @@ class DeviceInfo { manufacturerModel: manufacturerModel ?? this.manufacturerModel, semanticVersion: semanticVersion ?? this.semanticVersion, clientRepeat: clientRepeat ?? this.clientRepeat, + pathHashMode: pathHashMode ?? this.pathHashMode, supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan, spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz, spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz, diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 5a480e6..6af0578 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -2553,12 +2553,10 @@ class AppProvider with ChangeNotifier { try { _isReconnectSyncInProgress = true; _hasCompletedConnectionBootstrap = false; - // Initialize contacts provider with device public key to exclude self - // If already initialized (from early load), this will just filter out self-contact - // This must happen before getContacts to ensure proper filtering - await contactsProvider.initialize( + await contactsProvider.prepareForDeviceContactSync( devicePublicKey: connectionProvider.deviceInfo.publicKey, ); + channelsProvider.prepareForDeviceSync(); // Note: Device clock is automatically synced during connection in MeshCoreBleService // No need to sync it again here @@ -2634,10 +2632,14 @@ class AppProvider with ChangeNotifier { '๐Ÿ”„ [AppProvider] Device reconnected - syncing contacts and missed messages', ); - await contactsProvider.initialize( + await contactsProvider.prepareForDeviceContactSync( devicePublicKey: connectionProvider.deviceInfo.publicKey, ); + channelsProvider.prepareForDeviceSync(); await connectionProvider.getContacts(); + await connectionProvider.syncChannels( + maxChannels: connectionProvider.deviceInfo.maxChannels, + ); final messageCount = await connectionProvider.syncAllMessages( force: true, @@ -3642,6 +3644,7 @@ class AppProvider with ChangeNotifier { await connectionProvider.getContacts(); // Sync all channels so refresh reflects the full device state. + channelsProvider.prepareForDeviceSync(); await connectionProvider.syncChannels( maxChannels: connectionProvider.deviceInfo.maxChannels, ); diff --git a/lib/providers/channels_provider.dart b/lib/providers/channels_provider.dart index 3527cd3..3a9b5f2 100644 --- a/lib/providers/channels_provider.dart +++ b/lib/providers/channels_provider.dart @@ -7,7 +7,8 @@ class ChannelsProvider with ChangeNotifier { int _selectedChannelIndex = 0; // Default to public channel /// Get all channels - List get channels => _channels.values.toList()..sort((a, b) => a.index.compareTo(b.index)); + List get channels => + _channels.values.toList()..sort((a, b) => a.index.compareTo(b.index)); /// Get a specific channel by index Channel? getChannel(int index) => _channels[index]; @@ -54,12 +55,12 @@ class ChannelsProvider with ChangeNotifier { void removeChannel(int index) { if (_channels.containsKey(index)) { _channels.remove(index); - + // If the deleted channel was selected, switch to public channel if (_selectedChannelIndex == index) { _selectedChannelIndex = 0; } - + notifyListeners(); } } @@ -96,6 +97,13 @@ class ChannelsProvider with ChangeNotifier { notifyListeners(); } + /// Clear runtime channel state before a live device sync begins. + void prepareForDeviceSync() { + _channels.clear(); + _selectedChannelIndex = 0; + notifyListeners(); + } + /// Check if channels have been loaded bool get hasChannels => _channels.isNotEmpty; diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 1741f15..1f6b3a2 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -547,6 +547,7 @@ class ConnectionProvider with ChangeNotifier { manufacturerModel: deviceInfo['manufacturerModel'] as String?, semanticVersion: deviceInfo['semanticVersion'] as String?, clientRepeat: deviceInfo['clientRepeat'] as bool?, + pathHashMode: deviceInfo['pathHashMode'] as int?, supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?, spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?, spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?, @@ -563,6 +564,9 @@ class ConnectionProvider with ChangeNotifier { publicKey: selfInfo['publicKey'] as Uint8List?, advLat: selfInfo['advLat'] as int?, advLon: selfInfo['advLon'] as int?, + multiAcks: selfInfo['multiAcks'] as int?, + advertLocPolicy: selfInfo['advertLocPolicy'] as int?, + telemetryModes: selfInfo['telemetryModes'] as int?, manualAddContacts: selfInfo['manualAddContacts'] as bool?, radioFreq: selfInfo['radioFreq'] as int?, radioBw: selfInfo['radioBw'] as int?, @@ -605,6 +609,7 @@ class ConnectionProvider with ChangeNotifier { autoAddRoomServers: config['autoAddRoomServers'] as bool?, autoAddSensors: config['autoAddSensors'] as bool?, autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?, + autoAddMaxHops: config['autoAddMaxHops'] as int?, ); notifyListeners(); }; @@ -1853,11 +1858,13 @@ class ConnectionProvider with ChangeNotifier { return pendingPing; } - final future = _runSmartPing( - contactPublicKey: contactPublicKey, - hasPath: hasPath, - onRetryWithFlooding: onRetryWithFlooding, - ); + final future = _isSelfPublicKey(contactPublicKey) + ? _runSelfTelemetryPing(contactPublicKey) + : _runSmartPing( + contactPublicKey: contactPublicKey, + hasPath: hasPath, + onRetryWithFlooding: onRetryWithFlooding, + ); _pendingSmartPings[pingKey] = future; notifyListeners(); @@ -1944,10 +1951,53 @@ class ConnectionProvider with ChangeNotifier { } } + Future _runSelfTelemetryPing(Uint8List devicePublicKey) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return PingResult(success: false, usedFlooding: false, timedOut: true); + } + + try { + final pingFuture = _pingTracker.trackPing( + publicKey: devicePublicKey, + wasDirectAttempt: true, + ); + + // Firmware treats a 4-byte telemetry request as "self telemetry". + await _activeService.requestTelemetry(Uint8List(0), zeroHop: true); + + final gotResponse = await pingFuture; + return PingResult( + success: gotResponse, + usedFlooding: false, + timedOut: !gotResponse, + ); + } catch (e) { + _error = 'Failed to request self telemetry: $e'; + notifyListeners(); + return PingResult(success: false, usedFlooding: false, timedOut: true); + } + } + String _publicKeyToHex(Uint8List publicKey) { return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); } + bool _isSelfPublicKey(Uint8List publicKey) { + final selfKey = _deviceInfo.publicKey; + if (selfKey == null || selfKey.length != publicKey.length) { + return false; + } + + for (var i = 0; i < publicKey.length; i++) { + if (selfKey[i] != publicKey[i]) { + return false; + } + } + return true; + } + /// Send binary request to contact (modern replacement for requestTelemetry) /// /// Supports multiple request types: @@ -2292,6 +2342,7 @@ class ConnectionProvider with ChangeNotifier { autoAddRoomServers: config['autoAddRoomServers'] as bool?, autoAddSensors: config['autoAddSensors'] as bool?, autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?, + autoAddMaxHops: config['autoAddMaxHops'] as int?, ); notifyListeners(); } catch (e) { @@ -2303,6 +2354,7 @@ class ConnectionProvider with ChangeNotifier { autoAddRoomServers: null, autoAddSensors: null, autoAddOverwriteOldest: null, + autoAddMaxHops: null, ); notifyListeners(); return; @@ -2322,6 +2374,7 @@ class ConnectionProvider with ChangeNotifier { required bool autoAddRoomServers, required bool autoAddSensors, required bool overwriteOldest, + int maxHops = 0, }) async { if (!_activeService.isConnected) { _error = 'Not connected to device'; @@ -2336,6 +2389,7 @@ class ConnectionProvider with ChangeNotifier { autoAddRoomServers: autoAddRoomServers, autoAddSensors: autoAddSensors, overwriteOldest: overwriteOldest, + maxHops: maxHops, ); _deviceInfo = _deviceInfo.copyWith( autoAddUsers: autoAddUsers, @@ -2343,6 +2397,7 @@ class ConnectionProvider with ChangeNotifier { autoAddRoomServers: autoAddRoomServers, autoAddSensors: autoAddSensors, autoAddOverwriteOldest: overwriteOldest, + autoAddMaxHops: maxHops, ); notifyListeners(); } catch (e) { @@ -2351,6 +2406,23 @@ class ConnectionProvider with ChangeNotifier { } } + Future setPathHashMode(int mode) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + await _activeService.setPathHashMode(mode); + _deviceInfo = _deviceInfo.copyWith(pathHashMode: mode); + notifyListeners(); + } catch (e) { + _error = 'Failed to set path hash mode: $e'; + notifyListeners(); + } + } + /// Export a contact as a meshcore:// share URL. /// Pass null to export self. Future exportContactUrl(Uint8List? publicKey) async { @@ -2440,14 +2512,8 @@ class ConnectionProvider with ChangeNotifier { Future requestSelfTelemetry() async { if (!_activeService.isConnected) return; try { - // Request own telemetry by sending telemetry req with zero-length key - final deviceKey = _deviceInfo.publicKey; - if (deviceKey != null) { - await _activeService.requestTelemetry( - Uint8List.fromList(deviceKey), - zeroHop: true, - ); - } + // Firmware expects a 4-byte CMD_SEND_TELEMETRY_REQ frame for "self". + await _activeService.requestTelemetry(Uint8List(0), zeroHop: true); } catch (e) { debugPrint('โš ๏ธ [Provider] requestSelfTelemetry failed: $e'); } diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index e169d7d..1bc2af4 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -139,6 +139,8 @@ class ContactsProvider with ChangeNotifier { bool _isPersistingPendingAdverts = false; bool _persistPendingAdvertsRequested = false; String? _storageNamespace; + String? _selfPublicKeyHex; + ContactTelemetry? _selfTelemetry; // Add default public channel on initialization ContactsProvider() @@ -162,6 +164,7 @@ class ContactsProvider with ChangeNotifier { Uint8List? devicePublicKey, }) async { _storageNamespace = namespace; + _setSelfDevicePublicKey(devicePublicKey); await _loadFromStorage(force: true, devicePublicKey: devicePublicKey); } @@ -253,6 +256,7 @@ class ContactsProvider with ChangeNotifier { /// Initialize and load persisted contacts /// [devicePublicKey] - device's own public key to exclude from loaded contacts Future initialize({Uint8List? devicePublicKey}) async { + _setSelfDevicePublicKey(devicePublicKey); if (_isInitialized) { // If already initialized (from early load), just filter out self-contact if (devicePublicKey != null) { @@ -267,6 +271,36 @@ class ContactsProvider with ChangeNotifier { } } + /// Clear runtime contact state before a live device contact sync begins. + /// + /// This intentionally does not touch persisted storage. It keeps any saved + /// contact groups for the active profile, but removes stale in-memory device + /// contacts and discovery state so a newly connected device starts from an + /// empty list while sync is in progress. + Future prepareForDeviceContactSync({Uint8List? devicePublicKey}) async { + _setSelfDevicePublicKey(devicePublicKey); + _selfTelemetry = null; + if (!_isInitialized) { + final storedGroups = await _storageService.loadContactGroups( + namespace: _storageNamespace, + ); + _savedContactGroups + ..clear() + ..addAll(storedGroups); + _isInitialized = true; + } + + debugPrint( + '๐Ÿงน [ContactsProvider] Clearing runtime contacts before device sync', + ); + _contacts.clear(); + _pendingAdverts.clear(); + _estimatedLocations.clear(); + _rssiObservations.clear(); + _ensurePublicChannelExists(); + notifyListeners(); + } + /// Remove self-contact from loaded contacts (called after BLE connection established) void _removeSelfContact(Uint8List devicePublicKey) { final selfKeyHex = devicePublicKey @@ -347,6 +381,7 @@ class ContactsProvider with ChangeNotifier { } List get contacts => _contacts.values.toList(); + ContactTelemetry? get selfTelemetry => _selfTelemetry; List get favouriteContacts => _contacts.values.where((c) => c.isFavourite).toList(); List get savedContactGroups => @@ -537,9 +572,11 @@ class ContactsProvider with ChangeNotifier { // Replace existing observation from the same repeater, or add new final repeaterKey = '${observation.repeaterLocation.latitude},${observation.repeaterLocation.longitude}'; - observations.removeWhere((o) => - '${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' == - repeaterKey); + observations.removeWhere( + (o) => + '${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' == + repeaterKey, + ); observations.add(observation); // Keep at most 8 observations (most recent per repeater) @@ -956,13 +993,22 @@ class ContactsProvider with ChangeNotifier { // Find contact by public key prefix final contact = _findContactByPrefix(publicKeyPrefix); - if (contact == null) { + final isSelfTelemetry = + contact == null && _matchesSelfPrefix(publicKeyPrefix); + if (contact == null && !isSelfTelemetry) { debugPrint(' โŒ Contact not found for this prefix'); return; } - debugPrint(' โœ… Found contact: ${contact.advName}'); - debugPrint(' Old telemetry timestamp: ${contact.telemetry?.timestamp}'); + if (isSelfTelemetry) { + debugPrint(' โœ… Matched self telemetry response'); + debugPrint( + ' Old self telemetry timestamp: ${_selfTelemetry?.timestamp}', + ); + } else { + debugPrint(' โœ… Found contact: ${contact!.advName}'); + debugPrint(' Old telemetry timestamp: ${contact.telemetry?.timestamp}'); + } try { // Parse Cayenne LPP data @@ -985,7 +1031,9 @@ class ContactsProvider with ChangeNotifier { ); } - final previousTelemetry = contact.telemetry; + final previousTelemetry = isSelfTelemetry + ? _selfTelemetry + : contact!.telemetry; final mergedTelemetry = _mergeTelemetryForContact( existingTelemetry: previousTelemetry, @@ -1012,25 +1060,34 @@ class ContactsProvider with ChangeNotifier { ); } + if (isSelfTelemetry) { + _selfTelemetry = telemetry; + notifyListeners(); + debugPrint(' โœ… Updated self telemetry'); + return; + } + + final resolvedContact = contact!; + // Update contact with new telemetry AND last seen time // lastAdvert is Unix timestamp in seconds final currentTimestamp = (DateTime.now().millisecondsSinceEpoch / 1000) .round(); - debugPrint(' Old lastAdvert: ${contact.lastAdvert}'); + debugPrint(' Old lastAdvert: ${resolvedContact.lastAdvert}'); debugPrint(' New lastAdvert: $currentTimestamp'); final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation); - final updatedContact = contact.copyWith( + final updatedContact = resolvedContact.copyWith( telemetry: telemetry, lastAdvert: currentTimestamp, // Update last seen time advLat: persistedGps != null ? _coordinateToAdvertMicrodegrees(persistedGps.latitude) - : contact.advLat, + : resolvedContact.advLat, advLon: persistedGps != null ? _coordinateToAdvertMicrodegrees(persistedGps.longitude) - : contact.advLon, + : resolvedContact.advLon, ); - _contacts[contact.publicKeyHex] = updatedContact; + _contacts[resolvedContact.publicKeyHex] = updatedContact; debugPrint(' โœ… Updated contact in map (with new lastAdvert)'); _persistContacts(); @@ -1237,6 +1294,36 @@ class ContactsProvider with ChangeNotifier { return _contacts[keyHex]; } + void _setSelfDevicePublicKey(Uint8List? devicePublicKey) { + final nextKeyHex = _publicKeyHexOrNull(devicePublicKey); + if (_selfPublicKeyHex != nextKeyHex) { + _selfTelemetry = null; + } + _selfPublicKeyHex = nextKeyHex; + } + + String? _publicKeyHexOrNull(Uint8List? publicKey) { + if (publicKey == null || publicKey.isEmpty) { + return null; + } + + return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); + } + + bool _matchesSelfPrefix(Uint8List prefix) { + final selfKeyHex = _selfPublicKeyHex; + if (selfKeyHex == null || prefix.isEmpty) { + return false; + } + + final takeLen = prefix.length < 6 ? prefix.length : 6; + final prefixHex = prefix + .sublist(0, takeLen) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(''); + return selfKeyHex.startsWith(prefixHex); + } + /// Clear a contact's learned path locally so the UI and next send both /// prefer flood routing until the radio reports a fresh route. void markPathUnhealthy(Uint8List publicKey) { @@ -1642,6 +1729,7 @@ class ContactsProvider with ChangeNotifier { _pendingAdverts.clear(); _estimatedLocations.clear(); _rssiObservations.clear(); + _selfTelemetry = null; } Map _pendingAdvertToJson(PendingAdvert advert) { diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index 62a6c4c..07bca94 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; +import '../services/profiles_feature_service.dart'; import 'helpers/raw_session_retransmit.dart'; import '../utils/image_message_parser.dart'; @@ -75,6 +76,8 @@ class ImageProvider with ChangeNotifier { _restore(); } + String _scopedStorageKey() => ProfileStorageScope.scopedKey(_storageKey); + // โ”€โ”€ Accessors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ImageSession? session(String sessionId) => _sessions[sessionId]; @@ -292,18 +295,22 @@ class ImageProvider with ChangeNotifier { // โ”€โ”€ Persistence โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Future clearAll() async { - _sessions.clear(); - _outgoing.clear(); - _ignoredIncomingSessions.clear(); + _resetInMemoryState(); notifyListeners(); try { final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_storageKey); + await prefs.remove(_scopedStorageKey()); } catch (e) { debugPrint('โŒ [ImageProvider] Failed to clear storage: $e'); } } + Future reloadProfileScopedState() async { + _resetInMemoryState(); + await _restore(); + notifyListeners(); + } + void _evictExpiredOutgoing() { final now = DateTime.now(); _outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl); @@ -343,7 +350,7 @@ class ImageProvider with ChangeNotifier { ) .toList(), }; - await prefs.setString(_storageKey, jsonEncode(payload)); + await prefs.setString(_scopedStorageKey(), jsonEncode(payload)); } catch (e) { debugPrint('โŒ [ImageProvider] Failed to persist: $e'); } @@ -352,7 +359,7 @@ class ImageProvider with ChangeNotifier { Future _restore() async { try { final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_storageKey); + final raw = prefs.getString(_scopedStorageKey()); if (raw == null || raw.isEmpty) return; final parsed = jsonDecode(raw) as Map; @@ -428,6 +435,12 @@ class ImageProvider with ChangeNotifier { debugPrint('โŒ [ImageProvider] Failed to restore: $e'); } } + + void _resetInMemoryState() { + _sessions.clear(); + _outgoing.clear(); + _ignoredIncomingSessions.clear(); + } } class _OutgoingSession { diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index 3852837..5bd5fff 100644 --- a/lib/providers/sensors_provider.dart +++ b/lib/providers/sensors_provider.dart @@ -486,7 +486,14 @@ class SensorsProvider with ChangeNotifier { final existing = contactsProvider.findContactByKey( Uint8List.fromList(selfKey), ); - return existing ?? _buildSelfCandidate(connectionProvider); + final selfTelemetry = contactsProvider.selfTelemetry; + if (existing != null) { + return selfTelemetry == null + ? existing + : existing.copyWith(telemetry: selfTelemetry); + } + + return _buildSelfCandidate(connectionProvider, telemetry: selfTelemetry); } Future addSensor(Contact contact) async { @@ -564,6 +571,31 @@ class SensorsProvider with ChangeNotifier { notifyListeners(); } + Future reorderSensors(int oldIndex, int newIndex) async { + if (_watchedSensorKeys.length < 2) { + return; + } + if (oldIndex < 0 || + oldIndex >= _watchedSensorKeys.length || + newIndex < 0 || + newIndex > _watchedSensorKeys.length) { + return; + } + + var targetIndex = newIndex; + if (oldIndex < targetIndex) { + targetIndex -= 1; + } + if (oldIndex == targetIndex) { + return; + } + + final movedKey = _watchedSensorKeys.removeAt(oldIndex); + _watchedSensorKeys.insert(targetIndex, movedKey); + await _persistWatchedSensors(); + notifyListeners(); + } + List availableCandidates( ContactsProvider contactsProvider, { ConnectionProvider? connectionProvider, @@ -748,7 +780,10 @@ class SensorsProvider with ChangeNotifier { notifyListeners(); } - Contact? _buildSelfCandidate(ConnectionProvider connectionProvider) { + Contact? _buildSelfCandidate( + ConnectionProvider connectionProvider, { + ContactTelemetry? telemetry, + }) { final deviceInfo = connectionProvider.deviceInfo; final selfKey = deviceInfo.publicKey; if (selfKey == null || selfKey.isEmpty) { @@ -766,6 +801,7 @@ class SensorsProvider with ChangeNotifier { advLat: deviceInfo.advLat ?? 0, advLon: deviceInfo.advLon ?? 0, lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: telemetry, ); } } diff --git a/lib/providers/voice_provider.dart b/lib/providers/voice_provider.dart index 2465d14..6a6a84d 100644 --- a/lib/providers/voice_provider.dart +++ b/lib/providers/voice_provider.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; +import '../services/profiles_feature_service.dart'; import 'helpers/raw_session_retransmit.dart'; import '../utils/voice_message_parser.dart'; import '../services/voice_codec_service.dart'; @@ -92,6 +93,9 @@ class VoiceProvider with ChangeNotifier { _restorePersistedVoiceData(); } + String _storageKey() => + ProfileStorageScope.scopedKey(_voiceSessionsStorageKey); + // โ”€โ”€ Session accessors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ VoiceSession? session(String sessionId) => _sessions[sessionId]; @@ -329,19 +333,22 @@ class VoiceProvider with ChangeNotifier { } Future clearStoredVoiceData() async { - _sessions.clear(); - _outgoingSessions.clear(); - _ignoredIncomingSessions.clear(); - _playingSessionId = null; + await _resetInMemoryState(); notifyListeners(); try { final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_voiceSessionsStorageKey); + await prefs.remove(_storageKey()); } catch (e) { debugPrint('โŒ [VoiceProvider] Failed to clear stored voice data: $e'); } } + Future reloadProfileScopedState() async { + await _resetInMemoryState(); + await _restorePersistedVoiceData(); + notifyListeners(); + } + Future _persistVoiceData() async { try { final prefs = await SharedPreferences.getInstance(); @@ -365,7 +372,7 @@ class VoiceProvider with ChangeNotifier { ) .toList(), }; - await prefs.setString(_voiceSessionsStorageKey, jsonEncode(payload)); + await prefs.setString(_storageKey(), jsonEncode(payload)); } catch (e) { debugPrint('โŒ [VoiceProvider] Failed to persist voice data: $e'); } @@ -374,7 +381,7 @@ class VoiceProvider with ChangeNotifier { Future _restorePersistedVoiceData() async { try { final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_voiceSessionsStorageKey); + final raw = prefs.getString(_storageKey()); if (raw == null || raw.isEmpty) return; final parsed = jsonDecode(raw) as Map; @@ -437,6 +444,16 @@ class VoiceProvider with ChangeNotifier { } } + Future _resetInMemoryState() async { + if (_playingSessionId != null || _player.isPlaying) { + await _player.stop(); + } + _sessions.clear(); + _outgoingSessions.clear(); + _ignoredIncomingSessions.clear(); + _playingSessionId = null; + } + @override void dispose() { _playerEventsSub.cancel(); diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index b8492de..0d02883 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -178,12 +178,20 @@ class _DeviceConfigScreenState extends State { late TextEditingController _lonController; late TextEditingController _freqController; late TextEditingController _txPowerController; + late TextEditingController _gpsIntervalController; + late TextEditingController _autoAddMaxHopsController; late final ConnectionProvider _connectionProvider; - bool _telemetryEnabled = false; + int _baseTelemetryMode = 0; + int _locationTelemetryMode = 0; + int _environmentTelemetryMode = 0; + int _advertLocationPolicy = 0; + bool _multiAcksEnabled = false; bool _repeatEnabled = false; bool? _gpsEnabled; // null = not supported by hardware bool _gpsLoading = false; + bool _isSyncingDeviceTime = false; + int? _selectedPathHashMode; bool _autoAddDiscoveredContactsEnabled = true; bool _autoAddUsersEnabled = true; bool _autoAddRepeatersEnabled = true; @@ -250,6 +258,10 @@ class _DeviceConfigScreenState extends State { _txPowerController = TextEditingController( text: deviceInfo.txPower?.toString() ?? '20', ); + _gpsIntervalController = TextEditingController(); + _autoAddMaxHopsController = TextEditingController( + text: (deviceInfo.autoAddMaxHops ?? 0).toString(), + ); if (deviceInfo.radioBw != null && deviceInfo.radioBw! >= 0 && @@ -275,10 +287,17 @@ class _DeviceConfigScreenState extends State { ); _showCustomRadioSettings = _selectedRadioPreset == null; - // Check if telemetry is enabled (check if lat/lon are set and not zero) - _telemetryEnabled = - (deviceInfo.advLat != null && deviceInfo.advLat! != 0) || - (deviceInfo.advLon != null && deviceInfo.advLon! != 0); + final telemetryModes = deviceInfo.telemetryModes; + _baseTelemetryMode = telemetryModes != null ? telemetryModes & 0x03 : 0; + _locationTelemetryMode = telemetryModes != null + ? (telemetryModes >> 2) & 0x03 + : 0; + _environmentTelemetryMode = telemetryModes != null + ? (telemetryModes >> 4) & 0x03 + : 0; + _advertLocationPolicy = deviceInfo.advertLocPolicy ?? 0; + _multiAcksEnabled = (deviceInfo.multiAcks ?? 0) != 0; + _selectedPathHashMode = deviceInfo.pathHashMode; // Initialize repeat mode from device info (firmware v9+) _repeatEnabled = deviceInfo.clientRepeat ?? false; @@ -308,6 +327,8 @@ class _DeviceConfigScreenState extends State { _lonController.dispose(); _freqController.dispose(); _txPowerController.dispose(); + _gpsIntervalController.dispose(); + _autoAddMaxHopsController.dispose(); super.dispose(); } @@ -428,6 +449,7 @@ class _DeviceConfigScreenState extends State { deviceInfo.autoAddRoomServers, deviceInfo.autoAddSensors, deviceInfo.autoAddOverwriteOldest, + deviceInfo.autoAddMaxHops, ].join('|'); } @@ -467,22 +489,22 @@ class _DeviceConfigScreenState extends State { _autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true; _autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true; _overwriteOldestAutoAddEnabled = deviceInfo.autoAddOverwriteOldest ?? false; + _autoAddMaxHopsController.text = (deviceInfo.autoAddMaxHops ?? 0) + .toString(); } - int _telemetryModesForSave(ConnectionProvider connectionProvider) { - final deviceInfo = connectionProvider.deviceInfo; - final telemetryEnabled = - (deviceInfo.advLat != null && deviceInfo.advLat != 0) || - (deviceInfo.advLon != null && deviceInfo.advLon != 0); - return deviceInfo.telemetryModes ?? (telemetryEnabled ? 0x0A : 0x00); + int _telemetryModesForSave() { + return (_environmentTelemetryMode << 4) | + (_locationTelemetryMode << 2) | + _baseTelemetryMode; } - int _advertLocationPolicyForSave(ConnectionProvider connectionProvider) { - final deviceInfo = connectionProvider.deviceInfo; - final telemetryEnabled = - (deviceInfo.advLat != null && deviceInfo.advLat != 0) || - (deviceInfo.advLon != null && deviceInfo.advLon != 0); - return deviceInfo.advertLocPolicy ?? (telemetryEnabled ? 1 : 0); + int _advertLocationPolicyForSave() { + return _advertLocationPolicy; + } + + int _multiAcksForSave() { + return _multiAcksEnabled ? 1 : 0; } Future _savePublicInfo() async { @@ -501,8 +523,24 @@ class _DeviceConfigScreenState extends State { await connectionProvider.setAdvertName(_nameController.text); } - // Save position and telemetry settings - if (_telemetryEnabled) { + final gpsIntervalText = _gpsIntervalController.text.trim(); + if (gpsIntervalText.isNotEmpty) { + final gpsInterval = int.tryParse(gpsIntervalText); + if (gpsInterval == null || gpsInterval < 0 || gpsInterval > 86400) { + if (mounted) { + setState(() { + _publicInfoError = + 'GPS interval must be a whole number between 0 and 86400 seconds.'; + _isSavingPublicInfo = false; + }); + } + return; + } + await connectionProvider.setCustomVar('gps_interval', gpsIntervalText); + } + + // Save stored coordinates only when the firmware advert policy uses prefs. + if (_advertLocationPolicy == 2) { // Parse and validate coordinates final latResult = validator.parseLatitude(_latController.text); if (!latResult.isSuccess) { @@ -530,27 +568,15 @@ class _DeviceConfigScreenState extends State { latitude: latResult.value!, longitude: lonResult.value!, ); - - // Set telemetry modes to "Allow All" (mode 2 for both base and location) - final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2) - await connectionProvider.setOtherParams( - manualAddContacts: _autoAddFilterModeFlag, - telemetryModes: telemetryModes, - advertLocationPolicy: 1, - ); - } else { - // Clear position - await connectionProvider.setAdvertLatLon(latitude: 0.0, longitude: 0.0); - - // Set telemetry modes to "Deny" (mode 0) - final telemetryModes = 0x00; - await connectionProvider.setOtherParams( - manualAddContacts: _autoAddFilterModeFlag, - telemetryModes: telemetryModes, - advertLocationPolicy: 0, - ); } + await connectionProvider.setOtherParams( + manualAddContacts: _autoAddFilterModeFlag, + telemetryModes: _telemetryModesForSave(), + advertLocationPolicy: _advertLocationPolicyForSave(), + multiAcks: _multiAcksForSave(), + ); + // Refetch device info to update UI with new settings await connectionProvider.refreshDeviceInfo(); @@ -625,6 +651,10 @@ class _DeviceConfigScreenState extends State { // Save TX power await connectionProvider.setTxPower(txPowerResult.value!); + if (_selectedPathHashMode != null) { + await connectionProvider.setPathHashMode(_selectedPathHashMode!); + } + // Refetch device info to update UI with new settings await connectionProvider.refreshDeviceInfo(); @@ -658,6 +688,8 @@ class _DeviceConfigScreenState extends State { _autoAddDiscoveredContactsEnabled && _autoAddSensorsEnabled; final overwriteOldest = _autoAddDiscoveredContactsEnabled && _overwriteOldestAutoAddEnabled; + final maxHopsText = _autoAddMaxHopsController.text.trim(); + final maxHops = int.tryParse(maxHopsText); setState(() { _isSavingAutoDiscoverySettings = true; @@ -666,18 +698,22 @@ class _DeviceConfigScreenState extends State { }); try { + if (maxHops == null || maxHops < 0 || maxHops > 64) { + throw Exception('Auto-add max hops must be between 0 and 64.'); + } await connectionProvider.setAutoaddConfig( autoAddUsers: autoAddUsers, autoAddRepeaters: autoAddRepeaters, autoAddRoomServers: autoAddRoomServers, autoAddSensors: autoAddSensors, overwriteOldest: overwriteOldest, + maxHops: maxHops, ); await connectionProvider.setOtherParams( manualAddContacts: _autoAddFilterModeFlag, - telemetryModes: _telemetryModesForSave(connectionProvider), - advertLocationPolicy: _advertLocationPolicyForSave(connectionProvider), - multiAcks: connectionProvider.deviceInfo.multiAcks ?? 0, + telemetryModes: _telemetryModesForSave(), + advertLocationPolicy: _advertLocationPolicyForSave(), + multiAcks: _multiAcksForSave(), ); await connectionProvider.getAutoaddConfig(); @@ -709,8 +745,12 @@ class _DeviceConfigScreenState extends State { final vars = await _connectionProvider.getCustomVars(); if (!mounted) return; final gpsValue = vars['gps']; + final gpsIntervalValue = vars['gps_interval']; setState(() { _gpsEnabled = gpsValue != null ? gpsValue == '1' : null; + if (gpsIntervalValue != null) { + _gpsIntervalController.text = gpsIntervalValue; + } }); } catch (_) { // Device may not support custom vars (old firmware / no GPS hardware) @@ -794,7 +834,10 @@ class _DeviceConfigScreenState extends State { setState(() { _latController.text = position.latitude.toStringAsFixed(6); _lonController.text = position.longitude.toStringAsFixed(6); - _telemetryEnabled = true; + _advertLocationPolicy = 2; + if (_locationTelemetryMode == 0) { + _locationTelemetryMode = 2; + } }); if (mounted) { @@ -823,6 +866,37 @@ class _DeviceConfigScreenState extends State { } } + Future _syncDeviceTime() async { + setState(() => _isSyncingDeviceTime = true); + try { + _connectionProvider.clearError(); + await _connectionProvider.syncDeviceTime(); + final syncError = _connectionProvider.error; + if (syncError != null) { + throw Exception(syncError); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Device time synced to this phone.'), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to sync device time: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } finally { + if (mounted) { + setState(() => _isSyncingDeviceTime = false); + } + } + } + Future _confirmFactoryReset() async { final confirmed = await showDialog( context: context, @@ -1093,9 +1167,7 @@ class _DeviceConfigScreenState extends State { final deviceInfo = context.watch().deviceInfo; final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final locationSet = - (deviceInfo.advLat != null && deviceInfo.advLat != 0) || - (deviceInfo.advLon != null && deviceInfo.advLon != 0); + final locationSet = _advertLocationPolicy != 0; return Scaffold( appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), @@ -1173,6 +1245,130 @@ class _DeviceConfigScreenState extends State { ), ), SizedBox(height: 20), + _ConfigSectionCard( + title: 'Device info', + subtitle: + 'Capabilities reported by the connected radio and maintenance tools.', + icon: Icons.info_outline_rounded, + child: LayoutBuilder( + builder: (context, constraints) { + final cardWidth = constraints.maxWidth > 420 + ? (constraints.maxWidth - 24) / 3 + : (constraints.maxWidth - 12) / 2; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: cardWidth, + child: _StorageStat( + label: 'BLE PIN', + value: _formatBlePin(deviceInfo.blePin), + compact: true, + ), + ), + SizedBox( + width: cardWidth, + child: _StorageStat( + label: AppLocalizations.of( + context, + )!.maxContacts, + value: + deviceInfo.maxContacts?.toString() ?? + AppLocalizations.of(context)!.unknown, + compact: true, + ), + ), + SizedBox( + width: cardWidth, + child: _StorageStat( + label: AppLocalizations.of( + context, + )!.maxChannels, + value: + deviceInfo.maxChannels?.toString() ?? + AppLocalizations.of(context)!.unknown, + compact: true, + ), + ), + if (deviceInfo.pathHashMode != null) + SizedBox( + width: cardWidth, + child: _StorageStat( + label: 'Path hash', + value: _pathHashModeLabel( + deviceInfo.pathHashMode!, + ), + compact: true, + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: colorScheme.outlineVariant, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Clock maintenance', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + 'Refresh the radio clock if room logins or message timestamps look off.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isSyncingDeviceTime + ? null + : _syncDeviceTime, + icon: _isSyncingDeviceTime + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.schedule_rounded), + label: Text( + _isSyncingDeviceTime + ? 'Syncing time...' + : 'Sync device time', + ), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(46), + ), + ), + ), + ], + ), + ), + ], + ); + }, + ), + ), + SizedBox(height: 20), _ConfigSectionCard( title: AppLocalizations.of(context)!.autoDiscovery, subtitle: @@ -1310,6 +1506,22 @@ class _DeviceConfigScreenState extends State { : null, ), ), + const SizedBox(height: 16), + TextField( + controller: _autoAddMaxHopsController, + onChanged: (_) => _markAutoDiscoverySettingsDirty(), + decoration: InputDecoration( + labelText: 'Auto-add max hops', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: + '0 means no limit. 1 keeps auto-add to direct neighbors only.', + ), + keyboardType: TextInputType.number, + ), const SizedBox(height: 18), if (_autoDiscoverySettingsError != null) ...[ Text( @@ -1346,27 +1558,87 @@ class _DeviceConfigScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _SettingHighlightCard( - icon: _telemetryEnabled - ? Icons.travel_explore - : Icons.location_disabled, - title: AppLocalizations.of( - context, - )!.telemetryAndLocationSharing, - description: 'Share your location with nearby devices.', - accentColor: _telemetryEnabled - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - trailing: Switch( - value: _telemetryEnabled, - onChanged: (value) { - setState(() { - _telemetryEnabled = value; - _publicInfoSaved = false; - _publicInfoError = null; - }); - }, - ), + _ConfigDropdownField( + label: 'Base telemetry', + value: _baseTelemetryMode, + items: const [ + DropdownMenuItem(value: 0, child: Text('Deny')), + DropdownMenuItem( + value: 1, + child: Text('Use contact flags'), + ), + DropdownMenuItem(value: 2, child: Text('Allow all')), + ], + onChanged: (value) { + if (value == null) return; + setState(() { + _baseTelemetryMode = value; + _markPublicInfoDirty(); + }); + }, + ), + const SizedBox(height: 16), + _ConfigDropdownField( + label: 'Location telemetry', + value: _locationTelemetryMode, + items: const [ + DropdownMenuItem(value: 0, child: Text('Deny')), + DropdownMenuItem( + value: 1, + child: Text('Use contact flags'), + ), + DropdownMenuItem(value: 2, child: Text('Allow all')), + ], + onChanged: (value) { + if (value == null) return; + setState(() { + _locationTelemetryMode = value; + _markPublicInfoDirty(); + }); + }, + ), + const SizedBox(height: 16), + _ConfigDropdownField( + label: 'Environmental telemetry', + value: _environmentTelemetryMode, + items: const [ + DropdownMenuItem(value: 0, child: Text('Deny')), + DropdownMenuItem( + value: 1, + child: Text('Use contact flags'), + ), + DropdownMenuItem(value: 2, child: Text('Allow all')), + ], + onChanged: (value) { + if (value == null) return; + setState(() { + _environmentTelemetryMode = value; + _markPublicInfoDirty(); + }); + }, + ), + const SizedBox(height: 16), + _ConfigDropdownField( + label: 'GPS advert policy', + value: _advertLocationPolicy, + items: const [ + DropdownMenuItem(value: 0, child: Text('Hidden')), + DropdownMenuItem( + value: 1, + child: Text('Share live GPS'), + ), + DropdownMenuItem( + value: 2, + child: Text('Use saved coordinates'), + ), + ], + onChanged: (value) { + if (value == null) return; + setState(() { + _advertLocationPolicy = value; + _markPublicInfoDirty(); + }); + }, ), if (_gpsEnabled != null) ...[ const SizedBox(height: 12), @@ -1392,6 +1664,43 @@ class _DeviceConfigScreenState extends State { ), ), ], + const SizedBox(height: 16), + TextField( + controller: _gpsIntervalController, + onChanged: (_) => _markPublicInfoDirty(), + decoration: InputDecoration( + labelText: 'GPS interval (seconds)', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: + 'Firmware supports 0-86400 seconds. Older builds may not report the current value back.', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + _SettingHighlightCard( + icon: _multiAcksEnabled + ? Icons.mark_email_read_outlined + : Icons.mark_email_unread_outlined, + title: 'Multi-ACK mode', + description: + 'Ask the radio to request extra acknowledgements when the firmware supports it.', + accentColor: _multiAcksEnabled + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + trailing: Switch( + value: _multiAcksEnabled, + onChanged: (value) { + setState(() { + _multiAcksEnabled = value; + _markPublicInfoDirty(); + }); + }, + ), + ), const SizedBox(height: 18), TextField( controller: _nameController, @@ -1407,7 +1716,7 @@ class _DeviceConfigScreenState extends State { 'This is the name other devices will see on the mesh.', ), ), - if (_telemetryEnabled) ...[ + if (_advertLocationPolicy == 2) ...[ const SizedBox(height: 16), Container( padding: const EdgeInsets.all(14), @@ -1420,14 +1729,14 @@ class _DeviceConfigScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Shared location', + 'Saved coordinates', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w800, ), ), const SizedBox(height: 4), Text( - 'Set coordinates manually or use your current location.', + 'These coordinates are used when advert policy is set to saved coordinates.', style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -1463,6 +1772,22 @@ class _DeviceConfigScreenState extends State { ], ), ), + ] else if (_advertLocationPolicy == 1) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(22), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Text( + 'The firmware will advertise the live GPS fix from the onboard sensor manager when available.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), ], const SizedBox(height: 18), if (_publicInfoError != null) ...[ @@ -1708,6 +2033,45 @@ class _DeviceConfigScreenState extends State { ), keyboardType: TextInputType.number, ), + if (_selectedPathHashMode != null) ...[ + const SizedBox(height: 16), + DropdownButtonFormField( + key: ValueKey('path-hash-$_selectedPathHashMode'), + initialValue: _selectedPathHashMode, + decoration: InputDecoration( + labelText: 'Advert path hash size', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + helperText: + 'Controls the low-level hash size used in adverts and flood paths.', + ), + items: const [ + DropdownMenuItem( + value: 0, + child: Text('1 byte (mode 0)'), + ), + DropdownMenuItem( + value: 1, + child: Text('2 bytes (mode 1)'), + ), + DropdownMenuItem( + value: 2, + child: Text('3 bytes (mode 2)'), + ), + ], + onChanged: (int? newValue) { + if (newValue != null) { + setState(() { + _selectedPathHashMode = newValue; + }); + _markRadioSettingsDirty(); + } + }, + ), + ], ], ), ), @@ -1914,6 +2278,26 @@ class _DeviceConfigScreenState extends State { } return '$storageKb KB'; } + + String _formatBlePin(int? blePin) { + if (blePin == null) { + return AppLocalizations.of(context)!.unknown; + } + return blePin.toString().padLeft(6, '0'); + } + + String _pathHashModeLabel(int mode) { + switch (mode) { + case 0: + return '1 byte'; + case 1: + return '2 bytes'; + case 2: + return '3 bytes'; + default: + return 'Mode $mode'; + } + } } class _ConfigHeroCard extends StatelessWidget { @@ -2153,17 +2537,52 @@ class _ConfigSectionCard extends StatelessWidget { } } +class _ConfigDropdownField extends StatelessWidget { + final String label; + final T value; + final List> items; + final ValueChanged onChanged; + + const _ConfigDropdownField({ + required this.label, + required this.value, + required this.items, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return DropdownButtonFormField( + initialValue: value, + decoration: InputDecoration( + labelText: label, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(20)), + filled: true, + fillColor: colorScheme.surfaceContainerLowest, + ), + items: items, + onChanged: onChanged, + ); + } +} + class _StorageStat extends StatelessWidget { final String label; final String value; + final bool compact; - const _StorageStat({required this.label, required this.value}); + const _StorageStat({ + required this.label, + required this.value, + this.compact = false, + }); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Container( - padding: const EdgeInsets.all(14), + padding: EdgeInsets.all(compact ? 12 : 14), decoration: BoxDecoration( color: colorScheme.surfaceContainerLowest, borderRadius: BorderRadius.circular(20), @@ -2177,14 +2596,16 @@ class _StorageStat extends StatelessWidget { style: TextStyle( fontWeight: FontWeight.w600, color: colorScheme.onSurfaceVariant, + fontSize: compact ? 13 : null, ), ), - const SizedBox(height: 6), + SizedBox(height: compact ? 4 : 6), Text( value, style: TextStyle( fontWeight: FontWeight.w800, color: colorScheme.onSurface, + fontSize: compact ? 17 : null, ), ), ], diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 4e2d77a..5a19fbc 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -8,7 +8,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:vibration/vibration.dart'; import '../providers/connection_provider.dart'; import '../providers/app_provider.dart'; -import '../models/contact.dart'; import '../models/device_info.dart' show ConnectionMode, DeviceInfo; import '../providers/messages_provider.dart'; import '../providers/contacts_provider.dart'; @@ -439,133 +438,129 @@ class _HomeScreenState extends State // Request self telemetry so it's fresh context.read().requestSelfTelemetry(); - // Find the device's own contact to show self telemetry - final selfKey = deviceInfo.publicKey; - Contact? selfContact; - if (selfKey != null) { - selfContact = context.read().findContactByKey( - Uint8List.fromList(selfKey), - ); - } - final telemetry = selfContact?.telemetry; - showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (context) => SafeArea( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: Theme.of(context).dividerColor, - borderRadius: BorderRadius.circular(2), - ), + builder: (context) => Consumer( + builder: (context, contactsProvider, child) { + final telemetry = contactsProvider.selfTelemetry; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Theme.of(context).dividerColor, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + deviceInfo.selfName ?? deviceInfo.deviceName ?? 'Device', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 16), + _deviceInfoRow( + context, + Icons.bluetooth, + 'BLE Signal', + deviceInfo.signalRssi != null + ? '${deviceInfo.signalRssi} dBm' + : 'N/A', + ), + if (deviceInfo.batteryPercent != null) + _deviceInfoRow( + context, + BatteryDisplayHelper.getBatteryIcon( + deviceInfo.batteryPercent!, + ), + 'Battery', + '${deviceInfo.batteryPercent!.round()}%', + ), + if (deviceInfo.batteryMilliVolts != null) + _deviceInfoRow( + context, + Icons.bolt, + 'Voltage', + '${(deviceInfo.batteryMilliVolts! / 1000).toStringAsFixed(2)}V', + ), + if (deviceInfo.storageUsedKb != null && + deviceInfo.storageTotalKb != null) + _deviceInfoRow( + context, + Icons.storage, + 'Storage', + '${deviceInfo.storageUsedKb} / ${deviceInfo.storageTotalKb} KB', + ), + if (deviceInfo.firmwareVersion != null) + _deviceInfoRow( + context, + Icons.system_update, + 'Firmware', + 'v${deviceInfo.firmwareVersion}', + ), + if (deviceInfo.radioFreq != null) + _deviceInfoRow( + context, + Icons.radio, + 'Frequency', + '${(deviceInfo.radioFreq! / 1000).toStringAsFixed(3)} MHz', + ), + if (deviceInfo.txPower != null) + _deviceInfoRow( + context, + Icons.power, + 'TX Power', + '${deviceInfo.txPower} dBm', + ), + if (telemetry != null) ...[ + const Divider(height: 24), + Text( + 'Self Telemetry', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + if (telemetry.temperature != null) + _deviceInfoRow( + context, + Icons.thermostat, + 'Temperature', + '${telemetry.temperature!.toStringAsFixed(1)}ยฐC', + ), + if (telemetry.humidity != null) + _deviceInfoRow( + context, + Icons.water_drop, + 'Humidity', + '${telemetry.humidity!.toStringAsFixed(1)}%', + ), + if (telemetry.pressure != null) + _deviceInfoRow( + context, + Icons.compress, + 'Pressure', + '${telemetry.pressure!.toStringAsFixed(1)} hPa', + ), + if (telemetry.gpsLocation != null) + _deviceInfoRow( + context, + Icons.gps_fixed, + 'GPS', + '${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}', + ), + ], + ], ), - const SizedBox(height: 16), - Text( - deviceInfo.selfName ?? deviceInfo.deviceName ?? 'Device', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 16), - _deviceInfoRow( - context, - Icons.bluetooth, - 'BLE Signal', - deviceInfo.signalRssi != null - ? '${deviceInfo.signalRssi} dBm' - : 'N/A', - ), - if (deviceInfo.batteryPercent != null) - _deviceInfoRow( - context, - BatteryDisplayHelper.getBatteryIcon( - deviceInfo.batteryPercent!, - ), - 'Battery', - '${deviceInfo.batteryPercent!.round()}%', - ), - if (deviceInfo.batteryMilliVolts != null) - _deviceInfoRow( - context, - Icons.bolt, - 'Voltage', - '${(deviceInfo.batteryMilliVolts! / 1000).toStringAsFixed(2)}V', - ), - if (deviceInfo.storageUsedKb != null && - deviceInfo.storageTotalKb != null) - _deviceInfoRow( - context, - Icons.storage, - 'Storage', - '${deviceInfo.storageUsedKb} / ${deviceInfo.storageTotalKb} KB', - ), - if (deviceInfo.firmwareVersion != null) - _deviceInfoRow( - context, - Icons.system_update, - 'Firmware', - 'v${deviceInfo.firmwareVersion}', - ), - if (deviceInfo.radioFreq != null) - _deviceInfoRow( - context, - Icons.radio, - 'Frequency', - '${(deviceInfo.radioFreq! / 1000).toStringAsFixed(3)} MHz', - ), - if (deviceInfo.txPower != null) - _deviceInfoRow( - context, - Icons.power, - 'TX Power', - '${deviceInfo.txPower} dBm', - ), - if (telemetry != null) ...[ - const Divider(height: 24), - Text( - 'Self Telemetry', - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - if (telemetry.temperature != null) - _deviceInfoRow( - context, - Icons.thermostat, - 'Temperature', - '${telemetry.temperature!.toStringAsFixed(1)}ยฐC', - ), - if (telemetry.humidity != null) - _deviceInfoRow( - context, - Icons.water_drop, - 'Humidity', - '${telemetry.humidity!.toStringAsFixed(1)}%', - ), - if (telemetry.pressure != null) - _deviceInfoRow( - context, - Icons.compress, - 'Pressure', - '${telemetry.pressure!.toStringAsFixed(1)} hPa', - ), - if (telemetry.gpsLocation != null) - _deviceInfoRow( - context, - Icons.gps_fixed, - 'GPS', - '${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}', - ), - ], - ], - ), - ), + ), + ); + }, ), ); } @@ -587,9 +582,9 @@ class _HomeScreenState extends State ), Text( value, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w600, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), ], ), @@ -1224,58 +1219,56 @@ class _HomeScreenState extends State ), const SizedBox(height: 2), GestureDetector( - onTap: () => _showDeviceInfoSheet( - context, - deviceInfo, - ), + onTap: () => + _showDeviceInfoSheet(context, deviceInfo), child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isTcpConnected - ? Icons.wifi_rounded - : Icons.bluetooth_connected_rounded, - size: 13, - color: signalColor, - ), - if (!isTcpConnected && - deviceInfo.signalRssi != null) ...[ - const SizedBox(width: 4), - _buildMiniSignalBars( - activeBars: - BatteryDisplayHelper.getSignalBars( - deviceInfo.signalRssi!, - ), + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isTcpConnected + ? Icons.wifi_rounded + : Icons.bluetooth_connected_rounded, + size: 13, color: signalColor, ), - ], - if (deviceInfo.batteryPercent != null) ...[ - const SizedBox(width: 8), - Icon( - BatteryDisplayHelper.getBatteryIcon( - deviceInfo.batteryPercent!, + if (!isTcpConnected && + deviceInfo.signalRssi != null) ...[ + const SizedBox(width: 4), + _buildMiniSignalBars( + activeBars: + BatteryDisplayHelper.getSignalBars( + deviceInfo.signalRssi!, + ), + color: signalColor, ), - size: 13, - color: - BatteryDisplayHelper.getBatteryColor( - deviceInfo.batteryPercent!, - ), - ), - const SizedBox(width: 2), - Text( - '${deviceInfo.batteryPercent!.round()}%', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, + ], + if (deviceInfo.batteryPercent != null) ...[ + const SizedBox(width: 8), + Icon( + BatteryDisplayHelper.getBatteryIcon( + deviceInfo.batteryPercent!, + ), + size: 13, color: BatteryDisplayHelper.getBatteryColor( deviceInfo.batteryPercent!, ), ), - ), + const SizedBox(width: 2), + Text( + '${deviceInfo.batteryPercent!.round()}%', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: + BatteryDisplayHelper.getBatteryColor( + deviceInfo.batteryPercent!, + ), + ), + ), + ], ], - ], - ), + ), ), ], ), diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index d94b081..8f06d24 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -299,24 +299,28 @@ class _SensorsTabState extends State { final hasPersistedSensors = sensorsProvider.watchedSensorKeys.isNotEmpty; - return RefreshIndicator( - onRefresh: () => _refreshAll(context), - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), - children: [ - if (displayKeys.isEmpty) - const _EmptySensorsState() - else - ...displayKeys.map((key) { - final contact = sensorsProvider.contactForDisplay( - key, - contactsProvider: contactsProvider, - connectionProvider: connectionProvider, - ); - final availableFieldKeys = sensorMetricKeysFor(contact); - final visibleFields = sensorsProvider - .effectiveVisibleFieldsFor(key, availableFieldKeys); - return SensorTelemetryCard( + Widget buildSensorCard(String key, int index) { + final contact = sensorsProvider.contactForDisplay( + key, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + final availableFieldKeys = sensorMetricKeysFor(contact); + final visibleFields = sensorsProvider.effectiveVisibleFieldsFor( + key, + availableFieldKeys, + ); + + return Padding( + key: ValueKey('sensor_card_$key'), + padding: EdgeInsets.only( + bottom: index == displayKeys.length - 1 ? 0 : 12, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: SensorTelemetryCard( contact: contact, state: sensorsProvider.stateFor(key), visibleFields: visibleFields, @@ -348,10 +352,70 @@ class _SensorsTabState extends State { contactsProvider: contactsProvider, connectionProvider: connectionProvider, ), - ); - }), - ], - ), + ), + ), + if (hasPersistedSensors) ...[ + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(top: 20), + child: ReorderableDragStartListener( + index: index, + child: Tooltip( + message: 'Move card', + child: Container( + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 12, + ), + child: Icon( + Icons.drag_indicator, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ], + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () => _refreshAll(context), + child: displayKeys.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + children: const [_EmptySensorsState()], + ) + : hasPersistedSensors + ? ReorderableListView.builder( + buildDefaultDragHandles: false, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + itemCount: displayKeys.length, + onReorder: (oldIndex, newIndex) => + sensorsProvider.reorderSensors(oldIndex, newIndex), + itemBuilder: (context, index) => + buildSensorCard(displayKeys[index], index), + ) + : ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + children: [ + for (var i = 0; i < displayKeys.length; i++) + buildSensorCard(displayKeys[i], i), + ], + ), ); }, ), diff --git a/lib/services/profile_workspace_coordinator.dart b/lib/services/profile_workspace_coordinator.dart index e6d1bad..ff240d6 100644 --- a/lib/services/profile_workspace_coordinator.dart +++ b/lib/services/profile_workspace_coordinator.dart @@ -14,6 +14,8 @@ import '../providers/drawing_provider.dart'; import '../providers/map_provider.dart'; import '../providers/messages_provider.dart'; import '../providers/sensors_provider.dart'; +import '../providers/voice_provider.dart'; +import '../providers/image_provider.dart' as ip; import 'app_config_snapshot_service.dart'; import 'contact_storage_service.dart'; import 'device_config_applicator.dart'; @@ -33,6 +35,8 @@ class ProfileWorkspaceCoordinator { required this.mapProvider, required this.drawingProvider, required this.channelsProvider, + required this.voiceProvider, + required this.imageProvider, required this.appProvider, AppConfigSnapshotService? appConfigSnapshotService, MapWorkspaceSnapshotService? mapWorkspaceSnapshotService, @@ -58,13 +62,15 @@ class ProfileWorkspaceCoordinator { final MapProvider mapProvider; final DrawingProvider drawingProvider; final ChannelsProvider channelsProvider; + final VoiceProvider voiceProvider; + final ip.ImageProvider imageProvider; final AppProvider appProvider; final AppConfigSnapshotService _appConfigSnapshotService; final MapWorkspaceSnapshotService _mapWorkspaceSnapshotService; final DeviceConfigApplicator _deviceConfigApplicator; final MessageStorageService _messageStorageService; final ContactStorageService _contactStorageService; - bool _isSyncingDeviceProfile = false; + Future? _deviceProfileSyncFuture; Future setProfilesEnabled(bool enabled) async { final wasEnabled = profileManager.profilesEnabled; @@ -279,41 +285,54 @@ class ProfileWorkspaceCoordinator { } Future syncActiveProfileForCurrentDevice() async { - if (!profileManager.profilesEnabled || _isSyncingDeviceProfile) { + if (!profileManager.profilesEnabled) { return; } + final inFlightSync = _deviceProfileSyncFuture; + if (inFlightSync != null) { + await inFlightSync; + return; + } + + final syncFuture = _syncActiveProfileForCurrentDeviceInternal(); + _deviceProfileSyncFuture = syncFuture; + try { + await syncFuture; + } finally { + if (identical(_deviceProfileSyncFuture, syncFuture)) { + _deviceProfileSyncFuture = null; + } + } + } + + Future _syncActiveProfileForCurrentDeviceInternal() async { 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; + 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, + ); } Future _ensureProfileForCurrentDevice() async { @@ -387,6 +406,8 @@ class ProfileWorkspaceCoordinator { await sensorsProvider.reloadProfileScopedState(); await drawingProvider.reloadProfileScopedState(); await mapProvider.reloadProfileScopedState(); + await voiceProvider.reloadProfileScopedState(); + await imageProvider.reloadProfileScopedState(); await appProvider.reloadProfileScopedSettings(); } diff --git a/lib/widgets/connection_dialog.dart b/lib/widgets/connection_dialog.dart index 914d97a..c6cf227 100644 --- a/lib/widgets/connection_dialog.dart +++ b/lib/widgets/connection_dialog.dart @@ -5,8 +5,17 @@ import '../l10n/app_localizations.dart'; import '../providers/app_provider.dart'; import '../providers/connection_provider.dart'; import '../services/network_scanner_service.dart'; +import '../services/profile_workspace_coordinator.dart'; import '../services/serial/serial_transport.dart'; +Future _initializeConnectedWorkspace({ + required ProfileWorkspaceCoordinator profileWorkspaceCoordinator, + required AppProvider appProvider, +}) async { + await profileWorkspaceCoordinator.syncActiveProfileForCurrentDevice(); + await appProvider.initialize(); +} + /// Connection Dialog with tabs for BLE devices and Network servers class ConnectionDialog extends StatefulWidget { const ConnectionDialog({super.key}); @@ -426,6 +435,8 @@ class _ConnectionDialogState extends State Future connectBle() async { final appProvider = context.read(); + final profileWorkspaceCoordinator = context + .read(); setState(() { _connectingBleDeviceId = deviceId; }); @@ -436,7 +447,11 @@ class _ConnectionDialogState extends State ); if (success && connectionProvider.deviceInfo.isConnected) { - await appProvider.initialize(); + await _initializeConnectedWorkspace( + profileWorkspaceCoordinator: + profileWorkspaceCoordinator, + appProvider: appProvider, + ); } } finally { if (mounted) { @@ -533,6 +548,8 @@ class _ConnectionDialogState extends State final connectionProvider = context .read(); final appProvider = context.read(); + final profileWorkspaceCoordinator = context + .read(); final navigator = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); @@ -554,7 +571,11 @@ class _ConnectionDialogState extends State server.ipAddress, server.port, ); - await appProvider.initialize(); + await _initializeConnectedWorkspace( + profileWorkspaceCoordinator: + profileWorkspaceCoordinator, + appProvider: appProvider, + ); if (mounted) { navigator.pop(); @@ -781,6 +802,8 @@ class _SerialDeviceListState extends State<_SerialDeviceList> { try { final connectionProvider = context.read(); final appProvider = context.read(); + final profileWorkspaceCoordinator = context + .read(); final connection = await _transport.connect(device); final success = await connectionProvider.connectSerial( service: connection.service, @@ -791,7 +814,10 @@ class _SerialDeviceListState extends State<_SerialDeviceList> { if (!mounted) return; if (success) { - await appProvider.initialize(); + await _initializeConnectedWorkspace( + profileWorkspaceCoordinator: profileWorkspaceCoordinator, + appProvider: appProvider, + ); widget.onConnected(); } else { await connection.disconnect(); diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index ab3c8f6..6ff008e 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -352,6 +352,7 @@ class ContactTile extends StatelessWidget { void _showContactActionSheet(BuildContext context, Contact contact) { final l10n = AppLocalizations.of(context)!; + final canToggleFavourite = !contact.isChannel; final canMessage = contact.type == ContactType.chat || contact.type == ContactType.room || @@ -369,192 +370,168 @@ class ContactTile extends StatelessWidget { final sensorsProvider = context.read(); final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex); - showModalBottomSheet( - context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (sheetContext) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!contact.isChannel) - ListTile( - leading: Icon( - contact.isFavourite ? Icons.star : Icons.star_outline, - color: contact.isFavourite ? Colors.amber : null, - ), - title: Text( - contact.isFavourite - ? 'Remove from Favourites' - : 'Add to Favourites', - ), - onTap: () async { - Navigator.pop(sheetContext); - final toggled = contact.toggleFavourite(); - final connectionProvider = context - .read(); - await connectionProvider.addOrUpdateContact(toggled); - if (context.mounted) { - // Refresh contact from device so the local cache is updated - await connectionProvider.getContact(contact.publicKey); - } - }, - ), - if (!contact.isChannel) - ListTile( - leading: Icon(Icons.share_outlined), - title: Text(l10n.shareContact), - onTap: () async { - Navigator.pop(sheetContext); - final connectionProvider = context - .read(); - final url = await connectionProvider.exportContactUrl( - contact.publicKey, - ); - if (url != null && context.mounted) { - await Clipboard.setData(ClipboardData(text: url)); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.contactLinkCopiedToClipboard), - ), - ); - } - } else if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.failedToExportContact)), - ); - } - }, - ), - ListTile( - leading: Icon(Icons.message_outlined), - title: Text(l10n.messages), - enabled: canMessage, - onTap: !canMessage - ? null - : () async { - Navigator.pop(sheetContext); - await _openMessagesForContact(context, contact); - }, - ), - if (contact.displayLocation != null) - ListTile( - leading: Icon(Icons.map_outlined), - title: Text(l10n.viewOnMap), - onTap: () { - Navigator.pop(sheetContext); - _showContactOnMap(context, contact); - }, - ), - if (contact.type == ContactType.room && !contact.isPublicChannel) - ListTile( - leading: const Icon(Icons.login), - title: Text( - context - .read() - .getRoomLoginState(contact.publicKeyPrefix) - ?.isLoggedIn == - true - ? AppLocalizations.of(context)!.reLoginToRoom - : AppLocalizations.of(context)!.loginToRoom, - ), - onTap: () { - Navigator.pop(sheetContext); - _showRoomLoginDialog(context, contact); - }, - ), - if (canPreviewSensor) - ListTile( - leading: Icon(Icons.visibility_outlined), - title: Text(l10n.preview), - onTap: () async { - Navigator.pop(sheetContext); - await Future.delayed(Duration.zero); - if (!context.mounted) return; - await _showSensorPreviewView(context, contact); - }, - ), - if (canAddToSensors) - ListTile( - leading: Icon( - isInSensors ? Icons.sensors : Icons.sensors_outlined, - ), - title: Text( - isInSensors - ? l10n.contactInSensors - : l10n.contactAddToSensors, - ), - enabled: !isInSensors, - onTap: isInSensors - ? null - : () async { - Navigator.pop(sheetContext); - await _addContactToSensors(context, contact); - }, - ), - if (canSetPath) - ListTile( - leading: Icon(Icons.alt_route), - title: Text(l10n.contactSetPath), - onTap: () { - Navigator.pop(sheetContext); - _showSetRouteDialog(context, contact); - }, - ), - if (!contact.isChannel) - ListTile( - leading: Icon(Icons.route), - title: Text(l10n.trace), - onTap: () { - Navigator.pop(sheetContext); - _showTraceSheet(context, contact); - }, - ), - if (contact.type == ContactType.repeater) - ListTile( - leading: const Icon(Icons.hub_outlined), - title: const Text('View Neighbours'), - onTap: () { - Navigator.pop(sheetContext); - _showNeighbours(context, contact); - }, - ), - if (!contact.isPublicChannel) - ListTile( - leading: Icon(Icons.edit_outlined), - title: Text(l10n.editName), - onTap: () { - Navigator.pop(sheetContext); - _showNameOverrideDialog(context, contact); - }, - ), - if (!contact.isPublicChannel) - ListTile( - leading: Icon(Icons.delete, color: Colors.red), - title: Text( - contact.isChannel ? l10n.deleteChannel : l10n.deleteContact, - style: const TextStyle(color: Colors.red), - ), - onTap: () async { - Navigator.pop(sheetContext); - await Future.delayed(Duration.zero); - if (!context.mounted) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - if (contact.isChannel) { - _showDeleteChannelDialog(context, contact); - } else { - _showDeleteConfirmation(context, contact); - } - }); - }, - ), - ], - ), + final primaryActions = <_ContactSheetAction>[ + if (canMessage) + _ContactSheetAction( + icon: Icons.message_outlined, + label: l10n.messages, + onTap: () async { + Navigator.pop(context); + await _openMessagesForContact(context, contact); + }, ), + if (!contact.isChannel) + _ContactSheetAction( + icon: Icons.share_outlined, + label: l10n.share, + onTap: () async { + Navigator.pop(context); + final connectionProvider = context.read(); + final url = await connectionProvider.exportContactUrl( + contact.publicKey, + ); + if (url != null && context.mounted) { + await Clipboard.setData(ClipboardData(text: url)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.contactLinkCopiedToClipboard)), + ); + } + } else if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.failedToExportContact)), + ); + } + }, + ), + if (canSetPath) + _ContactSheetAction( + icon: Icons.alt_route, + label: l10n.contactSetPath, + onTap: () async { + Navigator.pop(context); + await _showSetRouteDialog(context, contact); + }, + ), + if (!contact.isPublicChannel) + _ContactSheetAction( + icon: Icons.delete_outline_rounded, + label: l10n.delete, + destructive: true, + onTap: () async { + Navigator.pop(context); + await Future.delayed(Duration.zero); + if (!context.mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + if (contact.isChannel) { + _showDeleteChannelDialog(context, contact); + } else { + _showDeleteConfirmation(context, contact); + } + }); + }, + ), + ]; + + final secondaryActions = <_ContactSheetAction>[ + if (contact.displayLocation != null) + _ContactSheetAction( + icon: Icons.map_outlined, + label: l10n.viewOnMap, + onTap: () async { + Navigator.pop(context); + _showContactOnMap(context, contact); + }, + ), + if (contact.type == ContactType.room && !contact.isPublicChannel) + _ContactSheetAction( + icon: Icons.login, + label: + context + .read() + .getRoomLoginState(contact.publicKeyPrefix) + ?.isLoggedIn == + true + ? l10n.reLoginToRoom + : l10n.loginToRoom, + onTap: () async { + Navigator.pop(context); + _showRoomLoginDialog(context, contact); + }, + ), + if (canPreviewSensor) + _ContactSheetAction( + icon: Icons.visibility_outlined, + label: l10n.preview, + onTap: () async { + Navigator.pop(context); + await Future.delayed(Duration.zero); + if (!context.mounted) return; + await _showSensorPreviewView(context, contact); + }, + ), + if (canAddToSensors) + _ContactSheetAction( + icon: isInSensors ? Icons.sensors : Icons.sensors_outlined, + label: isInSensors ? l10n.contactInSensors : l10n.contactAddToSensors, + enabled: !isInSensors, + onTap: () async { + Navigator.pop(context); + await _addContactToSensors(context, contact); + }, + ), + if (!contact.isChannel) + _ContactSheetAction( + icon: Icons.route, + label: l10n.trace, + onTap: () async { + Navigator.pop(context); + _showTraceSheet(context, contact); + }, + ), + if (contact.type == ContactType.repeater) + _ContactSheetAction( + icon: Icons.hub_outlined, + label: 'View Neighbours', + onTap: () async { + Navigator.pop(context); + _showNeighbours(context, contact); + }, + ), + if (!contact.isPublicChannel) + _ContactSheetAction( + icon: Icons.edit_outlined, + label: l10n.editName, + onTap: () async { + Navigator.pop(context); + _showNameOverrideDialog(context, contact); + }, + ), + ]; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) => _ContactActionSheet( + contact: contact, + primaryActions: primaryActions, + secondaryActions: secondaryActions, + showFavouriteButton: canToggleFavourite, + initialFavourite: contact.isFavourite, + onClose: () => Navigator.pop(sheetContext), + onToggleFavourite: !canToggleFavourite + ? null + : () async { + final toggled = contact.toggleFavourite(); + final connectionProvider = context.read(); + await connectionProvider.addOrUpdateContact(toggled); + if (context.mounted) { + await connectionProvider.getContact(contact.publicKey); + } + }, ), ); } @@ -1168,6 +1145,464 @@ class _SensorPreviewView extends StatelessWidget { } } +class _ContactSheetAction { + final IconData icon; + final String label; + final Future Function() onTap; + final bool destructive; + final bool enabled; + + const _ContactSheetAction({ + required this.icon, + required this.label, + required this.onTap, + this.destructive = false, + this.enabled = true, + }); +} + +class _ContactActionSheet extends StatefulWidget { + final Contact contact; + final List<_ContactSheetAction> primaryActions; + final List<_ContactSheetAction> secondaryActions; + final bool showFavouriteButton; + final bool initialFavourite; + final VoidCallback onClose; + final Future Function()? onToggleFavourite; + + const _ContactActionSheet({ + required this.contact, + required this.primaryActions, + required this.secondaryActions, + required this.showFavouriteButton, + required this.initialFavourite, + required this.onClose, + required this.onToggleFavourite, + }); + + @override + State<_ContactActionSheet> createState() => _ContactActionSheetState(); +} + +class _ContactActionSheetState extends State<_ContactActionSheet> { + late bool _isFavourite; + bool _isUpdatingFavourite = false; + + @override + void initState() { + super.initState(); + _isFavourite = widget.initialFavourite; + } + + Future _toggleFavourite() async { + final callback = widget.onToggleFavourite; + if (callback == null || _isUpdatingFavourite) { + return; + } + + setState(() { + _isUpdatingFavourite = true; + }); + + try { + await callback(); + if (!mounted) { + return; + } + setState(() { + _isFavourite = !_isFavourite; + }); + } finally { + if (mounted) { + setState(() { + _isUpdatingFavourite = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context)!; + final contact = widget.contact; + final title = contact.getLocalizedDisplayName(context); + final routeLabel = !contact.routeHasPath || contact.routeHopCount <= 0 + ? l10n.direct + : contact.routeCanonicalText; + final bottomInset = MediaQuery.of(context).viewPadding.bottom; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.88, + ), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.12), + blurRadius: 24, + offset: const Offset(0, -4), + ), + ], + ), + child: Material( + color: colorScheme.surface, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16, 8, 16, 16 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: ContactAvatar( + contact: contact, + radius: 28, + displayName: title, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: -0.45, + ), + ), + const SizedBox(height: 4), + Text( + contact.isPublicChannel + ? l10n.broadcastToAllNearby + : contact.publicKeyShort, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontFamily: contact.isPublicChannel + ? null + : 'monospace', + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _ContactSheetChip( + icon: + contact.routeHasPath && + contact.routeHopCount > 0 + ? Icons.alt_route + : Icons.north_east_rounded, + label: routeLabel, + monospace: + contact.routeHasPath && + contact.routeHopCount > 0, + ), + ], + ), + ], + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showFavouriteButton) + IconButton.filledTonal( + onPressed: _isUpdatingFavourite + ? null + : _toggleFavourite, + tooltip: l10n.favourites, + icon: _isUpdatingFavourite + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.primary, + ), + ) + : Icon( + _isFavourite + ? Icons.star_rounded + : Icons.star_outline, + color: _isFavourite ? Colors.amber : null, + ), + ), + if (widget.showFavouriteButton) const SizedBox(width: 8), + IconButton( + onPressed: widget.onClose, + tooltip: l10n.close, + icon: const Icon(Icons.close_rounded), + ), + ], + ), + ], + ), + if (widget.primaryActions.isNotEmpty) ...[ + const SizedBox(height: 20), + LayoutBuilder( + builder: (context, constraints) { + final columnCount = widget.primaryActions.length <= 1 + ? 1 + : widget.primaryActions.length == 2 + ? 2 + : 3; + final itemWidth = + (constraints.maxWidth - (12 * (columnCount - 1))) / + columnCount; + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final action in widget.primaryActions) + SizedBox( + width: itemWidth, + child: _ContactPrimaryActionButton(action: action), + ), + ], + ); + }, + ), + ], + if (widget.secondaryActions.isNotEmpty) ...[ + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + l10n.others, + style: theme.textTheme.labelLarge?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + ), + ), + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.28), + ), + ), + child: Column( + children: [ + for ( + var index = 0; + index < widget.secondaryActions.length; + index++ + ) + _ContactSecondaryActionTile( + action: widget.secondaryActions[index], + showDivider: + index != widget.secondaryActions.length - 1, + ), + ], + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _ContactPrimaryActionButton extends StatelessWidget { + final _ContactSheetAction action; + + const _ContactPrimaryActionButton({required this.action}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final accent = action.destructive ? colorScheme.error : colorScheme.primary; + final backgroundColor = action.destructive + ? colorScheme.errorContainer.withValues(alpha: 0.82) + : Color.alphaBlend( + accent.withValues(alpha: 0.12), + colorScheme.surfaceContainerLow, + ); + final foregroundColor = action.destructive + ? colorScheme.onErrorContainer + : accent; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: action.enabled ? action.onTap : null, + child: Ink( + height: 80, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: action.destructive + ? colorScheme.error.withValues(alpha: 0.18) + : accent.withValues(alpha: 0.14), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: foregroundColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(action.icon, color: foregroundColor, size: 15), + ), + const SizedBox(height: 6), + Text( + action.label, + maxLines: 1, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + color: action.enabled + ? (action.destructive + ? colorScheme.onErrorContainer + : colorScheme.onSurface) + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w800, + letterSpacing: -0.1, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ContactSecondaryActionTile extends StatelessWidget { + final _ContactSheetAction action; + final bool showDivider; + + const _ContactSecondaryActionTile({ + required this.action, + required this.showDivider, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final iconColor = action.enabled + ? (action.destructive + ? colorScheme.error + : colorScheme.onSurfaceVariant) + : colorScheme.onSurfaceVariant.withValues(alpha: 0.5); + final textColor = action.enabled + ? (action.destructive ? colorScheme.error : colorScheme.onSurface) + : colorScheme.onSurfaceVariant; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + enabled: action.enabled, + onTap: action.enabled ? action.onTap : null, + leading: Icon(action.icon, color: iconColor), + title: Text( + action.label, + style: TextStyle(color: textColor, fontWeight: FontWeight.w600), + ), + minLeadingWidth: 18, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + ), + if (showDivider) + Divider( + height: 1, + indent: 56, + endIndent: 16, + color: colorScheme.outlineVariant.withValues(alpha: 0.24), + ), + ], + ); + } +} + +class _ContactSheetChip extends StatelessWidget { + final IconData icon; + final String label; + final bool monospace; + + const _ContactSheetChip({ + required this.icon, + required this.label, + this.monospace = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + constraints: const BoxConstraints(maxWidth: 220), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + fontFamily: monospace ? 'monospace' : null, + ), + ), + ), + ], + ), + ); + } +} + class _ContactNameOverrideSheet extends StatefulWidget { final String initialValue; final String advertisedName; diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart index 22b0ce8..935565b 100644 --- a/lib/widgets/sensors/sensor_telemetry_card.dart +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -1243,6 +1243,11 @@ class SensorTelemetryCard extends StatelessWidget { metric.wide) ? constraints.maxWidth : compactWidth, + onLongPress: onRefresh == null + ? null + : () async { + await onRefresh!(); + }, ), ) .toList(), @@ -2190,6 +2195,7 @@ class SensorMetricTile extends StatelessWidget { final double width; final String keyPrefix; final bool allowMapPreview; + final GestureLongPressCallback? onLongPress; const SensorMetricTile({ super.key, @@ -2197,6 +2203,7 @@ class SensorMetricTile extends StatelessWidget { required this.width, this.keyPrefix = 'sensor_metric', this.allowMapPreview = true, + this.onLongPress, }); Future _showExpandedMap(BuildContext context) async { @@ -2271,142 +2278,152 @@ class SensorMetricTile extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - key: ValueKey('${keyPrefix}_${data.fieldKey}'), - width: width, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: data.accent.withValues(alpha: 0.08), + return Material( + color: Colors.transparent, + child: InkWell( + key: ValueKey('${keyPrefix}_${data.fieldKey}'), borderRadius: BorderRadius.circular(22), - border: Border.all(color: data.accent.withValues(alpha: 0.14)), - ), - child: data.mapLocation == null || !allowMapPreview - ? Stack( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, + onLongPress: onLongPress, + child: Container( + width: width, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: data.accent.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: data.accent.withValues(alpha: 0.14)), + ), + child: data.mapLocation == null || !allowMapPreview + ? Stack( children: [ - _MetricIcon(accent: data.accent, icon: data.icon), - const SizedBox(width: 10), - Expanded( - child: _MetricText(data: data, keyPrefix: keyPrefix), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MetricIcon(accent: data.accent, icon: data.icon), + const SizedBox(width: 10), + Expanded( + 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), + 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( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + ], + ) + : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _MetricIcon(accent: data.accent, icon: data.icon), - const SizedBox(width: 10), - Expanded( - child: _MetricText(data: data, keyPrefix: keyPrefix), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MetricIcon(accent: data.accent, icon: data.icon), + const SizedBox(width: 10), + Expanded( + child: _MetricText(data: data, keyPrefix: keyPrefix), + ), + ], ), - ], - ), - const SizedBox(height: 10), - Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(14), - onTap: () => _showExpandedMap(context), - child: ClipRRect( - borderRadius: BorderRadius.circular(14), - child: SizedBox( - height: 104, - width: double.infinity, - child: Stack( - children: [ - flutter_map.FlutterMap( - options: flutter_map.MapOptions( - initialCenter: data.mapLocation!, - initialZoom: 14, - interactionOptions: - const flutter_map.InteractionOptions( - flags: flutter_map.InteractiveFlag.none, - ), - ), + const SizedBox(height: 10), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => _showExpandedMap(context), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: SizedBox( + height: 104, + width: double.infinity, + child: Stack( children: [ - flutter_map.TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: - 'com.meshcore.sar.meshcore_sar_app', - ), - flutter_map.MarkerLayer( - markers: [ - flutter_map.Marker( - point: data.mapLocation!, - width: 32, - height: 32, - child: Icon( - Icons.location_on, - color: data.accent, - size: 28, - ), + flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCenter: data.mapLocation!, + initialZoom: 14, + interactionOptions: + const flutter_map.InteractionOptions( + flags: + flutter_map.InteractiveFlag.none, + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: + 'com.meshcore.sar.meshcore_sar_app', + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: data.mapLocation!, + width: 32, + height: 32, + child: Icon( + Icons.location_on, + color: data.accent, + size: 28, + ), + ), + ], ), ], ), + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.55, + ), + borderRadius: BorderRadius.circular(999), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.open_in_full, + size: 12, + color: Colors.white, + ), + SizedBox(width: 4), + Text( + 'Open map', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), ], ), - Positioned( - right: 8, - bottom: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 3, - ), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.55), - borderRadius: BorderRadius.circular(999), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.open_in_full, - size: 12, - color: Colors.white, - ), - SizedBox(width: 4), - Text( - 'Open map', - style: TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ], + ), ), ), ), - ), + ], ), - ], - ), + ), + ), ); } } diff --git a/test/providers/channels_provider_test.dart b/test/providers/channels_provider_test.dart new file mode 100644 index 0000000..09eef34 --- /dev/null +++ b/test/providers/channels_provider_test.dart @@ -0,0 +1,25 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/channels_provider.dart'; + +void main() { + group('ChannelsProvider device sync preparation', () { + test('clears runtime channel state before sync', () { + final provider = ChannelsProvider(); + + provider.addOrUpdateChannel( + index: 2, + name: 'Ops', + secret: Uint8List.fromList(List.filled(16, 7)), + ); + provider.selectChannel(2); + + provider.prepareForDeviceSync(); + + expect(provider.channels, isEmpty); + expect(provider.selectedChannelIndex, 0); + expect(provider.selectedChannel, isNull); + }); + }); +} diff --git a/test/providers/contacts_provider_profile_scope_test.dart b/test/providers/contacts_provider_profile_scope_test.dart index 94455a6..0f13e31 100644 --- a/test/providers/contacts_provider_profile_scope_test.dart +++ b/test/providers/contacts_provider_profile_scope_test.dart @@ -10,10 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - Contact createContact({ - required Uint8List key, - required String name, - }) { + Contact createContact({required Uint8List key, required String name}) { return Contact( publicKey: key, type: ContactType.chat, @@ -36,34 +33,37 @@ void main() { ); }); - test('initializeEarly respects the active profile storage namespace', () async { - final storage = ContactStorageService(); - final defaultContact = createContact( - key: Uint8List.fromList(List.filled(32, 1)), - name: 'Default Contact', - ); - final alphaContact = createContact( - key: Uint8List.fromList(List.filled(32, 2)), - name: 'Alpha Contact', - ); + test( + 'initializeEarly respects the active profile storage namespace', + () async { + final storage = ContactStorageService(); + final defaultContact = createContact( + key: Uint8List.fromList(List.filled(32, 1)), + name: 'Default Contact', + ); + final alphaContact = createContact( + key: Uint8List.fromList(List.filled(32, 2)), + name: 'Alpha Contact', + ); - await storage.saveContacts([defaultContact]); - await storage.saveContacts([alphaContact], namespace: 'alpha'); + await storage.saveContacts([defaultContact]); + await storage.saveContacts([alphaContact], namespace: 'alpha'); - ProfileStorageScope.setScope( - profilesEnabled: true, - activeProfileId: 'alpha', - ); + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'alpha', + ); - final provider = ContactsProvider(); - await provider.initializeEarly(); + final provider = ContactsProvider(); + await provider.initializeEarly(); - final names = provider.contacts - .where((contact) => !contact.isChannel) - .map((contact) => contact.advName) - .toList(); + final names = provider.contacts + .where((contact) => !contact.isChannel) + .map((contact) => contact.advName) + .toList(); - expect(names, ['Alpha Contact']); - expect(provider.storageNamespace, 'alpha'); - }); + expect(names, ['Alpha Contact']); + expect(provider.storageNamespace, 'alpha'); + }, + ); } diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index 16b0f15..740dbb3 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -173,14 +173,8 @@ void main() { var updated = scopedProvider.findContactByKey(scopedKey)!; expect(updated.telemetry, isNotNull); expect(updated.telemetry!.gpsLocation, isNull); - expect( - updated.displayLocation!.latitude, - closeTo(45.1234, 0.0001), - ); - expect( - updated.displayLocation!.longitude, - closeTo(13.8765, 0.0001), - ); + expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001)); + expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001)); // Invalid 0,0 GPS frame should behave the same way. final invalidGps = CayenneLppParser.createGpsData( @@ -192,14 +186,8 @@ void main() { updated = scopedProvider.findContactByKey(scopedKey)!; expect(updated.telemetry, isNotNull); expect(updated.telemetry!.gpsLocation, isNull); - expect( - updated.displayLocation!.latitude, - closeTo(45.1234, 0.0001), - ); - expect( - updated.displayLocation!.longitude, - closeTo(13.8765, 0.0001), - ); + expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001)); + expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001)); } }, ); @@ -337,95 +325,107 @@ void main() { expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0)); }); - test('retains scalar telemetry but wipes stale extra sensor fields on refresh', () { - final fullTelemetry = ContactTelemetry( - gpsLocation: const LatLng(46.0569, 14.5058), - batteryPercentage: 54.0, - batteryMilliVolts: 3780, - temperature: 19.5, - timestamp: DateTime.now().subtract(const Duration(minutes: 2)), - humidity: 58.0, - pressure: 1011.2, - extraSensorData: const {'pm25': 8.0}, - ); + test( + 'retains scalar telemetry but wipes stale extra sensor fields on refresh', + () { + final fullTelemetry = ContactTelemetry( + gpsLocation: const LatLng(46.0569, 14.5058), + batteryPercentage: 54.0, + batteryMilliVolts: 3780, + temperature: 19.5, + timestamp: DateTime.now().subtract(const Duration(minutes: 2)), + humidity: 58.0, + pressure: 1011.2, + extraSensorData: const {'pm25': 8.0}, + ); - provider.addOrUpdateContact( - createContact( - key: publicKey, - type: ContactType.chat, - ).copyWith(telemetry: fullTelemetry), - ); + provider.addOrUpdateContact( + createContact( + key: publicKey, + type: ContactType.chat, + ).copyWith(telemetry: fullTelemetry), + ); - final batteryOnly = CayenneLppParser.createBatteryData(3.95); - provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly); + final batteryOnly = CayenneLppParser.createBatteryData(3.95); + provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly); - final updated = provider.findContactByKey(publicKey)!; - expect(updated.telemetry, isNotNull); - expect(updated.telemetry!.gpsLocation, isNull); - expect(updated.displayLocation, const LatLng(46.0569, 14.5058)); - expect(updated.telemetry!.batteryMilliVolts, isNotNull); - expect(updated.telemetry!.batteryPercentage, isNotNull); - expect(updated.telemetry!.temperature, equals(19.5)); - expect(updated.telemetry!.humidity, equals(58.0)); - expect(updated.telemetry!.pressure, equals(1011.2)); - expect( - updated.telemetry!.extraSensorData, - containsPair('__source_channel:battery', 0), - ); - expect( - updated.telemetry!.extraSensorData, - containsPair('__source_channel:voltage', 0), - ); - expect(updated.telemetry!.extraSensorData, isNot(contains('pm25'))); - }); + final updated = provider.findContactByKey(publicKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.gpsLocation, isNull); + expect(updated.displayLocation, const LatLng(46.0569, 14.5058)); + expect(updated.telemetry!.batteryMilliVolts, isNotNull); + expect(updated.telemetry!.batteryPercentage, isNotNull); + expect(updated.telemetry!.temperature, equals(19.5)); + expect(updated.telemetry!.humidity, equals(58.0)); + expect(updated.telemetry!.pressure, equals(1011.2)); + expect( + updated.telemetry!.extraSensorData, + containsPair('__source_channel:battery', 0), + ); + expect( + updated.telemetry!.extraSensorData, + containsPair('__source_channel:voltage', 0), + ); + expect(updated.telemetry!.extraSensorData, isNot(contains('pm25'))); + }, + ); - test('replaces old source-channel mappings when a metric moves channels', () { - final initialTelemetry = ContactTelemetry( - gpsLocation: null, - batteryPercentage: null, - batteryMilliVolts: null, - temperature: 21.5, - timestamp: DateTime.now().subtract(const Duration(minutes: 2)), - humidity: null, - pressure: null, - extraSensorData: const { - '__source_channel:temperature': 2, - 'temperature_2': 21.5, - 'humidity_4': 66.0, - }, - ); + test( + 'replaces old source-channel mappings when a metric moves channels', + () { + final initialTelemetry = ContactTelemetry( + gpsLocation: null, + batteryPercentage: null, + batteryMilliVolts: null, + temperature: 21.5, + timestamp: DateTime.now().subtract(const Duration(minutes: 2)), + humidity: null, + pressure: null, + extraSensorData: const { + '__source_channel:temperature': 2, + 'temperature_2': 21.5, + 'humidity_4': 66.0, + }, + ); - provider.addOrUpdateContact( - createContact( - key: publicKey, - type: ContactType.chat, - ).copyWith(telemetry: initialTelemetry), - ); + provider.addOrUpdateContact( + createContact( + key: publicKey, + type: ContactType.chat, + ).copyWith(telemetry: initialTelemetry), + ); - final movedChannelTelemetry = CayenneLppParser.createTemperatureData( - 23.5, - channel: 3, - ); + final movedChannelTelemetry = CayenneLppParser.createTemperatureData( + 23.5, + channel: 3, + ); - provider.updateTelemetry(publicKey.sublist(0, 6), movedChannelTelemetry); + provider.updateTelemetry( + publicKey.sublist(0, 6), + movedChannelTelemetry, + ); - final updated = provider.findContactByKey(publicKey)!; - expect(updated.telemetry, isNotNull); - expect(updated.telemetry!.temperature, closeTo(23.5, 0.1)); - expect( - updated.telemetry!.extraSensorData, - containsPair('__source_channel:temperature', 3), - ); - expect( - updated.telemetry!.extraSensorData, - containsPair('temperature_3', closeTo(23.5, 0.1)), - ); - expect( - updated.telemetry!.extraSensorData, - isNot(contains('temperature_2')), - ); - expect(updated.telemetry!.extraSensorData, isNot(contains('humidity_4'))); - }); + final updated = provider.findContactByKey(publicKey)!; + expect(updated.telemetry, isNotNull); + expect(updated.telemetry!.temperature, closeTo(23.5, 0.1)); + expect( + updated.telemetry!.extraSensorData, + containsPair('__source_channel:temperature', 3), + ); + expect( + updated.telemetry!.extraSensorData, + containsPair('temperature_3', closeTo(23.5, 0.1)), + ); + expect( + updated.telemetry!.extraSensorData, + isNot(contains('temperature_2')), + ); + expect( + updated.telemetry!.extraSensorData, + isNot(contains('humidity_4')), + ); + }, + ); test('builds message snapshot from latest valid telemetry', () { final telemetryData = CayenneLppParser.createGpsData( @@ -870,6 +870,71 @@ void main() { ); }); }); + + group('ContactsProvider device sync preparation', () { + late ContactsProvider provider; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + provider = ContactsProvider(); + }); + + test( + 'clears runtime contacts before sync without erasing persisted contacts or saved groups', + () async { + final key = createPublicKey(140); + final pendingKey = createPublicKey(180); + + provider.addOrUpdateContact( + createContact(key: key, type: ContactType.chat, name: 'Synced Later'), + ); + provider.addPendingAdvert(pendingKey); + await provider.addSavedGroupForFilter('teamMembers', 'alpha'); + + await provider.prepareForDeviceContactSync(); + + expect(provider.chatContacts, isEmpty); + expect(provider.pendingAdverts, isEmpty); + expect(provider.savedGroupsForSection('teamMembers'), hasLength(1)); + + final restored = ContactsProvider(); + await restored.initializeEarly(); + + expect( + restored.chatContacts.map((contact) => contact.advName), + contains('Synced Later'), + ); + expect(restored.savedGroupsForSection('teamMembers'), hasLength(1)); + }, + ); + }); + + group('ContactsProvider self telemetry', () { + test( + 'stores self telemetry without re-adding the device as a contact', + () async { + SharedPreferences.setMockInitialValues({}); + final provider = ContactsProvider(); + final selfKey = createPublicKey(200); + + await provider.initialize(devicePublicKey: selfKey); + provider.updateTelemetry( + selfKey.sublist(0, 6), + CayenneLppParser.createTemperatureData(23.5, channel: 1), + ); + + expect(provider.selfTelemetry, isNotNull); + expect(provider.selfTelemetry!.temperature, closeTo(23.5, 0.1)); + expect(provider.findContactByKey(selfKey), isNull); + expect( + provider.contacts.any( + (contact) => contact.publicKeyHex == publicKeyHex(selfKey), + ), + isFalse, + ); + }, + ); + }); } String publicKeyHex(Uint8List publicKey) { diff --git a/test/providers/media_provider_profile_scope_test.dart b/test/providers/media_provider_profile_scope_test.dart new file mode 100644 index 0000000..bed6df2 --- /dev/null +++ b/test/providers/media_provider_profile_scope_test.dart @@ -0,0 +1,134 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart'; +import 'package:meshcore_sar_app/providers/voice_provider.dart'; +import 'package:meshcore_sar_app/services/profiles_feature_service.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/utils/image_message_parser.dart'; +import 'package:meshcore_sar_app/utils/voice_message_parser.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'alpha', + ); + }); + + test('VoiceProvider reloads profile-scoped sessions', () async { + final player = _FakeVoicePlayerService(); + final provider = VoiceProvider(codec: VoiceCodecService(), player: player); + addTearDown(provider.dispose); + + await provider.reloadProfileScopedState(); + provider.registerEnvelope( + const VoiceEnvelope( + sessionId: 'a1b2c3d4', + mode: VoicePacketMode.mode1200, + total: 2, + durationMs: 1600, + ), + ); + await Future.delayed(const Duration(milliseconds: 50)); + + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'beta', + ); + await provider.reloadProfileScopedState(); + expect(provider.session('a1b2c3d4'), isNull); + + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'alpha', + ); + await provider.reloadProfileScopedState(); + expect(provider.session('a1b2c3d4'), isNotNull); + }); + + test('ImageProvider reloads profile-scoped sessions', () async { + final provider = ImageProvider(); + + await provider.reloadProfileScopedState(); + provider.registerEnvelope( + const ImageEnvelope( + sessionId: 'a1b2c3d4', + format: ImageFormat.avif, + total: 2, + width: 32, + height: 32, + sizeBytes: 8, + ), + ); + provider.addFragment( + ImagePacket( + sessionId: 'a1b2c3d4', + format: ImageFormat.avif, + index: 0, + total: 2, + data: Uint8List.fromList([1, 2, 3, 4]), + ), + width: 32, + height: 32, + ); + await Future.delayed(const Duration(milliseconds: 50)); + + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'beta', + ); + await provider.reloadProfileScopedState(); + expect(provider.session('a1b2c3d4'), isNull); + + ProfileStorageScope.setScope( + profilesEnabled: true, + activeProfileId: 'alpha', + ); + await provider.reloadProfileScopedState(); + expect(provider.session('a1b2c3d4'), isNotNull); + }); +} + +class _FakeVoicePlayerService implements VoicePlayerService { + final StreamController _events = StreamController.broadcast(); + bool _isPlaying = false; + + @override + bool get isPlaying => _isPlaying; + + @override + Duration get position => Duration.zero; + + @override + Duration get duration => Duration.zero; + + @override + Stream get events => _events.stream; + + @override + Future play(Int16List pcmSamples, {required int sampleRateHz}) async { + _isPlaying = true; + _events.add(null); + } + + @override + Future stop() async { + _isPlaying = false; + _events.add(null); + } + + @override + void dispose() { + _events.close(); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/providers/sensors_provider_test.dart b/test/providers/sensors_provider_test.dart index 6f0c653..1e96291 100644 --- a/test/providers/sensors_provider_test.dart +++ b/test/providers/sensors_provider_test.dart @@ -6,6 +6,7 @@ import 'package:meshcore_sar_app/models/device_info.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/sensors_provider.dart'; +import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart'; import 'package:meshcore_sar_app/services/profiles_feature_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -19,10 +20,17 @@ class _FakeContactsProvider extends ContactsProvider { } class _FakeConnectionProvider extends ConnectionProvider { - _FakeConnectionProvider({required bool isConnected}) - : _isConnected = isConnected; + _FakeConnectionProvider({ + required bool isConnected, + Uint8List? publicKey, + String? selfName, + }) : _isConnected = isConnected, + _publicKey = publicKey, + _selfName = selfName; final bool _isConnected; + final Uint8List? _publicKey; + final String? _selfName; int pingCalls = 0; @@ -31,6 +39,8 @@ class _FakeConnectionProvider extends ConnectionProvider { connectionState: _isConnected ? ConnectionState.connected : ConnectionState.disconnected, + publicKey: _publicKey, + selfName: _selfName, ); @override @@ -65,9 +75,12 @@ void main() { expect(provider.isLoaded, isTrue); } - Contact buildSensorContact() { + Contact buildSensorContact({ + int firstByte = 0x44, + String name = 'WX Station', + }) { final publicKey = Uint8List(32); - publicKey[0] = 0x44; + publicKey[0] = firstByte; return Contact( publicKey: publicKey, @@ -75,7 +88,7 @@ void main() { flags: 0, outPathLen: 0, outPath: Uint8List(64), - advName: 'WX Station', + advName: name, lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, advLat: 0, advLon: 0, @@ -264,6 +277,36 @@ void main() { ); }); + test('watched sensor order persists across reloads', () async { + SharedPreferences.setMockInitialValues({}); + final first = buildSensorContact(firstByte: 0x44, name: 'First'); + final second = buildSensorContact(firstByte: 0x45, name: 'Second'); + final third = buildSensorContact(firstByte: 0x46, name: 'Third'); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(first); + await provider.addSensor(second); + await provider.addSensor(third); + + await provider.reorderSensors(2, 0); + + expect(provider.watchedSensorKeys, [ + third.publicKeyHex, + first.publicKeyHex, + second.publicKeyHex, + ]); + + final reloadedProvider = SensorsProvider(); + await waitUntilLoaded(reloadedProvider); + + expect(reloadedProvider.watchedSensorKeys, [ + third.publicKeyHex, + first.publicKeyHex, + second.publicKeyHex, + ]); + }); + test( 'unsupported auto refresh minutes normalize to nearest option', () async { @@ -340,4 +383,32 @@ void main() { ); }, ); + + test('selfContact includes stored self telemetry', () async { + SharedPreferences.setMockInitialValues({}); + final selfKey = Uint8List(32)..[0] = 0x66; + final contactsProvider = ContactsProvider(); + await contactsProvider.initialize(devicePublicKey: selfKey); + contactsProvider.updateTelemetry( + selfKey.sublist(0, 6), + CayenneLppParser.createTemperatureData(19.5, channel: 1), + ); + + final connectionProvider = _FakeConnectionProvider( + isConnected: true, + publicKey: selfKey, + selfName: 'My Device', + ); + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + + final selfContact = provider.selfContact( + contactsProvider, + connectionProvider, + ); + + expect(selfContact, isNotNull); + expect(selfContact!.telemetry, isNotNull); + expect(selfContact.telemetry!.temperature, closeTo(19.5, 0.1)); + }); } diff --git a/test/services/profile_workspace_coordinator_test.dart b/test/services/profile_workspace_coordinator_test.dart index d8ba9eb..c6ea614 100644 --- a/test/services/profile_workspace_coordinator_test.dart +++ b/test/services/profile_workspace_coordinator_test.dart @@ -13,6 +13,8 @@ import 'package:meshcore_sar_app/providers/drawing_provider.dart'; import 'package:meshcore_sar_app/providers/map_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/sensors_provider.dart'; +import 'package:meshcore_sar_app/providers/voice_provider.dart'; +import 'package:meshcore_sar_app/providers/image_provider.dart' as ip; import 'package:meshcore_sar_app/services/app_config_snapshot_service.dart'; import 'package:meshcore_sar_app/services/contact_storage_service.dart'; import 'package:meshcore_sar_app/services/device_config_applicator.dart'; @@ -207,15 +209,21 @@ void main() { final connectionProvider = _FakeConnectionProvider( deviceInfo: DeviceInfo(publicKey: Uint8List.fromList([1, 2, 3, 4])), ); + final voiceProvider = _FakeVoiceProvider(); + final imageProvider = _FakeImageProvider(); final coordinator = _buildCoordinator( profileManager: manager, connectionProvider: connectionProvider, + voiceProvider: voiceProvider, + imageProvider: imageProvider, ); await coordinator.syncActiveProfileForCurrentDevice(); expect(manager.activeProfileId, alpha.id); expect(connectionProvider.disconnectCallCount, 0); + expect(voiceProvider.reloadCallCount, 1); + expect(imageProvider.reloadCallCount, 1); }); test( @@ -257,6 +265,8 @@ void main() { ProfileWorkspaceCoordinator _buildCoordinator({ required ProfileManager profileManager, _FakeConnectionProvider? connectionProvider, + _FakeVoiceProvider? voiceProvider, + _FakeImageProvider? imageProvider, }) { return ProfileWorkspaceCoordinator( profileManager: profileManager, @@ -267,6 +277,8 @@ ProfileWorkspaceCoordinator _buildCoordinator({ mapProvider: _FakeMapProvider(), drawingProvider: _FakeDrawingProvider(), channelsProvider: _FakeChannelsProvider(), + voiceProvider: voiceProvider ?? _FakeVoiceProvider(), + imageProvider: imageProvider ?? _FakeImageProvider(), appProvider: _FakeAppProvider(), appConfigSnapshotService: _FakeAppConfigSnapshotService(), mapWorkspaceSnapshotService: _FakeMapWorkspaceSnapshotService(), @@ -332,9 +344,8 @@ class _FakeDeviceConfigApplicator extends DeviceConfigApplicator { } class _FakeConnectionProvider implements ConnectionProvider { - _FakeConnectionProvider({ - DeviceInfo? deviceInfo, - }) : deviceInfo = deviceInfo ?? DeviceInfo(); + _FakeConnectionProvider({DeviceInfo? deviceInfo}) + : deviceInfo = deviceInfo ?? DeviceInfo(); int disconnectCallCount = 0; @@ -407,6 +418,30 @@ class _FakeChannelsProvider implements ChannelsProvider { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +class _FakeVoiceProvider implements VoiceProvider { + int reloadCallCount = 0; + + @override + Future reloadProfileScopedState() async { + reloadCallCount += 1; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeImageProvider implements ip.ImageProvider { + int reloadCallCount = 0; + + @override + Future reloadProfileScopedState() async { + reloadCallCount += 1; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + class _FakeAppProvider implements AppProvider { @override Future reloadProfileScopedSettings() async {} diff --git a/test/widgets/sensor_telemetry_card_test.dart b/test/widgets/sensor_telemetry_card_test.dart index fb31c3b..9e2500a 100644 --- a/test/widgets/sensor_telemetry_card_test.dart +++ b/test/widgets/sensor_telemetry_card_test.dart @@ -189,4 +189,36 @@ void main() { expect(find.text('2ยฐC'), findsOneWidget); expect(find.text('12.3 mm'), findsOneWidget); }); + + testWidgets('long pressing a telemetry bubble triggers refresh', ( + tester, + ) async { + final contact = buildContact(); + var refreshCount = 0; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'temperature'}, + fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}), + onRefresh: () async { + refreshCount += 1; + }, + ), + ), + ), + ); + + await tester.longPress( + find.byKey(const ValueKey('sensor_metric_temperature')), + ); + await tester.pumpAndSettle(); + + expect(refreshCount, 1); + }); }