diff --git a/lib/models/device_info.dart b/lib/models/device_info.dart index 22b59fe..4a8c63c 100644 --- a/lib/models/device_info.dart +++ b/lib/models/device_info.dart @@ -14,9 +14,6 @@ enum ConnectionMode { /// Direct BLE connection to MeshCore device (default) ble, - /// Act as SSE server - share BLE device with multiple clients - sseServer, - /// Direct TCP/WiFi connection to MeshCore device (port 5000) tcp, } @@ -26,8 +23,6 @@ extension ConnectionModeExtension on ConnectionMode { switch (this) { case ConnectionMode.ble: return 'Direct (BLE)'; - case ConnectionMode.sseServer: - return 'Share Device (Server)'; case ConnectionMode.tcp: return 'Direct (WiFi)'; } @@ -37,8 +32,6 @@ extension ConnectionModeExtension on ConnectionMode { switch (this) { case ConnectionMode.ble: return 'Direct BLE connection to MeshCore device'; - case ConnectionMode.sseServer: - return 'Share BLE device with multiple clients over network'; case ConnectionMode.tcp: return 'Direct WiFi/TCP connection to MeshCore device'; } diff --git a/lib/models/sse_server_config.dart b/lib/models/sse_server_config.dart deleted file mode 100644 index 6eb1452..0000000 --- a/lib/models/sse_server_config.dart +++ /dev/null @@ -1,71 +0,0 @@ -/// SSE Server Configuration Model -/// -/// Configuration for the SSE (Server-Sent Events) web server that enables -/// multiple app instances to share a single MeshCore BLE device. -class SseServerConfig { - /// Server bind address (e.g., "0.0.0.0" for all interfaces, "127.0.0.1" for localhost) - final String host; - - /// Server port (default: 12929) - final int port; - - /// Whether the SSE server is enabled - final bool enabled; - - /// Optional authentication token for basic security - /// Clients must include this token in Authorization header - final String? authToken; - - const SseServerConfig({ - this.host = '0.0.0.0', - this.port = 12929, - this.enabled = false, - this.authToken, - }); - - /// Create a copy with updated fields - SseServerConfig copyWith({ - String? host, - int? port, - bool? enabled, - String? authToken, - }) { - return SseServerConfig( - host: host ?? this.host, - port: port ?? this.port, - enabled: enabled ?? this.enabled, - authToken: authToken ?? this.authToken, - ); - } - - /// Get server URL for clients to connect to - String getServerUrl({String? ipAddress}) { - final ip = ipAddress ?? host; - return 'http://$ip:$port'; - } - - /// Convert to JSON for persistence - Map toJson() { - return { - 'host': host, - 'port': port, - 'enabled': enabled, - 'authToken': authToken, - }; - } - - /// Create from JSON - factory SseServerConfig.fromJson(Map json) { - return SseServerConfig( - host: json['host'] as String? ?? '0.0.0.0', - port: json['port'] as int? ?? 12929, - enabled: json['enabled'] as bool? ?? false, - authToken: json['authToken'] as String?, - ); - } - - @override - String toString() { - return 'SseServerConfig(host: $host, port: $port, enabled: $enabled, hasAuth: ${authToken != null})'; - } -} diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 296d864..03efc9f 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -270,25 +270,10 @@ class AppProvider with ChangeNotifier { } Future _checkLowBatteryAlerts() async { - final recoveredIds = {}; - final deviceBattery = connectionProvider.deviceInfo.batteryPercent; if (deviceBattery != null && deviceBattery > _lowBatteryResetThresholdPercent) { - recoveredIds.add('device'); - } - - for (final contact in contactsProvider.contacts) { - if (contact.isChannel) continue; - final battery = contact.displayBattery; - if (battery == null) continue; - if (battery > _lowBatteryResetThresholdPercent) { - recoveredIds.add(contact.publicKeyHex); - } - } - - if (recoveredIds.isNotEmpty) { - _lowBatteryNotifiedNodeIds.removeAll(recoveredIds); + _lowBatteryNotifiedNodeIds.remove('device'); } if (connectionProvider.deviceInfo.isConnected && deviceBattery != null) { @@ -302,19 +287,6 @@ class AppProvider with ChangeNotifier { isCurrentDevice: true, ); } - - for (final contact in contactsProvider.contacts) { - if (contact.isChannel) continue; - final battery = contact.displayBattery; - if (battery == null) continue; - - await _notifyLowBatteryIfNeeded( - nodeId: contact.publicKeyHex, - nodeName: contact.displayName, - batteryPercent: battery, - isCurrentDevice: false, - ); - } } Future _notifyLowBatteryIfNeeded({ @@ -948,7 +920,7 @@ class AppProvider with ChangeNotifier { contactName: contact.advName.trim().isEmpty ? null : contact.advName, - ), + ), ); } if (contact.type == ContactType.repeater && @@ -966,9 +938,6 @@ class AppProvider with ChangeNotifier { devicePublicKey: devicePublicKey, ); unawaited(_pathHistoryService.recordLearnedPath(contact)); - - // Broadcast to SSE clients if server is running - connectionProvider.broadcastContactToSseClients(contact); }; // When all contacts are received @@ -982,11 +951,6 @@ class AppProvider with ChangeNotifier { unawaited(_pathHistoryService.recordLearnedPath(contact)); } debugPrint('Received ${contacts.length} contacts'); - - // Broadcast all contacts to SSE clients if server is running - for (final contact in contacts) { - connectionProvider.broadcastContactToSseClients(contact); - } }; // Setup callback for ConnectionProvider to query channel info @@ -1212,9 +1176,6 @@ class AppProvider with ChangeNotifier { contactLocationSnapshot: contactLocationSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot, ); - - // Broadcast drawing message to SSE clients if server is running - connectionProvider.broadcastMessageToSseClients(updatedMessage); } else { debugPrint('⚠️ [AppProvider] Failed to parse drawing message'); } @@ -1255,7 +1216,6 @@ class AppProvider with ChangeNotifier { contactLocationSnapshot: contactLocationSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot, ); - connectionProvider.broadcastMessageToSseClients(enrichedMessage); return; } @@ -1289,7 +1249,6 @@ class AppProvider with ChangeNotifier { contactLocationSnapshot: contactLocationSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot, ); - connectionProvider.broadcastMessageToSseClients(enrichedMessage); return; } @@ -1314,9 +1273,6 @@ class AppProvider with ChangeNotifier { contactLocationSnapshot: contactLocationSnapshot, receptionDetailsSnapshot: receptionDetailsSnapshot, ); - - // Broadcast message to SSE clients if server is running - connectionProvider.broadcastMessageToSseClients(enrichedMessage); }; // Keep a compact receive-time snapshot because packet logs roll over. @@ -1353,20 +1309,21 @@ class AppProvider with ChangeNotifier { connectionProvider.onRawDataReceived = (payload, snrRaw, rssiDbm) { final parsedAdvert = _tryParseRawAdvert(payload); if (parsedAdvert != null) { - final isNewPendingAdvert = contactsProvider.addOrUpdatePendingAdvertMetadata( - publicKey: parsedAdvert.publicKey, - typeValue: parsedAdvert.typeValue, - devicePublicKey: connectionProvider.deviceInfo.publicKey, - flags: parsedAdvert.flags, - advName: parsedAdvert.advName, - lastAdvert: parsedAdvert.lastAdvert, - advLat: parsedAdvert.advLat, - advLon: parsedAdvert.advLon, - signedEncodedPathLen: parsedAdvert.signedEncodedPathLen, - paddedPathBytes: parsedAdvert.paddedPathBytes, - rxRssiDbm: rssiDbm, - rxSnrRaw: snrRaw, - ); + final isNewPendingAdvert = contactsProvider + .addOrUpdatePendingAdvertMetadata( + publicKey: parsedAdvert.publicKey, + typeValue: parsedAdvert.typeValue, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + flags: parsedAdvert.flags, + advName: parsedAdvert.advName, + lastAdvert: parsedAdvert.lastAdvert, + advLat: parsedAdvert.advLat, + advLon: parsedAdvert.advLon, + signedEncodedPathLen: parsedAdvert.signedEncodedPathLen, + paddedPathBytes: parsedAdvert.paddedPathBytes, + rxRssiDbm: rssiDbm, + rxSnrRaw: snrRaw, + ); if (isNewPendingAdvert) { final contactKey = parsedAdvert.publicKey .map((b) => b.toRadixString(16).padLeft(2, '0')) @@ -1382,7 +1339,9 @@ class AppProvider with ChangeNotifier { unawaited(_requestRepeaterStatus(parsedAdvert.publicKey)); unawaited(_maybeRequestRepeaterOwnerInfo(parsedAdvert.publicKey)); } - if (contactsProvider.shouldEnrichPendingAdvert(parsedAdvert.publicKey)) { + if (contactsProvider.shouldEnrichPendingAdvert( + parsedAdvert.publicKey, + )) { unawaited(connectionProvider.previewContact(parsedAdvert.publicKey)); } return; @@ -1669,7 +1628,9 @@ class AppProvider with ChangeNotifier { unawaited( _notificationService.showContactDiscoveredNotification( contactKey: keyHex, - contactName: contactsProvider.pendingAdvertByKey(publicKey)?.advName, + contactName: contactsProvider + .pendingAdvertByKey(publicKey) + ?.advName, ), ); } @@ -2248,10 +2209,9 @@ class AppProvider with ChangeNotifier { } try { - final payload = utf8.decode( - responseData.sublist(4), - allowMalformed: true, - ).trim(); + final payload = utf8 + .decode(responseData.sublist(4), allowMalformed: true) + .trim(); if (payload.isEmpty) { return null; } @@ -2351,10 +2311,9 @@ class AppProvider with ChangeNotifier { String? advName; if (hasName && reader.remainingBytesCount > 0) { - final decodedName = utf8.decode( - reader.readRemainingBytes(), - allowMalformed: true, - ).trim(); + final decodedName = utf8 + .decode(reader.readRemainingBytes(), allowMalformed: true) + .trim(); if (decodedName.isNotEmpty) { advName = decodedName; } diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 30e80bb..4ff4bba 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -9,9 +9,7 @@ import 'package:crypto/crypto.dart'; import '../models/contact.dart'; import '../models/device_info.dart'; import '../models/room_login_state.dart'; -import '../models/sse_server_config.dart'; import 'package:meshcore_client/meshcore_client.dart' hide Contact; -import '../services/sse_server_service.dart'; import '../utils/sar_message_parser.dart'; import 'helpers/room_login_manager.dart'; import 'helpers/message_delivery_tracker.dart'; @@ -73,7 +71,6 @@ class _PendingContactRequest { class ConnectionProvider with ChangeNotifier { static const int _controlTypeNodeDiscoverReq = 0x80; final MeshCoreBleService _bleService = MeshCoreBleService(); - final SseServerService _sseServer = SseServerService(); MeshCoreTcpService? _tcpService; /// Expose BLE service for background location tracking @@ -89,10 +86,6 @@ class ConnectionProvider with ChangeNotifier { ConnectionMode _connectionMode = ConnectionMode.ble; ConnectionMode get connectionMode => _connectionMode; - /// SSE server configuration - SseServerConfig _sseServerConfig = const SseServerConfig(); - SseServerConfig get sseServerConfig => _sseServerConfig; - /// TCP host last connected to (for display / reconnection info) String? _tcpHost; String? get tcpHost => _tcpHost; @@ -471,11 +464,6 @@ class ConnectionProvider with ChangeNotifier { spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?, ); notifyListeners(); - if (_sseServer.isRunning) { - _sseServer.setDeviceName( - _deviceInfo.deviceName ?? _deviceInfo.selfName, - ); - } }; service.onSelfInfoReceived = (selfInfo) { @@ -495,11 +483,6 @@ class ConnectionProvider with ChangeNotifier { selfName: selfInfo['selfName'] as String?, ); notifyListeners(); - if (_sseServer.isRunning) { - _sseServer.setDeviceName( - _deviceInfo.deviceName ?? _deviceInfo.selfName, - ); - } }; service.onBatteryAndStorage = (millivolts, usedKb, totalKb) { @@ -857,12 +840,19 @@ class ConnectionProvider with ChangeNotifier { } try { + _error = null; _markSingleContactRequested( publicKey, source: ContactReceiveSource.requestedSingle, ); await _activeService.getContactByKey(publicKey); } catch (e) { + final errorText = e.toString(); + if (_error == 'Not found' || errorText.contains('Not found')) { + _error = 'Not found'; + notifyListeners(); + return; + } _error = 'Failed to get contact: $e'; debugPrint( '⚠️ [Provider] Failed to get contact by key, falling back to full contact sync', @@ -1360,6 +1350,7 @@ class ConnectionProvider with ChangeNotifier { } try { + _error = null; await _activeService.addOrUpdateContact(contact); } catch (e) { _error = 'Failed to add/update contact: $e'; @@ -1367,6 +1358,22 @@ class ConnectionProvider with ChangeNotifier { } } + Future importContactAdvert(Uint8List contactAdvertFrame) async { + if (!_activeService.isConnected) { + _error = 'Not connected to device'; + notifyListeners(); + return; + } + + try { + _error = null; + await _activeService.importContact(contactAdvertFrame); + } catch (e) { + _error = 'Failed to import contact: $e'; + notifyListeners(); + } + } + /// Send text message to contact /// /// Returns true if the message was successfully sent to the BLE service. @@ -2578,110 +2585,6 @@ class ConnectionProvider with ChangeNotifier { return _roomLoginManager.isLoggedIntoRoom(publicKeyPrefix); } - // ============================================================================ - // SSE Server Methods - // ============================================================================ - - /// Start SSE server to share BLE device with multiple clients - Future startSseServer(SseServerConfig config) async { - if (_sseServer.isRunning) { - debugPrint('⚠️ [ConnectionProvider] SSE server already running'); - return; - } - - try { - debugPrint('🚀 [ConnectionProvider] Starting SSE server...'); - _sseServerConfig = config; - - // Wire up callbacks - _sseServer.onSendMessage = (recipientPublicKey, text) async { - // Convert hex string to Uint8List - final bytes = []; - for (int i = 0; i < recipientPublicKey.length; i += 2) { - bytes.add( - int.parse(recipientPublicKey.substring(i, i + 2), radix: 16), - ); - } - return await sendTextMessage( - contactPublicKey: Uint8List.fromList(bytes), - text: text, - ); - }; - - _sseServer.onSendChannelMessage = (channelIdx, text) async { - await sendChannelMessage(channelIdx: channelIdx, text: text); - }; - - _sseServer.onSyncContacts = () async { - await getContacts(); - }; - - await _sseServer.startServer(config); - - // Set initial device name - _sseServer.setDeviceName(_deviceInfo.deviceName ?? _deviceInfo.selfName); - - _connectionMode = ConnectionMode.sseServer; - notifyListeners(); - - debugPrint('✅ [ConnectionProvider] SSE server started'); - } catch (e) { - _error = 'Failed to start SSE server: $e'; - debugPrint('❌ [ConnectionProvider] Failed to start SSE server: $e'); - notifyListeners(); - rethrow; - } - } - - /// Stop SSE server - Future stopSseServer() async { - if (!_sseServer.isRunning) { - return; - } - - debugPrint('🛑 [ConnectionProvider] Stopping SSE server...'); - await _sseServer.stopServer(); - - if (_connectionMode == ConnectionMode.sseServer) { - _connectionMode = ConnectionMode.ble; - } - - notifyListeners(); - debugPrint('✅ [ConnectionProvider] SSE server stopped'); - } - - /// Broadcast message to SSE clients (call this when receiving messages from BLE) - void broadcastMessageToSseClients(Message message) { - if (_sseServer.isRunning) { - _sseServer.broadcastMessage(message); - } - } - - /// Broadcast contact to SSE clients (call this when receiving contacts from BLE) - void broadcastContactToSseClients(Contact contact) { - if (_sseServer.isRunning) { - _sseServer.broadcastContact(contact); - } - } - - /// Get SSE server status - bool get isSseServerRunning => _sseServer.isRunning; - - /// Get number of connected SSE clients - int get sseClientCount => _sseServer.connectedClients; - - /// Set connection mode - void setConnectionMode(ConnectionMode mode) { - _connectionMode = mode; - notifyListeners(); - } - - /// Update SSE server configuration - void updateSseServerConfig(SseServerConfig config) { - _sseServerConfig = config; - notifyListeners(); - } - @override void dispose() { _rxActivityTimer?.cancel(); @@ -2689,7 +2592,6 @@ class ConnectionProvider with ChangeNotifier { _stopAckCleanupTimer(); _bleService.dispose(); _tcpService?.dispose(); - _sseServer.stopServer(); super.dispose(); } } diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index c48bbd4..07dbc25 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -450,6 +450,9 @@ class ContactsProvider with ChangeNotifier { List get repeaters => contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen); + List get sensorContacts => + contacts.where((c) => c.isSensor).toList()..sort(_sortByLastSeen); + List get rooms => contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen); @@ -553,6 +556,7 @@ class ContactsProvider with ChangeNotifier { ); _contacts[contact.publicKeyHex] = updatedContact; + _pendingAdverts.remove(contact.publicKeyHex); debugPrint( ' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}', ); @@ -581,6 +585,7 @@ class ContactsProvider with ChangeNotifier { incomingContact: contact, existingContact: existingContact, ); + _pendingAdverts.remove(contact.publicKeyHex); } if (excluded > 0) { debugPrint( @@ -1526,6 +1531,7 @@ class ContactsProvider with ChangeNotifier { return { 'chat': chatContacts.length, 'repeater': repeaters.length, + 'sensor': sensorContacts.length, 'room': rooms.length, 'total': contacts.length, }; diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index fdc42b4..5076cee 100644 --- a/lib/providers/sensors_provider.dart +++ b/lib/providers/sensors_provider.dart @@ -15,6 +15,10 @@ class SensorsProvider with ChangeNotifier { static const String _watchedSensorsKey = 'watched_sensor_keys'; static const String _visibleSensorMetricsKey = 'visible_sensor_metrics'; static const String _fieldSpanKey = 'sensor_field_spans'; + static const String _metricLabelKey = 'sensor_metric_labels'; + static const String _metricOrderKey = 'sensor_metric_order'; + static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes'; + static const List supportedAutoRefreshIntervals = [0, 1, 5, 15]; static const Set _defaultVisibleFields = { 'voltage', 'battery', @@ -23,6 +27,14 @@ class SensorsProvider with ChangeNotifier { 'pressure', 'gps', }; + static const List _defaultMetricOrder = [ + 'voltage', + 'battery', + 'temperature', + 'humidity', + 'pressure', + 'gps', + ]; final List _watchedSensorKeys = []; final Map _refreshStates = @@ -32,8 +44,15 @@ class SensorsProvider with ChangeNotifier { >{}; final Map> _fieldSpansBySensor = >{}; + final Map> _metricLabelsBySensor = + >{}; + final Map> _metricOrderBySensor = + >{}; + final Map _autoRefreshMinutesBySensor = {}; + final Map _lastRefreshAttemptAt = {}; bool _isLoaded = false; bool _isRefreshingAll = false; + bool _isRunningAutoRefreshTick = false; SensorsProvider() { unawaited(_loadWatchedSensors()); @@ -52,11 +71,17 @@ class SensorsProvider with ChangeNotifier { final stored = prefs.getStringList(_watchedSensorsKey) ?? []; final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey); final storedSpansJson = prefs.getString(_fieldSpanKey); + final storedLabelsJson = prefs.getString(_metricLabelKey); + final storedOrderJson = prefs.getString(_metricOrderKey); + final storedAutoRefreshJson = prefs.getString(_autoRefreshMinutesKey); _watchedSensorKeys ..clear() ..addAll(stored); _visibleFieldsBySensor.clear(); _fieldSpansBySensor.clear(); + _metricLabelsBySensor.clear(); + _metricOrderBySensor.clear(); + _autoRefreshMinutesBySensor.clear(); if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) { final decoded = jsonDecode(storedMetricsJson) as Map; for (final entry in decoded.entries) { @@ -72,12 +97,47 @@ class SensorsProvider with ChangeNotifier { .map((key, value) => MapEntry(key, value as int)); } } + if (storedLabelsJson != null && storedLabelsJson.isNotEmpty) { + final decoded = jsonDecode(storedLabelsJson) as Map; + for (final entry in decoded.entries) { + _metricLabelsBySensor[entry.key] = + (entry.value as Map).map( + (key, value) => MapEntry(key, value as String), + ); + } + } + if (storedOrderJson != null && storedOrderJson.isNotEmpty) { + final decoded = jsonDecode(storedOrderJson) as Map; + for (final entry in decoded.entries) { + _metricOrderBySensor[entry.key] = (entry.value as List) + .cast() + .toList(); + } + } + if (storedAutoRefreshJson != null && storedAutoRefreshJson.isNotEmpty) { + final decoded = + jsonDecode(storedAutoRefreshJson) as Map; + for (final entry in decoded.entries) { + final minutes = (entry.value as num).toInt(); + if (minutes > 0) { + _autoRefreshMinutesBySensor[entry.key] = minutes; + } + } + } + _autoRefreshMinutesBySensor.removeWhere( + (key, _) => !_watchedSensorKeys.contains(key), + ); for (final key in _watchedSensorKeys) { _visibleFieldsBySensor.putIfAbsent( key, () => Set.from(_defaultVisibleFields), ); _fieldSpansBySensor.putIfAbsent(key, () => {}); + _metricLabelsBySensor.putIfAbsent(key, () => {}); + _metricOrderBySensor.putIfAbsent( + key, + () => List.from(_defaultMetricOrder), + ); } } catch (e) { debugPrint('Error loading watched sensors: $e'); @@ -118,6 +178,36 @@ class SensorsProvider with ChangeNotifier { } } + Future _persistMetricLabels() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_metricLabelKey, jsonEncode(_metricLabelsBySensor)); + } catch (e) { + debugPrint('Error saving sensor metric labels: $e'); + } + } + + Future _persistMetricOrder() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_metricOrderKey, jsonEncode(_metricOrderBySensor)); + } catch (e) { + debugPrint('Error saving sensor metric order: $e'); + } + } + + Future _persistAutoRefreshMinutes() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _autoRefreshMinutesKey, + jsonEncode(_autoRefreshMinutesBySensor), + ); + } catch (e) { + debugPrint('Error saving sensor auto refresh minutes: $e'); + } + } + Set visibleFieldsFor(String publicKeyHex) => Set.unmodifiable( _visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields, ); @@ -131,6 +221,78 @@ class SensorsProvider with ChangeNotifier { return span == 2 ? 2 : 1; } + List metricOrderFor( + String publicKeyHex, + Iterable availableFieldKeys, + ) { + final available = availableFieldKeys.toList(); + final availableSet = available.toSet(); + final ordered = []; + final seen = {}; + final stored = + _metricOrderBySensor[publicKeyHex] ?? + List.from(_defaultMetricOrder); + + for (final fieldKey in stored) { + if (availableSet.contains(fieldKey) && seen.add(fieldKey)) { + ordered.add(fieldKey); + } + } + for (final fieldKey in available) { + if (seen.add(fieldKey)) { + ordered.add(fieldKey); + } + } + return List.unmodifiable(ordered); + } + + Map labelOverridesFor(String publicKeyHex) => + Map.unmodifiable( + _metricLabelsBySensor[publicKeyHex] ?? const {}, + ); + + String? labelOverrideFor(String publicKeyHex, String fieldKey) => + _metricLabelsBySensor[publicKeyHex]?[fieldKey]; + + int autoRefreshMinutesFor(String publicKeyHex) => + _autoRefreshMinutesBySensor[publicKeyHex] ?? 0; + + Future setAutoRefreshMinutes(String publicKeyHex, int minutes) async { + final normalizedMinutes = minutes <= 0 ? 0 : minutes; + final currentMinutes = autoRefreshMinutesFor(publicKeyHex); + if (currentMinutes == normalizedMinutes) { + return; + } + + if (normalizedMinutes == 0) { + _autoRefreshMinutesBySensor.remove(publicKeyHex); + } else { + _autoRefreshMinutesBySensor[publicKeyHex] = normalizedMinutes; + } + await _persistAutoRefreshMinutes(); + notifyListeners(); + } + + List dueAutoRefreshSensorKeys({DateTime? now}) { + final refreshTime = now ?? DateTime.now(); + final dueKeys = []; + + for (final key in _watchedSensorKeys) { + final minutes = autoRefreshMinutesFor(key); + if (minutes <= 0) { + continue; + } + + final lastRefreshAt = _lastRefreshAttemptAt[key]; + if (lastRefreshAt == null || + refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes)) { + dueKeys.add(key); + } + } + + return List.unmodifiable(dueKeys); + } + Future toggleMetric( String publicKeyHex, String fieldKey, @@ -140,8 +302,17 @@ class SensorsProvider with ChangeNotifier { publicKeyHex, () => Set.from(_defaultVisibleFields), ); + final metricOrder = _metricOrderBySensor.putIfAbsent( + publicKeyHex, + () => List.from(_defaultMetricOrder), + ); + var shouldPersistOrder = false; if (visible) { visibleFields.add(fieldKey); + if (!metricOrder.contains(fieldKey)) { + metricOrder.add(fieldKey); + shouldPersistOrder = true; + } } else { if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) { return; @@ -149,6 +320,9 @@ class SensorsProvider with ChangeNotifier { visibleFields.remove(fieldKey); } await _persistVisibleMetrics(); + if (shouldPersistOrder) { + await _persistMetricOrder(); + } notifyListeners(); } @@ -166,11 +340,62 @@ class SensorsProvider with ChangeNotifier { notifyListeners(); } + Future setMetricLabel( + String publicKeyHex, + String fieldKey, + String? label, + ) async { + final sensorLabels = _metricLabelsBySensor.putIfAbsent( + publicKeyHex, + () => {}, + ); + final trimmed = label?.trim(); + if (trimmed == null || trimmed.isEmpty) { + sensorLabels.remove(fieldKey); + } else { + sensorLabels[fieldKey] = trimmed; + } + if (sensorLabels.isEmpty) { + _metricLabelsBySensor.remove(publicKeyHex); + } + await _persistMetricLabels(); + notifyListeners(); + } + + Future moveMetric( + String publicKeyHex, { + required List availableFieldKeys, + required int oldIndex, + required int newIndex, + }) async { + if (oldIndex < 0 || + newIndex < 0 || + oldIndex >= availableFieldKeys.length || + newIndex >= availableFieldKeys.length || + oldIndex == newIndex) { + return; + } + + final reordered = List.from( + metricOrderFor(publicKeyHex, availableFieldKeys), + ); + final fieldKey = reordered.removeAt(oldIndex); + reordered.insert(newIndex, fieldKey); + + final storedTail = + (_metricOrderBySensor[publicKeyHex] ?? _defaultMetricOrder).where( + (key) => !reordered.contains(key), + ); + _metricOrderBySensor[publicKeyHex] = [...reordered, ...storedTail]; + await _persistMetricOrder(); + notifyListeners(); + } + bool isWatched(String publicKeyHex) => _watchedSensorKeys.contains(publicKeyHex); Future addSensor(Contact contact) async { - if (!contact.isChat && !contact.isRepeater) { + if (!contact.isChat && !contact.isRepeater && !contact.isSensor) { return; } if (_watchedSensorKeys.contains(contact.publicKeyHex)) { @@ -183,8 +408,14 @@ class SensorsProvider with ChangeNotifier { _defaultVisibleFields, ); _fieldSpansBySensor[contact.publicKeyHex] = {'gps': 2}; + _metricLabelsBySensor[contact.publicKeyHex] = {}; + _metricOrderBySensor[contact.publicKeyHex] = List.from( + _defaultMetricOrder, + ); await _persistVisibleMetrics(); await _persistFieldSpans(); + await _persistMetricLabels(); + await _persistMetricOrder(); notifyListeners(); } @@ -194,9 +425,16 @@ class SensorsProvider with ChangeNotifier { _refreshStateUpdatedAt.remove(publicKeyHex); _visibleFieldsBySensor.remove(publicKeyHex); _fieldSpansBySensor.remove(publicKeyHex); + _metricLabelsBySensor.remove(publicKeyHex); + _metricOrderBySensor.remove(publicKeyHex); + _autoRefreshMinutesBySensor.remove(publicKeyHex); + _lastRefreshAttemptAt.remove(publicKeyHex); await _persistWatchedSensors(); await _persistVisibleMetrics(); await _persistFieldSpans(); + await _persistMetricLabels(); + await _persistMetricOrder(); + await _persistAutoRefreshMinutes(); notifyListeners(); } @@ -204,6 +442,7 @@ class SensorsProvider with ChangeNotifier { final candidates = [ ...contactsProvider.chatContacts, ...contactsProvider.repeaters, + ...contactsProvider.sensorContacts, ]; candidates.removeWhere((contact) => isWatched(contact.publicKeyHex)); candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime)); @@ -239,7 +478,14 @@ class SensorsProvider with ChangeNotifier { required String publicKeyHex, required ContactsProvider contactsProvider, required ConnectionProvider connectionProvider, + DateTime? requestedAt, }) async { + if (stateFor(publicKeyHex) == SensorRefreshState.refreshing) { + return; + } + + _lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now(); + Contact? contact; for (final entry in contactsProvider.contacts) { if (entry.publicKeyHex == publicKeyHex) { @@ -266,6 +512,38 @@ class SensorsProvider with ChangeNotifier { ); } + Future refreshDueSensors({ + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + DateTime? now, + }) async { + if (!connectionProvider.deviceInfo.isConnected || + _isRefreshingAll || + _isRunningAutoRefreshTick) { + return; + } + + final refreshTime = now ?? DateTime.now(); + final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime); + if (dueKeys.isEmpty) { + return; + } + + _isRunningAutoRefreshTick = true; + try { + for (final key in dueKeys) { + await refreshSensor( + publicKeyHex: key, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + requestedAt: refreshTime, + ); + } + } finally { + _isRunningAutoRefreshTick = false; + } + } + void clearExpiredRefreshStates({DateTime? now}) { final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention); final keysToClear = []; diff --git a/lib/screens/add_contact_screen.dart b/lib/screens/add_contact_screen.dart new file mode 100644 index 0000000..c8b221a --- /dev/null +++ b/lib/screens/add_contact_screen.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../providers/connection_provider.dart'; + +class AddContactScreen extends StatefulWidget { + const AddContactScreen({super.key}); + + @override + State createState() => _AddContactScreenState(); +} + +class _AddContactScreenState extends State { + final TextEditingController _advertController = TextEditingController(); + bool _isImporting = false; + bool _importSucceeded = false; + String? _validationError; + + @override + void initState() { + super.initState(); + _loadClipboardIfPresent(); + } + + @override + void dispose() { + _advertController.dispose(); + super.dispose(); + } + + Future _loadClipboardIfPresent() async { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + final text = clipboardData?.text?.trim(); + if (!mounted || text == null || text.isEmpty) { + return; + } + if (_normalizeAdvertText(text) == null) { + return; + } + _advertController.text = text; + } + + String? _normalizeAdvertText(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + + var normalized = trimmed; + if (normalized.startsWith('meshcore://')) { + normalized = normalized.substring('meshcore://'.length); + } + + normalized = normalized.replaceAll(RegExp(r'\s+'), ''); + if (normalized.isEmpty) { + return null; + } + + final isHex = RegExp(r'^[0-9a-fA-F]+$').hasMatch(normalized); + if (!isHex || normalized.length.isOdd) { + return null; + } + + return normalized.toLowerCase(); + } + + Uint8List _hexToBytes(String hex) { + final bytes = []; + for (var i = 0; i < hex.length; i += 2) { + bytes.add(int.parse(hex.substring(i, i + 2), radix: 16)); + } + return Uint8List.fromList(bytes); + } + + Future _pasteFromClipboard() async { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + final text = clipboardData?.text; + if (text == null || text.trim().isEmpty) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Clipboard is empty'))); + return; + } + + setState(() { + _advertController.text = text.trim(); + _importSucceeded = false; + _validationError = null; + }); + } + + Future _importContact() async { + final normalized = _normalizeAdvertText(_advertController.text); + if (normalized == null) { + setState(() { + _validationError = + 'Enter a valid meshcore:// advert or raw hexadecimal contact advert.'; + }); + return; + } + + final advertBytes = _hexToBytes(normalized); + if (advertBytes.length < 98) { + setState(() { + _validationError = + 'Advert is too short. Expected exported contact data.'; + }); + return; + } + + setState(() { + _isImporting = true; + _importSucceeded = false; + _validationError = null; + }); + + final connectionProvider = context.read(); + await connectionProvider.importContactAdvert(advertBytes); + final importError = connectionProvider.error; + + if (importError == null) { + await connectionProvider.getContacts(); + } + + if (!mounted) { + return; + } + + setState(() { + _isImporting = false; + }); + + if (importError != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(importError))); + return; + } + + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Contact imported'))); + setState(() { + _importSucceeded = true; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final normalized = _normalizeAdvertText(_advertController.text); + + return Scaffold( + appBar: AppBar(title: const Text('Add Contact')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Import an exported contact advert, like meshcore-open.', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Paste a `meshcore://...` link or raw hex advert from the clipboard.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _advertController, + minLines: 4, + maxLines: 8, + onChanged: (_) { + if (_validationError != null || _importSucceeded) { + setState(() { + _importSucceeded = false; + _validationError = null; + }); + } + }, + decoration: InputDecoration( + labelText: 'Contact advert', + hintText: 'meshcore://...', + alignLabelWithHint: true, + border: const OutlineInputBorder(), + errorText: _validationError, + ), + ), + const SizedBox(height: 12), + if (normalized != null) + Text( + 'Advert size: ${normalized.length ~/ 2} bytes', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _isImporting ? null : _pasteFromClipboard, + icon: const Icon(Icons.content_paste_go_outlined), + label: const Text('Paste'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: (_isImporting || _importSucceeded) + ? null + : _importContact, + icon: _isImporting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : _importSucceeded + ? const Icon(Icons.check_circle_outline) + : const Icon(Icons.person_add_alt_1_outlined), + label: Text( + _isImporting + ? 'Importing...' + : _importSucceeded + ? 'Added' + : 'Add Contact', + ), + ), + ), + ], + ), + if (_importSucceeded) ...[ + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.check_circle, + size: 18, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Contact added. Paste or edit another advert to import again.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index ca747d2..2d125c7 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -17,6 +17,7 @@ import '../utils/avatar_label_helper.dart'; import '../widgets/common/contact_avatar.dart'; import '../widgets/contacts/contact_tile.dart'; import '../widgets/contacts/add_channel_dialog.dart'; +import 'add_contact_screen.dart'; class ContactsTab extends StatefulWidget { final VoidCallback? onNavigateToMap; @@ -37,6 +38,7 @@ class _ContactsTabState extends State { final Map _sectionFilters = { ContactSection.teamMembers: '', ContactSection.repeaters: '', + ContactSection.sensors: '', ContactSection.rooms: '', ContactSection.channels: '', }; @@ -44,6 +46,7 @@ class _ContactsTabState extends State { final Map _sortModes = { ContactSection.teamMembers: ContactSortMode.lastSeen, ContactSection.repeaters: ContactSortMode.lastSeen, + ContactSection.sensors: ContactSortMode.lastSeen, ContactSection.rooms: ContactSortMode.lastSeen, }; @@ -387,6 +390,12 @@ class _ContactsTabState extends State { ); } + Future _openAddContactScreen(BuildContext context) async { + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (context) => const AddContactScreen())); + } + Future _showDeleteChannelDialog( BuildContext context, Contact channel, @@ -539,6 +548,10 @@ class _ContactsTabState extends State { contactsProvider.repeaters, ContactSection.repeaters, ); + final allSensors = _sortContacts( + contactsProvider.sensorContacts, + ContactSection.sensors, + ); final allRooms = _sortContacts( contactsProvider.rooms, ContactSection.rooms, @@ -580,6 +593,19 @@ class _ContactsTabState extends State { final showRepeatersOthersGroup = visibleSavedRepeaterGroups.length > 1 && ungroupedRepeaters.isNotEmpty; + final sensors = _filterContactsForSection( + allSensors, + ContactSection.sensors, + ); + final savedSensorGroups = _buildSavedGroupsForSection( + contactsProvider, + allSensors, + ContactSection.sensors, + ); + final visibleSavedSensorGroups = + _showSavedGroupsForSection(ContactSection.sensors) + ? savedSensorGroups + : const <_RenderedSavedGroup>[]; final rooms = _filterContactsForSection( allRooms, ContactSection.rooms, @@ -608,12 +634,14 @@ class _ContactsTabState extends State { : const <_RenderedSavedGroup>[]; final showTeamMembersSection = allChatContacts.isNotEmpty; final showRepeatersSection = allRepeaters.isNotEmpty; + final showSensorsSection = allSensors.isNotEmpty; final showRoomsSection = allRooms.isNotEmpty; final showChannelsSection = allChannels.isNotEmpty; // Check if there are any displayable contacts final hasDisplayableContacts = allChatContacts.isNotEmpty || allRepeaters.isNotEmpty || + allSensors.isNotEmpty || allRooms.isNotEmpty || allChannels.isNotEmpty; @@ -638,6 +666,18 @@ class _ContactsTabState extends State { style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), + if (context + .watch() + .deviceInfo + .isConnected) + Padding( + padding: const EdgeInsets.only(top: 16), + child: OutlinedButton.icon( + onPressed: () => _openAddContactScreen(context), + icon: const Icon(Icons.person_add_alt_1_outlined), + label: const Text('Add Contact'), + ), + ), ], ), ); @@ -743,6 +783,36 @@ class _ContactsTabState extends State { const Divider(height: 32), ], + // Sensors + if (showSensorsSection) ...[ + _SectionHeader( + title: 'Sensors', + count: sensors.length, + icon: Icons.sensors, + trailing: _buildSortMenu(context, ContactSection.sensors), + ), + _buildSectionFilterField( + context, + ContactSection.sensors, + contactsProvider, + ), + ..._buildSavedGroupCards( + visibleSavedSensorGroups, + ContactSection.sensors, + ), + if (sensors.isEmpty && + _sectionHasActiveFilter(ContactSection.sensors)) + _buildNoFilterResults(context) + else + ..._buildContactSectionItems( + _excludeGroupedContacts( + sensors, + visibleSavedSensorGroups, + ), + ), + const Divider(height: 32), + ], + // Rooms if (showRoomsSection) ...[ _SectionHeader( @@ -811,16 +881,36 @@ class _ContactsTabState extends State { horizontal: 16, vertical: 8, ), - child: OutlinedButton.icon( - onPressed: () => _showAddChannelDialog(context), - icon: const Icon(Icons.add_circle_outline), - label: Text(l10n.addChannel), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _openAddContactScreen(context), + icon: const Icon(Icons.person_add_alt_1_outlined), + label: const Text('Add Contact'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), ), - ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: () => _showAddChannelDialog(context), + icon: const Icon(Icons.add_circle_outline), + label: Text(l10n.addChannel), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + ), + ], ), ), ], @@ -1172,7 +1262,7 @@ class _ContactsTabState extends State { enum ContactSortMode { lastSeen, distance } -enum ContactSection { teamMembers, repeaters, rooms, channels } +enum ContactSection { teamMembers, repeaters, sensors, rooms, channels } class _RenderedSavedGroup { final SavedContactGroup group; diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index d6cda68..eaa3478 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -125,15 +125,29 @@ class _DiscoveryScreenState extends State { final connectionProvider = context.read(); final contactsProvider = context.read(); - await connectionProvider.getContact(advert.publicKey); - if (connectionProvider.error == 'Not found') { - connectionProvider.clearError(); - final fallbackContact = _contactFromPendingAdvert(advert); - await connectionProvider.addOrUpdateContact(fallbackContact); - contactsProvider.addOrUpdateContact( - fallbackContact, - devicePublicKey: connectionProvider.deviceInfo.publicKey, + final canAddDirectly = _canAddPendingAdvertDirectly(advert); + if (canAddDirectly) { + final added = await _addPendingAdvertToRadio( + advert, + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, ); + if (!added) { + return; + } + } else { + await connectionProvider.getContact(advert.publicKey); + if (connectionProvider.error == 'Not found') { + connectionProvider.clearError(); + final added = await _addPendingAdvertToRadio( + advert, + connectionProvider: connectionProvider, + contactsProvider: contactsProvider, + ); + if (!added) { + return; + } + } } if ((advert.typeValue ?? 0) == _sensorAdvertType) { await connectionProvider.requestTelemetry(advert.publicKey); @@ -200,6 +214,37 @@ class _DiscoveryScreenState extends State { ); } + bool _canAddPendingAdvertDirectly(PendingAdvert advert) { + return advert.publicKey.length == 32 && + advert.typeValue != null && + advert.typeValue != 0; + } + + Future _addPendingAdvertToRadio( + PendingAdvert advert, { + required ConnectionProvider connectionProvider, + required ContactsProvider contactsProvider, + }) async { + final contact = _contactFromPendingAdvert(advert); + await connectionProvider.addOrUpdateContact(contact); + + final addError = connectionProvider.error; + if (addError != null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to add contact: $addError')), + ); + } + return false; + } + + contactsProvider.addOrUpdateContact( + contact, + devicePublicKey: connectionProvider.deviceInfo.publicKey, + ); + return true; + } + String _displayNameForAdvert( PendingAdvert advert, ContactsProvider contactsProvider, @@ -375,207 +420,203 @@ class _DiscoveryScreenState extends State { ), body: FutureBuilder>( future: _cachedNodesFuture, - builder: (context, nodesSnapshot) => - Consumer2( - builder: (context, contactsProvider, connectionProvider, child) { - final pendingAdverts = contactsProvider.pendingAdverts; - final isConnected = connectionProvider.deviceInfo.isConnected; - final cachedNodes = nodesSnapshot.data ?? const []; + builder: (context, nodesSnapshot) => Consumer2( + builder: (context, contactsProvider, connectionProvider, child) { + final pendingAdverts = contactsProvider.pendingAdverts; + final isConnected = connectionProvider.deviceInfo.isConnected; + final cachedNodes = nodesSnapshot.data ?? const []; - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon(Icons.person_search), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Pending discoveries (${pendingAdverts.length})', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - 'Resolve entries manually so they do not auto-populate contacts.', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: isConnected && - pendingAdverts.isNotEmpty && - !_isResolvingAll - ? () => _resolveAll(pendingAdverts) - : null, - icon: _isResolvingAll - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon( - Icons.download_for_offline_outlined, - ), - label: const Text('Resolve all'), - ), - ), - const SizedBox(width: 10), - Expanded( - child: OutlinedButton.icon( - onPressed: pendingAdverts.isNotEmpty - ? _clearAllDiscoveries - : null, - icon: const Icon(Icons.clear_all_rounded), - label: const Text('Clear all'), - ), - ), - ], + const Icon(Icons.person_search), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Pending discoveries (${pendingAdverts.length})', + style: Theme.of(context).textTheme.titleMedium, + ), ), ], ), - ), - ), - const SizedBox(height: 16), - if (pendingAdverts.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 48), - child: Column( - children: [ - Icon( - Icons.person_search_outlined, - size: 64, - color: Theme.of(context).disabledColor, - ), - const SizedBox(height: 16), - Text( - 'No pending discoveries', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - 'Unknown adverts will appear here until you choose to resolve them.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium, - ), - ], + const SizedBox(height: 12), + Text( + 'Resolve entries manually so they do not auto-populate contacts.', + style: Theme.of(context).textTheme.bodyMedium, ), - ), - ...pendingAdverts.map((advert) { - final isResolving = _resolvingAdvertKeys.contains( - advert.publicKeyHex, - ); - final displayName = _displayNameForAdvert( - advert, - contactsProvider, - cachedNodes, - ); - final typeLabel = _resolvedTypeLabelForAdvert( - advert, - cachedNodes, - ); - final downMetric = SignalMetric.fromValues( - rssiDbm: advert.rxRssiDbm, - snrDb: advert.rxSnr, - ); - final upMetric = SignalMetric.fromValues( - rssiDbm: advert.repeaterLastRssi, - snrDb: advert.repeaterLastSnr, - ); - final detailLines = [ - '${l10n.publicKey}: ${advert.shortDisplayKey}', - ]; - final summaryParts = []; - final battery = advert.repeaterBatteryPercent; - if (battery != null) { - summaryParts.add('Battery ${battery.round()}%'); - } - if (advert.repeaterQueueLen != null) { - summaryParts.add('Queue ${advert.repeaterQueueLen}'); - } - if (summaryParts.isNotEmpty) { - detailLines.add(summaryParts.join(' • ')); - } - detailLines.add( - '${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}', - ); - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - CircleAvatar( - child: Icon(_iconForAdvert(advert)), - ), - const SizedBox(width: 12), - Expanded( - child: _buildAdvertTitle( - context, - displayName: displayName, - subtitle: typeLabel, - ), - ), - if (downMetric != null || upMetric != null) ...[ - const SizedBox(width: 12), - _buildSignalSummary( - context, - downMetric: downMetric, - upMetric: upMetric, - ), - ], - const SizedBox(width: 8), - isResolving - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : IconButton( - visualDensity: VisualDensity.compact, - icon: const Icon( - Icons.person_add_alt_1, - ), - tooltip: 'Resolve contact', - onPressed: isConnected - ? () => _resolveAdvert(advert) - : null, + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: + isConnected && + pendingAdverts.isNotEmpty && + !_isResolvingAll + ? () => _resolveAll(pendingAdverts) + : null, + icon: _isResolvingAll + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, ), - ], + ) + : const Icon( + Icons.download_for_offline_outlined, + ), + label: const Text('Resolve all'), ), - const SizedBox(height: 10), - Text( - detailLines.join('\n'), - style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(width: 10), + Expanded( + child: OutlinedButton.icon( + onPressed: pendingAdverts.isNotEmpty + ? _clearAllDiscoveries + : null, + icon: const Icon(Icons.clear_all_rounded), + label: const Text('Clear all'), ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + if (pendingAdverts.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + children: [ + Icon( + Icons.person_search_outlined, + size: 64, + color: Theme.of(context).disabledColor, + ), + const SizedBox(height: 16), + Text( + 'No pending discoveries', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Unknown adverts will appear here until you choose to resolve them.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ...pendingAdverts.map((advert) { + final isResolving = _resolvingAdvertKeys.contains( + advert.publicKeyHex, + ); + final displayName = _displayNameForAdvert( + advert, + contactsProvider, + cachedNodes, + ); + final typeLabel = _resolvedTypeLabelForAdvert( + advert, + cachedNodes, + ); + final downMetric = SignalMetric.fromValues( + rssiDbm: advert.rxRssiDbm, + snrDb: advert.rxSnr, + ); + final upMetric = SignalMetric.fromValues( + rssiDbm: advert.repeaterLastRssi, + snrDb: advert.repeaterLastSnr, + ); + final detailLines = [ + '${l10n.publicKey}: ${advert.shortDisplayKey}', + ]; + final summaryParts = []; + final battery = advert.repeaterBatteryPercent; + if (battery != null) { + summaryParts.add('Battery ${battery.round()}%'); + } + if (advert.repeaterQueueLen != null) { + summaryParts.add('Queue ${advert.repeaterQueueLen}'); + } + if (summaryParts.isNotEmpty) { + detailLines.add(summaryParts.join(' • ')); + } + detailLines.add( + '${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}', + ); + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircleAvatar(child: Icon(_iconForAdvert(advert))), + const SizedBox(width: 12), + Expanded( + child: _buildAdvertTitle( + context, + displayName: displayName, + subtitle: typeLabel, + ), + ), + if (downMetric != null || upMetric != null) ...[ + const SizedBox(width: 12), + _buildSignalSummary( + context, + downMetric: downMetric, + upMetric: upMetric, + ), + ], + const SizedBox(width: 8), + isResolving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : IconButton( + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.person_add_alt_1), + tooltip: 'Resolve contact', + onPressed: isConnected + ? () => _resolveAdvert(advert) + : null, + ), ], ), - ), - ); - }), - ], - ); - }, - ), + const SizedBox(height: 10), + Text( + detailLines.join('\n'), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); + }), + ], + ); + }, + ), ), ); } @@ -629,9 +670,9 @@ class _DiscoveryScreenState extends State { const SizedBox(width: 4), Text( metric.valueLabel, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - fontWeight: FontWeight.w700, - ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700), ), ], ); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 48fe9a7..b37736d 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -802,7 +802,11 @@ class _HomeScreenState extends State onNavigateToMessages: () => _navigateToTab(_HomeTab.messages), ); case _HomeTab.sensors: - return const SensorsTab(); + return SensorsTab( + isActive: + _currentTab == _HomeTab.sensors && + _lifecycleState == AppLifecycleState.resumed, + ); case _HomeTab.map: return MapTab( onFullscreenChanged: (isFullscreen) { diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index 4350d72..ba5eebd 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -1,19 +1,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart' as flutter_map; -import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -import '../l10n/app_localizations.dart'; import '../models/contact.dart'; import '../providers/connection_provider.dart'; import '../providers/contacts_provider.dart'; import '../providers/sensors_provider.dart'; -import '../utils/location_formats.dart'; +import '../widgets/sensors/sensor_telemetry_card.dart'; class SensorsTab extends StatefulWidget { - const SensorsTab({super.key}); + final bool isActive; + + const SensorsTab({super.key, this.isActive = true}); @override State createState() => _SensorsTabState(); @@ -25,7 +24,10 @@ class _SensorsTabState extends State { @override void initState() { super.initState(); - _scheduleMinuteTicker(); + if (widget.isActive) { + unawaited(_handleMinuteTick()); + _scheduleMinuteTicker(); + } } @override @@ -34,8 +36,28 @@ class _SensorsTabState extends State { super.dispose(); } + @override + void didUpdateWidget(covariant SensorsTab oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isActive == widget.isActive) { + return; + } + + if (widget.isActive) { + unawaited(_handleMinuteTick()); + _scheduleMinuteTicker(); + return; + } + + _minuteTicker?.cancel(); + _minuteTicker = null; + } + void _scheduleMinuteTicker() { _minuteTicker?.cancel(); + if (!widget.isActive) { + return; + } final now = DateTime.now(); final nextMinute = DateTime( @@ -49,16 +71,31 @@ class _SensorsTabState extends State { _minuteTicker = Timer(delay, () { if (!mounted) return; - context.read().clearExpiredRefreshStates(); - setState(() {}); + unawaited(_handleMinuteTick()); _minuteTicker = Timer.periodic(const Duration(minutes: 1), (_) { - if (!mounted) return; - context.read().clearExpiredRefreshStates(); - setState(() {}); + unawaited(_handleMinuteTick()); }); }); } + Future _handleMinuteTick() async { + if (!mounted || !widget.isActive) { + return; + } + + final sensorsProvider = context.read(); + sensorsProvider.clearExpiredRefreshStates(); + await sensorsProvider.refreshDueSensors( + contactsProvider: context.read(), + connectionProvider: context.read(), + now: DateTime.now(), + ); + if (!mounted) { + return; + } + setState(() {}); + } + Future _showAddSensorSheet(BuildContext context) async { final sensorsProvider = context.read(); final contactsProvider = context.read(); @@ -140,62 +177,123 @@ class _SensorsTabState extends State { builder: (sheetContext) => Consumer( builder: (context, sensorsProvider, child) { final visibleFields = sensorsProvider.visibleFieldsFor(publicKeyHex); - final options = _fieldOptionsFor(contact); + final autoRefreshMinutes = sensorsProvider.autoRefreshMinutesFor( + publicKeyHex, + ); + final options = sensorMetricOptionsFor( + contact, + labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex), + ); + final orderedFieldKeys = sensorsProvider.metricOrderFor( + publicKeyHex, + options.map((option) => option.key), + ); + final optionByKey = { + for (final option in options) option.key: option, + }; + final orderedOptions = orderedFieldKeys + .map((fieldKey) => optionByKey[fieldKey]) + .whereType() + .toList(growable: false); return SafeArea( child: ListView( shrinkWrap: true, padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), children: [ + Text( + 'Auto refresh telemetry', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Refresh this contact automatically while the device is connected.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: SensorsProvider.supportedAutoRefreshIntervals + .map( + (minutes) => ChoiceChip( + label: Text(minutes == 0 ? 'Off' : '${minutes}m'), + selected: autoRefreshMinutes == minutes, + onSelected: (_) { + sensorsProvider.setAutoRefreshMinutes( + publicKeyHex, + minutes, + ); + }, + ), + ) + .toList(growable: false), + ), + const SizedBox(height: 20), Text( 'Visible fields', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( - 'Choose which values appear on sensor cards.', + 'Choose which values appear on sensor cards and rename them.', style: Theme.of(context).textTheme.bodyMedium, ), + const SizedBox(height: 8), + Text( + 'Use the arrows to change card order.', + style: Theme.of(context).textTheme.bodySmall, + ), const SizedBox(height: 20), - ...options.map((option) { + ...orderedOptions.asMap().entries.map((entry) { + final index = entry.key; + final option = entry.value; final visible = visibleFields.contains(option.key); final span = sensorsProvider.fieldSpanFor( publicKeyHex, option.key, ); - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - Expanded( - child: FilterChip( - selected: visible, - label: Text(option.label), - onSelected: (value) { - sensorsProvider.toggleMetric( - publicKeyHex, - option.key, - value, - ); - }, - ), - ), - const SizedBox(width: 10), - SegmentedButton( - segments: const [ - ButtonSegment(value: 1, label: Text('1x')), - ButtonSegment(value: 2, label: Text('2x')), - ], - selected: {span}, - onSelectionChanged: (selection) { - sensorsProvider.setFieldSpan( - publicKeyHex, - option.key, - selection.first, - ); - }, - ), - ], + return SensorMetricSelectorItem( + option: option, + visible: visible, + span: span, + canMoveUp: index > 0, + canMoveDown: index < orderedOptions.length - 1, + onToggle: (value) { + sensorsProvider.toggleMetric( + publicKeyHex, + option.key, + value, + ); + }, + onRename: () => _showMetricRenameDialog( + context, + publicKeyHex: publicKeyHex, + option: option, + sensorsProvider: sensorsProvider, ), + onMoveUp: index > 0 + ? () => sensorsProvider.moveMetric( + publicKeyHex, + availableFieldKeys: orderedFieldKeys, + oldIndex: index, + newIndex: index - 1, + ) + : null, + onMoveDown: index < orderedOptions.length - 1 + ? () => sensorsProvider.moveMetric( + publicKeyHex, + availableFieldKeys: orderedFieldKeys, + oldIndex: index, + newIndex: index + 1, + ) + : null, + onSpanChanged: (selection) { + sensorsProvider.setFieldSpan( + publicKeyHex, + option.key, + selection, + ); + }, ); }), ], @@ -206,6 +304,71 @@ class _SensorsTabState extends State { ); } + Future _showMetricRenameDialog( + BuildContext context, { + required String publicKeyHex, + required SensorMetricOption option, + required SensorsProvider sensorsProvider, + }) async { + final controller = TextEditingController( + text: + sensorsProvider.labelOverrideFor(publicKeyHex, option.key) ?? + option.defaultLabel, + ); + final didSave = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Rename value'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Set a custom label for ${option.label}.', + style: Theme.of(dialogContext).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + TextField( + controller: controller, + autofocus: true, + decoration: InputDecoration( + labelText: 'Label', + hintText: option.defaultLabel, + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => Navigator.of(dialogContext).pop(true), + ), + ], + ), + actions: [ + if (sensorsProvider.labelOverrideFor(publicKeyHex, option.key) != + null) + TextButton(onPressed: controller.clear, child: const Text('Reset')), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Save'), + ), + ], + ), + ); + if (didSave != true) { + controller.dispose(); + return; + } + + final nextLabel = controller.text.trim(); + await sensorsProvider.setMetricLabel( + publicKeyHex, + option.key, + nextLabel == option.defaultLabel ? null : nextLabel, + ); + controller.dispose(); + } + Future _refreshAll(BuildContext context) async { await context.read().refreshAll( contactsProvider: context.read(), @@ -240,10 +403,15 @@ class _SensorsTabState extends State { break; } } - return _SensorCard( + return SensorTelemetryCard( contact: contact, state: sensorsProvider.stateFor(key), visibleFields: sensorsProvider.visibleFieldsFor(key), + fieldOrder: sensorsProvider.metricOrderFor( + key, + sensorsProvider.visibleFieldsFor(key), + ), + labelOverrides: sensorsProvider.labelOverridesFor(key), fieldSpans: { for (final field in sensorsProvider.visibleFieldsFor( key, @@ -271,6 +439,132 @@ class _SensorsTabState extends State { } } +class SensorMetricSelectorItem extends StatelessWidget { + final SensorMetricOption option; + final bool visible; + final int span; + final bool canMoveUp; + final bool canMoveDown; + final ValueChanged onToggle; + final VoidCallback onRename; + final VoidCallback? onMoveUp; + final VoidCallback? onMoveDown; + final ValueChanged onSpanChanged; + + const SensorMetricSelectorItem({ + super.key, + required this.option, + required this.visible, + required this.span, + required this.canMoveUp, + required this.canMoveDown, + required this.onToggle, + required this.onRename, + this.onMoveUp, + this.onMoveDown, + required this.onSpanChanged, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: FilterChip( + selected: visible, + label: Text(option.label), + onSelected: onToggle, + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Rename', + onPressed: onRename, + icon: const Icon(Icons.edit_outlined), + ), + ], + ), + if (option.valuePreview != null || option.channel != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 6, + children: [ + if (option.valuePreview != null) + Text( + option.valuePreview!, + key: ValueKey('sensor_selector_value_${option.key}'), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + if (option.channel != null) + Container( + key: ValueKey('sensor_selector_channel_${option.key}'), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'ch${option.channel}', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + children: [ + IconButton( + tooltip: 'Move up', + onPressed: canMoveUp ? onMoveUp : null, + icon: const Icon(Icons.arrow_upward), + ), + IconButton( + tooltip: 'Move down', + onPressed: canMoveDown ? onMoveDown : null, + icon: const Icon(Icons.arrow_downward), + ), + SegmentedButton( + segments: const [ + ButtonSegment(value: 1, label: Text('1x')), + ButtonSegment(value: 2, label: Text('2x')), + ], + selected: {span}, + onSelectionChanged: (selection) { + onSpanChanged(selection.first); + }, + ), + ], + ), + ), + ], + ), + ); + } +} + class _SensorCandidatePreview extends StatelessWidget { final Contact contact; @@ -347,769 +641,15 @@ class _EmptySensorsState extends StatelessWidget { } } -class _SensorCard extends StatelessWidget { - final Contact? contact; - final SensorRefreshState state; - final Set visibleFields; - final Map fieldSpans; - final Future Function() onRemove; - final Future Function() onRefresh; - final VoidCallback onCustomize; - - const _SensorCard({ - required this.contact, - required this.state, - required this.visibleFields, - required this.fieldSpans, - required this.onRemove, - required this.onRefresh, - required this.onCustomize, - }); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final telemetry = contact?.telemetry; - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final metrics = contact == null || telemetry == null - ? const <_MetricCardData>[] - : _buildMetricCards(l10n, telemetry, contact!); - - return Container( - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(28), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - colorScheme.surfaceContainerLow, - colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), - ], - ), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.35), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.045), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 8, - runSpacing: 6, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - contact?.displayName ?? 'Unavailable node', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - if (state == SensorRefreshState.timeout) - const _InlineAlertBadge(label: 'No response'), - ], - ), - if (telemetry != null) ...[ - const SizedBox(height: 2), - Wrap( - spacing: 6, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - '${_formatTelemetryDateTime(telemetry.timestamp)} • ${_formatTelemetryTime(telemetry.timestamp)}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - if (state == SensorRefreshState.refreshing) - const _InlineStateMeta( - label: 'Refreshing', - color: Color(0xFF266AC2), - spinning: true, - ), - if (state == SensorRefreshState.success) - const _InlineStateMeta( - label: 'Updated', - color: Color(0xFF218B63), - icon: Icons.check_circle, - ), - if (state == SensorRefreshState.unavailable) - const _InlineStateMeta( - label: 'Unavailable', - color: Color(0xFFB13B55), - icon: Icons.error_outline, - ), - ], - ), - ], - ], - ), - ), - PopupMenuButton( - onSelected: (value) async { - if (value == 'refresh') { - await onRefresh(); - } else if (value == 'remove') { - await onRemove(); - } else if (value == 'customize') { - onCustomize(); - } - }, - itemBuilder: (context) => [ - PopupMenuItem( - value: 'refresh', - child: Text(l10n.refresh), - ), - const PopupMenuItem( - value: 'customize', - child: Text('Customize fields'), - ), - const PopupMenuItem( - value: 'remove', - child: Text('Remove'), - ), - ], - ), - ], - ), - const SizedBox(height: 12), - if (contact == null) - const Text( - 'This node is no longer available in the contact list.', - ) - else if (telemetry == null) - const Text( - 'No telemetry received yet. Use Refresh from the menu or pull down to fetch it.', - ) - else if (metrics.isEmpty) - const Text( - 'All fields are hidden. Use Visible fields to choose what to show.', - ) - else - LayoutBuilder( - builder: (context, constraints) { - const spacing = 8.0; - final compactWidth = (constraints.maxWidth - spacing) / 2; - - return Wrap( - spacing: spacing, - runSpacing: spacing, - children: metrics - .map( - (metric) => _MetricTile( - data: metric, - width: - (fieldSpans[metric.fieldKey] == 2 || - metric.wide) - ? constraints.maxWidth - : compactWidth, - ), - ) - .toList(), - ); - }, - ), - ], - ), - ), - ); - } - - List<_MetricCardData> _buildMetricCards( - AppLocalizations l10n, - dynamic telemetry, - Contact contact, - ) { - final items = <_MetricCardData>[]; - - if (visibleFields.contains('voltage') && - telemetry.batteryMilliVolts != null) { - items.add( - _MetricCardData( - fieldKey: 'voltage', - icon: Icons.bolt, - label: l10n.voltage, - value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V', - accent: const Color(0xFF0A7D61), - ), - ); - } - if (visibleFields.contains('battery') && - telemetry.batteryPercentage != null) { - items.add( - _MetricCardData( - fieldKey: 'battery', - icon: Icons.battery_5_bar, - label: l10n.battery, - value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%', - accent: const Color(0xFF4B8E2F), - ), - ); - } - if (visibleFields.contains('temperature') && - telemetry.temperature != null) { - items.add( - _MetricCardData( - fieldKey: 'temperature', - icon: Icons.thermostat, - label: l10n.temperature, - value: '${telemetry.temperature!.toStringAsFixed(1)}°C', - accent: const Color(0xFFC76821), - ), - ); - } - if (visibleFields.contains('humidity') && telemetry.humidity != null) { - items.add( - _MetricCardData( - fieldKey: 'humidity', - icon: Icons.water_drop, - label: l10n.humidity, - value: '${telemetry.humidity!.toStringAsFixed(1)}%', - accent: const Color(0xFF246BB2), - ), - ); - } - if (visibleFields.contains('pressure') && telemetry.pressure != null) { - items.add( - _MetricCardData( - fieldKey: 'pressure', - icon: Icons.compress, - label: l10n.pressure, - value: '${telemetry.pressure!.toStringAsFixed(1)} hPa', - accent: const Color(0xFF6B4BAE), - ), - ); - } - if (visibleFields.contains('gps') && telemetry.gpsLocation != null) { - items.add( - _MetricCardData( - fieldKey: 'gps', - icon: Icons.place, - label: l10n.gpsTelemetry, - value: - '${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}', - accent: const Color(0xFFAA3F57), - wide: true, - mapLocation: LatLng( - telemetry.gpsLocation!.latitude, - telemetry.gpsLocation!.longitude, - ), - secondaryValue: formatPlusCode( - telemetry.gpsLocation!.latitude, - telemetry.gpsLocation!.longitude, - ), - ), - ); - } - if (telemetry.extraSensorData != null) { - for (final entry in telemetry.extraSensorData!.entries) { - final fieldKey = _extraFieldKey(entry.key); - if (!visibleFields.contains(fieldKey)) { - continue; - } - items.add( - _MetricCardData( - fieldKey: fieldKey, - icon: Icons.sensors, - label: _formatExtraFieldLabel(entry.key), - value: '${entry.value}', - accent: const Color(0xFF3E657C), - ), - ); - } - } - - return items; - } - - String _formatTelemetryTime(DateTime timestamp) { - final diff = DateTime.now().difference(timestamp); - if (diff.inMinutes < 1) return 'now'; - if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; - if (diff.inHours < 24) return '${diff.inHours}h ago'; - return '${diff.inDays}d ago'; - } - - String _formatTelemetryDateTime(DateTime timestamp) { - final local = timestamp.toLocal(); - final year = local.year.toString().padLeft(4, '0'); - final month = local.month.toString().padLeft(2, '0'); - final day = local.day.toString().padLeft(2, '0'); - final hour = local.hour.toString().padLeft(2, '0'); - final minute = local.minute.toString().padLeft(2, '0'); - return '$year-$month-$day $hour:$minute'; - } -} - -class _InlineStateMeta extends StatelessWidget { - final String label; - final Color color; - final IconData? icon; - final bool spinning; - - const _InlineStateMeta({ - required this.label, - required this.color, - this.icon, - this.spinning = false, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(999), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (spinning) - SizedBox( - width: 11, - height: 11, - child: CircularProgressIndicator( - strokeWidth: 1.7, - valueColor: AlwaysStoppedAnimation(color), - ), - ) - else if (icon != null) - Icon(icon, size: 11, color: color), - const SizedBox(width: 4), - Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ); - } -} - -class _InlineAlertBadge extends StatelessWidget { - final String label; - - const _InlineAlertBadge({required this.label}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFFC17B1D).withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: const Color(0xFFC17B1D), - fontWeight: FontWeight.w700, - ), - ), - ); - } -} - -class _MetricTile extends StatelessWidget { - final _MetricCardData data; - final double width; - - const _MetricTile({required this.data, required this.width}); - - Future _showExpandedMap(BuildContext context) async { - final location = data.mapLocation; - if (location == null) return; - - await Navigator.of(context).push( - MaterialPageRoute( - builder: (pageContext) { - return Scaffold( - appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(data.label), - Text( - data.value, - style: Theme.of(pageContext).textTheme.bodySmall, - ), - ], - ), - ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (data.secondaryValue != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Text( - data.secondaryValue!, - style: Theme.of(pageContext).textTheme.bodyMedium, - ), - ), - Expanded( - child: flutter_map.FlutterMap( - options: flutter_map.MapOptions( - initialCenter: location, - initialZoom: 15, - ), - children: [ - flutter_map.TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: - 'com.meshcore.sar.meshcore_sar_app', - ), - flutter_map.MarkerLayer( - markers: [ - flutter_map.Marker( - point: location, - width: 40, - height: 40, - child: Icon( - Icons.location_on, - color: data.accent, - size: 34, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ); - }, - fullscreenDialog: true, - ), - ); - } - - @override - Widget build(BuildContext context) { - return Container( - width: width, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: data.accent.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(22), - border: Border.all(color: data.accent.withValues(alpha: 0.14)), - ), - child: data.mapLocation == null - ? Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _MetricIcon(accent: data.accent, icon: data.icon), - const SizedBox(width: 10), - Expanded(child: _MetricText(data: data)), - ], - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _MetricIcon(accent: data.accent, icon: data.icon), - const SizedBox(width: 10), - Expanded(child: _MetricText(data: data)), - ], - ), - const SizedBox(height: 10), - Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(14), - onTap: () => _showExpandedMap(context), - child: ClipRRect( - borderRadius: BorderRadius.circular(14), - child: SizedBox( - height: 104, - width: double.infinity, - child: Stack( - children: [ - flutter_map.FlutterMap( - options: flutter_map.MapOptions( - initialCenter: data.mapLocation!, - initialZoom: 14, - interactionOptions: - const flutter_map.InteractionOptions( - flags: flutter_map.InteractiveFlag.none, - ), - ), - children: [ - flutter_map.TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: - 'com.meshcore.sar.meshcore_sar_app', - ), - flutter_map.MarkerLayer( - markers: [ - flutter_map.Marker( - point: data.mapLocation!, - width: 32, - height: 32, - child: Icon( - Icons.location_on, - color: data.accent, - size: 28, - ), - ), - ], - ), - ], - ), - Positioned( - right: 8, - bottom: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 3, - ), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.55), - borderRadius: BorderRadius.circular(999), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.open_in_full, - size: 12, - color: Colors.white, - ), - SizedBox(width: 4), - Text( - 'Open map', - style: TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } -} - -class _MetricIcon extends StatelessWidget { - final Color accent; - final IconData icon; - - const _MetricIcon({required this.accent, required this.icon}); - - @override - Widget build(BuildContext context) { - return Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, color: accent, size: 18), - ); - } -} - -class _MetricText extends StatelessWidget { - final _MetricCardData data; - - const _MetricText({required this.data}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - data.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: data.accent, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - data.value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - height: 1.1, - ), - ), - if (data.secondaryValue != null) ...[ - const SizedBox(height: 4), - Text( - data.secondaryValue!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ], - ], - ); - } -} - -class _MetricCardData { - final String fieldKey; - final IconData icon; - final String label; - final String value; - final String? secondaryValue; - final Color accent; - final bool wide; - final LatLng? mapLocation; - - const _MetricCardData({ - required this.fieldKey, - required this.icon, - required this.label, - required this.value, - this.secondaryValue, - required this.accent, - this.wide = false, - this.mapLocation, - }); -} - -class _FieldOption { - final String key; - final String label; - - const _FieldOption({required this.key, required this.label}); -} - -List<_FieldOption> _fieldOptionsFor(Contact? contact) { - final telemetry = contact?.telemetry; - final options = <_FieldOption>[ - if (telemetry?.batteryMilliVolts != null) - const _FieldOption(key: 'voltage', label: 'Voltage'), - if (telemetry?.batteryPercentage != null) - const _FieldOption(key: 'battery', label: 'Battery'), - if (telemetry?.temperature != null) - const _FieldOption(key: 'temperature', label: 'Temperature'), - if (telemetry?.humidity != null) - const _FieldOption(key: 'humidity', label: 'Humidity'), - if (telemetry?.pressure != null) - const _FieldOption(key: 'pressure', label: 'Pressure'), - if (telemetry?.gpsLocation != null) - const _FieldOption(key: 'gps', label: 'GPS'), - ]; - - final extraSensorData = telemetry?.extraSensorData; - if (extraSensorData != null) { - for (final key in extraSensorData.keys) { - options.add( - _FieldOption( - key: _extraFieldKey(key), - label: _formatExtraFieldLabel(key), - ), - ); - } - } - - return options; -} - -String _extraFieldKey(String label) { - return 'extra:$label'; -} - -String _formatExtraFieldLabel(String rawKey) { - final knownPrefixes = { - 'altitude': 'Altitude', - 'illuminance': 'Illuminance', - 'presence': 'Presence', - 'digital_input': 'Digital input', - 'digital_output': 'Digital output', - 'analog_input': 'Analog input', - 'analog_output': 'Analog output', - 'accelerometer': 'Accelerometer', - 'gyrometer': 'Gyrometer', - }; - - for (final entry in knownPrefixes.entries) { - final prefix = '${entry.key}_'; - if (rawKey == entry.key) { - return entry.value; - } - if (rawKey.startsWith(prefix)) { - final suffix = rawKey.substring(prefix.length); - final channel = int.tryParse(suffix); - if (channel != null) { - return '${entry.value} (ch $channel)'; - } - return entry.value; - } - } - - final parts = rawKey.split('_'); - if (parts.isEmpty) return rawKey; - final channel = parts.length > 1 ? parts.last : null; - final base = parts.length > 1 - ? parts.sublist(0, parts.length - 1).join(' ') - : rawKey; - final title = base - .split(' ') - .where((part) => part.isNotEmpty) - .map((part) => '${part[0].toUpperCase()}${part.substring(1)}') - .join(' '); - if (channel != null && int.tryParse(channel) != null) { - return '$title (ch $channel)'; - } - return title; -} - IconData _typeIcon(Contact contact) { + if (contact.isSensor) { + return Icons.sensors; + } if (contact.isRepeater) { return Icons.router; } if (contact.isChat) { - return Icons.sensors; + return Icons.person; } return Icons.device_hub; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f52edd9..e690dd0 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -31,7 +31,6 @@ import '../utils/image_message_parser.dart'; import '../utils/voice_message_parser.dart'; import '../theme/app_theme.dart'; import '../l10n/app_localizations.dart'; -import '../widgets/connection_mode_selector.dart'; import '../widgets/update_dialog.dart'; import 'sar_template_management_screen.dart'; import 'welcome_wizard_screen.dart'; @@ -80,6 +79,7 @@ class _SettingsScreenState extends State { bool _openMapInFullscreen = false; bool _messageNotificationsEnabled = true; bool _sarNotificationsEnabled = true; + bool _discoveryNotificationsEnabled = true; bool _updateNotificationsEnabled = true; bool _muteForegroundNotifications = true; bool _isDeveloperModeEnabled = false; @@ -149,6 +149,7 @@ class _SettingsScreenState extends State { setState(() { _messageNotificationsEnabled = service.messageNotificationsEnabled; _sarNotificationsEnabled = service.sarNotificationsEnabled; + _discoveryNotificationsEnabled = service.discoveryNotificationsEnabled; _updateNotificationsEnabled = service.updateNotificationsEnabled; _muteForegroundNotifications = service.muteForegroundNotifications; }); @@ -1051,6 +1052,22 @@ class _SettingsScreenState extends State { await NotificationService().setSarNotificationsEnabled(value); }, ), + SwitchListTile( + secondary: const Icon(Icons.contact_page_outlined), + title: const Text('Discovery notifications'), + subtitle: const Text( + 'Notify when new contacts appear in Discovery', + ), + value: _discoveryNotificationsEnabled, + onChanged: (value) async { + setState(() { + _discoveryNotificationsEnabled = value; + }); + await NotificationService().setDiscoveryNotificationsEnabled( + value, + ); + }, + ), SwitchListTile( secondary: const Icon(Icons.system_update), title: const Text('Update notifications'), @@ -1536,11 +1553,6 @@ class _SettingsScreenState extends State { ), ]), - if (!kIsWeb) ...[ - _buildSectionHeader('Network Sharing'), - const ConnectionModeSelector(), - ], - _buildSectionHeader(AppLocalizations.of(context)!.permissionsSection), _buildSettingsCard([ SwitchListTile( diff --git a/lib/services/cayenne_lpp_parser.dart b/lib/services/cayenne_lpp_parser.dart index 0d69fc9..332df6c 100644 --- a/lib/services/cayenne_lpp_parser.dart +++ b/lib/services/cayenne_lpp_parser.dart @@ -5,6 +5,22 @@ import 'package:meshcore_client/meshcore_client.dart'; /// Cayenne LPP (Low Power Payload) data parser /// Used for decoding telemetry sensor data from MeshCore devices class CayenneLppParser { + static const int _selfTelemetryChannel = 1; + static const int _lppGenericSensor = 100; + static const int _lppCurrent = 117; + static const int _lppFrequency = 118; + static const int _lppPercentage = 120; + static const int _lppAltitude = 121; + static const int _lppConcentration = 125; + static const int _lppPower = 128; + static const int _lppSpeed = 129; + static const int _lppDistance = 130; + static const int _lppEnergy = 131; + static const int _lppDirection = 132; + static const int _lppUnixTime = 133; + static const int _lppColour = 135; + static const int _lppSwitch = 142; + /// Parse Cayenne LPP data into ContactTelemetry static ContactTelemetry parse(Uint8List data) { debugPrint(' [CayenneLPP] Parsing LPP data...'); @@ -25,7 +41,8 @@ class CayenneLppParser { int fieldCount = 0; while (reader.hasRemaining) { - if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) { + if (fieldCount > 0 && + _isZeroPaddedTail(data, reader.remainingBytesCount)) { debugPrint( ' Detected zero-padded telemetry tail, stopping parse at position ' '${data.length - reader.remainingBytesCount}', @@ -65,14 +82,16 @@ class CayenneLppParser { final value = rawValue / 100.0; debugPrint(' Analog Input (raw): $rawValue'); debugPrint(' Analog Input (volts): ${value}V'); - extraSensorData['analog_input_$channel'] = value; - // If this is a battery reading - if (channel == 0 || channel == 1) { + if (_isBatteryChannel(channel)) { batteryMilliVolts = value * 1000; batteryPercentage = _calculateBatteryPercentage(value); + extraSensorData[_sourceChannelKey('battery')] = channel; + extraSensorData[_sourceChannelKey('voltage')] = channel; debugPrint( ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', ); + } else { + extraSensorData['analog_input_$channel'] = value; } break; @@ -85,7 +104,7 @@ class CayenneLppParser { break; case MeshCoreConstants.lppIlluminanceSensor: - final value = reader.readUInt16BE(); + final value = reader.readUInt16BE().toDouble(); debugPrint(' Illuminance: $value lux'); extraSensorData['illuminance_$channel'] = value; break; @@ -98,18 +117,36 @@ class CayenneLppParser { case MeshCoreConstants.lppTemperatureSensor: final rawValue = reader.readInt16BE(); - temperature = rawValue / 10.0; + final value = rawValue / 10.0; debugPrint(' Temperature (raw): $rawValue'); - debugPrint( - ' Temperature: ${temperature.toStringAsFixed(1)}°C', - ); + debugPrint(' Temperature: ${value.toStringAsFixed(1)}°C'); + if (channel == _selfTelemetryChannel) { + temperature = value; + extraSensorData[_sourceChannelKey('temperature')] = channel; + } else { + extraSensorData['temperature_$channel'] = value; + if (temperature == null) { + temperature = value; + extraSensorData[_sourceChannelKey('temperature')] = channel; + } + } break; case MeshCoreConstants.lppHumiditySensor: final rawValue = reader.readByte(); - humidity = rawValue / 2.0; + final value = rawValue / 2.0; debugPrint(' Humidity (raw): $rawValue'); - debugPrint(' Humidity: ${humidity.toStringAsFixed(1)}%'); + debugPrint(' Humidity: ${value.toStringAsFixed(1)}%'); + if (channel == _selfTelemetryChannel) { + humidity = value; + extraSensorData[_sourceChannelKey('humidity')] = channel; + } else { + extraSensorData['humidity_$channel'] = value; + if (humidity == null) { + humidity = value; + extraSensorData[_sourceChannelKey('humidity')] = channel; + } + } break; case MeshCoreConstants.lppAccelerometer: @@ -126,9 +163,19 @@ class CayenneLppParser { case MeshCoreConstants.lppBarometer: final rawValue = reader.readUInt16BE(); - pressure = rawValue / 10.0; + final value = rawValue / 10.0; debugPrint(' Barometer (raw): $rawValue'); - debugPrint(' Barometer: ${pressure.toStringAsFixed(1)} hPa'); + debugPrint(' Barometer: ${value.toStringAsFixed(1)} hPa'); + if (channel == _selfTelemetryChannel) { + pressure = value; + extraSensorData[_sourceChannelKey('pressure')] = channel; + } else { + extraSensorData['pressure_$channel'] = value; + if (pressure == null) { + pressure = value; + extraSensorData[_sourceChannelKey('pressure')] = channel; + } + } break; case MeshCoreConstants.lppVoltageSensor: @@ -136,12 +183,17 @@ class CayenneLppParser { final value = rawValue / 100.0; debugPrint(' Voltage (raw): $rawValue'); debugPrint(' Voltage: ${value}V'); - // Treat voltage sensor as battery reading - batteryMilliVolts = value * 1000; - batteryPercentage = _calculateBatteryPercentage(value); - debugPrint( - ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', - ); + if (_isBatteryChannel(channel)) { + batteryMilliVolts = value * 1000; + batteryPercentage = _calculateBatteryPercentage(value); + extraSensorData[_sourceChannelKey('battery')] = channel; + extraSensorData[_sourceChannelKey('voltage')] = channel; + debugPrint( + ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', + ); + } else { + extraSensorData['voltage_$channel'] = value; + } break; case MeshCoreConstants.lppGyrometer: @@ -194,9 +246,111 @@ class CayenneLppParser { } gpsLocation = LatLng(lat, lon); + extraSensorData[_sourceChannelKey('gps')] = channel; extraSensorData['altitude_$channel'] = alt; break; + case _lppGenericSensor: + final value = _readUInt32BE(reader).toDouble(); + debugPrint(' Generic Sensor: $value'); + extraSensorData['generic_sensor_$channel'] = value; + break; + + case _lppCurrent: + final rawValue = reader.readInt16BE(); + final value = rawValue / 1000.0; + debugPrint(' Current (raw): $rawValue'); + debugPrint(' Current: ${value}A'); + extraSensorData['current_$channel'] = value; + break; + + case _lppFrequency: + final value = _readUInt32BE(reader).toDouble(); + debugPrint(' Frequency: ${value}Hz'); + extraSensorData['frequency_$channel'] = value; + break; + + case _lppPercentage: + final value = reader.readByte().toDouble(); + debugPrint(' Percentage: $value%'); + if (_isBatteryChannel(channel)) { + batteryPercentage = value; + extraSensorData[_sourceChannelKey('battery')] = channel; + } else { + extraSensorData['percentage_$channel'] = value; + } + break; + + case _lppAltitude: + final rawValue = reader.readInt16BE(); + final value = rawValue.toDouble(); + debugPrint(' Altitude: ${value}m'); + extraSensorData['altitude_$channel'] = value; + break; + + case _lppConcentration: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Concentration: ${value}ppm'); + extraSensorData['concentration_$channel'] = value; + break; + + case _lppPower: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Power: ${value}W'); + extraSensorData['power_$channel'] = value; + break; + + case _lppSpeed: + final rawValue = reader.readUInt16BE(); + final value = rawValue / 100.0; + debugPrint(' Speed: ${value}m/s'); + extraSensorData['speed_$channel'] = value; + break; + + case _lppDistance: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Distance: ${value}m'); + extraSensorData['distance_$channel'] = value; + break; + + case _lppEnergy: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Energy: ${value}kWh'); + extraSensorData['energy_$channel'] = value; + break; + + case _lppDirection: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Direction: $value°'); + extraSensorData['direction_$channel'] = value; + break; + + case _lppUnixTime: + final value = _readUInt32BE(reader); + debugPrint(' Unix time: $value'); + extraSensorData['unixtime_$channel'] = value; + break; + + case _lppColour: + final red = reader.readByte(); + final green = reader.readByte(); + final blue = reader.readByte(); + debugPrint(' Colour: r=$red, g=$green, b=$blue'); + extraSensorData['colour_$channel'] = { + 'r': red, + 'g': green, + 'b': blue, + }; + break; + + case _lppSwitch: + final value = reader.readByte(); + debugPrint(' Switch: $value'); + extraSensorData['switch_$channel'] = value; + break; + default: debugPrint( ' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes', @@ -258,6 +412,16 @@ class CayenneLppParser { return ((voltage - 3.0) / 1.2) * 100.0; } + static bool _isBatteryChannel(int channel) => + channel == 0 || channel == _selfTelemetryChannel; + + static int _readUInt32BE(BufferReader reader) { + final bytes = reader.readBytes(4); + return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]; + } + + static String _sourceChannelKey(String fieldKey) => '__source_channel:$fieldKey'; + static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) { final start = data.length - remainingBytes; for (int i = start; i < data.length; i++) { diff --git a/lib/services/map_marker_service.dart b/lib/services/map_marker_service.dart index 5ea3f0c..7101110 100644 --- a/lib/services/map_marker_service.dart +++ b/lib/services/map_marker_service.dart @@ -42,89 +42,100 @@ class MapMarkerService { double mapRotation = 0, Position? userPosition, }) { - return contacts.map((contact) { - final location = contact.displayLocation; - if (location == null) return null; + return contacts + .map((contact) { + final location = contact.displayLocation; + if (location == null) return null; - return Marker( - point: location, - width: 80, - height: 100, - rotate: false, // Don't rotate the entire marker with map - child: Transform.rotate( - angle: -mapRotation * pi / 180, - child: GestureDetector( - onTap: onTap != null ? () => onTap(contact) : null, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Location update time indicator - Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: getLocationAgeColor(contact), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - contact.timeSinceLocationUpdate, - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 2), - // Marker icon - Container( - decoration: BoxDecoration( - color: Colors.white, - shape: contact.type == ContactType.channel || - contact.type == ContactType.room - ? BoxShape.rectangle - : BoxShape.circle, - borderRadius: contact.type == ContactType.channel || - contact.type == ContactType.room - ? BorderRadius.circular(14) - : null, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), + return Marker( + point: location, + width: 80, + height: 100, + rotate: false, // Don't rotate the entire marker with map + child: Transform.rotate( + angle: -mapRotation * pi / 180, + child: GestureDetector( + onTap: onTap != null ? () => onTap(contact) : null, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Location update time indicator + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: getLocationAgeColor(contact), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + contact.timeSinceLocationUpdate, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), ), - ], - ), - padding: const EdgeInsets.all(2), - child: ContactAvatar(contact: contact, radius: 16), - ), - const SizedBox(height: 2), - // Name label (without emoji) - Container( - constraints: const BoxConstraints(maxWidth: 80), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - contact.displayName, - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - textAlign: TextAlign.center, - ), + const SizedBox(height: 2), + // Marker icon + Container( + decoration: BoxDecoration( + color: Colors.white, + shape: + contact.type == ContactType.channel || + contact.type == ContactType.room + ? BoxShape.rectangle + : BoxShape.circle, + borderRadius: + contact.type == ContactType.channel || + contact.type == ContactType.room + ? BorderRadius.circular(14) + : null, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(2), + child: ContactAvatar(contact: contact, radius: 16), + ), + const SizedBox(height: 2), + // Name label (without emoji) + Container( + constraints: const BoxConstraints(maxWidth: 80), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + contact.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ], ), - ], + ), ), - ), - ), - ); - }).whereType().toList(); + ); + }) + .whereType() + .toList(); } /// Generate markers for SAR events. @@ -157,7 +168,10 @@ class MapMarkerService { children: [ // Time ago label Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), decoration: BoxDecoration( color: getSarMarkerColor(marker.type), borderRadius: BorderRadius.circular(3), @@ -188,7 +202,7 @@ class MapMarkerService { ), padding: const EdgeInsets.all(6), child: Text( - marker.emoji, // Use custom emoji if available + marker.emoji, // Use custom emoji if available style: const TextStyle(fontSize: 18), ), ), @@ -196,13 +210,17 @@ class MapMarkerService { // Type label Container( constraints: const BoxConstraints(maxWidth: 90), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(3), ), child: Text( - marker.displayName, // Uses notes if available, otherwise type.displayName + marker + .displayName, // Uses notes if available, otherwise type.displayName style: const TextStyle( color: Colors.white, fontSize: 9, @@ -269,7 +287,8 @@ class MapMarkerService { final dLat = (lat2 - lat1) * pi / 180; final dLon = (lon2 - lon1) * pi / 180; - final a = sin(dLat / 2) * sin(dLat / 2) + + final a = + sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * pi / 180) * cos(lat2 * pi / 180) * sin(dLon / 2) * @@ -299,8 +318,8 @@ class MapMarkerService { final lat2Rad = lat2 * pi / 180; final y = sin(dLon) * cos(lat2Rad); - final x = cos(lat1Rad) * sin(lat2Rad) - - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + final x = + cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); final bearing = atan2(y, x) * 180 / pi; return (bearing + 360) % 360; @@ -368,6 +387,8 @@ class MapMarkerService { return Colors.deepPurple; // Purple for repeaters case ContactType.room: return Colors.teal; // Teal for rooms + case ContactType.sensor: + return Colors.green; // Green for sensors case ContactType.channel: return Colors.orange; // Orange for channels case ContactType.none: @@ -389,6 +410,8 @@ class MapMarkerService { return Icons.router; // Router icon for repeaters case ContactType.room: return Icons.forum; // Forum/chat icon for rooms + case ContactType.sensor: + return Icons.sensors; // Sensors icon for sensor nodes case ContactType.channel: return Icons.public; // Public icon for channels case ContactType.none: @@ -466,7 +489,8 @@ class MapMarkerService { } if (allPoints.isEmpty) { - return defaultCenter ?? const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia + return defaultCenter ?? + const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia } double lat = 0, lng = 0; diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 495642b..7819c2c 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -16,6 +16,7 @@ class NotificationService { FlutterLocalNotificationsPlugin(); static const String _prefMessagesEnabled = 'notifications_messages_enabled'; static const String _prefSarEnabled = 'notifications_sar_enabled'; + static const String _prefDiscoveryEnabled = 'notifications_discovery_enabled'; static const String _prefUpdatesEnabled = 'notifications_updates_enabled'; static const String _prefMuteForeground = 'notifications_mute_foreground'; @@ -23,6 +24,7 @@ class NotificationService { bool _permissionGranted = false; bool _messageNotificationsEnabled = true; bool _sarNotificationsEnabled = true; + bool _discoveryNotificationsEnabled = true; bool _updateNotificationsEnabled = true; bool _muteForegroundNotifications = true; AppLifecycleState _lifecycleState = AppLifecycleState.resumed; @@ -63,6 +65,7 @@ class NotificationService { bool get messageNotificationsEnabled => _messageNotificationsEnabled; bool get sarNotificationsEnabled => _sarNotificationsEnabled; + bool get discoveryNotificationsEnabled => _discoveryNotificationsEnabled; bool get updateNotificationsEnabled => _updateNotificationsEnabled; bool get muteForegroundNotifications => _muteForegroundNotifications; bool get isAppInForeground => _lifecycleState == AppLifecycleState.resumed; @@ -175,6 +178,8 @@ class NotificationService { final prefs = await SharedPreferences.getInstance(); _messageNotificationsEnabled = prefs.getBool(_prefMessagesEnabled) ?? true; _sarNotificationsEnabled = prefs.getBool(_prefSarEnabled) ?? true; + _discoveryNotificationsEnabled = + prefs.getBool(_prefDiscoveryEnabled) ?? true; _updateNotificationsEnabled = prefs.getBool(_prefUpdatesEnabled) ?? true; _muteForegroundNotifications = prefs.getBool(_prefMuteForeground) ?? true; } @@ -191,6 +196,12 @@ class NotificationService { await prefs.setBool(_prefSarEnabled, value); } + Future setDiscoveryNotificationsEnabled(bool value) async { + _discoveryNotificationsEnabled = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefDiscoveryEnabled, value); + } + Future setUpdateNotificationsEnabled(bool value) async { _updateNotificationsEnabled = value; final prefs = await SharedPreferences.getInstance(); @@ -854,7 +865,7 @@ class NotificationService { }) async { if (!_isInitialized) return false; if (!_permissionGranted) return false; - if (!_messageNotificationsEnabled) return false; + if (!_discoveryNotificationsEnabled) return false; if (_shouldSuppressForegroundNotifications()) return false; final shortKey = contactKey.length > 12 diff --git a/lib/services/sse_server_service.dart b/lib/services/sse_server_service.dart deleted file mode 100644 index 6837d46..0000000 --- a/lib/services/sse_server_service.dart +++ /dev/null @@ -1,785 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:shelf/shelf.dart' as shelf; -import 'package:shelf/shelf_io.dart' as io; -import 'package:nsd/nsd.dart'; -import '../models/message.dart'; -import '../models/contact.dart'; -import '../models/sse_server_config.dart'; -import 'network_scanner_service.dart'; - -/// SSE Server Service -/// -/// Provides a web server with SSE (Server-Sent Events) endpoints for -/// real-time message and contact updates, enabling multiple app instances -/// to share a single MeshCore BLE device. -/// -/// Endpoints: -/// - GET /sse/messages - SSE stream for message updates -/// - GET /sse/contacts - SSE stream for contact updates -/// - POST /api/messages - Send message -/// - POST /api/messages/channel - Send channel message -/// - POST /api/contacts/sync - Trigger contact sync -/// - GET /api/messages/history - Get all messages -/// - GET /api/contacts - Get all contacts -/// - GET /api/status - Server health check -class SseServerService { - HttpServer? _server; - SseServerConfig? _config; - Registration? _bonjourRegistration; - - /// Active SSE connections for messages - final Set> _messageStreams = {}; - - /// Active SSE connections for contacts - final Set> _contactStreams = {}; - - /// Message history (for new clients) - final List _messageHistory = []; - - /// Contact list (for new clients) - final Map _contacts = {}; - - /// Timer for cleaning up dead connections - Timer? _cleanupTimer; - - /// Device name (for status endpoint) - String? _deviceName; - - /// Set device name - void setDeviceName(String? name) { - _deviceName = name; - debugPrint('📝 [SseServer] Device name set to: $name'); - } - - /// Callback for when a client requests to send a message - Future Function(String recipientPublicKey, String text)? onSendMessage; - - /// Callback for when a client requests to send a channel message - Future Function(int channelIdx, String text)? onSendChannelMessage; - - /// Callback for when a client requests contact sync - Future Function()? onSyncContacts; - - /// Check if server is running - bool get isRunning => _server != null; - - /// Get current configuration - SseServerConfig? get config => _config; - - /// Get number of connected clients - int get connectedClients => _messageStreams.length; - - /// CORS middleware - static shelf.Middleware get _corsHeaders { - return shelf.createMiddleware( - responseHandler: (shelf.Response response) { - return response.change( - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': - 'Origin, Content-Type, Authorization', - }, - ); - }, - ); - } - - /// Start the SSE server - Future startServer(SseServerConfig config) async { - if (_server != null) { - debugPrint('⚠️ [SseServer] Server already running'); - return; - } - - _config = config; - - try { - debugPrint( - '🚀 [SseServer] Starting server on ${config.host}:${config.port}', - ); - - // Create shelf handler with CORS support - final handler = const shelf.Pipeline() - .addMiddleware(_corsHeaders) - .addMiddleware(shelf.logRequests()) - .addHandler(_handleRequest); - - // Start HTTP server - _server = await io.serve(handler, config.host, config.port); - - debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}'); - - // Start cleanup timer for dead connections - _startCleanupTimer(); - - // Register Bonjour/mDNS service - await _registerBonjourService(config); - } catch (e) { - debugPrint('❌ [SseServer] Failed to start server: $e'); - _server = null; - rethrow; - } - } - - /// Register Bonjour/mDNS service for network discovery - Future _registerBonjourService(SseServerConfig config) async { - try { - debugPrint( - '📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...', - ); - - _bonjourRegistration = await register( - const Service( - name: 'MeshCore SSE Server', - type: NetworkScannerService.serviceType, - port: 0, // Will be set dynamically - ), - ); - - // Update with actual port - if (_bonjourRegistration != null) { - // Unregister and re-register with correct port - await unregister(_bonjourRegistration!); - _bonjourRegistration = await register( - Service( - name: 'MeshCore SSE Server', - type: NetworkScannerService.serviceType, - port: config.port, - ), - ); - debugPrint( - '✅ [SseServer] Bonjour service registered on port ${config.port}', - ); - } - } catch (e) { - debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e'); - // Don't throw - server can still work without Bonjour - } - } - - /// Start cleanup timer to remove dead connections - void _startCleanupTimer() { - _cleanupTimer?.cancel(); - _cleanupTimer = Timer.periodic(const Duration(seconds: 60), (timer) { - _cleanupDeadConnections(); - }); - debugPrint('🧹 [SseServer] Cleanup timer started (60s interval)'); - } - - /// Clean up dead/closed connections - void _cleanupDeadConnections() { - // Clean up message streams - final deadMessageStreams = _messageStreams - .where((s) => s.isClosed) - .toList(); - for (final stream in deadMessageStreams) { - _messageStreams.remove(stream); - } - - // Clean up contact streams - final deadContactStreams = _contactStreams - .where((s) => s.isClosed) - .toList(); - for (final stream in deadContactStreams) { - _contactStreams.remove(stream); - } - - if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) { - debugPrint( - '🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams', - ); - debugPrint( - ' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients', - ); - } - } - - /// Stop the SSE server - Future stopServer() async { - if (_server == null) { - return; - } - - debugPrint('🛑 [SseServer] Stopping server...'); - - // Stop cleanup timer - _cleanupTimer?.cancel(); - _cleanupTimer = null; - - // Close all SSE streams - for (final stream in _messageStreams) { - await stream.close(); - } - _messageStreams.clear(); - - for (final stream in _contactStreams) { - await stream.close(); - } - _contactStreams.clear(); - - // Unregister Bonjour service - if (_bonjourRegistration != null) { - try { - await unregister(_bonjourRegistration!); - debugPrint('✅ [SseServer] Bonjour service unregistered'); - } catch (e) { - debugPrint('⚠️ [SseServer] Failed to unregister Bonjour service: $e'); - } - _bonjourRegistration = null; - } - - // Close HTTP server - await _server!.close(force: true); - _server = null; - _config = null; - - debugPrint('✅ [SseServer] Server stopped'); - } - - /// Main request handler - Future _handleRequest(shelf.Request request) async { - // Check authentication if token is configured - if (_config?.authToken != null) { - final authHeader = request.headers['authorization']; - if (authHeader != 'Bearer ${_config!.authToken}') { - return shelf.Response.forbidden('Invalid authentication token'); - } - } - - final path = request.url.path; - final method = request.method; - - debugPrint('📨 [SseServer] $method /$path'); - - // Route requests - if (method == 'GET' && path == 'sse/messages') { - return _handleSseMessages(request); - } else if (method == 'GET' && path == 'sse/contacts') { - return _handleSseContacts(request); - } else if (method == 'POST' && path == 'api/messages') { - return _handlePostMessage(request); - } else if (method == 'POST' && path == 'api/messages/channel') { - return _handlePostChannelMessage(request); - } else if (method == 'POST' && path == 'api/contacts/sync') { - return _handlePostContactsSync(request); - } else if (method == 'GET' && path == 'api/messages/history') { - return _handleGetMessageHistory(request); - } else if (method == 'GET' && path == 'api/contacts') { - return _handleGetContacts(request); - } else if (method == 'GET' && path == 'api/status') { - return _handleGetStatus(request); - } else if (method == 'GET' && path == '') { - return _handleRoot(request); - } - - return shelf.Response.notFound('Not found'); - } - - /// Handle SSE messages stream - shelf.Response _handleSseMessages(shelf.Request request) { - return request.hijack((channel) async { - debugPrint( - '📥 [SseServer] New SSE client connected (messages) via hijack', - ); - - // Set up the sink for sending data - final sink = utf8.encoder.startChunkedConversion(channel.sink); - - // Send SSE headers - sink.add('HTTP/1.1 200 OK\r\n'); - sink.add('Content-Type: text/event-stream\r\n'); - sink.add('Cache-Control: no-cache\r\n'); - sink.add('Connection: keep-alive\r\n'); - sink.add('\r\n'); - - // Create controller for this connection - final controller = StreamController(); - _messageStreams.add(controller); - - debugPrint(' Total clients: ${_messageStreams.length}'); - - // Send initial connection event - sink.add(': connected\n\n'); - - // Send initial message history - for (final message in _messageHistory) { - final event = _formatSseEvent('message', _messageToJson(message)); - sink.add(event); - } - - // Start keep-alive timer - final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), ( - timer, - ) { - try { - sink.add(': keepalive\n\n'); - } catch (e) { - debugPrint('⚠️ [SseServer] Keep-alive failed: $e'); - timer.cancel(); - } - }); - - // Listen to controller for new messages to broadcast - final subscription = controller.stream.listen( - (data) { - try { - sink.add(data); - } catch (e) { - debugPrint('⚠️ [SseServer] Failed to send data: $e'); - } - }, - onDone: () { - debugPrint('📤 [SseServer] Controller stream closed'); - }, - ); - - // Wait for channel to close - await channel.stream.drain(); - - // Cleanup - keepAliveTimer.cancel(); - await subscription.cancel(); - _messageStreams.remove(controller); - await controller.close(); - - debugPrint('📤 [SseServer] SSE client disconnected (messages)'); - debugPrint(' Total clients: ${_messageStreams.length}'); - }); - } - - /// Handle SSE contacts stream - shelf.Response _handleSseContacts(shelf.Request request) { - return request.hijack((channel) async { - debugPrint( - '📥 [SseServer] New SSE client connected (contacts) via hijack', - ); - - // Set up the sink for sending data - final sink = utf8.encoder.startChunkedConversion(channel.sink); - - // Send SSE headers - sink.add('HTTP/1.1 200 OK\r\n'); - sink.add('Content-Type: text/event-stream\r\n'); - sink.add('Cache-Control: no-cache\r\n'); - sink.add('Connection: keep-alive\r\n'); - sink.add('\r\n'); - - // Create controller for this connection - final controller = StreamController(); - _contactStreams.add(controller); - - debugPrint(' Total clients: ${_contactStreams.length}'); - - // Send initial connection event - sink.add(': connected\n\n'); - - // Send initial contact list - for (final contact in _contacts.values) { - final event = _formatSseEvent('contact', _contactToJson(contact)); - sink.add(event); - } - - // Start keep-alive timer - final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), ( - timer, - ) { - try { - sink.add(': keepalive\n\n'); - } catch (e) { - debugPrint('⚠️ [SseServer] Keep-alive failed: $e'); - timer.cancel(); - } - }); - - // Listen to controller for new messages to broadcast - final subscription = controller.stream.listen( - (data) { - try { - sink.add(data); - } catch (e) { - debugPrint('⚠️ [SseServer] Failed to send data: $e'); - } - }, - onDone: () { - debugPrint('📤 [SseServer] Controller stream closed'); - }, - ); - - // Wait for channel to close - await channel.stream.drain(); - - // Cleanup - keepAliveTimer.cancel(); - await subscription.cancel(); - _contactStreams.remove(controller); - await controller.close(); - - debugPrint('📤 [SseServer] SSE client disconnected (contacts)'); - debugPrint(' Total clients: ${_contactStreams.length}'); - }); - } - - /// Handle POST message request - Future _handlePostMessage(shelf.Request request) async { - try { - final body = await request.readAsString(); - final json = jsonDecode(body) as Map; - - final recipientPublicKey = json['recipientPublicKey'] as String; - final text = json['text'] as String; - - if (onSendMessage == null) { - return shelf.Response.internalServerError( - body: jsonEncode({'error': 'Send message callback not configured'}), - ); - } - - final success = await onSendMessage!(recipientPublicKey, text); - - return shelf.Response.ok( - jsonEncode({'success': success}), - headers: {'content-type': 'application/json'}, - ); - } catch (e) { - debugPrint('❌ [SseServer] Error handling POST message: $e'); - return shelf.Response.internalServerError( - body: jsonEncode({'error': e.toString()}), - ); - } - } - - /// Handle POST channel message request - Future _handlePostChannelMessage( - shelf.Request request, - ) async { - try { - final body = await request.readAsString(); - final json = jsonDecode(body) as Map; - - final channelIdx = json['channelIdx'] as int; - final text = json['text'] as String; - - if (onSendChannelMessage == null) { - return shelf.Response.internalServerError( - body: jsonEncode({ - 'error': 'Send channel message callback not configured', - }), - ); - } - - await onSendChannelMessage!(channelIdx, text); - - return shelf.Response.ok( - jsonEncode({'success': true}), - headers: {'content-type': 'application/json'}, - ); - } catch (e) { - debugPrint('❌ [SseServer] Error handling POST channel message: $e'); - return shelf.Response.internalServerError( - body: jsonEncode({'error': e.toString()}), - ); - } - } - - /// Handle POST contacts sync request - Future _handlePostContactsSync(shelf.Request request) async { - try { - if (onSyncContacts == null) { - return shelf.Response.internalServerError( - body: jsonEncode({'error': 'Sync contacts callback not configured'}), - ); - } - - await onSyncContacts!(); - - return shelf.Response.ok( - jsonEncode({'success': true}), - headers: {'content-type': 'application/json'}, - ); - } catch (e) { - debugPrint('❌ [SseServer] Error handling POST contacts sync: $e'); - return shelf.Response.internalServerError( - body: jsonEncode({'error': e.toString()}), - ); - } - } - - /// Handle GET message history request - shelf.Response _handleGetMessageHistory(shelf.Request request) { - final messages = _messageHistory.map(_messageToJson).toList(); - return shelf.Response.ok( - jsonEncode({'messages': messages}), - headers: {'content-type': 'application/json'}, - ); - } - - /// Handle GET contacts request - shelf.Response _handleGetContacts(shelf.Request request) { - final contacts = _contacts.values.map(_contactToJson).toList(); - return shelf.Response.ok( - jsonEncode({'contacts': contacts}), - headers: {'content-type': 'application/json'}, - ); - } - - /// Handle GET status request - shelf.Response _handleGetStatus(shelf.Request request) { - return shelf.Response.ok( - jsonEncode({ - 'status': 'running', - 'connectedClients': connectedClients, - 'messageCount': _messageHistory.length, - 'contactCount': _contacts.length, - 'deviceName': _deviceName, - }), - headers: {'content-type': 'application/json'}, - ); - } - - /// Handle root request (landing page) - shelf.Response _handleRoot(shelf.Request request) { - final html = ''' - - - - MeshCore SAR - SSE Server - - - - -
-

🚀 MeshCore SAR Server

-
✅ Server is running
-

This server enables multiple MeshCore SAR clients to share a single BLE device.

- -

📡 SSE Endpoints

-
GET /sse/messages
-
GET /sse/contacts
- -

🔧 API Endpoints

-
POST /api/messages
-
POST /api/messages/channel
-
POST /api/contacts/sync
-
GET /api/messages/history
-
GET /api/contacts
-
GET /api/status
- -

📊 Stats

-

Connected clients: Loading...

-

Messages: Loading...

-

Contacts: Loading...

-
- - - - -'''; - return shelf.Response.ok(html, headers: {'content-type': 'text/html'}); - } - - /// Broadcast a new message to all SSE clients - void broadcastMessage(Message message) { - // Add to history (limit to 1000 messages) - _messageHistory.add(message); - if (_messageHistory.length > 1000) { - _messageHistory.removeAt(0); - } - - // Broadcast to all connected clients - final event = _formatSseEvent('message', _messageToJson(message)); - final deadStreams = >[]; - - for (final stream in _messageStreams) { - if (stream.isClosed) { - deadStreams.add(stream); - } else { - try { - stream.add(event); - } catch (e) { - debugPrint( - '⚠️ [SseServer] Failed to send to stream, marking as dead: $e', - ); - deadStreams.add(stream); - } - } - } - - // Remove dead streams - for (final stream in deadStreams) { - _messageStreams.remove(stream); - stream.close().catchError( - (e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'), - ); - } - - if (deadStreams.isNotEmpty) { - debugPrint( - '🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast', - ); - } - - debugPrint( - '📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients', - ); - } - - /// Broadcast a new or updated contact to all SSE clients - void broadcastContact(Contact contact) { - // Update contact list - _contacts[contact.publicKeyHex] = contact; - - // Broadcast to all connected clients - final event = _formatSseEvent('contact', _contactToJson(contact)); - final deadStreams = >[]; - - for (final stream in _contactStreams) { - if (stream.isClosed) { - deadStreams.add(stream); - } else { - try { - stream.add(event); - } catch (e) { - debugPrint( - '⚠️ [SseServer] Failed to send to stream, marking as dead: $e', - ); - deadStreams.add(stream); - } - } - } - - // Remove dead streams - for (final stream in deadStreams) { - _contactStreams.remove(stream); - stream.close().catchError( - (e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'), - ); - } - - if (deadStreams.isNotEmpty) { - debugPrint( - '🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast', - ); - } - - debugPrint( - '📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients', - ); - } - - /// Format SSE event - String _formatSseEvent(String eventType, Map data) { - final jsonData = jsonEncode(data); - return 'event: $eventType\ndata: $jsonData\n\n'; - } - - /// Convert Message to JSON - Map _messageToJson(Message message) { - return { - 'id': message.id, - 'messageType': message.messageType.name, - 'senderPublicKeyPrefix': message.senderPublicKeyPrefix?.toList(), - 'channelIdx': message.channelIdx, - 'pathLen': message.pathLen, - 'textType': message.textType.value, - 'senderTimestamp': message.senderTimestamp, - 'text': message.text, - 'isSarMarker': message.isSarMarker, - 'sarGpsCoordinates': message.sarGpsCoordinates != null - ? { - 'latitude': message.sarGpsCoordinates!.latitude, - 'longitude': message.sarGpsCoordinates!.longitude, - } - : null, - 'sarNotes': message.sarNotes, - 'sarCustomEmoji': message.sarCustomEmoji, - 'sarColorIndex': message.sarColorIndex, - 'receivedAt': message.receivedAt.toIso8601String(), - 'senderName': message.senderName, - 'deliveryStatus': message.deliveryStatus.name, - 'expectedAckTag': message.expectedAckTag, - 'suggestedTimeoutMs': message.suggestedTimeoutMs, - 'roundTripTimeMs': message.roundTripTimeMs, - 'deliveredAt': message.deliveredAt?.toIso8601String(), - 'recipientPublicKey': message.recipientPublicKey?.toList(), - 'retryAttempt': message.retryAttempt, - 'lastRetryAt': message.lastRetryAt?.toIso8601String(), - 'usedFloodFallback': message.usedFloodFallback, - 'isRead': message.isRead, - 'echoCount': message.echoCount, - 'firstEchoAt': message.firstEchoAt?.toIso8601String(), - 'lastEchoSnrRaw': message.lastEchoSnrRaw, - 'lastEchoRssiDbm': message.lastEchoRssiDbm, - 'lastEchoAt': message.lastEchoAt?.toIso8601String(), - 'isDrawing': message.isDrawing, - 'drawingId': message.drawingId, - }; - } - - /// Convert Contact to JSON - Map _contactToJson(Contact contact) { - return { - 'publicKey': contact.publicKey.toList(), - 'publicKeyHex': contact.publicKeyHex, - 'type': contact.type.value, - 'flags': contact.flags, - 'outPathLen': contact.outPathLen, - 'outPath': contact.outPath.toList(), - 'advName': contact.advName, - 'lastAdvert': contact.lastAdvert, - 'advLat': contact.advLat, - 'advLon': contact.advLon, - 'lastMod': contact.lastMod, - 'telemetry': contact.telemetry != null - ? { - 'batteryPercentage': contact.telemetry!.batteryPercentage, - 'batteryMilliVolts': contact.telemetry!.batteryMilliVolts, - 'temperature': contact.telemetry!.temperature, - 'humidity': contact.telemetry!.humidity, - 'pressure': contact.telemetry!.pressure, - 'gpsLocation': contact.telemetry!.gpsLocation != null - ? { - 'latitude': contact.telemetry!.gpsLocation!.latitude, - 'longitude': contact.telemetry!.gpsLocation!.longitude, - } - : null, - 'timestamp': contact.telemetry!.timestamp.toIso8601String(), - } - : null, - }; - } - - /// Clear message history - void clearMessageHistory() { - _messageHistory.clear(); - } - - /// Clear contact list - void clearContacts() { - _contacts.clear(); - } -} diff --git a/lib/widgets/common/contact_avatar.dart b/lib/widgets/common/contact_avatar.dart index cdfaf08..28d27d0 100644 --- a/lib/widgets/common/contact_avatar.dart +++ b/lib/widgets/common/contact_avatar.dart @@ -108,6 +108,8 @@ class ContactAvatar extends StatelessWidget { return Colors.orange; case ContactType.room: return Colors.purple; + case ContactType.sensor: + return Colors.green; case ContactType.channel: return Colors.teal; } @@ -130,6 +132,8 @@ class ContactAvatar extends StatelessWidget { return Icons.router; case ContactType.room: return Icons.meeting_room; + case ContactType.sensor: + return Icons.sensors; case ContactType.channel: return Icons.public; } diff --git a/lib/widgets/connection_mode_selector.dart b/lib/widgets/connection_mode_selector.dart deleted file mode 100644 index 6d61f6e..0000000 --- a/lib/widgets/connection_mode_selector.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'dart:io'; -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import '../providers/connection_provider.dart'; -import '../models/sse_server_config.dart'; - -/// Connection Mode Selector Widget -/// -/// Allows user to enable/disable SSE Server mode to share device with multiple clients -class ConnectionModeSelector extends StatefulWidget { - const ConnectionModeSelector({super.key}); - - @override - State createState() => _ConnectionModeSelectorState(); -} - -class _ConnectionModeSelectorState extends State { - List _localIPs = []; - - @override - void initState() { - super.initState(); - _loadLocalIPs(); - } - - Future _loadLocalIPs() async { - if (kIsWeb) { - return; - } - - final Set ipsSet = {}; - - try { - final interfaces = await NetworkInterface.list(); - for (final interface in interfaces) { - for (final addr in interface.addresses) { - if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) { - ipsSet.add(addr.address); - } - } - } - } catch (e) { - debugPrint('Error getting network interfaces: $e'); - } - - if (mounted) { - setState(() { - _localIPs = ipsSet.toList(); - }); - } - } - - @override - void dispose() { - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final connectionProvider = Provider.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Section Header - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - 'Network Sharing', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ), - - // SSE Server Toggle - SwitchListTile( - secondary: const Icon(Icons.share), - title: const Text('Share Device (Server)'), - subtitle: Text( - connectionProvider.isSseServerRunning - ? 'Server running on port ${connectionProvider.sseServerConfig.port} - ${connectionProvider.sseClientCount} client(s) connected' - : 'Share BLE device with multiple clients over network', - ), - value: connectionProvider.isSseServerRunning, - onChanged: (enabled) async { - if (enabled) { - // Start server with default config (port 12929, no auth) - final config = const SseServerConfig(port: 12929, enabled: true); - - try { - await connectionProvider.startSseServer(config); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('SSE server started on port 12929'), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to start server: $e'), - backgroundColor: Colors.red, - ), - ); - } - } - } else { - // Stop server - await connectionProvider.stopSseServer(); - } - }, - ), - - // Show IP addresses when server is running - if (connectionProvider.isSseServerRunning && _localIPs.isNotEmpty) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - 'Connect from other devices:', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ..._localIPs.map((ip) { - final url = 'http://$ip:${connectionProvider.sseServerConfig.port}'; - return ListTile( - dense: true, - leading: const Icon(Icons.wifi, size: 20), - title: Text( - url, - style: const TextStyle(fontFamily: 'monospace', fontSize: 13), - ), - trailing: IconButton( - icon: const Icon(Icons.copy, size: 20), - tooltip: 'Copy URL', - onPressed: () { - Clipboard.setData(ClipboardData(text: url)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Copied $url'), - duration: const Duration(seconds: 1), - ), - ); - }, - ), - ); - }), - ], - ], - ); - } -} diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 51f3315..11b9560 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -16,6 +16,7 @@ import 'contact_route_dialog.dart'; import 'contact_trace_sheet.dart'; import 'room_login_sheet.dart'; import '../common/contact_avatar.dart'; +import '../sensors/sensor_telemetry_card.dart'; import '../../utils/toast_logger.dart'; import '../../l10n/app_localizations.dart'; @@ -327,10 +328,13 @@ class ContactTile extends StatelessWidget { final canSetPath = contact.type == ContactType.chat || contact.type == ContactType.room || - contact.type == ContactType.repeater; + contact.type == ContactType.repeater || + contact.type == ContactType.sensor; final canAddToSensors = contact.type == ContactType.chat || - contact.type == ContactType.repeater; + contact.type == ContactType.repeater || + contact.type == ContactType.sensor; + final canPreviewSensor = contact.isSensor; final sensorsProvider = context.read(); final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex); @@ -381,6 +385,17 @@ class ContactTile extends StatelessWidget { _showRoomLoginDialog(context, contact); }, ), + if (canPreviewSensor) + ListTile( + leading: const Icon(Icons.visibility_outlined), + title: const Text('Preview'), + onTap: () async { + Navigator.pop(sheetContext); + await Future.delayed(Duration.zero); + if (!context.mounted) return; + await _showSensorPreviewSheet(context, contact); + }, + ), if (canAddToSensors) ListTile( leading: Icon( @@ -454,6 +469,53 @@ class ContactTile extends StatelessWidget { ); } + Future _showSensorPreviewSheet( + BuildContext context, + Contact contact, + ) async { + final publicKeyHex = contact.publicKeyHex; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (sheetContext) => Consumer2( + builder: (context, contactsProvider, sensorsProvider, child) { + Contact? liveContact; + for (final entry in contactsProvider.contacts) { + if (entry.publicKeyHex == publicKeyHex) { + liveContact = entry; + break; + } + } + + final previewContact = liveContact ?? contact; + final visibleFields = sensorMetricKeysFor(previewContact); + final fieldOrder = sensorsProvider.metricOrderFor( + publicKeyHex, + visibleFields, + ); + + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: SensorTelemetryCard( + contact: previewContact, + state: sensorsProvider.stateFor(publicKeyHex), + visibleFields: visibleFields, + fieldOrder: fieldOrder, + labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex), + fieldSpans: sensorFullWidthFieldSpans(visibleFields), + margin: EdgeInsets.zero, + emptyMetricsMessage: 'No telemetry fields available yet.', + ), + ), + ); + }, + ), + ); + } + void _showContactOnMap(BuildContext context, Contact contact) { final location = contact.displayLocation; if (location == null) { diff --git a/lib/widgets/contacts/room_login_sheet.dart b/lib/widgets/contacts/room_login_sheet.dart index 3145d90..9f1a80a 100644 --- a/lib/widgets/contacts/room_login_sheet.dart +++ b/lib/widgets/contacts/room_login_sheet.dart @@ -147,6 +147,10 @@ class _RoomLoginSheetState extends State { try { // Manually add the room contact to the radio's flash storage await connectionProvider.addOrUpdateContact(widget.contact); + final addError = connectionProvider.error; + if (addError != null) { + throw Exception(addError); + } debugPrint( '✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT', diff --git a/lib/widgets/map/compass/compass_contact_list.dart b/lib/widgets/map/compass/compass_contact_list.dart index 1af2a5f..51bc4ba 100644 --- a/lib/widgets/map/compass/compass_contact_list.dart +++ b/lib/widgets/map/compass/compass_contact_list.dart @@ -42,6 +42,7 @@ class CompassContactList extends StatelessWidget { // Split contacts by type final persons = >[]; final repeaters = >[]; + final sensors = >[]; final rooms = >[]; // Calculate bearings and distances for each contact @@ -70,6 +71,8 @@ class CompassContactList extends StatelessWidget { if (contact.isRepeater) { repeaters.add(item); + } else if (contact.isSensor) { + sensors.add(item); } else if (contact.isRoom) { rooms.add(item); } else { @@ -78,9 +81,18 @@ class CompassContactList extends StatelessWidget { } // Sort each list by distance - persons.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double)); - repeaters.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double)); - rooms.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double)); + persons.sort( + (a, b) => (a['distance'] as double).compareTo(b['distance'] as double), + ); + repeaters.sort( + (a, b) => (a['distance'] as double).compareTo(b['distance'] as double), + ); + sensors.sort( + (a, b) => (a['distance'] as double).compareTo(b['distance'] as double), + ); + rooms.sort( + (a, b) => (a['distance'] as double).compareTo(b['distance'] as double), + ); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -91,17 +103,34 @@ class CompassContactList extends StatelessWidget { padding: const EdgeInsets.only(left: 16, bottom: 8, top: 4), child: Text( l10n.teamMembers, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), - ...persons.map((item) => _buildContactTile( + ...persons.map( + (item) => _buildContactTile( + context, + item, + Icons.groups, + Theme.of(context).colorScheme.primary, + ), + ), + ], + if (showContacts && sensors.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12), + child: Text( + 'Sensors', + style: Theme.of( context, - item, - Icons.groups, - Theme.of(context).colorScheme.primary, - )), + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ...sensors.map( + (item) => + _buildContactTile(context, item, Icons.sensors, Colors.green), + ), ], // Repeaters section if (showRepeaters && repeaters.isNotEmpty) ...[ @@ -109,17 +138,15 @@ class CompassContactList extends StatelessWidget { padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12), child: Text( l10n.repeaters, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), - ...repeaters.map((item) => _buildContactTile( - context, - item, - Icons.router, - Colors.purple, - )), + ...repeaters.map( + (item) => + _buildContactTile(context, item, Icons.router, Colors.purple), + ), ], // Rooms section if (rooms.isNotEmpty) ...[ @@ -127,17 +154,19 @@ class CompassContactList extends StatelessWidget { padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12), child: Text( l10n.rooms, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ...rooms.map( + (item) => _buildContactTile( + context, + item, + Icons.meeting_room, + Colors.teal, ), ), - ...rooms.map((item) => _buildContactTile( - context, - item, - Icons.meeting_room, - Colors.teal, - )), ], ], ); @@ -161,24 +190,14 @@ class CompassContactList extends StatelessWidget { : Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), border: selectedContact == contact - ? Border.all( - color: Theme.of(context).colorScheme.primary, - width: 2, - ) + ? Border.all(color: Theme.of(context).colorScheme.primary, width: 2) : null, ), child: ListTile( dense: true, leading: contact.roleEmoji != null - ? Text( - contact.roleEmoji!, - style: const TextStyle(fontSize: 24), - ) - : Icon( - defaultIcon, - color: iconColor, - size: 24, - ), + ? Text(contact.roleEmoji!, style: const TextStyle(fontSize: 24)) + : Icon(defaultIcon, color: iconColor, size: 24), title: Text(contact.displayName), subtitle: Text( '${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}', @@ -190,16 +209,16 @@ class CompassContactList extends StatelessWidget { children: [ Text( '${bearing.round()}°', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold), ), if (heading != null) Text( _formatRelativeBearing(bearing, heading!, context), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Colors.grey, - ), + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: Colors.grey), ), ], ), @@ -217,15 +236,14 @@ class CompassContactList extends StatelessWidget { } // Calculate bearing between two points (in degrees) - double _calculateBearing( - double lat1, double lon1, double lat2, double lon2) { + double _calculateBearing(double lat1, double lon1, double lat2, double lon2) { final dLon = (lon2 - lon1) * pi / 180; final lat1Rad = lat1 * pi / 180; final lat2Rad = lat2 * pi / 180; final y = sin(dLon) * cos(lat2Rad); - final x = cos(lat1Rad) * sin(lat2Rad) - - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); + final x = + cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon); final bearing = atan2(y, x) * 180 / pi; return (bearing + 360) % 360; @@ -233,12 +251,17 @@ class CompassContactList extends StatelessWidget { // Calculate distance between two points (in meters) double _calculateDistance( - double lat1, double lon1, double lat2, double lon2) { + double lat1, + double lon1, + double lat2, + double lon2, + ) { const R = 6371000; // Earth's radius in meters final dLat = (lat2 - lat1) * pi / 180; final dLon = (lon2 - lon1) * pi / 180; - final a = sin(dLat / 2) * sin(dLat / 2) + + final a = + sin(dLat / 2) * sin(dLat / 2) + cos(lat1 * pi / 180) * cos(lat2 * pi / 180) * sin(dLon / 2) * @@ -262,7 +285,11 @@ class CompassContactList extends StatelessWidget { } } - String _formatRelativeBearing(double bearing, double heading, BuildContext context) { + String _formatRelativeBearing( + double bearing, + double heading, + BuildContext context, + ) { final l10n = AppLocalizations.of(context)!; // Calculate relative bearing (how much to turn from current heading) double relative = bearing - heading; diff --git a/lib/widgets/map_markers.dart b/lib/widgets/map_markers.dart index 8baa87e..f00de1e 100644 --- a/lib/widgets/map_markers.dart +++ b/lib/widgets/map_markers.dart @@ -12,98 +12,107 @@ class MapMarkers { Function(Contact)? onContactTap, double mapRotation = 0, }) { - return contacts.map((contact) { - final location = contact.displayLocation; - if (location == null) return null; + return contacts + .map((contact) { + final location = contact.displayLocation; + if (location == null) return null; - return Marker( - point: location, - width: 80, - height: 100, - rotate: false, // Don't rotate the entire marker with map - child: Transform.rotate( - angle: -mapRotation * 3.14159265359 / 180, - child: GestureDetector( - onTap: () { - if (onContactTap != null) { - onContactTap(contact); - } else { - _showContactInfo(context, contact); - } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Location update time indicator - Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: _getLocationAgeColor(contact), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - contact.timeSinceLocationUpdate, - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 2), - // Marker icon or emoji - Container( - decoration: BoxDecoration( - color: _getContactTypeColor(contact, context), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 4, - offset: const Offset(0, 2), + return Marker( + point: location, + width: 80, + height: 100, + rotate: false, // Don't rotate the entire marker with map + child: Transform.rotate( + angle: -mapRotation * 3.14159265359 / 180, + child: GestureDetector( + onTap: () { + if (onContactTap != null) { + onContactTap(contact); + } else { + _showContactInfo(context, contact); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Location update time indicator + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, ), - ], - ), - padding: const EdgeInsets.all(6), - child: contact.roleEmoji != null - ? Text( - contact.roleEmoji!, - style: const TextStyle(fontSize: 18), - ) - : Icon( - _getContactTypeIcon(contact), + decoration: BoxDecoration( + color: _getLocationAgeColor(contact), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + contact.timeSinceLocationUpdate, + style: const TextStyle( color: Colors.white, - size: 18, + fontSize: 9, + fontWeight: FontWeight.bold, ), - ), - const SizedBox(height: 2), - // Name label (without emoji) - Container( - constraints: const BoxConstraints(maxWidth: 80), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - contact.displayName, - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, + ), ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - textAlign: TextAlign.center, - ), + const SizedBox(height: 2), + // Marker icon or emoji + Container( + decoration: BoxDecoration( + color: _getContactTypeColor(contact, context), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(6), + child: contact.roleEmoji != null + ? Text( + contact.roleEmoji!, + style: const TextStyle(fontSize: 18), + ) + : Icon( + _getContactTypeIcon(contact), + color: Colors.white, + size: 18, + ), + ), + const SizedBox(height: 2), + // Name label (without emoji) + Container( + constraints: const BoxConstraints(maxWidth: 80), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + contact.displayName, + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + ), + ), + ], ), - ], + ), ), - ), - ), - ); - }).whereType().toList(); + ); + }) + .whereType() + .toList(); } static List createSarMarkers( @@ -133,7 +142,10 @@ class MapMarkers { children: [ // Time ago label Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), decoration: BoxDecoration( color: _getSarMarkerColor(marker), borderRadius: BorderRadius.circular(3), @@ -164,7 +176,7 @@ class MapMarkers { ), padding: const EdgeInsets.all(6), child: Text( - marker.emoji, // Use custom emoji if available + marker.emoji, // Use custom emoji if available style: const TextStyle(fontSize: 18), ), ), @@ -172,7 +184,10 @@ class MapMarkers { // Type label Container( constraints: const BoxConstraints(maxWidth: 90), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(3), @@ -183,8 +198,12 @@ class MapMarkers { debugPrint('🗺️ [MapMarker] Displaying SAR marker:'); debugPrint(' marker.notes: "${marker.notes}"'); debugPrint(' marker.type: ${marker.type}'); - debugPrint(' marker.type.displayName: ${marker.type.displayName}'); - debugPrint(' marker.displayName: ${marker.displayName}'); + debugPrint( + ' marker.type.displayName: ${marker.type.displayName}', + ); + debugPrint( + ' marker.displayName: ${marker.displayName}', + ); return Text( marker.displayName, @@ -241,17 +260,25 @@ class MapMarkers { _InfoRow( 'Voltage', '${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V' - '${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}', + '${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}', ) else if (contact.displayBattery != null) _InfoRow('Battery', '${contact.displayBattery!.round()}%'), if (contact.telemetry?.temperature != null) _InfoRow( - 'Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'), + 'Temperature', + '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C', + ), if (contact.telemetry?.humidity != null) - _InfoRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'), + _InfoRow( + 'Humidity', + '${contact.telemetry!.humidity!.toStringAsFixed(1)}%', + ), if (contact.telemetry?.pressure != null) - _InfoRow('Pressure', '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'), + _InfoRow( + 'Pressure', + '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa', + ), _InfoRow('Last Seen', contact.timeSinceLastSeen), _InfoRow('Public Key', contact.publicKeyShort), ], @@ -272,7 +299,10 @@ class MapMarkers { builder: (context) => AlertDialog( title: Row( children: [ - Text(marker.emoji, style: const TextStyle(fontSize: 24)), // Use custom emoji if available + Text( + marker.emoji, + style: const TextStyle(fontSize: 24), + ), // Use custom emoji if available const SizedBox(width: 8), Expanded(child: Text(marker.displayName)), ], @@ -313,7 +343,9 @@ class MapMarkers { static Color _getSarMarkerColor(SarMarker marker) { // If marker has a color index, use it (new format) - if (marker.colorIndex != null && marker.colorIndex! >= 0 && marker.colorIndex! < 8) { + if (marker.colorIndex != null && + marker.colorIndex! >= 0 && + marker.colorIndex! < 8) { final colorHex = SarTemplate.getColorFromIndex(marker.colorIndex!); final hexCode = colorHex.replaceAll('#', ''); return Color(int.parse('FF$hexCode', radix: 16)); @@ -342,6 +374,8 @@ class MapMarkers { return Colors.deepPurple; // Purple for repeaters case ContactType.room: return Colors.teal; // Teal for rooms + case ContactType.sensor: + return Colors.green; // Green for sensors case ContactType.channel: return Colors.orange; // Orange for channels case ContactType.none: @@ -357,6 +391,8 @@ class MapMarkers { return Icons.router; // Router icon for repeaters case ContactType.room: return Icons.forum; // Forum/chat icon for rooms + case ContactType.sensor: + return Icons.sensors; // Sensors icon for sensor nodes case ContactType.channel: return Icons.public; // Public icon for channels case ContactType.none: @@ -385,9 +421,7 @@ class _InfoRow extends StatelessWidget { style: const TextStyle(fontWeight: FontWeight.w600), ), ), - Expanded( - child: Text(value), - ), + Expanded(child: Text(value)), ], ), ); diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart new file mode 100644 index 0000000..22a6ca5 --- /dev/null +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -0,0 +1,1866 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:latlong2/latlong.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/contact.dart'; +import '../../providers/sensors_provider.dart'; +import '../../utils/location_formats.dart'; + +class SensorMetricOption { + final String key; + final String label; + final String defaultLabel; + final int? channel; + final String? valuePreview; + + const SensorMetricOption({ + required this.key, + required this.label, + required this.defaultLabel, + this.channel, + this.valuePreview, + }); +} + +List sensorMetricOptionsFor( + Contact? contact, { + Map labelOverrides = const {}, +}) { + final telemetry = contact?.telemetry; + final extraSensorData = telemetry?.extraSensorData; + final batteryMilliVolts = telemetry?.batteryMilliVolts; + final batteryPercentage = telemetry?.batteryPercentage; + final temperature = telemetry?.temperature; + final humidity = telemetry?.humidity; + final pressure = telemetry?.pressure; + final gpsLocation = telemetry?.gpsLocation; + final options = [ + if (batteryMilliVolts != null) + SensorMetricOption( + key: 'voltage', + label: _selectorMetricLabel( + _resolvedMetricLabel( + 'voltage', + 'Voltage', + labelOverrides: labelOverrides, + ), + _sourceChannelForField(extraSensorData, 'voltage'), + ), + defaultLabel: 'Voltage', + channel: _sourceChannelForField(extraSensorData, 'voltage'), + valuePreview: '${(batteryMilliVolts / 1000).toStringAsFixed(3)}V', + ), + if (batteryPercentage != null) + SensorMetricOption( + key: 'battery', + label: _selectorMetricLabel( + _resolvedMetricLabel( + 'battery', + 'Battery', + labelOverrides: labelOverrides, + ), + _sourceChannelForField(extraSensorData, 'battery'), + ), + defaultLabel: 'Battery', + channel: _sourceChannelForField(extraSensorData, 'battery'), + valuePreview: '${batteryPercentage.toStringAsFixed(0)}%', + ), + if (temperature != null) + SensorMetricOption( + key: 'temperature', + label: _selectorMetricLabel( + _resolvedMetricLabel( + 'temperature', + 'Temperature', + labelOverrides: labelOverrides, + ), + _sourceChannelForField(extraSensorData, 'temperature'), + ), + defaultLabel: 'Temperature', + channel: _sourceChannelForField(extraSensorData, 'temperature'), + valuePreview: '${temperature.toStringAsFixed(1)}°C', + ), + if (humidity != null) + SensorMetricOption( + key: 'humidity', + label: _selectorMetricLabel( + _resolvedMetricLabel( + 'humidity', + 'Humidity', + labelOverrides: labelOverrides, + ), + _sourceChannelForField(extraSensorData, 'humidity'), + ), + defaultLabel: 'Humidity', + channel: _sourceChannelForField(extraSensorData, 'humidity'), + valuePreview: '${humidity.toStringAsFixed(1)}%', + ), + if (pressure != null) + SensorMetricOption( + key: 'pressure', + label: _selectorMetricLabel( + _resolvedMetricLabel( + 'pressure', + 'Pressure', + labelOverrides: labelOverrides, + ), + _sourceChannelForField(extraSensorData, 'pressure'), + ), + defaultLabel: 'Pressure', + channel: _sourceChannelForField(extraSensorData, 'pressure'), + valuePreview: '${pressure.toStringAsFixed(1)} hPa', + ), + if (gpsLocation != null) + SensorMetricOption( + key: 'gps', + label: _selectorMetricLabel( + _resolvedMetricLabel('gps', 'GPS', labelOverrides: labelOverrides), + _sourceChannelForField(extraSensorData, 'gps'), + ), + defaultLabel: 'GPS', + channel: _sourceChannelForField(extraSensorData, 'gps'), + valuePreview: + '${gpsLocation.latitude.toStringAsFixed(5)}, ${gpsLocation.longitude.toStringAsFixed(5)}', + ), + ]; + + if (extraSensorData != null) { + for (final key in extraSensorData.keys) { + if (_isTelemetryMetadataKey(key)) { + continue; + } + final metricKey = _parseMetricKey(key); + final fieldKey = _extraFieldKey(key); + final defaultLabel = _formatExtraFieldLabel(key); + options.add( + SensorMetricOption( + key: fieldKey, + label: _selectorMetricLabel( + _resolvedMetricLabel( + fieldKey, + defaultLabel, + labelOverrides: labelOverrides, + ), + metricKey.channel, + ), + defaultLabel: defaultLabel, + channel: metricKey.channel, + valuePreview: _sensorMetricPreviewValue(key, extraSensorData[key]), + ), + ); + } + } + + return options; +} + +Set sensorMetricKeysFor(Contact? contact) { + return sensorMetricOptionsFor(contact).map((option) => option.key).toSet(); +} + +Map sensorDefaultFieldSpans(Iterable fieldKeys) { + final spans = {}; + if (fieldKeys.contains('gps')) { + spans['gps'] = 2; + } + return spans; +} + +Map sensorFullWidthFieldSpans(Iterable fieldKeys) { + return {for (final fieldKey in fieldKeys) fieldKey: 2}; +} + +String? _sensorMetricPreviewValue(String rawKey, dynamic value) { + final metricKey = _parseMetricKey(rawKey); + + switch (metricKey.baseKey) { + case 'altitude': + final meters = _previewAsDouble(value); + if (meters == null) return null; + return '${_formatPreviewNumber(meters, maxFractionDigits: 1)} m'; + + case 'illuminance': + final lux = _previewAsDouble(value); + if (lux == null) return null; + return '${_formatPreviewNumber(lux, maxFractionDigits: 0)} lx'; + + case 'presence': + final isPresent = _previewAsBool(value); + if (isPresent == null) return null; + return isPresent ? 'Detected' : 'Clear'; + + case 'digital_input': + case 'digital_output': + final isHigh = _previewAsBool(value); + if (isHigh == null) return null; + return isHigh ? 'High' : 'Low'; + + case 'analog_input': + case 'analog_output': + case 'generic_sensor': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return _formatPreviewNumber(reading, maxFractionDigits: 3); + + case 'accelerometer': + final vector = _previewAsVector3(value); + if (vector == null) return null; + return 'X ${_formatPreviewNumber(vector.x)} • ' + 'Y ${_formatPreviewNumber(vector.y)} • ' + 'Z ${_formatPreviewNumber(vector.z)} g'; + + case 'gyrometer': + final vector = _previewAsVector3(value); + if (vector == null) return null; + return 'X ${_formatPreviewNumber(vector.x)} • ' + 'Y ${_formatPreviewNumber(vector.y)} • ' + 'Z ${_formatPreviewNumber(vector.z)} deg/s'; + + case 'current': + final amps = _previewAsDouble(value); + if (amps == null) return null; + return _formatPreviewCurrent(amps); + + case 'frequency': + final hertz = _previewAsDouble(value); + if (hertz == null) return null; + return _formatPreviewFrequency(hertz); + + case 'percentage': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return '${_formatPreviewNumber(reading, maxFractionDigits: 1)}%'; + + case 'concentration': + case 'co2': + case 'tvoc': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return '${_formatPreviewNumber(reading, maxFractionDigits: 0)} ppm'; + + case 'power': + final watts = _previewAsDouble(value); + if (watts == null) return null; + return _formatPreviewPower(watts); + + case 'speed': + final metersPerSecond = _previewAsDouble(value); + if (metersPerSecond == null) return null; + return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; + + case 'distance': + final meters = _previewAsDouble(value); + if (meters == null) return null; + return _formatPreviewDistance(meters); + + case 'energy': + final kilowattHours = _previewAsDouble(value); + if (kilowattHours == null) return null; + return _formatPreviewEnergy(kilowattHours); + + case 'direction': + final degrees = _previewAsDouble(value); + if (degrees == null) return null; + return '${_formatPreviewNumber(degrees, maxFractionDigits: 0)} deg'; + + case 'unixtime': + final seconds = _previewAsInt(value); + if (seconds == null) return null; + final timestamp = DateTime.fromMillisecondsSinceEpoch( + seconds * 1000, + isUtc: true, + ).toLocal(); + return _formatPreviewTelemetryDateTime(timestamp); + + case 'colour': + final color = _previewAsRgb(value); + if (color == null) return null; + return '#${color.r.toRadixString(16).padLeft(2, '0').toUpperCase()}' + '${color.g.toRadixString(16).padLeft(2, '0').toUpperCase()}' + '${color.b.toRadixString(16).padLeft(2, '0').toUpperCase()}'; + + case 'switch': + final isOn = _previewAsBool(value); + if (isOn == null) return null; + return isOn ? 'On' : 'Off'; + + case 'voltage': + final volts = _previewAsDouble(value); + if (volts == null) return null; + return '${_formatPreviewNumber(volts, maxFractionDigits: 3)} V'; + + case 'pm25': + case 'pm10': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return '${_formatPreviewNumber(reading, maxFractionDigits: 1)} ug/m3'; + + case 'uv': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return _formatPreviewNumber(reading, maxFractionDigits: 1); + } + + if (value is num) { + return _formatPreviewNumber(value, maxFractionDigits: 2); + } + + if (value is Map) { + return value.entries + .map((entry) => '${entry.key} ${entry.value}') + .join(' • '); + } + + return value?.toString(); +} + +_Vector3? _previewAsVector3(dynamic value) { + if (value is! Map) return null; + final x = _previewAsDouble(value['x']); + final y = _previewAsDouble(value['y']); + final z = _previewAsDouble(value['z']); + if (x == null || y == null || z == null) return null; + return _Vector3(x: x, y: y, z: z); +} + +_RgbColor? _previewAsRgb(dynamic value) { + if (value is! Map) return null; + final red = _previewAsInt(value['r']); + final green = _previewAsInt(value['g']); + final blue = _previewAsInt(value['b']); + if (red == null || green == null || blue == null) return null; + return _RgbColor(r: red, g: green, b: blue); +} + +double? _previewAsDouble(dynamic value) { + if (value is num) return value.toDouble(); + return null; +} + +int? _previewAsInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.round(); + return null; +} + +bool? _previewAsBool(dynamic value) { + if (value is bool) return value; + if (value is num) return value != 0; + return null; +} + +String _formatPreviewCurrent(double amps) { + final absolute = amps.abs(); + if (absolute < 1.0) { + return '${_formatPreviewNumber(amps * 1000, maxFractionDigits: 1)} mA'; + } + return '${_formatPreviewNumber(amps, maxFractionDigits: 3)} A'; +} + +String _formatPreviewPower(double watts) { + final absolute = watts.abs(); + if (absolute < 1.0) { + return '${_formatPreviewNumber(watts * 1000, maxFractionDigits: 1)} mW'; + } + return '${_formatPreviewNumber(watts, maxFractionDigits: 2)} W'; +} + +String _formatPreviewFrequency(double hertz) { + final absolute = hertz.abs(); + if (absolute >= 1000000) { + return '${_formatPreviewNumber(hertz / 1000000, maxFractionDigits: 2)} MHz'; + } + if (absolute >= 1000) { + return '${_formatPreviewNumber(hertz / 1000, maxFractionDigits: 2)} kHz'; + } + return '${_formatPreviewNumber(hertz, maxFractionDigits: 0)} Hz'; +} + +String _formatPreviewDistance(double meters) { + final absolute = meters.abs(); + if (absolute < 1.0) { + return '${_formatPreviewNumber(meters * 1000, maxFractionDigits: 0)} mm'; + } + if (absolute >= 1000.0) { + return '${_formatPreviewNumber(meters / 1000, maxFractionDigits: 2)} km'; + } + return '${_formatPreviewNumber(meters, maxFractionDigits: 2)} m'; +} + +String _formatPreviewEnergy(double kilowattHours) { + final absolute = kilowattHours.abs(); + if (absolute < 1.0) { + return '${_formatPreviewNumber(kilowattHours * 1000, maxFractionDigits: 1)} Wh'; + } + return '${_formatPreviewNumber(kilowattHours, maxFractionDigits: 3)} kWh'; +} + +String _formatPreviewNumber(num value, {int maxFractionDigits = 2}) { + final absolute = value.abs(); + final digits = absolute >= 100 + ? 0 + : absolute >= 10 + ? math.min(maxFractionDigits, 1) + : maxFractionDigits; + final text = value.toStringAsFixed(digits); + return text.replaceFirst(RegExp(r'\.?0+$'), ''); +} + +String _formatPreviewTelemetryDateTime(DateTime timestamp) { + final local = timestamp.toLocal(); + final year = local.year.toString().padLeft(4, '0'); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '$year-$month-$day $hour:$minute'; +} + +class SensorTelemetryCard extends StatelessWidget { + final Contact? contact; + final SensorRefreshState state; + final Set visibleFields; + final List? fieldOrder; + final Map fieldSpans; + final Future Function()? onRemove; + final Future Function()? onRefresh; + final VoidCallback? onCustomize; + final EdgeInsetsGeometry margin; + final String emptyMetricsMessage; + final Map labelOverrides; + + const SensorTelemetryCard({ + super.key, + required this.contact, + required this.state, + required this.visibleFields, + this.fieldOrder, + required this.fieldSpans, + this.onRemove, + this.onRefresh, + this.onCustomize, + this.margin = const EdgeInsets.only(bottom: 16), + this.emptyMetricsMessage = + 'All fields are hidden. Use Visible fields to choose what to show.', + this.labelOverrides = const {}, + }); + + bool get _showsMenu => + onRefresh != null || onCustomize != null || onRemove != null; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final telemetry = contact?.telemetry; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final metrics = contact == null || telemetry == null + ? const <_MetricCardData>[] + : _sortMetricsByFieldOrder( + _buildMetricCards(l10n, telemetry, contact!), + ); + + return Container( + margin: margin, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.surfaceContainerLow, + colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), + ], + ), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.045), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + contact?.displayName ?? 'Unavailable node', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (state == SensorRefreshState.timeout) + const _InlineAlertBadge(label: 'No response'), + ], + ), + if (telemetry != null) ...[ + const SizedBox(height: 2), + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + '${_formatTelemetryDateTime(telemetry.timestamp)} • ${_formatTelemetryTime(telemetry.timestamp)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (state == SensorRefreshState.refreshing) + const _InlineStateMeta( + label: 'Refreshing', + color: Color(0xFF266AC2), + spinning: true, + ), + if (state == SensorRefreshState.success) + const _InlineStateMeta( + label: 'Updated', + color: Color(0xFF218B63), + icon: Icons.check_circle, + ), + if (state == SensorRefreshState.unavailable) + const _InlineStateMeta( + label: 'Unavailable', + color: Color(0xFFB13B55), + icon: Icons.error_outline, + ), + ], + ), + ], + ], + ), + ), + if (_showsMenu) + PopupMenuButton( + onSelected: (value) async { + if (value == 'refresh' && onRefresh != null) { + await onRefresh!(); + } else if (value == 'remove' && onRemove != null) { + await onRemove!(); + } else if (value == 'customize' && onCustomize != null) { + onCustomize!(); + } + }, + itemBuilder: (context) { + final items = >[]; + if (onRefresh != null) { + items.add( + PopupMenuItem( + value: 'refresh', + child: Text(l10n.refresh), + ), + ); + } + if (onCustomize != null) { + items.add( + const PopupMenuItem( + value: 'customize', + child: Text('Customize fields'), + ), + ); + } + if (onRemove != null) { + items.add( + const PopupMenuItem( + value: 'remove', + child: Text('Remove'), + ), + ); + } + return items; + }, + ), + ], + ), + const SizedBox(height: 12), + if (contact == null) + const Text( + 'This node is no longer available in the contact list.', + ) + else if (telemetry == null) + const Text( + 'No telemetry received yet. Use Refresh from the menu or pull down to fetch it.', + ) + else if (metrics.isEmpty) + Text(emptyMetricsMessage) + else + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final compactWidth = (constraints.maxWidth - spacing) / 2; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: metrics + .map( + (metric) => _MetricTile( + data: metric, + width: + (fieldSpans[metric.fieldKey] == 2 || + metric.wide) + ? constraints.maxWidth + : compactWidth, + ), + ) + .toList(), + ); + }, + ), + ], + ), + ), + ); + } + + List<_MetricCardData> _buildMetricCards( + AppLocalizations l10n, + dynamic telemetry, + Contact contact, + ) { + final items = <_MetricCardData>[]; + + if (visibleFields.contains('voltage') && + telemetry.batteryMilliVolts != null) { + items.add( + _MetricCardData( + fieldKey: 'voltage', + icon: Icons.bolt, + label: _resolvedMetricLabel( + 'voltage', + l10n.voltage, + labelOverrides: labelOverrides, + ), + value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V', + accent: const Color(0xFF0A7D61), + channel: _sourceChannelForField(telemetry.extraSensorData, 'voltage'), + ), + ); + } + if (visibleFields.contains('battery') && + telemetry.batteryPercentage != null) { + items.add( + _MetricCardData( + fieldKey: 'battery', + icon: Icons.battery_5_bar, + label: _resolvedMetricLabel( + 'battery', + l10n.battery, + labelOverrides: labelOverrides, + ), + value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%', + accent: const Color(0xFF4B8E2F), + channel: _sourceChannelForField(telemetry.extraSensorData, 'battery'), + ), + ); + } + if (visibleFields.contains('temperature') && + telemetry.temperature != null) { + items.add( + _MetricCardData( + fieldKey: 'temperature', + icon: Icons.thermostat, + label: _resolvedMetricLabel( + 'temperature', + l10n.temperature, + labelOverrides: labelOverrides, + ), + value: '${telemetry.temperature!.toStringAsFixed(1)}°C', + accent: const Color(0xFFC76821), + channel: _sourceChannelForField( + telemetry.extraSensorData, + 'temperature', + ), + ), + ); + } + if (visibleFields.contains('humidity') && telemetry.humidity != null) { + items.add( + _MetricCardData( + fieldKey: 'humidity', + icon: Icons.water_drop, + label: _resolvedMetricLabel( + 'humidity', + l10n.humidity, + labelOverrides: labelOverrides, + ), + value: '${telemetry.humidity!.toStringAsFixed(1)}%', + accent: const Color(0xFF246BB2), + channel: _sourceChannelForField( + telemetry.extraSensorData, + 'humidity', + ), + ), + ); + } + if (visibleFields.contains('pressure') && telemetry.pressure != null) { + items.add( + _MetricCardData( + fieldKey: 'pressure', + icon: Icons.compress, + label: _resolvedMetricLabel( + 'pressure', + l10n.pressure, + labelOverrides: labelOverrides, + ), + value: '${telemetry.pressure!.toStringAsFixed(1)} hPa', + accent: const Color(0xFF6B4BAE), + channel: _sourceChannelForField( + telemetry.extraSensorData, + 'pressure', + ), + ), + ); + } + if (visibleFields.contains('gps') && telemetry.gpsLocation != null) { + items.add( + _MetricCardData( + fieldKey: 'gps', + icon: Icons.place, + label: _resolvedMetricLabel( + 'gps', + l10n.gpsTelemetry, + labelOverrides: labelOverrides, + ), + value: + '${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}', + accent: const Color(0xFFAA3F57), + wide: true, + mapLocation: LatLng( + telemetry.gpsLocation!.latitude, + telemetry.gpsLocation!.longitude, + ), + secondaryValue: formatPlusCode( + telemetry.gpsLocation!.latitude, + telemetry.gpsLocation!.longitude, + ), + channel: _sourceChannelForField(telemetry.extraSensorData, 'gps'), + ), + ); + } + if (telemetry.extraSensorData != null) { + for (final entry in telemetry.extraSensorData!.entries) { + if (_isTelemetryMetadataKey(entry.key)) { + continue; + } + final fieldKey = _extraFieldKey(entry.key); + if (!visibleFields.contains(fieldKey)) { + continue; + } + final metric = _buildExtraMetricCardData(entry.key, entry.value); + if (metric != null) { + items.add(metric); + } + } + } + + return items; + } + + List<_MetricCardData> _sortMetricsByFieldOrder( + List<_MetricCardData> metrics, + ) { + final order = fieldOrder; + if (order == null || order.isEmpty || metrics.length < 2) { + return metrics; + } + + final orderIndex = { + for (var i = 0; i < order.length; i++) order[i]: i, + }; + final indexedMetrics = metrics.asMap().entries.toList(); + indexedMetrics.sort((left, right) { + final leftOrder = orderIndex[left.value.fieldKey] ?? order.length; + final rightOrder = orderIndex[right.value.fieldKey] ?? order.length; + if (leftOrder != rightOrder) { + return leftOrder.compareTo(rightOrder); + } + return left.key.compareTo(right.key); + }); + return indexedMetrics.map((entry) => entry.value).toList(growable: false); + } + + _MetricCardData? _buildExtraMetricCardData(String rawKey, dynamic value) { + final metricKey = _parseMetricKey(rawKey); + final fieldKey = _extraFieldKey(rawKey); + final label = _resolvedMetricLabel( + fieldKey, + _formatExtraFieldLabel(rawKey), + labelOverrides: labelOverrides, + ); + + switch (metricKey.baseKey) { + case 'altitude': + final meters = _asDouble(value); + if (meters == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.terrain_outlined, + label: label, + value: '${_formatNumber(meters, maxFractionDigits: 1)} m', + accent: const Color(0xFF7A5C3E), + channel: metricKey.channel, + ); + + case 'illuminance': + final lux = _asDouble(value); + if (lux == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.light_mode_outlined, + label: label, + value: '${_formatNumber(lux, maxFractionDigits: 0)} lx', + secondaryValue: + '~${_formatNumber(_approxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2 daylight', + accent: const Color(0xFFC17B1D), + channel: metricKey.channel, + ); + + case 'presence': + final isPresent = _asBool(value); + if (isPresent == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sensor_occupied_outlined, + label: label, + value: isPresent ? 'Detected' : 'Clear', + accent: const Color(0xFFAA3F57), + channel: metricKey.channel, + ); + + case 'digital_input': + final isHigh = _asBool(value); + if (isHigh == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.input_outlined, + label: label, + value: isHigh ? 'High' : 'Low', + accent: const Color(0xFF3A6D8C), + channel: metricKey.channel, + ); + + case 'digital_output': + final isHigh = _asBool(value); + if (isHigh == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.output_outlined, + label: label, + value: isHigh ? 'High' : 'Low', + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + + case 'analog_input': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.tune, + label: label, + value: _formatNumber(reading, maxFractionDigits: 3), + accent: const Color(0xFF5A6C84), + channel: metricKey.channel, + ); + + case 'analog_output': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.tune, + label: label, + value: _formatNumber(reading, maxFractionDigits: 3), + accent: const Color(0xFF4B7785), + channel: metricKey.channel, + ); + + case 'accelerometer': + final vector = _asVector3(value); + if (vector == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.vibration_outlined, + label: label, + value: + 'X ${_formatNumber(vector.x)} • Y ${_formatNumber(vector.y)} • Z ${_formatNumber(vector.z)} g', + secondaryValue: + '|a| ${_formatNumber(_vectorMagnitude(vector), maxFractionDigits: 2)} g', + accent: const Color(0xFF5A4C99), + wide: true, + channel: metricKey.channel, + ); + + case 'gyrometer': + final vector = _asVector3(value); + if (vector == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.threed_rotation, + label: label, + value: + 'X ${_formatNumber(vector.x)} • Y ${_formatNumber(vector.y)} • Z ${_formatNumber(vector.z)} deg/s', + secondaryValue: + '|w| ${_formatNumber(_vectorMagnitude(vector), maxFractionDigits: 2)} deg/s', + accent: const Color(0xFF6C4F96), + wide: true, + channel: metricKey.channel, + ); + + case 'generic_sensor': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sensors, + label: label, + value: _formatNumber(reading, maxFractionDigits: 2), + accent: const Color(0xFF3E657C), + channel: metricKey.channel, + ); + + case 'current': + final amps = _asDouble(value); + if (amps == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.electric_bolt, + label: label, + value: _formatCurrent(amps), + accent: const Color(0xFF1C7C54), + channel: metricKey.channel, + ); + + case 'frequency': + final hz = _asDouble(value); + if (hz == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.graphic_eq, + label: label, + value: _formatFrequency(hz), + accent: const Color(0xFF2C6BA0), + channel: metricKey.channel, + ); + + case 'percentage': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.percent, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 1)}%', + accent: const Color(0xFF4B8E2F), + channel: metricKey.channel, + ); + + case 'concentration': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.bubble_chart_outlined, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 0)} ppm', + accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, + ); + + case 'power': + final watts = _asDouble(value); + if (watts == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.flash_on_outlined, + label: label, + value: _formatPower(watts), + accent: const Color(0xFFB5622E), + channel: metricKey.channel, + ); + + case 'speed': + final metersPerSecond = _asDouble(value); + if (metersPerSecond == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.air, + label: label, + value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', + accent: const Color(0xFF2B78A0), + channel: metricKey.channel, + ); + + case 'distance': + final meters = _asDouble(value); + if (meters == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.straighten, + label: label, + value: _formatDistance(meters), + accent: const Color(0xFF577590), + channel: metricKey.channel, + ); + + case 'energy': + final kwh = _asDouble(value); + if (kwh == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.battery_charging_full, + label: label, + value: _formatEnergy(kwh), + accent: const Color(0xFF9C6644), + channel: metricKey.channel, + ); + + case 'direction': + final degrees = _asDouble(value); + if (degrees == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.explore_outlined, + label: label, + value: '${_formatNumber(degrees, maxFractionDigits: 0)} deg', + secondaryValue: _formatCardinalDirection(degrees), + accent: const Color(0xFF8A5A44), + channel: metricKey.channel, + ); + + case 'unixtime': + final seconds = _asInt(value); + if (seconds == null) return null; + final timestamp = DateTime.fromMillisecondsSinceEpoch( + seconds * 1000, + isUtc: true, + ).toLocal(); + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.schedule, + label: label, + value: _formatTelemetryDateTime(timestamp), + secondaryValue: _formatTelemetryTime(timestamp), + accent: const Color(0xFF6B7280), + wide: true, + channel: metricKey.channel, + ); + + case 'colour': + final color = _asRgb(value); + if (color == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.palette_outlined, + label: label, + value: + '#${color.r.toRadixString(16).padLeft(2, '0').toUpperCase()}${color.g.toRadixString(16).padLeft(2, '0').toUpperCase()}${color.b.toRadixString(16).padLeft(2, '0').toUpperCase()}', + secondaryValue: 'R ${color.r} • G ${color.g} • B ${color.b}', + accent: Color.fromARGB(255, color.r, color.g, color.b), + wide: true, + channel: metricKey.channel, + ); + + case 'switch': + final isOn = _asBool(value); + if (isOn == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: isOn ? Icons.toggle_on : Icons.toggle_off, + label: label, + value: isOn ? 'On' : 'Off', + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + + case 'voltage': + final volts = _asDouble(value); + if (volts == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.bolt, + label: label, + value: '${_formatNumber(volts, maxFractionDigits: 3)} V', + accent: const Color(0xFF0A7D61), + channel: metricKey.channel, + ); + } + + switch (metricKey.baseKey) { + case 'co2': + case 'tvoc': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.bubble_chart_outlined, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 0)} ppm', + accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, + ); + + case 'pm25': + case 'pm10': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.grain, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 1)} ug/m3', + accent: const Color(0xFF7A6C5D), + channel: metricKey.channel, + ); + + case 'uv': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.wb_sunny_outlined, + label: label, + value: _formatNumber(reading, maxFractionDigits: 1), + accent: const Color(0xFFC17B1D), + channel: metricKey.channel, + ); + } + + if (value is num) { + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sensors, + label: label, + value: _formatNumber(value, maxFractionDigits: 2), + accent: const Color(0xFF3E657C), + channel: metricKey.channel, + ); + } + + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sensors, + label: label, + value: '$value', + accent: const Color(0xFF3E657C), + wide: value is Map, + channel: metricKey.channel, + ); + } + + double _approxDaylightIrradiance(double lux) { + return lux / 120.0; + } + + String _formatCurrent(double amps) { + final absolute = amps.abs(); + if (absolute < 1.0) { + return '${_formatNumber(amps * 1000, maxFractionDigits: 1)} mA'; + } + return '${_formatNumber(amps, maxFractionDigits: 3)} A'; + } + + String _formatPower(double watts) { + final absolute = watts.abs(); + if (absolute < 1.0) { + return '${_formatNumber(watts * 1000, maxFractionDigits: 1)} mW'; + } + return '${_formatNumber(watts, maxFractionDigits: 2)} W'; + } + + String _formatFrequency(double hertz) { + final absolute = hertz.abs(); + if (absolute >= 1000000) { + return '${_formatNumber(hertz / 1000000, maxFractionDigits: 2)} MHz'; + } + if (absolute >= 1000) { + return '${_formatNumber(hertz / 1000, maxFractionDigits: 2)} kHz'; + } + return '${_formatNumber(hertz, maxFractionDigits: 0)} Hz'; + } + + String _formatDistance(double meters) { + final absolute = meters.abs(); + if (absolute < 1.0) { + return '${_formatNumber(meters * 1000, maxFractionDigits: 0)} mm'; + } + if (absolute >= 1000.0) { + return '${_formatNumber(meters / 1000, maxFractionDigits: 2)} km'; + } + return '${_formatNumber(meters, maxFractionDigits: 2)} m'; + } + + String _formatEnergy(double kilowattHours) { + final absolute = kilowattHours.abs(); + if (absolute < 1.0) { + return '${_formatNumber(kilowattHours * 1000, maxFractionDigits: 1)} Wh'; + } + return '${_formatNumber(kilowattHours, maxFractionDigits: 3)} kWh'; + } + + String _formatCardinalDirection(double degrees) { + const points = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + final normalized = ((degrees % 360) + 360) % 360; + final index = ((normalized + 22.5) ~/ 45) % points.length; + return points[index]; + } + + String _formatNumber(num value, {int maxFractionDigits = 2}) { + final absolute = value.abs(); + final digits = absolute >= 100 + ? 0 + : absolute >= 10 + ? math.min(maxFractionDigits, 1) + : maxFractionDigits; + final text = value.toStringAsFixed(digits); + return text.replaceFirst(RegExp(r'\.?0+$'), ''); + } + + _Vector3? _asVector3(dynamic value) { + if (value is! Map) return null; + final x = _asDouble(value['x']); + final y = _asDouble(value['y']); + final z = _asDouble(value['z']); + if (x == null || y == null || z == null) return null; + return _Vector3(x: x, y: y, z: z); + } + + _RgbColor? _asRgb(dynamic value) { + if (value is! Map) return null; + final red = _asInt(value['r']); + final green = _asInt(value['g']); + final blue = _asInt(value['b']); + if (red == null || green == null || blue == null) return null; + return _RgbColor(r: red, g: green, b: blue); + } + + double? _asDouble(dynamic value) { + if (value is num) return value.toDouble(); + return null; + } + + int? _asInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.round(); + return null; + } + + bool? _asBool(dynamic value) { + if (value is bool) return value; + if (value is num) return value != 0; + return null; + } + + double _vectorMagnitude(_Vector3 vector) { + return math.sqrt( + vector.x * vector.x + vector.y * vector.y + vector.z * vector.z, + ); + } + + String _formatTelemetryTime(DateTime timestamp) { + final diff = DateTime.now().difference(timestamp); + if (diff.inMinutes < 1) return 'now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } + + String _formatTelemetryDateTime(DateTime timestamp) { + final local = timestamp.toLocal(); + final year = local.year.toString().padLeft(4, '0'); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '$year-$month-$day $hour:$minute'; + } +} + +class _InlineStateMeta extends StatelessWidget { + final String label; + final Color color; + final IconData? icon; + final bool spinning; + + const _InlineStateMeta({ + required this.label, + required this.color, + this.icon, + this.spinning = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (spinning) + SizedBox( + width: 11, + height: 11, + child: CircularProgressIndicator( + strokeWidth: 1.7, + valueColor: AlwaysStoppedAnimation(color), + ), + ) + else if (icon != null) + Icon(icon, size: 11, color: color), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _InlineAlertBadge extends StatelessWidget { + final String label; + + const _InlineAlertBadge({required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFC17B1D).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: const Color(0xFFC17B1D), + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _MetricTile extends StatelessWidget { + final _MetricCardData data; + final double width; + + const _MetricTile({required this.data, required this.width}); + + Future _showExpandedMap(BuildContext context) async { + final location = data.mapLocation; + if (location == null) return; + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (pageContext) { + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(data.label), + Text( + data.value, + style: Theme.of(pageContext).textTheme.bodySmall, + ), + ], + ), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (data.secondaryValue != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + data.secondaryValue!, + style: Theme.of(pageContext).textTheme.bodyMedium, + ), + ), + Expanded( + child: flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCenter: location, + initialZoom: 15, + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: + 'com.meshcore.sar.meshcore_sar_app', + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: location, + width: 40, + height: 40, + child: Icon( + Icons.location_on, + color: data.accent, + size: 34, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + }, + fullscreenDialog: true, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + key: ValueKey('sensor_metric_${data.fieldKey}'), + width: width, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: data.accent.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: data.accent.withValues(alpha: 0.14)), + ), + child: data.mapLocation == null + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MetricIcon(accent: data.accent, icon: data.icon), + const SizedBox(width: 10), + Expanded(child: _MetricText(data: data)), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MetricIcon(accent: data.accent, icon: data.icon), + const SizedBox(width: 10), + Expanded(child: _MetricText(data: data)), + ], + ), + const SizedBox(height: 10), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => _showExpandedMap(context), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: SizedBox( + height: 104, + width: double.infinity, + child: Stack( + children: [ + flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCenter: data.mapLocation!, + initialZoom: 14, + interactionOptions: + const flutter_map.InteractionOptions( + flags: flutter_map.InteractiveFlag.none, + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: + 'com.meshcore.sar.meshcore_sar_app', + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: data.mapLocation!, + width: 32, + height: 32, + child: Icon( + Icons.location_on, + color: data.accent, + size: 28, + ), + ), + ], + ), + ], + ), + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(999), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.open_in_full, + size: 12, + color: Colors.white, + ), + SizedBox(width: 4), + Text( + 'Open map', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _MetricIcon extends StatelessWidget { + final Color accent; + final IconData icon; + + const _MetricIcon({required this.accent, required this.icon}); + + @override + Widget build(BuildContext context) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: accent, size: 18), + ); + } +} + +class _MetricText extends StatelessWidget { + final _MetricCardData data; + + const _MetricText({required this.data}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + data.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: data.accent, + fontWeight: FontWeight.w700, + ), + ), + ), + if (data.channel != null) ...[ + const SizedBox(width: 8), + Container( + key: ValueKey('sensor_metric_channel_${data.fieldKey}'), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: data.accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'ch${data.channel}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: data.accent, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 4), + Text( + data.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + if (data.secondaryValue != null) ...[ + const SizedBox(height: 4), + Text( + data.secondaryValue!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ); + } +} + +class _MetricCardData { + final String fieldKey; + final IconData icon; + final String label; + final String value; + final String? secondaryValue; + final Color accent; + final bool wide; + final LatLng? mapLocation; + final int? channel; + + const _MetricCardData({ + required this.fieldKey, + required this.icon, + required this.label, + required this.value, + this.secondaryValue, + required this.accent, + this.wide = false, + this.mapLocation, + this.channel, + }); +} + +class _ParsedMetricKey { + final String baseKey; + final int? channel; + + const _ParsedMetricKey({required this.baseKey, this.channel}); +} + +class _Vector3 { + final double x; + final double y; + final double z; + + const _Vector3({required this.x, required this.y, required this.z}); +} + +class _RgbColor { + final int r; + final int g; + final int b; + + const _RgbColor({required this.r, required this.g, required this.b}); +} + +const String _telemetrySourceChannelPrefix = '__source_channel:'; + +String _extraFieldKey(String label) { + return 'extra:$label'; +} + +bool _isTelemetryMetadataKey(String key) { + return key.startsWith(_telemetrySourceChannelPrefix); +} + +String _telemetrySourceChannelKey(String fieldKey) { + return '$_telemetrySourceChannelPrefix$fieldKey'; +} + +int? _sourceChannelForField( + Map? extraSensorData, + String fieldKey, +) { + final value = extraSensorData?[_telemetrySourceChannelKey(fieldKey)]; + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return null; +} + +String _resolvedMetricLabel( + String fieldKey, + String defaultLabel, { + Map labelOverrides = const {}, +}) { + final override = labelOverrides[fieldKey]?.trim(); + if (override == null || override.isEmpty) { + return defaultLabel; + } + return override; +} + +String _selectorMetricLabel(String label, int? channel) { + if (channel == null) { + return label; + } + return '$label (ch $channel)'; +} + +String _formatExtraFieldLabel(String rawKey) { + final metricKey = _parseMetricKey(rawKey); + return _knownMetricLabels[metricKey.baseKey] ?? + _fallbackMetricLabel(metricKey.baseKey); +} + +const List _knownMetricBaseKeys = [ + 'generic_sensor', + 'digital_output', + 'digital_input', + 'analog_output', + 'analog_input', + 'accelerometer', + 'illuminance', + 'concentration', + 'percentage', + 'direction', + 'frequency', + 'distance', + 'altitude', + 'humidity', + 'pressure', + 'temperature', + 'gyrometer', + 'unixtime', + 'presence', + 'current', + 'voltage', + 'colour', + 'switch', + 'energy', + 'power', + 'speed', + 'pm25', + 'pm10', + 'tvoc', + 'co2', + 'rpm', + 'cond', + 'uv', +]; + +const Map _knownMetricLabels = { + 'accelerometer': 'Accelerometer', + 'altitude': 'Altitude', + 'analog_input': 'Analog input', + 'analog_output': 'Analog output', + 'co2': 'CO2', + 'colour': 'Color', + 'concentration': 'Concentration', + 'cond': 'Conductivity', + 'current': 'Current', + 'digital_input': 'Digital input', + 'digital_output': 'Digital output', + 'direction': 'Direction', + 'distance': 'Distance', + 'energy': 'Energy', + 'frequency': 'Frequency', + 'generic_sensor': 'Generic sensor', + 'gyrometer': 'Gyrometer', + 'humidity': 'Humidity', + 'illuminance': 'Illuminance', + 'percentage': 'Percentage', + 'pm10': 'PM10', + 'pm25': 'PM2.5', + 'power': 'Power', + 'presence': 'Presence', + 'pressure': 'Pressure', + 'rpm': 'RPM', + 'speed': 'Speed', + 'switch': 'Switch', + 'temperature': 'Temperature', + 'tvoc': 'TVOC', + 'unixtime': 'Time', + 'uv': 'UV index', + 'voltage': 'Voltage', +}; + +_ParsedMetricKey _parseMetricKey(String rawKey) { + for (final baseKey in _knownMetricBaseKeys) { + if (rawKey == baseKey) { + return _ParsedMetricKey(baseKey: baseKey); + } + if (rawKey.startsWith('${baseKey}_')) { + final channel = int.tryParse(rawKey.substring(baseKey.length + 1)); + if (channel != null) { + return _ParsedMetricKey(baseKey: baseKey, channel: channel); + } + } + } + + final parts = rawKey.split('_'); + if (parts.length > 1) { + final channel = int.tryParse(parts.last); + if (channel != null) { + return _ParsedMetricKey( + baseKey: parts.sublist(0, parts.length - 1).join('_'), + channel: channel, + ); + } + } + + return _ParsedMetricKey(baseKey: rawKey); +} + +String _fallbackMetricLabel(String rawKey) { + return rawKey + .split('_') + .where((part) => part.isNotEmpty) + .map((part) => '${part[0].toUpperCase()}${part.substring(1)}') + .join(' '); +} diff --git a/pubspec.lock b/pubspec.lock index af69e4f..f261013 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -631,14 +631,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" - http_methods: - dependency: transitive - description: - name: http_methods - sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" - url: "https://pub.dev" - source: hosted - version: "1.1.1" http_parser: dependency: transitive description: @@ -795,8 +787,8 @@ packages: dependency: "direct main" description: path: "." - ref: "813f5b3" - resolved-ref: "813f5b3e0b9d2ea6b85a428be443bf5e6a38c6c5" + ref: f600789fac7f743b1c7db8aa24e441405413f669 + resolved-ref: f600789fac7f743b1c7db8aa24e441405413f669 url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0" @@ -1208,22 +1200,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" - shelf: - dependency: "direct main" - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" - source: hosted - version: "1.4.2" - shelf_router: - dependency: "direct main" - description: - name: shelf_router - sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 - url: "https://pub.dev" - source: hosted - version: "1.1.4" simple_sparse_list: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f967c03..68ed14c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: meshcore_client: git: url: https://github.com/dz0ny/meshcore_client.git - ref: "813f5b3" + ref: "f600789fac7f743b1c7db8aa24e441405413f669" # Codec2 ultra-low-bitrate speech codec (FFI plugin) codec2_flutter: @@ -118,10 +118,6 @@ dependencies: # XML parsing for GPX import/export xml: ^6.5.0 - # SSE web server for multi-user support - shelf: ^1.4.0 - shelf_router: ^1.1.0 - # Network Service Discovery (Bonjour/mDNS) nsd: ^4.0.3 diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index 688b86a..384a17f 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -142,6 +142,7 @@ void main() { final contactTypes = [ ContactType.chat, ContactType.repeater, + ContactType.sensor, ContactType.room, ContactType.channel, ]; diff --git a/test/providers/sensors_provider_test.dart b/test/providers/sensors_provider_test.dart new file mode 100644 index 0000000..f5ed43d --- /dev/null +++ b/test/providers/sensors_provider_test.dart @@ -0,0 +1,194 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/device_info.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/sensors_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeContactsProvider extends ContactsProvider { + _FakeContactsProvider(this._contacts); + + final List _contacts; + + @override + List get contacts => _contacts; +} + +class _FakeConnectionProvider extends ConnectionProvider { + _FakeConnectionProvider({required bool isConnected}) + : _isConnected = isConnected; + + final bool _isConnected; + + int pingCalls = 0; + + @override + DeviceInfo get deviceInfo => DeviceInfo( + connectionState: _isConnected + ? ConnectionState.connected + : ConnectionState.disconnected, + ); + + @override + Future smartPing({ + required Uint8List contactPublicKey, + required bool hasPath, + Function()? onRetryWithFlooding, + }) async { + pingCalls += 1; + return const PingResult( + success: true, + usedFlooding: false, + timedOut: false, + ); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future waitUntilLoaded(SensorsProvider provider) async { + for (var i = 0; i < 20 && !provider.isLoaded; i++) { + await Future.delayed(Duration.zero); + } + expect(provider.isLoaded, isTrue); + } + + Contact buildSensorContact() { + final publicKey = Uint8List(32); + publicKey[0] = 0x44; + + return Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + + test('metric label overrides persist across reloads', () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildSensorContact(); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + await provider.setMetricLabel( + contact.publicKeyHex, + 'extra:illuminance_2', + 'Solar', + ); + + expect( + provider.labelOverrideFor(contact.publicKeyHex, 'extra:illuminance_2'), + 'Solar', + ); + + final reloadedProvider = SensorsProvider(); + await waitUntilLoaded(reloadedProvider); + + expect( + reloadedProvider.labelOverrideFor( + contact.publicKeyHex, + 'extra:illuminance_2', + ), + 'Solar', + ); + }); + + test('metric order persists across reloads', () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildSensorContact(); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + await provider.moveMetric( + contact.publicKeyHex, + availableFieldKeys: const ['voltage', 'battery', 'temperature'], + oldIndex: 2, + newIndex: 0, + ); + + expect( + provider.metricOrderFor(contact.publicKeyHex, const [ + 'voltage', + 'battery', + 'temperature', + ]), + const ['temperature', 'voltage', 'battery'], + ); + + final reloadedProvider = SensorsProvider(); + await waitUntilLoaded(reloadedProvider); + + expect( + reloadedProvider.metricOrderFor(contact.publicKeyHex, const [ + 'voltage', + 'battery', + 'temperature', + ]), + const ['temperature', 'voltage', 'battery'], + ); + }); + + test('auto refresh minutes persist across reloads', () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildSensorContact(); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + await provider.setAutoRefreshMinutes(contact.publicKeyHex, 5); + + expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 5); + + final reloadedProvider = SensorsProvider(); + await waitUntilLoaded(reloadedProvider); + + expect(reloadedProvider.autoRefreshMinutesFor(contact.publicKeyHex), 5); + }); + + test('refreshDueSensors respects per-contact interval', () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildSensorContact(); + final contactsProvider = _FakeContactsProvider([contact]); + final connectionProvider = _FakeConnectionProvider(isConnected: true); + final start = DateTime(2026, 3, 15, 9, 0); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + await provider.setAutoRefreshMinutes(contact.publicKeyHex, 5); + + await provider.refreshDueSensors( + now: start, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 1); + + await provider.refreshDueSensors( + now: start.add(const Duration(minutes: 4)), + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 1); + + await provider.refreshDueSensors( + now: start.add(const Duration(minutes: 5)), + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 2); + }); +} diff --git a/test/screens/contacts_tab_test.dart b/test/screens/contacts_tab_test.dart index 66e9033..23541a9 100644 --- a/test/screens/contacts_tab_test.dart +++ b/test/screens/contacts_tab_test.dart @@ -57,6 +57,25 @@ void main() { ); } + Contact buildSensor({required int seed, required String name}) { + final publicKey = Uint8List(32); + publicKey[0] = seed; + publicKey[1] = seed + 1; + + return Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: -1, + outPath: Uint8List(0), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 46056000 + seed, + advLon: 14505000 + seed, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + Future pumpContactsTab( WidgetTester tester, { List contacts = const [], @@ -192,4 +211,14 @@ void main() { expect(find.text('Others'), findsNothing); expect(find.text('Lone Relay'), findsOneWidget); }); + + testWidgets('sensor contacts render in their own section', (tester) async { + await pumpContactsTab( + tester, + contacts: [buildSensor(seed: 60, name: 'WX Station')], + ); + + expect(find.text('Sensors'), findsOneWidget); + expect(find.text('WX Station'), findsOneWidget); + }); } diff --git a/test/screens/sensors_tab_test.dart b/test/screens/sensors_tab_test.dart new file mode 100644 index 0000000..e55d576 --- /dev/null +++ b/test/screens/sensors_tab_test.dart @@ -0,0 +1,100 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/sensors_provider.dart'; +import 'package:meshcore_sar_app/screens/sensors_tab.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future waitUntilLoaded(SensorsProvider provider) async { + for (var i = 0; i < 20 && !provider.isLoaded; i++) { + await Future.delayed(Duration.zero); + } + expect(provider.isLoaded, isTrue); + } + + Contact buildSensorContact() { + final publicKey = Uint8List(32); + publicKey[0] = 0x44; + + return Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + batteryPercentage: 84, + temperature: 21.5, + extraSensorData: const { + '__source_channel:battery': 1, + '__source_channel:temperature': 1, + 'illuminance_2': 500.0, + }, + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + } + + testWidgets('customize sheet shows metric value previews and channels', ( + tester, + ) async { + final contact = buildSensorContact(); + final sensorsProvider = SensorsProvider(); + final contactsProvider = ContactsProvider(); + + await waitUntilLoaded(sensorsProvider); + contactsProvider.addOrUpdateContact(contact); + await sensorsProvider.addSensor(contact); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: contactsProvider, + ), + ChangeNotifierProvider.value(value: sensorsProvider), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const SensorsTab(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Customize fields')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect( + find.byKey(const ValueKey('sensor_selector_value_extra:illuminance_2')), + findsOneWidget, + ); + expect(find.text('500 lx'), findsOneWidget); + expect( + find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')), + findsOneWidget, + ); + expect(find.text('ch2'), findsOneWidget); + }); +} diff --git a/test/services/cayenne_lpp_parser_test.dart b/test/services/cayenne_lpp_parser_test.dart index 1a45ae2..82379e3 100644 --- a/test/services/cayenne_lpp_parser_test.dart +++ b/test/services/cayenne_lpp_parser_test.dart @@ -360,6 +360,78 @@ void main() { expect(accel['z'], closeTo(2.0, 0.001)); }); + test('extended MeshCore LPP types decode to structured extra data', () { + const lppGenericSensor = 100; + const lppCurrent = 117; + const lppFrequency = 118; + const lppAltitude = 121; + const lppConcentration = 125; + const lppPower = 128; + const lppSpeed = 129; + const lppDistance = 130; + const lppEnergy = 131; + const lppDirection = 132; + const lppUnixTime = 133; + const lppColour = 135; + const lppSwitch = 142; + + final payload = Uint8List.fromList([ + 2, lppGenericSensor, 0x00, 0x00, 0x01, 0x2C, // 300 + 3, lppCurrent, 0x00, 0x0F, // 0.015 A + 4, lppFrequency, 0x00, 0x00, 0x03, 0xE8, // 1000 Hz + 5, lppAltitude, 0x01, 0xF4, // 500 m + 6, lppConcentration, 0x01, 0x9F, // 415 ppm + 7, lppPower, 0x00, 0xFA, // 250 W + 8, lppSpeed, 0x04, 0xD2, // 12.34 m/s + 9, lppDistance, 0x00, 0x00, 0x04, 0xD2, // 1.234 m + 10, lppEnergy, 0x00, 0x00, 0x04, 0xD2, // 1.234 kWh + 11, lppDirection, 0x01, 0x0E, // 270 deg + 12, lppUnixTime, 0x65, 0xF0, 0x00, 0x00, // 1710221312 + 13, lppColour, 0xFF, 0x80, 0x40, // #FF8040 + 14, lppSwitch, 0x01, // on + ]); + + final decoded = CayenneLppParser.parse(payload); + + expect(decoded.extraSensorData, isNotNull); + expect(decoded.extraSensorData!['generic_sensor_2'], equals(300.0)); + expect(decoded.extraSensorData!['current_3'], closeTo(0.015, 0.0001)); + expect(decoded.extraSensorData!['frequency_4'], equals(1000.0)); + expect(decoded.extraSensorData!['altitude_5'], equals(500.0)); + expect(decoded.extraSensorData!['concentration_6'], equals(415.0)); + expect(decoded.extraSensorData!['power_7'], equals(250.0)); + expect(decoded.extraSensorData!['speed_8'], closeTo(12.34, 0.001)); + expect(decoded.extraSensorData!['distance_9'], closeTo(1.234, 0.0001)); + expect(decoded.extraSensorData!['energy_10'], closeTo(1.234, 0.0001)); + expect(decoded.extraSensorData!['direction_11'], equals(270.0)); + expect(decoded.extraSensorData!['unixtime_12'], equals(1710227456)); + expect( + decoded.extraSensorData!['colour_13'], + equals({'r': 255, 'g': 128, 'b': 64}), + ); + expect(decoded.extraSensorData!['switch_14'], equals(1)); + }); + + test( + 'percentage battery and non-battery voltage channels are preserved separately', + () { + const lppPercentage = 120; + + final payload = Uint8List.fromList([ + 1, lppPercentage, 66, // battery % + 2, MeshCoreConstants.lppVoltageSensor, 0x01, 0x81, // 3.85 V + 2, MeshCoreConstants.lppTemperatureSensor, 0x00, 0xEB, // 23.5 C + ]); + + final decoded = CayenneLppParser.parse(payload); + + expect(decoded.batteryPercentage, equals(66.0)); + expect(decoded.extraSensorData!['voltage_2'], closeTo(3.85, 0.001)); + expect(decoded.temperature, closeTo(23.5, 0.1)); + expect(decoded.extraSensorData!['temperature_2'], closeTo(23.5, 0.1)); + }, + ); + test('unknown sensor type is skipped gracefully', () { final buffer = ByteData(5); buffer.setUint8(0, 0); diff --git a/test/services/notification_service_test.dart b/test/services/notification_service_test.dart new file mode 100644 index 0000000..cfd45c2 --- /dev/null +++ b/test/services/notification_service_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/services/notification_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('discovery notification preference persists independently', () async { + final service = NotificationService(); + + await service.setMessageNotificationsEnabled(true); + await service.setDiscoveryNotificationsEnabled(false); + + final prefs = await SharedPreferences.getInstance(); + + expect(service.messageNotificationsEnabled, isTrue); + expect(service.discoveryNotificationsEnabled, isFalse); + expect(prefs.getBool('notifications_messages_enabled'), isTrue); + expect(prefs.getBool('notifications_discovery_enabled'), isFalse); + }); +} diff --git a/test/widgets/contact_avatar_test.dart b/test/widgets/contact_avatar_test.dart index 4859cb5..842524e 100644 --- a/test/widgets/contact_avatar_test.dart +++ b/test/widgets/contact_avatar_test.dart @@ -31,7 +31,9 @@ void main() { Future pumpAvatar(WidgetTester tester, Contact contact) async { await tester.pumpWidget( MaterialApp( - home: Scaffold(body: Center(child: ContactAvatar(contact: contact))), + home: Scaffold( + body: Center(child: ContactAvatar(contact: contact)), + ), ), ); } @@ -85,7 +87,11 @@ void main() { testWidgets('renders non-hash label avatar for channels', (tester) async { await pumpAvatar( tester, - buildContact(name: 'Command Net', type: ContactType.channel, secondByte: 3), + buildContact( + name: 'Command Net', + type: ContactType.channel, + secondByte: 3, + ), ); expect(find.text('CN'), findsOneWidget); @@ -103,4 +109,15 @@ void main() { expect(find.byType(CircleAvatar), findsOneWidget); expect(find.byIcon(Icons.person), findsNothing); }); + + testWidgets('renders sensor icon avatar for sensor contacts', (tester) async { + await pumpAvatar( + tester, + buildContact(name: 'WX Station', type: ContactType.sensor), + ); + + expect(find.byType(CircleAvatar), findsOneWidget); + expect(find.byIcon(Icons.sensors), findsOneWidget); + expect(find.text('WS'), findsNothing); + }); } diff --git a/test/widgets/contact_tile_test.dart b/test/widgets/contact_tile_test.dart index df7a906..3b75b16 100644 --- a/test/widgets/contact_tile_test.dart +++ b/test/widgets/contact_tile_test.dart @@ -10,6 +10,7 @@ import 'package:meshcore_sar_app/providers/map_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/sensors_provider.dart'; import 'package:meshcore_sar_app/widgets/contacts/contact_tile.dart'; +import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -40,14 +41,21 @@ void main() { ); } - Future pumpTile(WidgetTester tester, Contact contact) async { + Future pumpTile( + WidgetTester tester, + Contact contact, { + SensorsProvider? sensorsProvider, + }) async { + final resolvedSensorsProvider = sensorsProvider ?? SensorsProvider(); await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => ConnectionProvider()), ChangeNotifierProvider(create: (_) => ContactsProvider()), ChangeNotifierProvider(create: (_) => MessagesProvider()), - ChangeNotifierProvider(create: (_) => SensorsProvider()), + ChangeNotifierProvider.value( + value: resolvedSensorsProvider, + ), ChangeNotifierProvider(create: (_) => MapProvider()), ], child: MaterialApp( @@ -104,4 +112,79 @@ void main() { expect(find.text(contact.publicKeyShort), findsNothing); expect(find.byIcon(Icons.key_outlined), findsNothing); }); + + testWidgets('sensor contacts can be added to sensors', (tester) async { + await pumpTile( + tester, + buildContact(name: 'WX Station', type: ContactType.sensor), + ); + + await tester.tap(find.text('WX Station')); + await tester.pumpAndSettle(); + + expect(find.text('Add to Sensors'), findsOneWidget); + }); + + testWidgets('sensor preview shows telemetry card', (tester) async { + final contact = buildContact(name: 'WX Station', type: ContactType.sensor) + .copyWith( + telemetry: ContactTelemetry( + batteryPercentage: 84, + temperature: 21.5, + humidity: 58.0, + extraSensorData: const { + '__source_channel:battery': 1, + '__source_channel:temperature': 1, + '__source_channel:humidity': 1, + 'co2': 415.0, + 'illuminance_2': 500.0, + 'current_2': 0.015, + 'power_2': 0.25, + 'distance_2': 1.234, + }, + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + + await pumpTile(tester, contact); + + await tester.tap(find.text('WX Station')); + await tester.pumpAndSettle(); + + expect(find.text('Preview'), findsOneWidget); + + await tester.tap(find.text('Preview')); + await tester.pumpAndSettle(); + + expect(find.text('Battery'), findsOneWidget); + expect(find.text('84%'), findsOneWidget); + expect(find.text('Temperature'), findsOneWidget); + expect(find.text('21.5°C'), findsOneWidget); + expect(find.text('CO2'), findsOneWidget); + expect(find.text('415 ppm'), findsOneWidget); + expect(find.text('Illuminance'), findsOneWidget); + expect(find.text('~4.2 W/m2 daylight'), findsOneWidget); + expect(find.text('Current'), findsOneWidget); + expect(find.text('15 mA'), findsOneWidget); + expect(find.text('Power'), findsOneWidget); + expect(find.text('Distance'), findsOneWidget); + expect( + find.byKey(const ValueKey('sensor_metric_channel_battery')), + findsOneWidget, + ); + expect( + find.text('ch1'), + findsWidgets, + ); + expect( + find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')), + findsOneWidget, + ); + + final sensorCardSize = tester.getSize(find.byType(SensorTelemetryCard)); + final batteryTileSize = tester.getSize( + find.byKey(const ValueKey('sensor_metric_battery')), + ); + expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8)); + }); } diff --git a/test/widgets/sensor_metric_selector_item_test.dart b/test/widgets/sensor_metric_selector_item_test.dart new file mode 100644 index 0000000..7e7b3d4 --- /dev/null +++ b/test/widgets/sensor_metric_selector_item_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/screens/sensors_tab.dart'; +import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart'; + +void main() { + testWidgets('renders selector previews and channel badges', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SensorMetricSelectorItem( + option: const SensorMetricOption( + key: 'extra:illuminance_2', + label: 'Illuminance (ch 2)', + defaultLabel: 'Illuminance', + channel: 2, + valuePreview: '500 lx', + ), + visible: true, + span: 1, + canMoveUp: true, + canMoveDown: true, + onToggle: (_) {}, + onRename: () {}, + onMoveUp: () {}, + onMoveDown: () {}, + onSpanChanged: (_) {}, + ), + ), + ), + ); + + expect(find.text('500 lx'), findsOneWidget); + expect( + find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')), + findsOneWidget, + ); + expect(find.text('ch2'), findsOneWidget); + }); +} diff --git a/test/widgets/sensor_telemetry_card_test.dart b/test/widgets/sensor_telemetry_card_test.dart new file mode 100644 index 0000000..0a8e560 --- /dev/null +++ b/test/widgets/sensor_telemetry_card_test.dart @@ -0,0 +1,144 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/providers/sensors_provider.dart'; +import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart'; + +void main() { + Contact buildContact() { + final publicKey = Uint8List(32); + publicKey[0] = 0x44; + + return Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + temperature: 21.5, + extraSensorData: const { + '__source_channel:temperature': 1, + 'illuminance_2': 500.0, + }, + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + } + + testWidgets('renders custom labels and channel badges', (tester) async { + final contact = buildContact(); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'temperature', 'extra:illuminance_2'}, + labelOverrides: const { + 'temperature': 'Ambient', + 'extra:illuminance_2': 'Light', + }, + fieldSpans: sensorFullWidthFieldSpans( + const {'temperature', 'extra:illuminance_2'}, + ), + ), + ), + ), + ); + + expect(find.text('Ambient'), findsOneWidget); + expect(find.text('Light'), findsOneWidget); + expect(find.text('Temperature'), findsNothing); + expect(find.text('Illuminance'), findsNothing); + expect( + find.byKey(const ValueKey('sensor_metric_channel_temperature')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')), + findsOneWidget, + ); + }); + + testWidgets('renders metrics in the provided order', (tester) async { + final publicKey = Uint8List(32); + publicKey[0] = 0x45; + final contact = Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + batteryPercentage: 84, + temperature: 21.5, + extraSensorData: const { + '__source_channel:battery': 1, + '__source_channel:temperature': 1, + 'illuminance_2': 500.0, + }, + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const { + 'battery', + 'temperature', + 'extra:illuminance_2', + }, + fieldOrder: const [ + 'extra:illuminance_2', + 'temperature', + 'battery', + ], + fieldSpans: sensorFullWidthFieldSpans( + const { + 'battery', + 'temperature', + 'extra:illuminance_2', + }, + ), + ), + ), + ), + ); + + final illuminanceTop = tester.getTopLeft( + find.byKey(const ValueKey('sensor_metric_extra:illuminance_2')), + ); + final temperatureTop = tester.getTopLeft( + find.byKey(const ValueKey('sensor_metric_temperature')), + ); + final batteryTop = tester.getTopLeft( + find.byKey(const ValueKey('sensor_metric_battery')), + ); + + expect(illuminanceTop.dy, lessThan(temperatureTop.dy)); + expect(temperatureTop.dy, lessThan(batteryTop.dy)); + }); +}