fix: Tighten device settings layout

This commit is contained in:
Janez T
2026-03-21 20:44:29 +01:00
parent 2d24481aba
commit dc297b0b9f
23 changed files with 2358 additions and 778 deletions

View File

@@ -372,6 +372,8 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
mapProvider: mapProvider, mapProvider: mapProvider,
drawingProvider: context.read<DrawingProvider>(), drawingProvider: context.read<DrawingProvider>(),
channelsProvider: context.read<ChannelsProvider>(), channelsProvider: context.read<ChannelsProvider>(),
voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(),
appProvider: appProvider, appProvider: appProvider,
), ),
), ),

View File

@@ -71,6 +71,7 @@ class DeviceInfo {
final bool? autoAddRoomServers; final bool? autoAddRoomServers;
final bool? autoAddSensors; final bool? autoAddSensors;
final bool? autoAddOverwriteOldest; final bool? autoAddOverwriteOldest;
final int? autoAddMaxHops;
final int? radioFreq; final int? radioFreq;
final int? radioBw; final int? radioBw;
final int? radioSf; final int? radioSf;
@@ -95,6 +96,7 @@ class DeviceInfo {
// Repeat mode (firmware v9+) // Repeat mode (firmware v9+)
final bool? clientRepeat; final bool? clientRepeat;
final int? pathHashMode;
final bool? supportsSpectrumScan; final bool? supportsSpectrumScan;
final int? spectrumScanMinKhz; final int? spectrumScanMinKhz;
final int? spectrumScanMaxKhz; final int? spectrumScanMaxKhz;
@@ -123,6 +125,7 @@ class DeviceInfo {
this.autoAddRoomServers, this.autoAddRoomServers,
this.autoAddSensors, this.autoAddSensors,
this.autoAddOverwriteOldest, this.autoAddOverwriteOldest,
this.autoAddMaxHops,
this.radioFreq, this.radioFreq,
this.radioBw, this.radioBw,
this.radioSf, this.radioSf,
@@ -139,6 +142,7 @@ class DeviceInfo {
this.manufacturerModel, this.manufacturerModel,
this.semanticVersion, this.semanticVersion,
this.clientRepeat, this.clientRepeat,
this.pathHashMode,
this.supportsSpectrumScan, this.supportsSpectrumScan,
this.spectrumScanMinKhz, this.spectrumScanMinKhz,
this.spectrumScanMaxKhz, this.spectrumScanMaxKhz,
@@ -254,6 +258,7 @@ class DeviceInfo {
bool? autoAddRoomServers, bool? autoAddRoomServers,
bool? autoAddSensors, bool? autoAddSensors,
bool? autoAddOverwriteOldest, bool? autoAddOverwriteOldest,
int? autoAddMaxHops,
int? radioFreq, int? radioFreq,
int? radioBw, int? radioBw,
int? radioSf, int? radioSf,
@@ -270,6 +275,7 @@ class DeviceInfo {
String? manufacturerModel, String? manufacturerModel,
String? semanticVersion, String? semanticVersion,
bool? clientRepeat, bool? clientRepeat,
int? pathHashMode,
bool? supportsSpectrumScan, bool? supportsSpectrumScan,
int? spectrumScanMinKhz, int? spectrumScanMinKhz,
int? spectrumScanMaxKhz, int? spectrumScanMaxKhz,
@@ -299,6 +305,7 @@ class DeviceInfo {
autoAddSensors: autoAddSensors ?? this.autoAddSensors, autoAddSensors: autoAddSensors ?? this.autoAddSensors,
autoAddOverwriteOldest: autoAddOverwriteOldest:
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest, autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
autoAddMaxHops: autoAddMaxHops ?? this.autoAddMaxHops,
radioFreq: radioFreq ?? this.radioFreq, radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw, radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf, radioSf: radioSf ?? this.radioSf,
@@ -315,6 +322,7 @@ class DeviceInfo {
manufacturerModel: manufacturerModel ?? this.manufacturerModel, manufacturerModel: manufacturerModel ?? this.manufacturerModel,
semanticVersion: semanticVersion ?? this.semanticVersion, semanticVersion: semanticVersion ?? this.semanticVersion,
clientRepeat: clientRepeat ?? this.clientRepeat, clientRepeat: clientRepeat ?? this.clientRepeat,
pathHashMode: pathHashMode ?? this.pathHashMode,
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan, supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz, spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz, spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,

View File

@@ -2553,12 +2553,10 @@ class AppProvider with ChangeNotifier {
try { try {
_isReconnectSyncInProgress = true; _isReconnectSyncInProgress = true;
_hasCompletedConnectionBootstrap = false; _hasCompletedConnectionBootstrap = false;
// Initialize contacts provider with device public key to exclude self await contactsProvider.prepareForDeviceContactSync(
// 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(
devicePublicKey: connectionProvider.deviceInfo.publicKey, devicePublicKey: connectionProvider.deviceInfo.publicKey,
); );
channelsProvider.prepareForDeviceSync();
// Note: Device clock is automatically synced during connection in MeshCoreBleService // Note: Device clock is automatically synced during connection in MeshCoreBleService
// No need to sync it again here // No need to sync it again here
@@ -2634,10 +2632,14 @@ class AppProvider with ChangeNotifier {
'🔄 [AppProvider] Device reconnected - syncing contacts and missed messages', '🔄 [AppProvider] Device reconnected - syncing contacts and missed messages',
); );
await contactsProvider.initialize( await contactsProvider.prepareForDeviceContactSync(
devicePublicKey: connectionProvider.deviceInfo.publicKey, devicePublicKey: connectionProvider.deviceInfo.publicKey,
); );
channelsProvider.prepareForDeviceSync();
await connectionProvider.getContacts(); await connectionProvider.getContacts();
await connectionProvider.syncChannels(
maxChannels: connectionProvider.deviceInfo.maxChannels,
);
final messageCount = await connectionProvider.syncAllMessages( final messageCount = await connectionProvider.syncAllMessages(
force: true, force: true,
@@ -3642,6 +3644,7 @@ class AppProvider with ChangeNotifier {
await connectionProvider.getContacts(); await connectionProvider.getContacts();
// Sync all channels so refresh reflects the full device state. // Sync all channels so refresh reflects the full device state.
channelsProvider.prepareForDeviceSync();
await connectionProvider.syncChannels( await connectionProvider.syncChannels(
maxChannels: connectionProvider.deviceInfo.maxChannels, maxChannels: connectionProvider.deviceInfo.maxChannels,
); );

View File

@@ -7,7 +7,8 @@ class ChannelsProvider with ChangeNotifier {
int _selectedChannelIndex = 0; // Default to public channel int _selectedChannelIndex = 0; // Default to public channel
/// Get all channels /// Get all channels
List<Channel> get channels => _channels.values.toList()..sort((a, b) => a.index.compareTo(b.index)); List<Channel> get channels =>
_channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
/// Get a specific channel by index /// Get a specific channel by index
Channel? getChannel(int index) => _channels[index]; Channel? getChannel(int index) => _channels[index];
@@ -96,6 +97,13 @@ class ChannelsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Clear runtime channel state before a live device sync begins.
void prepareForDeviceSync() {
_channels.clear();
_selectedChannelIndex = 0;
notifyListeners();
}
/// Check if channels have been loaded /// Check if channels have been loaded
bool get hasChannels => _channels.isNotEmpty; bool get hasChannels => _channels.isNotEmpty;

View File

@@ -547,6 +547,7 @@ class ConnectionProvider with ChangeNotifier {
manufacturerModel: deviceInfo['manufacturerModel'] as String?, manufacturerModel: deviceInfo['manufacturerModel'] as String?,
semanticVersion: deviceInfo['semanticVersion'] as String?, semanticVersion: deviceInfo['semanticVersion'] as String?,
clientRepeat: deviceInfo['clientRepeat'] as bool?, clientRepeat: deviceInfo['clientRepeat'] as bool?,
pathHashMode: deviceInfo['pathHashMode'] as int?,
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?, supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?, spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?, spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
@@ -563,6 +564,9 @@ class ConnectionProvider with ChangeNotifier {
publicKey: selfInfo['publicKey'] as Uint8List?, publicKey: selfInfo['publicKey'] as Uint8List?,
advLat: selfInfo['advLat'] as int?, advLat: selfInfo['advLat'] as int?,
advLon: selfInfo['advLon'] 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?, manualAddContacts: selfInfo['manualAddContacts'] as bool?,
radioFreq: selfInfo['radioFreq'] as int?, radioFreq: selfInfo['radioFreq'] as int?,
radioBw: selfInfo['radioBw'] as int?, radioBw: selfInfo['radioBw'] as int?,
@@ -605,6 +609,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: config['autoAddRoomServers'] as bool?, autoAddRoomServers: config['autoAddRoomServers'] as bool?,
autoAddSensors: config['autoAddSensors'] as bool?, autoAddSensors: config['autoAddSensors'] as bool?,
autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?, autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?,
autoAddMaxHops: config['autoAddMaxHops'] as int?,
); );
notifyListeners(); notifyListeners();
}; };
@@ -1853,11 +1858,13 @@ class ConnectionProvider with ChangeNotifier {
return pendingPing; return pendingPing;
} }
final future = _runSmartPing( final future = _isSelfPublicKey(contactPublicKey)
contactPublicKey: contactPublicKey, ? _runSelfTelemetryPing(contactPublicKey)
hasPath: hasPath, : _runSmartPing(
onRetryWithFlooding: onRetryWithFlooding, contactPublicKey: contactPublicKey,
); hasPath: hasPath,
onRetryWithFlooding: onRetryWithFlooding,
);
_pendingSmartPings[pingKey] = future; _pendingSmartPings[pingKey] = future;
notifyListeners(); notifyListeners();
@@ -1944,10 +1951,53 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<PingResult> _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) { String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); 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) /// Send binary request to contact (modern replacement for requestTelemetry)
/// ///
/// Supports multiple request types: /// Supports multiple request types:
@@ -2292,6 +2342,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: config['autoAddRoomServers'] as bool?, autoAddRoomServers: config['autoAddRoomServers'] as bool?,
autoAddSensors: config['autoAddSensors'] as bool?, autoAddSensors: config['autoAddSensors'] as bool?,
autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?, autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?,
autoAddMaxHops: config['autoAddMaxHops'] as int?,
); );
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
@@ -2303,6 +2354,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: null, autoAddRoomServers: null,
autoAddSensors: null, autoAddSensors: null,
autoAddOverwriteOldest: null, autoAddOverwriteOldest: null,
autoAddMaxHops: null,
); );
notifyListeners(); notifyListeners();
return; return;
@@ -2322,6 +2374,7 @@ class ConnectionProvider with ChangeNotifier {
required bool autoAddRoomServers, required bool autoAddRoomServers,
required bool autoAddSensors, required bool autoAddSensors,
required bool overwriteOldest, required bool overwriteOldest,
int maxHops = 0,
}) async { }) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
@@ -2336,6 +2389,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: autoAddRoomServers, autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors, autoAddSensors: autoAddSensors,
overwriteOldest: overwriteOldest, overwriteOldest: overwriteOldest,
maxHops: maxHops,
); );
_deviceInfo = _deviceInfo.copyWith( _deviceInfo = _deviceInfo.copyWith(
autoAddUsers: autoAddUsers, autoAddUsers: autoAddUsers,
@@ -2343,6 +2397,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: autoAddRoomServers, autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors, autoAddSensors: autoAddSensors,
autoAddOverwriteOldest: overwriteOldest, autoAddOverwriteOldest: overwriteOldest,
autoAddMaxHops: maxHops,
); );
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
@@ -2351,6 +2406,23 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<void> 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. /// Export a contact as a meshcore:// share URL.
/// Pass null to export self. /// Pass null to export self.
Future<String?> exportContactUrl(Uint8List? publicKey) async { Future<String?> exportContactUrl(Uint8List? publicKey) async {
@@ -2440,14 +2512,8 @@ class ConnectionProvider with ChangeNotifier {
Future<void> requestSelfTelemetry() async { Future<void> requestSelfTelemetry() async {
if (!_activeService.isConnected) return; if (!_activeService.isConnected) return;
try { try {
// Request own telemetry by sending telemetry req with zero-length key // Firmware expects a 4-byte CMD_SEND_TELEMETRY_REQ frame for "self".
final deviceKey = _deviceInfo.publicKey; await _activeService.requestTelemetry(Uint8List(0), zeroHop: true);
if (deviceKey != null) {
await _activeService.requestTelemetry(
Uint8List.fromList(deviceKey),
zeroHop: true,
);
}
} catch (e) { } catch (e) {
debugPrint('⚠️ [Provider] requestSelfTelemetry failed: $e'); debugPrint('⚠️ [Provider] requestSelfTelemetry failed: $e');
} }

View File

@@ -139,6 +139,8 @@ class ContactsProvider with ChangeNotifier {
bool _isPersistingPendingAdverts = false; bool _isPersistingPendingAdverts = false;
bool _persistPendingAdvertsRequested = false; bool _persistPendingAdvertsRequested = false;
String? _storageNamespace; String? _storageNamespace;
String? _selfPublicKeyHex;
ContactTelemetry? _selfTelemetry;
// Add default public channel on initialization // Add default public channel on initialization
ContactsProvider() ContactsProvider()
@@ -162,6 +164,7 @@ class ContactsProvider with ChangeNotifier {
Uint8List? devicePublicKey, Uint8List? devicePublicKey,
}) async { }) async {
_storageNamespace = namespace; _storageNamespace = namespace;
_setSelfDevicePublicKey(devicePublicKey);
await _loadFromStorage(force: true, devicePublicKey: devicePublicKey); await _loadFromStorage(force: true, devicePublicKey: devicePublicKey);
} }
@@ -253,6 +256,7 @@ class ContactsProvider with ChangeNotifier {
/// Initialize and load persisted contacts /// Initialize and load persisted contacts
/// [devicePublicKey] - device's own public key to exclude from loaded contacts /// [devicePublicKey] - device's own public key to exclude from loaded contacts
Future<void> initialize({Uint8List? devicePublicKey}) async { Future<void> initialize({Uint8List? devicePublicKey}) async {
_setSelfDevicePublicKey(devicePublicKey);
if (_isInitialized) { if (_isInitialized) {
// If already initialized (from early load), just filter out self-contact // If already initialized (from early load), just filter out self-contact
if (devicePublicKey != null) { 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<void> 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) /// Remove self-contact from loaded contacts (called after BLE connection established)
void _removeSelfContact(Uint8List devicePublicKey) { void _removeSelfContact(Uint8List devicePublicKey) {
final selfKeyHex = devicePublicKey final selfKeyHex = devicePublicKey
@@ -347,6 +381,7 @@ class ContactsProvider with ChangeNotifier {
} }
List<Contact> get contacts => _contacts.values.toList(); List<Contact> get contacts => _contacts.values.toList();
ContactTelemetry? get selfTelemetry => _selfTelemetry;
List<Contact> get favouriteContacts => List<Contact> get favouriteContacts =>
_contacts.values.where((c) => c.isFavourite).toList(); _contacts.values.where((c) => c.isFavourite).toList();
List<SavedContactGroup> get savedContactGroups => List<SavedContactGroup> get savedContactGroups =>
@@ -537,9 +572,11 @@ class ContactsProvider with ChangeNotifier {
// Replace existing observation from the same repeater, or add new // Replace existing observation from the same repeater, or add new
final repeaterKey = final repeaterKey =
'${observation.repeaterLocation.latitude},${observation.repeaterLocation.longitude}'; '${observation.repeaterLocation.latitude},${observation.repeaterLocation.longitude}';
observations.removeWhere((o) => observations.removeWhere(
'${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' == (o) =>
repeaterKey); '${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' ==
repeaterKey,
);
observations.add(observation); observations.add(observation);
// Keep at most 8 observations (most recent per repeater) // Keep at most 8 observations (most recent per repeater)
@@ -956,13 +993,22 @@ class ContactsProvider with ChangeNotifier {
// Find contact by public key prefix // Find contact by public key prefix
final contact = _findContactByPrefix(publicKeyPrefix); final contact = _findContactByPrefix(publicKeyPrefix);
if (contact == null) { final isSelfTelemetry =
contact == null && _matchesSelfPrefix(publicKeyPrefix);
if (contact == null && !isSelfTelemetry) {
debugPrint(' ❌ Contact not found for this prefix'); debugPrint(' ❌ Contact not found for this prefix');
return; return;
} }
debugPrint(' ✅ Found contact: ${contact.advName}'); if (isSelfTelemetry) {
debugPrint(' Old telemetry timestamp: ${contact.telemetry?.timestamp}'); 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 { try {
// Parse Cayenne LPP data // 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( final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: previousTelemetry, 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 // Update contact with new telemetry AND last seen time
// lastAdvert is Unix timestamp in seconds // lastAdvert is Unix timestamp in seconds
final currentTimestamp = (DateTime.now().millisecondsSinceEpoch / 1000) final currentTimestamp = (DateTime.now().millisecondsSinceEpoch / 1000)
.round(); .round();
debugPrint(' Old lastAdvert: ${contact.lastAdvert}'); debugPrint(' Old lastAdvert: ${resolvedContact.lastAdvert}');
debugPrint(' New lastAdvert: $currentTimestamp'); debugPrint(' New lastAdvert: $currentTimestamp');
final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation); final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation);
final updatedContact = contact.copyWith( final updatedContact = resolvedContact.copyWith(
telemetry: telemetry, telemetry: telemetry,
lastAdvert: currentTimestamp, // Update last seen time lastAdvert: currentTimestamp, // Update last seen time
advLat: persistedGps != null advLat: persistedGps != null
? _coordinateToAdvertMicrodegrees(persistedGps.latitude) ? _coordinateToAdvertMicrodegrees(persistedGps.latitude)
: contact.advLat, : resolvedContact.advLat,
advLon: persistedGps != null advLon: persistedGps != null
? _coordinateToAdvertMicrodegrees(persistedGps.longitude) ? _coordinateToAdvertMicrodegrees(persistedGps.longitude)
: contact.advLon, : resolvedContact.advLon,
); );
_contacts[contact.publicKeyHex] = updatedContact; _contacts[resolvedContact.publicKeyHex] = updatedContact;
debugPrint(' ✅ Updated contact in map (with new lastAdvert)'); debugPrint(' ✅ Updated contact in map (with new lastAdvert)');
_persistContacts(); _persistContacts();
@@ -1237,6 +1294,36 @@ class ContactsProvider with ChangeNotifier {
return _contacts[keyHex]; 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 /// Clear a contact's learned path locally so the UI and next send both
/// prefer flood routing until the radio reports a fresh route. /// prefer flood routing until the radio reports a fresh route.
void markPathUnhealthy(Uint8List publicKey) { void markPathUnhealthy(Uint8List publicKey) {
@@ -1642,6 +1729,7 @@ class ContactsProvider with ChangeNotifier {
_pendingAdverts.clear(); _pendingAdverts.clear();
_estimatedLocations.clear(); _estimatedLocations.clear();
_rssiObservations.clear(); _rssiObservations.clear();
_selfTelemetry = null;
} }
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) { Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../services/profiles_feature_service.dart';
import 'helpers/raw_session_retransmit.dart'; import 'helpers/raw_session_retransmit.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
@@ -75,6 +76,8 @@ class ImageProvider with ChangeNotifier {
_restore(); _restore();
} }
String _scopedStorageKey() => ProfileStorageScope.scopedKey(_storageKey);
// ── Accessors ──────────────────────────────────────────────────────────── // ── Accessors ────────────────────────────────────────────────────────────
ImageSession? session(String sessionId) => _sessions[sessionId]; ImageSession? session(String sessionId) => _sessions[sessionId];
@@ -292,18 +295,22 @@ class ImageProvider with ChangeNotifier {
// ── Persistence ────────────────────────────────────────────────────────── // ── Persistence ──────────────────────────────────────────────────────────
Future<void> clearAll() async { Future<void> clearAll() async {
_sessions.clear(); _resetInMemoryState();
_outgoing.clear();
_ignoredIncomingSessions.clear();
notifyListeners(); notifyListeners();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_storageKey); await prefs.remove(_scopedStorageKey());
} catch (e) { } catch (e) {
debugPrint('❌ [ImageProvider] Failed to clear storage: $e'); debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
} }
} }
Future<void> reloadProfileScopedState() async {
_resetInMemoryState();
await _restore();
notifyListeners();
}
void _evictExpiredOutgoing() { void _evictExpiredOutgoing() {
final now = DateTime.now(); final now = DateTime.now();
_outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl); _outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
@@ -343,7 +350,7 @@ class ImageProvider with ChangeNotifier {
) )
.toList(), .toList(),
}; };
await prefs.setString(_storageKey, jsonEncode(payload)); await prefs.setString(_scopedStorageKey(), jsonEncode(payload));
} catch (e) { } catch (e) {
debugPrint('❌ [ImageProvider] Failed to persist: $e'); debugPrint('❌ [ImageProvider] Failed to persist: $e');
} }
@@ -352,7 +359,7 @@ class ImageProvider with ChangeNotifier {
Future<void> _restore() async { Future<void> _restore() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey); final raw = prefs.getString(_scopedStorageKey());
if (raw == null || raw.isEmpty) return; if (raw == null || raw.isEmpty) return;
final parsed = jsonDecode(raw) as Map<String, dynamic>; final parsed = jsonDecode(raw) as Map<String, dynamic>;
@@ -428,6 +435,12 @@ class ImageProvider with ChangeNotifier {
debugPrint('❌ [ImageProvider] Failed to restore: $e'); debugPrint('❌ [ImageProvider] Failed to restore: $e');
} }
} }
void _resetInMemoryState() {
_sessions.clear();
_outgoing.clear();
_ignoredIncomingSessions.clear();
}
} }
class _OutgoingSession { class _OutgoingSession {

View File

@@ -486,7 +486,14 @@ class SensorsProvider with ChangeNotifier {
final existing = contactsProvider.findContactByKey( final existing = contactsProvider.findContactByKey(
Uint8List.fromList(selfKey), 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<void> addSensor(Contact contact) async { Future<void> addSensor(Contact contact) async {
@@ -564,6 +571,31 @@ class SensorsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> 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<Contact> availableCandidates( List<Contact> availableCandidates(
ContactsProvider contactsProvider, { ContactsProvider contactsProvider, {
ConnectionProvider? connectionProvider, ConnectionProvider? connectionProvider,
@@ -748,7 +780,10 @@ class SensorsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Contact? _buildSelfCandidate(ConnectionProvider connectionProvider) { Contact? _buildSelfCandidate(
ConnectionProvider connectionProvider, {
ContactTelemetry? telemetry,
}) {
final deviceInfo = connectionProvider.deviceInfo; final deviceInfo = connectionProvider.deviceInfo;
final selfKey = deviceInfo.publicKey; final selfKey = deviceInfo.publicKey;
if (selfKey == null || selfKey.isEmpty) { if (selfKey == null || selfKey.isEmpty) {
@@ -766,6 +801,7 @@ class SensorsProvider with ChangeNotifier {
advLat: deviceInfo.advLat ?? 0, advLat: deviceInfo.advLat ?? 0,
advLon: deviceInfo.advLon ?? 0, advLon: deviceInfo.advLon ?? 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: telemetry,
); );
} }
} }

View File

@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../services/profiles_feature_service.dart';
import 'helpers/raw_session_retransmit.dart'; import 'helpers/raw_session_retransmit.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../services/voice_codec_service.dart'; import '../services/voice_codec_service.dart';
@@ -92,6 +93,9 @@ class VoiceProvider with ChangeNotifier {
_restorePersistedVoiceData(); _restorePersistedVoiceData();
} }
String _storageKey() =>
ProfileStorageScope.scopedKey(_voiceSessionsStorageKey);
// ── Session accessors ──────────────────────────────────────────────────── // ── Session accessors ────────────────────────────────────────────────────
VoiceSession? session(String sessionId) => _sessions[sessionId]; VoiceSession? session(String sessionId) => _sessions[sessionId];
@@ -329,19 +333,22 @@ class VoiceProvider with ChangeNotifier {
} }
Future<void> clearStoredVoiceData() async { Future<void> clearStoredVoiceData() async {
_sessions.clear(); await _resetInMemoryState();
_outgoingSessions.clear();
_ignoredIncomingSessions.clear();
_playingSessionId = null;
notifyListeners(); notifyListeners();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_voiceSessionsStorageKey); await prefs.remove(_storageKey());
} catch (e) { } catch (e) {
debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e'); debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e');
} }
} }
Future<void> reloadProfileScopedState() async {
await _resetInMemoryState();
await _restorePersistedVoiceData();
notifyListeners();
}
Future<void> _persistVoiceData() async { Future<void> _persistVoiceData() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -365,7 +372,7 @@ class VoiceProvider with ChangeNotifier {
) )
.toList(), .toList(),
}; };
await prefs.setString(_voiceSessionsStorageKey, jsonEncode(payload)); await prefs.setString(_storageKey(), jsonEncode(payload));
} catch (e) { } catch (e) {
debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e'); debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e');
} }
@@ -374,7 +381,7 @@ class VoiceProvider with ChangeNotifier {
Future<void> _restorePersistedVoiceData() async { Future<void> _restorePersistedVoiceData() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_voiceSessionsStorageKey); final raw = prefs.getString(_storageKey());
if (raw == null || raw.isEmpty) return; if (raw == null || raw.isEmpty) return;
final parsed = jsonDecode(raw) as Map<String, dynamic>; final parsed = jsonDecode(raw) as Map<String, dynamic>;
@@ -437,6 +444,16 @@ class VoiceProvider with ChangeNotifier {
} }
} }
Future<void> _resetInMemoryState() async {
if (_playingSessionId != null || _player.isPlaying) {
await _player.stop();
}
_sessions.clear();
_outgoingSessions.clear();
_ignoredIncomingSessions.clear();
_playingSessionId = null;
}
@override @override
void dispose() { void dispose() {
_playerEventsSub.cancel(); _playerEventsSub.cancel();

View File

@@ -178,12 +178,20 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
late TextEditingController _lonController; late TextEditingController _lonController;
late TextEditingController _freqController; late TextEditingController _freqController;
late TextEditingController _txPowerController; late TextEditingController _txPowerController;
late TextEditingController _gpsIntervalController;
late TextEditingController _autoAddMaxHopsController;
late final ConnectionProvider _connectionProvider; 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 _repeatEnabled = false;
bool? _gpsEnabled; // null = not supported by hardware bool? _gpsEnabled; // null = not supported by hardware
bool _gpsLoading = false; bool _gpsLoading = false;
bool _isSyncingDeviceTime = false;
int? _selectedPathHashMode;
bool _autoAddDiscoveredContactsEnabled = true; bool _autoAddDiscoveredContactsEnabled = true;
bool _autoAddUsersEnabled = true; bool _autoAddUsersEnabled = true;
bool _autoAddRepeatersEnabled = true; bool _autoAddRepeatersEnabled = true;
@@ -250,6 +258,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_txPowerController = TextEditingController( _txPowerController = TextEditingController(
text: deviceInfo.txPower?.toString() ?? '20', text: deviceInfo.txPower?.toString() ?? '20',
); );
_gpsIntervalController = TextEditingController();
_autoAddMaxHopsController = TextEditingController(
text: (deviceInfo.autoAddMaxHops ?? 0).toString(),
);
if (deviceInfo.radioBw != null && if (deviceInfo.radioBw != null &&
deviceInfo.radioBw! >= 0 && deviceInfo.radioBw! >= 0 &&
@@ -275,10 +287,17 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
); );
_showCustomRadioSettings = _selectedRadioPreset == null; _showCustomRadioSettings = _selectedRadioPreset == null;
// Check if telemetry is enabled (check if lat/lon are set and not zero) final telemetryModes = deviceInfo.telemetryModes;
_telemetryEnabled = _baseTelemetryMode = telemetryModes != null ? telemetryModes & 0x03 : 0;
(deviceInfo.advLat != null && deviceInfo.advLat! != 0) || _locationTelemetryMode = telemetryModes != null
(deviceInfo.advLon != null && deviceInfo.advLon! != 0); ? (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+) // Initialize repeat mode from device info (firmware v9+)
_repeatEnabled = deviceInfo.clientRepeat ?? false; _repeatEnabled = deviceInfo.clientRepeat ?? false;
@@ -308,6 +327,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_lonController.dispose(); _lonController.dispose();
_freqController.dispose(); _freqController.dispose();
_txPowerController.dispose(); _txPowerController.dispose();
_gpsIntervalController.dispose();
_autoAddMaxHopsController.dispose();
super.dispose(); super.dispose();
} }
@@ -428,6 +449,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
deviceInfo.autoAddRoomServers, deviceInfo.autoAddRoomServers,
deviceInfo.autoAddSensors, deviceInfo.autoAddSensors,
deviceInfo.autoAddOverwriteOldest, deviceInfo.autoAddOverwriteOldest,
deviceInfo.autoAddMaxHops,
].join('|'); ].join('|');
} }
@@ -467,22 +489,22 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true; _autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true;
_autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true; _autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true;
_overwriteOldestAutoAddEnabled = deviceInfo.autoAddOverwriteOldest ?? false; _overwriteOldestAutoAddEnabled = deviceInfo.autoAddOverwriteOldest ?? false;
_autoAddMaxHopsController.text = (deviceInfo.autoAddMaxHops ?? 0)
.toString();
} }
int _telemetryModesForSave(ConnectionProvider connectionProvider) { int _telemetryModesForSave() {
final deviceInfo = connectionProvider.deviceInfo; return (_environmentTelemetryMode << 4) |
final telemetryEnabled = (_locationTelemetryMode << 2) |
(deviceInfo.advLat != null && deviceInfo.advLat != 0) || _baseTelemetryMode;
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
return deviceInfo.telemetryModes ?? (telemetryEnabled ? 0x0A : 0x00);
} }
int _advertLocationPolicyForSave(ConnectionProvider connectionProvider) { int _advertLocationPolicyForSave() {
final deviceInfo = connectionProvider.deviceInfo; return _advertLocationPolicy;
final telemetryEnabled = }
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
(deviceInfo.advLon != null && deviceInfo.advLon != 0); int _multiAcksForSave() {
return deviceInfo.advertLocPolicy ?? (telemetryEnabled ? 1 : 0); return _multiAcksEnabled ? 1 : 0;
} }
Future<void> _savePublicInfo() async { Future<void> _savePublicInfo() async {
@@ -501,8 +523,24 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
await connectionProvider.setAdvertName(_nameController.text); await connectionProvider.setAdvertName(_nameController.text);
} }
// Save position and telemetry settings final gpsIntervalText = _gpsIntervalController.text.trim();
if (_telemetryEnabled) { 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 // Parse and validate coordinates
final latResult = validator.parseLatitude(_latController.text); final latResult = validator.parseLatitude(_latController.text);
if (!latResult.isSuccess) { if (!latResult.isSuccess) {
@@ -530,27 +568,15 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
latitude: latResult.value!, latitude: latResult.value!,
longitude: lonResult.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 // Refetch device info to update UI with new settings
await connectionProvider.refreshDeviceInfo(); await connectionProvider.refreshDeviceInfo();
@@ -625,6 +651,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Save TX power // Save TX power
await connectionProvider.setTxPower(txPowerResult.value!); await connectionProvider.setTxPower(txPowerResult.value!);
if (_selectedPathHashMode != null) {
await connectionProvider.setPathHashMode(_selectedPathHashMode!);
}
// Refetch device info to update UI with new settings // Refetch device info to update UI with new settings
await connectionProvider.refreshDeviceInfo(); await connectionProvider.refreshDeviceInfo();
@@ -658,6 +688,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
_autoAddDiscoveredContactsEnabled && _autoAddSensorsEnabled; _autoAddDiscoveredContactsEnabled && _autoAddSensorsEnabled;
final overwriteOldest = final overwriteOldest =
_autoAddDiscoveredContactsEnabled && _overwriteOldestAutoAddEnabled; _autoAddDiscoveredContactsEnabled && _overwriteOldestAutoAddEnabled;
final maxHopsText = _autoAddMaxHopsController.text.trim();
final maxHops = int.tryParse(maxHopsText);
setState(() { setState(() {
_isSavingAutoDiscoverySettings = true; _isSavingAutoDiscoverySettings = true;
@@ -666,18 +698,22 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}); });
try { try {
if (maxHops == null || maxHops < 0 || maxHops > 64) {
throw Exception('Auto-add max hops must be between 0 and 64.');
}
await connectionProvider.setAutoaddConfig( await connectionProvider.setAutoaddConfig(
autoAddUsers: autoAddUsers, autoAddUsers: autoAddUsers,
autoAddRepeaters: autoAddRepeaters, autoAddRepeaters: autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers, autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors, autoAddSensors: autoAddSensors,
overwriteOldest: overwriteOldest, overwriteOldest: overwriteOldest,
maxHops: maxHops,
); );
await connectionProvider.setOtherParams( await connectionProvider.setOtherParams(
manualAddContacts: _autoAddFilterModeFlag, manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: _telemetryModesForSave(connectionProvider), telemetryModes: _telemetryModesForSave(),
advertLocationPolicy: _advertLocationPolicyForSave(connectionProvider), advertLocationPolicy: _advertLocationPolicyForSave(),
multiAcks: connectionProvider.deviceInfo.multiAcks ?? 0, multiAcks: _multiAcksForSave(),
); );
await connectionProvider.getAutoaddConfig(); await connectionProvider.getAutoaddConfig();
@@ -709,8 +745,12 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
final vars = await _connectionProvider.getCustomVars(); final vars = await _connectionProvider.getCustomVars();
if (!mounted) return; if (!mounted) return;
final gpsValue = vars['gps']; final gpsValue = vars['gps'];
final gpsIntervalValue = vars['gps_interval'];
setState(() { setState(() {
_gpsEnabled = gpsValue != null ? gpsValue == '1' : null; _gpsEnabled = gpsValue != null ? gpsValue == '1' : null;
if (gpsIntervalValue != null) {
_gpsIntervalController.text = gpsIntervalValue;
}
}); });
} catch (_) { } catch (_) {
// Device may not support custom vars (old firmware / no GPS hardware) // Device may not support custom vars (old firmware / no GPS hardware)
@@ -794,7 +834,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
setState(() { setState(() {
_latController.text = position.latitude.toStringAsFixed(6); _latController.text = position.latitude.toStringAsFixed(6);
_lonController.text = position.longitude.toStringAsFixed(6); _lonController.text = position.longitude.toStringAsFixed(6);
_telemetryEnabled = true; _advertLocationPolicy = 2;
if (_locationTelemetryMode == 0) {
_locationTelemetryMode = 2;
}
}); });
if (mounted) { if (mounted) {
@@ -823,6 +866,37 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
} }
} }
Future<void> _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<void> _confirmFactoryReset() async { Future<void> _confirmFactoryReset() async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@@ -1093,9 +1167,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo; final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final locationSet = final locationSet = _advertLocationPolicy != 0;
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
@@ -1173,6 +1245,130 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
), ),
SizedBox(height: 20), 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( _ConfigSectionCard(
title: AppLocalizations.of(context)!.autoDiscovery, title: AppLocalizations.of(context)!.autoDiscovery,
subtitle: subtitle:
@@ -1310,6 +1506,22 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
: null, : 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), const SizedBox(height: 18),
if (_autoDiscoverySettingsError != null) ...[ if (_autoDiscoverySettingsError != null) ...[
Text( Text(
@@ -1346,27 +1558,87 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_SettingHighlightCard( _ConfigDropdownField(
icon: _telemetryEnabled label: 'Base telemetry',
? Icons.travel_explore value: _baseTelemetryMode,
: Icons.location_disabled, items: const [
title: AppLocalizations.of( DropdownMenuItem(value: 0, child: Text('Deny')),
context, DropdownMenuItem(
)!.telemetryAndLocationSharing, value: 1,
description: 'Share your location with nearby devices.', child: Text('Use contact flags'),
accentColor: _telemetryEnabled ),
? colorScheme.primary DropdownMenuItem(value: 2, child: Text('Allow all')),
: colorScheme.onSurfaceVariant, ],
trailing: Switch( onChanged: (value) {
value: _telemetryEnabled, if (value == null) return;
onChanged: (value) { setState(() {
setState(() { _baseTelemetryMode = value;
_telemetryEnabled = value; _markPublicInfoDirty();
_publicInfoSaved = false; });
_publicInfoError = null; },
}); ),
}, 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) ...[ if (_gpsEnabled != null) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -1392,6 +1664,43 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
), ),
], ],
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), const SizedBox(height: 18),
TextField( TextField(
controller: _nameController, controller: _nameController,
@@ -1407,7 +1716,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
'This is the name other devices will see on the mesh.', 'This is the name other devices will see on the mesh.',
), ),
), ),
if (_telemetryEnabled) ...[ if (_advertLocationPolicy == 2) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Container( Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
@@ -1420,14 +1729,14 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Shared location', 'Saved coordinates',
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( 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( style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -1463,6 +1772,22 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
], ],
), ),
), ),
] 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), const SizedBox(height: 18),
if (_publicInfoError != null) ...[ if (_publicInfoError != null) ...[
@@ -1708,6 +2033,45 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
if (_selectedPathHashMode != null) ...[
const SizedBox(height: 16),
DropdownButtonFormField<int>(
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<DeviceConfigScreen> {
} }
return '$storageKb KB'; 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 { class _ConfigHeroCard extends StatelessWidget {
@@ -2153,17 +2537,52 @@ class _ConfigSectionCard extends StatelessWidget {
} }
} }
class _ConfigDropdownField<T> extends StatelessWidget {
final String label;
final T value;
final List<DropdownMenuItem<T>> items;
final ValueChanged<T?> 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<T>(
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 { class _StorageStat extends StatelessWidget {
final String label; final String label;
final String value; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Container( return Container(
padding: const EdgeInsets.all(14), padding: EdgeInsets.all(compact ? 12 : 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerLowest, color: colorScheme.surfaceContainerLowest,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -2177,14 +2596,16 @@ class _StorageStat extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontSize: compact ? 13 : null,
), ),
), ),
const SizedBox(height: 6), SizedBox(height: compact ? 4 : 6),
Text( Text(
value, value,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
color: colorScheme.onSurface, color: colorScheme.onSurface,
fontSize: compact ? 17 : null,
), ),
), ),
], ],

View File

@@ -8,7 +8,6 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:vibration/vibration.dart'; import 'package:vibration/vibration.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../models/contact.dart';
import '../models/device_info.dart' show ConnectionMode, DeviceInfo; import '../models/device_info.dart' show ConnectionMode, DeviceInfo;
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
@@ -439,133 +438,129 @@ class _HomeScreenState extends State<HomeScreen>
// Request self telemetry so it's fresh // Request self telemetry so it's fresh
context.read<ConnectionProvider>().requestSelfTelemetry(); context.read<ConnectionProvider>().requestSelfTelemetry();
// Find the device's own contact to show self telemetry
final selfKey = deviceInfo.publicKey;
Contact? selfContact;
if (selfKey != null) {
selfContact = context.read<ContactsProvider>().findContactByKey(
Uint8List.fromList(selfKey),
);
}
final telemetry = selfContact?.telemetry;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (context) => SafeArea( builder: (context) => Consumer<ContactsProvider>(
child: Padding( builder: (context, contactsProvider, child) {
padding: const EdgeInsets.all(20), final telemetry = contactsProvider.selfTelemetry;
child: Column(
mainAxisSize: MainAxisSize.min, return SafeArea(
children: [ child: Padding(
Container( padding: const EdgeInsets.all(20),
width: 40, child: Column(
height: 4, mainAxisSize: MainAxisSize.min,
decoration: BoxDecoration( children: [
color: Theme.of(context).dividerColor, Container(
borderRadius: BorderRadius.circular(2), 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<HomeScreen>
), ),
Text( Text(
value, value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.w600, context,
), ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
), ),
], ],
), ),
@@ -1224,58 +1219,56 @@ class _HomeScreenState extends State<HomeScreen>
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
GestureDetector( GestureDetector(
onTap: () => _showDeviceInfoSheet( onTap: () =>
context, _showDeviceInfoSheet(context, deviceInfo),
deviceInfo,
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
isTcpConnected isTcpConnected
? Icons.wifi_rounded ? Icons.wifi_rounded
: Icons.bluetooth_connected_rounded, : Icons.bluetooth_connected_rounded,
size: 13, size: 13,
color: signalColor,
),
if (!isTcpConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4),
_buildMiniSignalBars(
activeBars:
BatteryDisplayHelper.getSignalBars(
deviceInfo.signalRssi!,
),
color: signalColor, color: signalColor,
), ),
], if (!isTcpConnected &&
if (deviceInfo.batteryPercent != null) ...[ deviceInfo.signalRssi != null) ...[
const SizedBox(width: 8), const SizedBox(width: 4),
Icon( _buildMiniSignalBars(
BatteryDisplayHelper.getBatteryIcon( activeBars:
deviceInfo.batteryPercent!, BatteryDisplayHelper.getSignalBars(
deviceInfo.signalRssi!,
),
color: signalColor,
), ),
size: 13, ],
color: if (deviceInfo.batteryPercent != null) ...[
BatteryDisplayHelper.getBatteryColor( const SizedBox(width: 8),
deviceInfo.batteryPercent!, Icon(
), BatteryDisplayHelper.getBatteryIcon(
), deviceInfo.batteryPercent!,
const SizedBox(width: 2), ),
Text( size: 13,
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color:
BatteryDisplayHelper.getBatteryColor( BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!, deviceInfo.batteryPercent!,
), ),
), ),
), const SizedBox(width: 2),
Text(
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
],
], ],
], ),
),
), ),
], ],
), ),

View File

@@ -299,24 +299,28 @@ class _SensorsTabState extends State<SensorsTab> {
final hasPersistedSensors = final hasPersistedSensors =
sensorsProvider.watchedSensorKeys.isNotEmpty; sensorsProvider.watchedSensorKeys.isNotEmpty;
return RefreshIndicator( Widget buildSensorCard(String key, int index) {
onRefresh: () => _refreshAll(context), final contact = sensorsProvider.contactForDisplay(
child: ListView( key,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), contactsProvider: contactsProvider,
children: [ connectionProvider: connectionProvider,
if (displayKeys.isEmpty) );
const _EmptySensorsState() final availableFieldKeys = sensorMetricKeysFor(contact);
else final visibleFields = sensorsProvider.effectiveVisibleFieldsFor(
...displayKeys.map((key) { key,
final contact = sensorsProvider.contactForDisplay( availableFieldKeys,
key, );
contactsProvider: contactsProvider,
connectionProvider: connectionProvider, return Padding(
); key: ValueKey<String>('sensor_card_$key'),
final availableFieldKeys = sensorMetricKeysFor(contact); padding: EdgeInsets.only(
final visibleFields = sensorsProvider bottom: index == displayKeys.length - 1 ? 0 : 12,
.effectiveVisibleFieldsFor(key, availableFieldKeys); ),
return SensorTelemetryCard( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SensorTelemetryCard(
contact: contact, contact: contact,
state: sensorsProvider.stateFor(key), state: sensorsProvider.stateFor(key),
visibleFields: visibleFields, visibleFields: visibleFields,
@@ -348,10 +352,70 @@ class _SensorsTabState extends State<SensorsTab> {
contactsProvider: contactsProvider, contactsProvider: contactsProvider,
connectionProvider: connectionProvider, 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),
],
),
); );
}, },
), ),

View File

@@ -14,6 +14,8 @@ import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart'; import '../providers/map_provider.dart';
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
import '../providers/sensors_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 'app_config_snapshot_service.dart';
import 'contact_storage_service.dart'; import 'contact_storage_service.dart';
import 'device_config_applicator.dart'; import 'device_config_applicator.dart';
@@ -33,6 +35,8 @@ class ProfileWorkspaceCoordinator {
required this.mapProvider, required this.mapProvider,
required this.drawingProvider, required this.drawingProvider,
required this.channelsProvider, required this.channelsProvider,
required this.voiceProvider,
required this.imageProvider,
required this.appProvider, required this.appProvider,
AppConfigSnapshotService? appConfigSnapshotService, AppConfigSnapshotService? appConfigSnapshotService,
MapWorkspaceSnapshotService? mapWorkspaceSnapshotService, MapWorkspaceSnapshotService? mapWorkspaceSnapshotService,
@@ -58,13 +62,15 @@ class ProfileWorkspaceCoordinator {
final MapProvider mapProvider; final MapProvider mapProvider;
final DrawingProvider drawingProvider; final DrawingProvider drawingProvider;
final ChannelsProvider channelsProvider; final ChannelsProvider channelsProvider;
final VoiceProvider voiceProvider;
final ip.ImageProvider imageProvider;
final AppProvider appProvider; final AppProvider appProvider;
final AppConfigSnapshotService _appConfigSnapshotService; final AppConfigSnapshotService _appConfigSnapshotService;
final MapWorkspaceSnapshotService _mapWorkspaceSnapshotService; final MapWorkspaceSnapshotService _mapWorkspaceSnapshotService;
final DeviceConfigApplicator _deviceConfigApplicator; final DeviceConfigApplicator _deviceConfigApplicator;
final MessageStorageService _messageStorageService; final MessageStorageService _messageStorageService;
final ContactStorageService _contactStorageService; final ContactStorageService _contactStorageService;
bool _isSyncingDeviceProfile = false; Future<void>? _deviceProfileSyncFuture;
Future<void> setProfilesEnabled(bool enabled) async { Future<void> setProfilesEnabled(bool enabled) async {
final wasEnabled = profileManager.profilesEnabled; final wasEnabled = profileManager.profilesEnabled;
@@ -279,41 +285,54 @@ class ProfileWorkspaceCoordinator {
} }
Future<void> syncActiveProfileForCurrentDevice() async { Future<void> syncActiveProfileForCurrentDevice() async {
if (!profileManager.profilesEnabled || _isSyncingDeviceProfile) { if (!profileManager.profilesEnabled) {
return; 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<void> _syncActiveProfileForCurrentDeviceInternal() async {
final deviceKey = _currentDeviceProfileKey; final deviceKey = _currentDeviceProfileKey;
if (deviceKey == null) { if (deviceKey == null) {
return; return;
} }
_isSyncingDeviceProfile = true; final profile = await _ensureProfileForCurrentDevice();
try { final targetProfileId = profile.id;
final profile = await _ensureProfileForCurrentDevice(); if (targetProfileId == profileManager.activeProfileId) {
final targetProfileId = profile.id; return;
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;
} }
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<ConfigProfile> _ensureProfileForCurrentDevice() async { Future<ConfigProfile> _ensureProfileForCurrentDevice() async {
@@ -387,6 +406,8 @@ class ProfileWorkspaceCoordinator {
await sensorsProvider.reloadProfileScopedState(); await sensorsProvider.reloadProfileScopedState();
await drawingProvider.reloadProfileScopedState(); await drawingProvider.reloadProfileScopedState();
await mapProvider.reloadProfileScopedState(); await mapProvider.reloadProfileScopedState();
await voiceProvider.reloadProfileScopedState();
await imageProvider.reloadProfileScopedState();
await appProvider.reloadProfileScopedSettings(); await appProvider.reloadProfileScopedSettings();
} }

View File

@@ -5,8 +5,17 @@ import '../l10n/app_localizations.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../services/network_scanner_service.dart'; import '../services/network_scanner_service.dart';
import '../services/profile_workspace_coordinator.dart';
import '../services/serial/serial_transport.dart'; import '../services/serial/serial_transport.dart';
Future<void> _initializeConnectedWorkspace({
required ProfileWorkspaceCoordinator profileWorkspaceCoordinator,
required AppProvider appProvider,
}) async {
await profileWorkspaceCoordinator.syncActiveProfileForCurrentDevice();
await appProvider.initialize();
}
/// Connection Dialog with tabs for BLE devices and Network servers /// Connection Dialog with tabs for BLE devices and Network servers
class ConnectionDialog extends StatefulWidget { class ConnectionDialog extends StatefulWidget {
const ConnectionDialog({super.key}); const ConnectionDialog({super.key});
@@ -426,6 +435,8 @@ class _ConnectionDialogState extends State<ConnectionDialog>
Future<void> connectBle() async { Future<void> connectBle() async {
final appProvider = context.read<AppProvider>(); final appProvider = context.read<AppProvider>();
final profileWorkspaceCoordinator = context
.read<ProfileWorkspaceCoordinator>();
setState(() { setState(() {
_connectingBleDeviceId = deviceId; _connectingBleDeviceId = deviceId;
}); });
@@ -436,7 +447,11 @@ class _ConnectionDialogState extends State<ConnectionDialog>
); );
if (success && if (success &&
connectionProvider.deviceInfo.isConnected) { connectionProvider.deviceInfo.isConnected) {
await appProvider.initialize(); await _initializeConnectedWorkspace(
profileWorkspaceCoordinator:
profileWorkspaceCoordinator,
appProvider: appProvider,
);
} }
} finally { } finally {
if (mounted) { if (mounted) {
@@ -533,6 +548,8 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final connectionProvider = context final connectionProvider = context
.read<ConnectionProvider>(); .read<ConnectionProvider>();
final appProvider = context.read<AppProvider>(); final appProvider = context.read<AppProvider>();
final profileWorkspaceCoordinator = context
.read<ProfileWorkspaceCoordinator>();
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
@@ -554,7 +571,11 @@ class _ConnectionDialogState extends State<ConnectionDialog>
server.ipAddress, server.ipAddress,
server.port, server.port,
); );
await appProvider.initialize(); await _initializeConnectedWorkspace(
profileWorkspaceCoordinator:
profileWorkspaceCoordinator,
appProvider: appProvider,
);
if (mounted) { if (mounted) {
navigator.pop(); navigator.pop();
@@ -781,6 +802,8 @@ class _SerialDeviceListState extends State<_SerialDeviceList> {
try { try {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final appProvider = context.read<AppProvider>(); final appProvider = context.read<AppProvider>();
final profileWorkspaceCoordinator = context
.read<ProfileWorkspaceCoordinator>();
final connection = await _transport.connect(device); final connection = await _transport.connect(device);
final success = await connectionProvider.connectSerial( final success = await connectionProvider.connectSerial(
service: connection.service, service: connection.service,
@@ -791,7 +814,10 @@ class _SerialDeviceListState extends State<_SerialDeviceList> {
if (!mounted) return; if (!mounted) return;
if (success) { if (success) {
await appProvider.initialize(); await _initializeConnectedWorkspace(
profileWorkspaceCoordinator: profileWorkspaceCoordinator,
appProvider: appProvider,
);
widget.onConnected(); widget.onConnected();
} else { } else {
await connection.disconnect(); await connection.disconnect();

View File

@@ -352,6 +352,7 @@ class ContactTile extends StatelessWidget {
void _showContactActionSheet(BuildContext context, Contact contact) { void _showContactActionSheet(BuildContext context, Contact contact) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final canToggleFavourite = !contact.isChannel;
final canMessage = final canMessage =
contact.type == ContactType.chat || contact.type == ContactType.chat ||
contact.type == ContactType.room || contact.type == ContactType.room ||
@@ -369,192 +370,168 @@ class ContactTile extends StatelessWidget {
final sensorsProvider = context.read<SensorsProvider>(); final sensorsProvider = context.read<SensorsProvider>();
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex); final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
showModalBottomSheet( final primaryActions = <_ContactSheetAction>[
context: context, if (canMessage)
shape: const RoundedRectangleBorder( _ContactSheetAction(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), icon: Icons.message_outlined,
), label: l10n.messages,
builder: (sheetContext) => SafeArea( onTap: () async {
child: SingleChildScrollView( Navigator.pop(context);
child: Column( await _openMessagesForContact(context, contact);
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<ConnectionProvider>();
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<ConnectionProvider>();
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<ConnectionProvider>()
.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<void>.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<void>.delayed(Duration.zero);
if (!context.mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
if (contact.isChannel) {
_showDeleteChannelDialog(context, contact);
} else {
_showDeleteConfirmation(context, contact);
}
});
},
),
],
),
), ),
if (!contact.isChannel)
_ContactSheetAction(
icon: Icons.share_outlined,
label: l10n.share,
onTap: () async {
Navigator.pop(context);
final connectionProvider = context.read<ConnectionProvider>();
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<void>.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<ConnectionProvider>()
.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<void>.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<void>(
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<ConnectionProvider>();
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<void> 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<void> 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<void> _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 { class _ContactNameOverrideSheet extends StatefulWidget {
final String initialValue; final String initialValue;
final String advertisedName; final String advertisedName;

View File

@@ -1243,6 +1243,11 @@ class SensorTelemetryCard extends StatelessWidget {
metric.wide) metric.wide)
? constraints.maxWidth ? constraints.maxWidth
: compactWidth, : compactWidth,
onLongPress: onRefresh == null
? null
: () async {
await onRefresh!();
},
), ),
) )
.toList(), .toList(),
@@ -2190,6 +2195,7 @@ class SensorMetricTile extends StatelessWidget {
final double width; final double width;
final String keyPrefix; final String keyPrefix;
final bool allowMapPreview; final bool allowMapPreview;
final GestureLongPressCallback? onLongPress;
const SensorMetricTile({ const SensorMetricTile({
super.key, super.key,
@@ -2197,6 +2203,7 @@ class SensorMetricTile extends StatelessWidget {
required this.width, required this.width,
this.keyPrefix = 'sensor_metric', this.keyPrefix = 'sensor_metric',
this.allowMapPreview = true, this.allowMapPreview = true,
this.onLongPress,
}); });
Future<void> _showExpandedMap(BuildContext context) async { Future<void> _showExpandedMap(BuildContext context) async {
@@ -2271,142 +2278,152 @@ class SensorMetricTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Material(
key: ValueKey('${keyPrefix}_${data.fieldKey}'), color: Colors.transparent,
width: width, child: InkWell(
padding: const EdgeInsets.all(8), key: ValueKey('${keyPrefix}_${data.fieldKey}'),
decoration: BoxDecoration(
color: data.accent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
border: Border.all(color: data.accent.withValues(alpha: 0.14)), onLongPress: onLongPress,
), child: Container(
child: data.mapLocation == null || !allowMapPreview width: width,
? Stack( padding: const EdgeInsets.all(8),
children: [ decoration: BoxDecoration(
Row( color: data.accent.withValues(alpha: 0.08),
crossAxisAlignment: CrossAxisAlignment.start, borderRadius: BorderRadius.circular(22),
border: Border.all(color: data.accent.withValues(alpha: 0.14)),
),
child: data.mapLocation == null || !allowMapPreview
? Stack(
children: [ children: [
_MetricIcon(accent: data.accent, icon: data.icon), Row(
const SizedBox(width: 10), crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: _MetricText(data: data, keyPrefix: keyPrefix), _MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10),
Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix),
),
],
), ),
], if (data.channel != null)
), Positioned(
if (data.channel != null) right: 0,
Positioned( bottom: 0,
right: 0, child: Text(
bottom: 0, 'ch${data.channel}',
child: Text( style: TextStyle(
'ch${data.channel}', fontSize: 9,
style: TextStyle( fontWeight: FontWeight.w700,
fontSize: 9, color: data.accent.withValues(alpha: 0.5),
fontWeight: FontWeight.w700, ),
color: data.accent.withValues(alpha: 0.5), ),
), ),
), ],
), )
], : Column(
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_MetricIcon(accent: data.accent, icon: data.icon), Row(
const SizedBox(width: 10), crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: _MetricText(data: data, keyPrefix: keyPrefix), _MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10),
Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix),
),
],
), ),
], const SizedBox(height: 10),
), Material(
const SizedBox(height: 10), color: Colors.transparent,
Material( child: InkWell(
color: Colors.transparent, borderRadius: BorderRadius.circular(14),
child: InkWell( onTap: () => _showExpandedMap(context),
borderRadius: BorderRadius.circular(14), child: ClipRRect(
onTap: () => _showExpandedMap(context), borderRadius: BorderRadius.circular(14),
child: ClipRRect( child: SizedBox(
borderRadius: BorderRadius.circular(14), height: 104,
child: SizedBox( width: double.infinity,
height: 104, child: Stack(
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,
),
),
children: [ children: [
flutter_map.TileLayer( flutter_map.FlutterMap(
urlTemplate: options: flutter_map.MapOptions(
'https://tile.openstreetmap.org/{z}/{x}/{y}.png', initialCenter: data.mapLocation!,
userAgentPackageName: initialZoom: 14,
'com.meshcore.sar.meshcore_sar_app', interactionOptions:
), const flutter_map.InteractionOptions(
flutter_map.MarkerLayer( flags:
markers: [ flutter_map.InteractiveFlag.none,
flutter_map.Marker( ),
point: data.mapLocation!, ),
width: 32, children: [
height: 32, flutter_map.TileLayer(
child: Icon( urlTemplate:
Icons.location_on, 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
color: data.accent, userAgentPackageName:
size: 28, '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,
),
),
],
),
),
),
],
), ),
), ),
), ),
), ],
), ),
], ),
), ),
); );
} }
} }

View File

@@ -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<int>.filled(16, 7)),
);
provider.selectChannel(2);
provider.prepareForDeviceSync();
expect(provider.channels, isEmpty);
expect(provider.selectedChannelIndex, 0);
expect(provider.selectedChannel, isNull);
});
});
}

View File

@@ -10,10 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
Contact createContact({ Contact createContact({required Uint8List key, required String name}) {
required Uint8List key,
required String name,
}) {
return Contact( return Contact(
publicKey: key, publicKey: key,
type: ContactType.chat, type: ContactType.chat,
@@ -36,34 +33,37 @@ void main() {
); );
}); });
test('initializeEarly respects the active profile storage namespace', () async { test(
final storage = ContactStorageService(); 'initializeEarly respects the active profile storage namespace',
final defaultContact = createContact( () async {
key: Uint8List.fromList(List<int>.filled(32, 1)), final storage = ContactStorageService();
name: 'Default Contact', final defaultContact = createContact(
); key: Uint8List.fromList(List<int>.filled(32, 1)),
final alphaContact = createContact( name: 'Default Contact',
key: Uint8List.fromList(List<int>.filled(32, 2)), );
name: 'Alpha Contact', final alphaContact = createContact(
); key: Uint8List.fromList(List<int>.filled(32, 2)),
name: 'Alpha Contact',
);
await storage.saveContacts([defaultContact]); await storage.saveContacts([defaultContact]);
await storage.saveContacts([alphaContact], namespace: 'alpha'); await storage.saveContacts([alphaContact], namespace: 'alpha');
ProfileStorageScope.setScope( ProfileStorageScope.setScope(
profilesEnabled: true, profilesEnabled: true,
activeProfileId: 'alpha', activeProfileId: 'alpha',
); );
final provider = ContactsProvider(); final provider = ContactsProvider();
await provider.initializeEarly(); await provider.initializeEarly();
final names = provider.contacts final names = provider.contacts
.where((contact) => !contact.isChannel) .where((contact) => !contact.isChannel)
.map((contact) => contact.advName) .map((contact) => contact.advName)
.toList(); .toList();
expect(names, <String>['Alpha Contact']); expect(names, <String>['Alpha Contact']);
expect(provider.storageNamespace, 'alpha'); expect(provider.storageNamespace, 'alpha');
}); },
);
} }

View File

@@ -173,14 +173,8 @@ void main() {
var updated = scopedProvider.findContactByKey(scopedKey)!; var updated = scopedProvider.findContactByKey(scopedKey)!;
expect(updated.telemetry, isNotNull); expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNull); expect(updated.telemetry!.gpsLocation, isNull);
expect( expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001));
updated.displayLocation!.latitude, expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001));
closeTo(45.1234, 0.0001),
);
expect(
updated.displayLocation!.longitude,
closeTo(13.8765, 0.0001),
);
// Invalid 0,0 GPS frame should behave the same way. // Invalid 0,0 GPS frame should behave the same way.
final invalidGps = CayenneLppParser.createGpsData( final invalidGps = CayenneLppParser.createGpsData(
@@ -192,14 +186,8 @@ void main() {
updated = scopedProvider.findContactByKey(scopedKey)!; updated = scopedProvider.findContactByKey(scopedKey)!;
expect(updated.telemetry, isNotNull); expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNull); expect(updated.telemetry!.gpsLocation, isNull);
expect( expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001));
updated.displayLocation!.latitude, expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001));
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)); expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
}); });
test('retains scalar telemetry but wipes stale extra sensor fields on refresh', () { test(
final fullTelemetry = ContactTelemetry( 'retains scalar telemetry but wipes stale extra sensor fields on refresh',
gpsLocation: const LatLng(46.0569, 14.5058), () {
batteryPercentage: 54.0, final fullTelemetry = ContactTelemetry(
batteryMilliVolts: 3780, gpsLocation: const LatLng(46.0569, 14.5058),
temperature: 19.5, batteryPercentage: 54.0,
timestamp: DateTime.now().subtract(const Duration(minutes: 2)), batteryMilliVolts: 3780,
humidity: 58.0, temperature: 19.5,
pressure: 1011.2, timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
extraSensorData: const {'pm25': 8.0}, humidity: 58.0,
); pressure: 1011.2,
extraSensorData: const {'pm25': 8.0},
);
provider.addOrUpdateContact( provider.addOrUpdateContact(
createContact( createContact(
key: publicKey, key: publicKey,
type: ContactType.chat, type: ContactType.chat,
).copyWith(telemetry: fullTelemetry), ).copyWith(telemetry: fullTelemetry),
); );
final batteryOnly = CayenneLppParser.createBatteryData(3.95); final batteryOnly = CayenneLppParser.createBatteryData(3.95);
provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly); provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly);
final updated = provider.findContactByKey(publicKey)!; final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull); expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNull); expect(updated.telemetry!.gpsLocation, isNull);
expect(updated.displayLocation, const LatLng(46.0569, 14.5058)); expect(updated.displayLocation, const LatLng(46.0569, 14.5058));
expect(updated.telemetry!.batteryMilliVolts, isNotNull); expect(updated.telemetry!.batteryMilliVolts, isNotNull);
expect(updated.telemetry!.batteryPercentage, isNotNull); expect(updated.telemetry!.batteryPercentage, isNotNull);
expect(updated.telemetry!.temperature, equals(19.5)); expect(updated.telemetry!.temperature, equals(19.5));
expect(updated.telemetry!.humidity, equals(58.0)); expect(updated.telemetry!.humidity, equals(58.0));
expect(updated.telemetry!.pressure, equals(1011.2)); expect(updated.telemetry!.pressure, equals(1011.2));
expect( expect(
updated.telemetry!.extraSensorData, updated.telemetry!.extraSensorData,
containsPair('__source_channel:battery', 0), containsPair('__source_channel:battery', 0),
); );
expect( expect(
updated.telemetry!.extraSensorData, updated.telemetry!.extraSensorData,
containsPair('__source_channel:voltage', 0), containsPair('__source_channel:voltage', 0),
); );
expect(updated.telemetry!.extraSensorData, isNot(contains('pm25'))); expect(updated.telemetry!.extraSensorData, isNot(contains('pm25')));
}); },
);
test('replaces old source-channel mappings when a metric moves channels', () { test(
final initialTelemetry = ContactTelemetry( 'replaces old source-channel mappings when a metric moves channels',
gpsLocation: null, () {
batteryPercentage: null, final initialTelemetry = ContactTelemetry(
batteryMilliVolts: null, gpsLocation: null,
temperature: 21.5, batteryPercentage: null,
timestamp: DateTime.now().subtract(const Duration(minutes: 2)), batteryMilliVolts: null,
humidity: null, temperature: 21.5,
pressure: null, timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
extraSensorData: const { humidity: null,
'__source_channel:temperature': 2, pressure: null,
'temperature_2': 21.5, extraSensorData: const {
'humidity_4': 66.0, '__source_channel:temperature': 2,
}, 'temperature_2': 21.5,
); 'humidity_4': 66.0,
},
);
provider.addOrUpdateContact( provider.addOrUpdateContact(
createContact( createContact(
key: publicKey, key: publicKey,
type: ContactType.chat, type: ContactType.chat,
).copyWith(telemetry: initialTelemetry), ).copyWith(telemetry: initialTelemetry),
); );
final movedChannelTelemetry = CayenneLppParser.createTemperatureData( final movedChannelTelemetry = CayenneLppParser.createTemperatureData(
23.5, 23.5,
channel: 3, channel: 3,
); );
provider.updateTelemetry(publicKey.sublist(0, 6), movedChannelTelemetry); provider.updateTelemetry(
publicKey.sublist(0, 6),
movedChannelTelemetry,
);
final updated = provider.findContactByKey(publicKey)!; final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull); expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.temperature, closeTo(23.5, 0.1)); expect(updated.telemetry!.temperature, closeTo(23.5, 0.1));
expect( expect(
updated.telemetry!.extraSensorData, updated.telemetry!.extraSensorData,
containsPair('__source_channel:temperature', 3), containsPair('__source_channel:temperature', 3),
); );
expect( expect(
updated.telemetry!.extraSensorData, updated.telemetry!.extraSensorData,
containsPair('temperature_3', closeTo(23.5, 0.1)), containsPair('temperature_3', closeTo(23.5, 0.1)),
); );
expect( expect(
updated.telemetry!.extraSensorData, updated.telemetry!.extraSensorData,
isNot(contains('temperature_2')), isNot(contains('temperature_2')),
); );
expect(updated.telemetry!.extraSensorData, isNot(contains('humidity_4'))); expect(
}); updated.telemetry!.extraSensorData,
isNot(contains('humidity_4')),
);
},
);
test('builds message snapshot from latest valid telemetry', () { test('builds message snapshot from latest valid telemetry', () {
final telemetryData = CayenneLppParser.createGpsData( 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) { String publicKeyHex(Uint8List publicKey) {

View File

@@ -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<void>.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<void>.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<void> _events = StreamController<void>.broadcast();
bool _isPlaying = false;
@override
bool get isPlaying => _isPlaying;
@override
Duration get position => Duration.zero;
@override
Duration get duration => Duration.zero;
@override
Stream<void> get events => _events.stream;
@override
Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
_isPlaying = true;
_events.add(null);
}
@override
Future<void> stop() async {
_isPlaying = false;
_events.add(null);
}
@override
void dispose() {
_events.close();
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}

View File

@@ -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/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_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/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:meshcore_sar_app/services/profiles_feature_service.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -19,10 +20,17 @@ class _FakeContactsProvider extends ContactsProvider {
} }
class _FakeConnectionProvider extends ConnectionProvider { class _FakeConnectionProvider extends ConnectionProvider {
_FakeConnectionProvider({required bool isConnected}) _FakeConnectionProvider({
: _isConnected = isConnected; required bool isConnected,
Uint8List? publicKey,
String? selfName,
}) : _isConnected = isConnected,
_publicKey = publicKey,
_selfName = selfName;
final bool _isConnected; final bool _isConnected;
final Uint8List? _publicKey;
final String? _selfName;
int pingCalls = 0; int pingCalls = 0;
@@ -31,6 +39,8 @@ class _FakeConnectionProvider extends ConnectionProvider {
connectionState: _isConnected connectionState: _isConnected
? ConnectionState.connected ? ConnectionState.connected
: ConnectionState.disconnected, : ConnectionState.disconnected,
publicKey: _publicKey,
selfName: _selfName,
); );
@override @override
@@ -65,9 +75,12 @@ void main() {
expect(provider.isLoaded, isTrue); expect(provider.isLoaded, isTrue);
} }
Contact buildSensorContact() { Contact buildSensorContact({
int firstByte = 0x44,
String name = 'WX Station',
}) {
final publicKey = Uint8List(32); final publicKey = Uint8List(32);
publicKey[0] = 0x44; publicKey[0] = firstByte;
return Contact( return Contact(
publicKey: publicKey, publicKey: publicKey,
@@ -75,7 +88,7 @@ void main() {
flags: 0, flags: 0,
outPathLen: 0, outPathLen: 0,
outPath: Uint8List(64), outPath: Uint8List(64),
advName: 'WX Station', advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0, advLat: 0,
advLon: 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, <String>[
third.publicKeyHex,
first.publicKeyHex,
second.publicKeyHex,
]);
final reloadedProvider = SensorsProvider();
await waitUntilLoaded(reloadedProvider);
expect(reloadedProvider.watchedSensorKeys, <String>[
third.publicKeyHex,
first.publicKeyHex,
second.publicKeyHex,
]);
});
test( test(
'unsupported auto refresh minutes normalize to nearest option', 'unsupported auto refresh minutes normalize to nearest option',
() async { () 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));
});
} }

View File

@@ -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/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_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/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/app_config_snapshot_service.dart';
import 'package:meshcore_sar_app/services/contact_storage_service.dart'; import 'package:meshcore_sar_app/services/contact_storage_service.dart';
import 'package:meshcore_sar_app/services/device_config_applicator.dart'; import 'package:meshcore_sar_app/services/device_config_applicator.dart';
@@ -207,15 +209,21 @@ void main() {
final connectionProvider = _FakeConnectionProvider( final connectionProvider = _FakeConnectionProvider(
deviceInfo: DeviceInfo(publicKey: Uint8List.fromList([1, 2, 3, 4])), deviceInfo: DeviceInfo(publicKey: Uint8List.fromList([1, 2, 3, 4])),
); );
final voiceProvider = _FakeVoiceProvider();
final imageProvider = _FakeImageProvider();
final coordinator = _buildCoordinator( final coordinator = _buildCoordinator(
profileManager: manager, profileManager: manager,
connectionProvider: connectionProvider, connectionProvider: connectionProvider,
voiceProvider: voiceProvider,
imageProvider: imageProvider,
); );
await coordinator.syncActiveProfileForCurrentDevice(); await coordinator.syncActiveProfileForCurrentDevice();
expect(manager.activeProfileId, alpha.id); expect(manager.activeProfileId, alpha.id);
expect(connectionProvider.disconnectCallCount, 0); expect(connectionProvider.disconnectCallCount, 0);
expect(voiceProvider.reloadCallCount, 1);
expect(imageProvider.reloadCallCount, 1);
}); });
test( test(
@@ -257,6 +265,8 @@ void main() {
ProfileWorkspaceCoordinator _buildCoordinator({ ProfileWorkspaceCoordinator _buildCoordinator({
required ProfileManager profileManager, required ProfileManager profileManager,
_FakeConnectionProvider? connectionProvider, _FakeConnectionProvider? connectionProvider,
_FakeVoiceProvider? voiceProvider,
_FakeImageProvider? imageProvider,
}) { }) {
return ProfileWorkspaceCoordinator( return ProfileWorkspaceCoordinator(
profileManager: profileManager, profileManager: profileManager,
@@ -267,6 +277,8 @@ ProfileWorkspaceCoordinator _buildCoordinator({
mapProvider: _FakeMapProvider(), mapProvider: _FakeMapProvider(),
drawingProvider: _FakeDrawingProvider(), drawingProvider: _FakeDrawingProvider(),
channelsProvider: _FakeChannelsProvider(), channelsProvider: _FakeChannelsProvider(),
voiceProvider: voiceProvider ?? _FakeVoiceProvider(),
imageProvider: imageProvider ?? _FakeImageProvider(),
appProvider: _FakeAppProvider(), appProvider: _FakeAppProvider(),
appConfigSnapshotService: _FakeAppConfigSnapshotService(), appConfigSnapshotService: _FakeAppConfigSnapshotService(),
mapWorkspaceSnapshotService: _FakeMapWorkspaceSnapshotService(), mapWorkspaceSnapshotService: _FakeMapWorkspaceSnapshotService(),
@@ -332,9 +344,8 @@ class _FakeDeviceConfigApplicator extends DeviceConfigApplicator {
} }
class _FakeConnectionProvider implements ConnectionProvider { class _FakeConnectionProvider implements ConnectionProvider {
_FakeConnectionProvider({ _FakeConnectionProvider({DeviceInfo? deviceInfo})
DeviceInfo? deviceInfo, : deviceInfo = deviceInfo ?? DeviceInfo();
}) : deviceInfo = deviceInfo ?? DeviceInfo();
int disconnectCallCount = 0; int disconnectCallCount = 0;
@@ -407,6 +418,30 @@ class _FakeChannelsProvider implements ChannelsProvider {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
} }
class _FakeVoiceProvider implements VoiceProvider {
int reloadCallCount = 0;
@override
Future<void> reloadProfileScopedState() async {
reloadCallCount += 1;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeImageProvider implements ip.ImageProvider {
int reloadCallCount = 0;
@override
Future<void> reloadProfileScopedState() async {
reloadCallCount += 1;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _FakeAppProvider implements AppProvider { class _FakeAppProvider implements AppProvider {
@override @override
Future<void> reloadProfileScopedSettings() async {} Future<void> reloadProfileScopedSettings() async {}

View File

@@ -189,4 +189,36 @@ void main() {
expect(find.text('2°C'), findsOneWidget); expect(find.text('2°C'), findsOneWidget);
expect(find.text('12.3 mm'), 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);
});
} }