import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; import '../services/profiles_feature_service.dart'; import '../widgets/sensors/sensor_telemetry_card.dart'; import 'connection_provider.dart'; import 'contacts_provider.dart'; enum SensorRefreshState { idle, refreshing, success, timeout, unavailable } class SensorsProvider with ChangeNotifier { static const Duration _successStateRetention = Duration(minutes: 1); 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, 5, 15, 30, 60, 360, 720, 1440, ]; static const Set _defaultVisibleFields = { 'voltage', 'battery', 'temperature', 'humidity', 'pressure', 'gps', }; static const List _defaultMetricOrder = [ 'voltage', 'battery', 'temperature', 'humidity', 'pressure', 'gps', ]; final List _watchedSensorKeys = []; final Map _refreshStates = {}; final Map _refreshStateUpdatedAt = {}; final Map> _visibleFieldsBySensor = >{}; 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()); } String _key(String baseKey) => ProfileStorageScope.scopedKey(baseKey); List get watchedSensorKeys => List.unmodifiable(_watchedSensorKeys); bool get isLoaded => _isLoaded; bool get isRefreshingAll => _isRefreshingAll; SensorRefreshState stateFor(String publicKeyHex) => _refreshStates[publicKeyHex] ?? SensorRefreshState.idle; Future _loadWatchedSensors() async { try { final prefs = await SharedPreferences.getInstance(); final stored = prefs.getStringList(_key(_watchedSensorsKey)) ?? []; final storedMetricsJson = prefs.getString(_key(_visibleSensorMetricsKey)); final storedSpansJson = prefs.getString(_key(_fieldSpanKey)); final storedLabelsJson = prefs.getString(_key(_metricLabelKey)); final storedOrderJson = prefs.getString(_key(_metricOrderKey)); final storedAutoRefreshJson = prefs.getString( _key(_autoRefreshMinutesKey), ); _watchedSensorKeys ..clear() ..addAll(stored); _refreshStates.clear(); _refreshStateUpdatedAt.clear(); _visibleFieldsBySensor.clear(); _fieldSpansBySensor.clear(); _metricLabelsBySensor.clear(); _metricOrderBySensor.clear(); _autoRefreshMinutesBySensor.clear(); _lastRefreshAttemptAt.clear(); if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) { final decoded = jsonDecode(storedMetricsJson) as Map; for (final entry in decoded.entries) { _visibleFieldsBySensor[entry.key] = (entry.value as List) .cast() .toSet(); } } if (storedSpansJson != null && storedSpansJson.isNotEmpty) { final decoded = jsonDecode(storedSpansJson) as Map; for (final entry in decoded.entries) { _fieldSpansBySensor[entry.key] = (entry.value as Map) .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 = _normalizeAutoRefreshMinutes( (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'); } finally { _isLoaded = true; notifyListeners(); } } Future reloadProfileScopedState() async { _isLoaded = false; await _loadWatchedSensors(); } Future _persistWatchedSensors() async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setStringList(_key(_watchedSensorsKey), _watchedSensorKeys); } catch (e) { debugPrint('Error saving watched sensors: $e'); } } Future _persistVisibleMetrics() async { try { final prefs = await SharedPreferences.getInstance(); final encoded = >{}; for (final entry in _visibleFieldsBySensor.entries) { encoded[entry.key] = entry.value.toList(); } await prefs.setString( _key(_visibleSensorMetricsKey), jsonEncode(encoded), ); } catch (e) { debugPrint('Error saving visible sensor metrics: $e'); } } Future _persistFieldSpans() async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setString( _key(_fieldSpanKey), jsonEncode(_fieldSpansBySensor), ); } catch (e) { debugPrint('Error saving sensor field spans: $e'); } } Future _persistMetricLabels() async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setString( _key(_metricLabelKey), jsonEncode(_metricLabelsBySensor), ); } catch (e) { debugPrint('Error saving sensor metric labels: $e'); } } Future _persistMetricOrder() async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setString( _key(_metricOrderKey), jsonEncode(_metricOrderBySensor), ); } catch (e) { debugPrint('Error saving sensor metric order: $e'); } } Future _persistAutoRefreshMinutes() async { try { final prefs = await SharedPreferences.getInstance(); await prefs.setString( _key(_autoRefreshMinutesKey), jsonEncode(_autoRefreshMinutesBySensor), ); } catch (e) { debugPrint('Error saving sensor auto refresh minutes: $e'); } } Set visibleFieldsFor(String publicKeyHex) => Set.unmodifiable( _visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields, ); Set effectiveVisibleFieldsFor( String publicKeyHex, Iterable availableFieldKeys, ) { final storedVisibleFields = _visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields; if (!_shouldAutoIncludeAvailableFields(publicKeyHex, storedVisibleFields)) { return Set.unmodifiable(storedVisibleFields); } return Set.unmodifiable({ ...storedVisibleFields, ...availableFieldKeys, }); } bool showsField(String publicKeyHex, String fieldKey) => visibleFieldsFor(publicKeyHex).contains(fieldKey); int fieldSpanFor(String publicKeyHex, String fieldKey) { final sensorSpans = _fieldSpansBySensor[publicKeyHex]; final span = sensorSpans?[fieldKey] ?? 1; 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 = _normalizeAutoRefreshMinutes(minutes); final currentMinutes = autoRefreshMinutesFor(publicKeyHex); if (currentMinutes == normalizedMinutes) { return; } if (normalizedMinutes == 0) { _autoRefreshMinutesBySensor.remove(publicKeyHex); } else { _autoRefreshMinutesBySensor[publicKeyHex] = normalizedMinutes; } await _persistAutoRefreshMinutes(); notifyListeners(); } static int _normalizeAutoRefreshMinutes(int minutes) { if (minutes <= 0) { return 0; } if (supportedAutoRefreshIntervals.contains(minutes)) { return minutes; } final positiveIntervals = supportedAutoRefreshIntervals.where( (value) => value > 0, ); var bestMatch = positiveIntervals.first; var bestDistance = (minutes - bestMatch).abs(); for (final interval in positiveIntervals.skip(1)) { final distance = (minutes - interval).abs(); if (distance < bestDistance) { bestMatch = interval; bestDistance = distance; } } return bestMatch; } 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, bool visible, ) async { final visibleFields = _visibleFieldsBySensor.putIfAbsent( 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; } visibleFields.remove(fieldKey); } await _persistVisibleMetrics(); if (shouldPersistOrder) { await _persistMetricOrder(); } notifyListeners(); } Future setFieldSpan( String publicKeyHex, String fieldKey, int span, ) async { final sensorSpans = _fieldSpansBySensor.putIfAbsent( publicKeyHex, () => {}, ); sensorSpans[fieldKey] = span == 2 ? 2 : 1; await _persistFieldSpans(); 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 && !contact.isSensor) { return; } if (_watchedSensorKeys.contains(contact.publicKeyHex)) { return; } _watchedSensorKeys.add(contact.publicKeyHex); final initialVisibleFields = _initialVisibleFieldsForContact(contact); await _persistWatchedSensors(); _visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields; _fieldSpansBySensor[contact.publicKeyHex] = {'gps': 2}; _metricLabelsBySensor[contact.publicKeyHex] = {}; _metricOrderBySensor[contact.publicKeyHex] = metricOrderFor( contact.publicKeyHex, initialVisibleFields, ); await _persistVisibleMetrics(); await _persistFieldSpans(); await _persistMetricLabels(); await _persistMetricOrder(); notifyListeners(); } bool _shouldAutoIncludeAvailableFields( String publicKeyHex, Set storedVisibleFields, ) { if (!setEquals(storedVisibleFields, _defaultVisibleFields)) { return false; } final storedOrder = _metricOrderBySensor[publicKeyHex]; if (storedOrder != null && !listEquals(storedOrder, _defaultMetricOrder)) { return false; } final storedLabels = _metricLabelsBySensor[publicKeyHex]; if (storedLabels != null && storedLabels.isNotEmpty) { return false; } final storedSpans = _fieldSpansBySensor[publicKeyHex]; if (storedSpans != null && !(storedSpans.length == 1 && storedSpans['gps'] == 2)) { return false; } return true; } Set _initialVisibleFieldsForContact(Contact contact) { return {..._defaultVisibleFields, ...sensorMetricKeysFor(contact)}; } Future removeSensor(String publicKeyHex) async { _watchedSensorKeys.remove(publicKeyHex); _refreshStates.remove(publicKeyHex); _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(); } List availableCandidates(ContactsProvider contactsProvider) { final candidates = [ ...contactsProvider.chatContacts, ...contactsProvider.repeaters, ...contactsProvider.sensorContacts, ]; candidates.removeWhere((contact) => isWatched(contact.publicKeyHex)); candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime)); return candidates; } Future refreshAll({ required ContactsProvider contactsProvider, required ConnectionProvider connectionProvider, }) async { if (_isRefreshingAll || _watchedSensorKeys.isEmpty) { return; } _isRefreshingAll = true; notifyListeners(); try { for (final key in _watchedSensorKeys) { await refreshSensor( publicKeyHex: key, contactsProvider: contactsProvider, connectionProvider: connectionProvider, ); } } finally { _isRefreshingAll = false; notifyListeners(); } } Future refreshSensor({ 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) { contact = entry; break; } } if (contact == null) { _setRefreshState(publicKeyHex, SensorRefreshState.unavailable); return; } _setRefreshState(publicKeyHex, SensorRefreshState.refreshing); final result = await connectionProvider.smartPing( contactPublicKey: contact.publicKey, hasPath: contact.hasPath, ); _setRefreshState( publicKeyHex, result.success ? SensorRefreshState.success : SensorRefreshState.timeout, ); } 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 = []; for (final entry in _refreshStates.entries) { if (entry.value != SensorRefreshState.success) { continue; } final updatedAt = _refreshStateUpdatedAt[entry.key]; if (updatedAt == null || !updatedAt.isAfter(cutoff)) { keysToClear.add(entry.key); } } if (keysToClear.isEmpty) { return; } for (final key in keysToClear) { _refreshStates.remove(key); _refreshStateUpdatedAt.remove(key); } notifyListeners(); } void _setRefreshState(String publicKeyHex, SensorRefreshState state) { _refreshStates[publicKeyHex] = state; _refreshStateUpdatedAt[publicKeyHex] = DateTime.now(); notifyListeners(); } }