From df993cd176243ca2a73f554917e4a4bbf425b4f8 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 15 Mar 2026 10:10:22 +0100 Subject: [PATCH] Enhance sensor telemetry cards --- lib/providers/sensors_provider.dart | 277 ++++++++ lib/screens/home_screen.dart | 6 +- lib/screens/sensors_tab.dart | 348 ++++++++-- lib/services/cayenne_lpp_parser.dart | 26 +- lib/widgets/contacts/contact_tile.dart | 8 +- .../sensors/sensor_telemetry_card.dart | 607 ++++++++++++++++-- test/providers/sensors_provider_test.dart | 194 ++++++ test/screens/sensors_tab_test.dart | 100 +++ test/widgets/contact_tile_test.dart | 41 +- test/widgets/sensor_telemetry_card_test.dart | 144 +++++ 10 files changed, 1664 insertions(+), 87 deletions(-) create mode 100644 test/providers/sensors_provider_test.dart create mode 100644 test/screens/sensors_tab_test.dart create mode 100644 test/widgets/sensor_telemetry_card_test.dart diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index e785526..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,6 +340,57 @@ 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); @@ -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(); } @@ -240,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) { @@ -267,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/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 ad83efd..35fddab 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -10,7 +10,9 @@ import '../providers/sensors_provider.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(); @@ -22,7 +24,10 @@ class _SensorsTabState extends State { @override void initState() { super.initState(); - _scheduleMinuteTicker(); + if (widget.isActive) { + unawaited(_handleMinuteTick()); + _scheduleMinuteTicker(); + } } @override @@ -31,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( @@ -46,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(); @@ -137,23 +177,76 @@ class _SensorsTabState extends State { builder: (sheetContext) => Consumer( builder: (context, sensorsProvider, child) { final visibleFields = sensorsProvider.visibleFieldsFor(publicKeyHex); - final options = sensorMetricOptionsFor(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, @@ -161,35 +254,146 @@ class _SensorsTabState extends State { ); return Padding( padding: const EdgeInsets.only(bottom: 12), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, 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')), + Row( + children: [ + Expanded( + child: FilterChip( + selected: visible, + label: Text(option.label), + onSelected: (value) { + sensorsProvider.toggleMetric( + publicKeyHex, + option.key, + value, + ); + }, + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Rename', + onPressed: () => _showMetricRenameDialog( + context, + publicKeyHex: publicKeyHex, + option: option, + sensorsProvider: sensorsProvider, + ), + icon: const Icon(Icons.edit_outlined), + ), ], - selected: {span}, - onSelectionChanged: (selection) { - sensorsProvider.setFieldSpan( - publicKeyHex, - option.key, - selection.first, - ); - }, + ), + 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.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).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.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'ch${option.channel}', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: Theme.of( + context, + ).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: index > 0 + ? () => sensorsProvider.moveMetric( + publicKeyHex, + availableFieldKeys: orderedFieldKeys, + oldIndex: index, + newIndex: index - 1, + ) + : null, + icon: const Icon(Icons.arrow_upward), + ), + IconButton( + tooltip: 'Move down', + onPressed: index < orderedOptions.length - 1 + ? () => sensorsProvider.moveMetric( + publicKeyHex, + availableFieldKeys: orderedFieldKeys, + oldIndex: index, + newIndex: index + 1, + ) + : 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) { + sensorsProvider.setFieldSpan( + publicKeyHex, + option.key, + selection.first, + ); + }, + ), + ], + ), ), ], ), @@ -203,6 +407,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(), @@ -241,6 +510,11 @@ class _SensorsTabState extends State { 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, diff --git a/lib/services/cayenne_lpp_parser.dart b/lib/services/cayenne_lpp_parser.dart index 911844a..332df6c 100644 --- a/lib/services/cayenne_lpp_parser.dart +++ b/lib/services/cayenne_lpp_parser.dart @@ -85,6 +85,8 @@ class CayenneLppParser { 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)', ); @@ -120,9 +122,13 @@ class CayenneLppParser { debugPrint(' Temperature: ${value.toStringAsFixed(1)}°C'); if (channel == _selfTelemetryChannel) { temperature = value; + extraSensorData[_sourceChannelKey('temperature')] = channel; } else { extraSensorData['temperature_$channel'] = value; - temperature ??= value; + if (temperature == null) { + temperature = value; + extraSensorData[_sourceChannelKey('temperature')] = channel; + } } break; @@ -133,9 +139,13 @@ class CayenneLppParser { debugPrint(' Humidity: ${value.toStringAsFixed(1)}%'); if (channel == _selfTelemetryChannel) { humidity = value; + extraSensorData[_sourceChannelKey('humidity')] = channel; } else { extraSensorData['humidity_$channel'] = value; - humidity ??= value; + if (humidity == null) { + humidity = value; + extraSensorData[_sourceChannelKey('humidity')] = channel; + } } break; @@ -158,9 +168,13 @@ class CayenneLppParser { debugPrint(' Barometer: ${value.toStringAsFixed(1)} hPa'); if (channel == _selfTelemetryChannel) { pressure = value; + extraSensorData[_sourceChannelKey('pressure')] = channel; } else { extraSensorData['pressure_$channel'] = value; - pressure ??= value; + if (pressure == null) { + pressure = value; + extraSensorData[_sourceChannelKey('pressure')] = channel; + } } break; @@ -172,6 +186,8 @@ class CayenneLppParser { 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)', ); @@ -230,6 +246,7 @@ class CayenneLppParser { } gpsLocation = LatLng(lat, lon); + extraSensorData[_sourceChannelKey('gps')] = channel; extraSensorData['altitude_$channel'] = alt; break; @@ -258,6 +275,7 @@ class CayenneLppParser { debugPrint(' Percentage: $value%'); if (_isBatteryChannel(channel)) { batteryPercentage = value; + extraSensorData[_sourceChannelKey('battery')] = channel; } else { extraSensorData['percentage_$channel'] = value; } @@ -402,6 +420,8 @@ class CayenneLppParser { 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/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index e423a5a..11b9560 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -491,6 +491,10 @@ class ContactTile extends StatelessWidget { final previewContact = liveContact ?? contact; final visibleFields = sensorMetricKeysFor(previewContact); + final fieldOrder = sensorsProvider.metricOrderFor( + publicKeyHex, + visibleFields, + ); return SafeArea( child: SingleChildScrollView( @@ -499,7 +503,9 @@ class ContactTile extends StatelessWidget { contact: previewContact, state: sensorsProvider.stateFor(publicKeyHex), visibleFields: visibleFields, - fieldSpans: sensorDefaultFieldSpans(visibleFields), + fieldOrder: fieldOrder, + labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex), + fieldSpans: sensorFullWidthFieldSpans(visibleFields), margin: EdgeInsets.zero, emptyMetricsMessage: 'No telemetry fields available yet.', ), diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart index 78091d4..22a6ca5 100644 --- a/lib/widgets/sensors/sensor_telemetry_card.dart +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -12,34 +12,143 @@ 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}); + const SensorMetricOption({ + required this.key, + required this.label, + required this.defaultLabel, + this.channel, + this.valuePreview, + }); } -List sensorMetricOptionsFor(Contact? contact) { +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 (telemetry?.batteryMilliVolts != null) - const SensorMetricOption(key: 'voltage', label: 'Voltage'), - if (telemetry?.batteryPercentage != null) - const SensorMetricOption(key: 'battery', label: 'Battery'), - if (telemetry?.temperature != null) - const SensorMetricOption(key: 'temperature', label: 'Temperature'), - if (telemetry?.humidity != null) - const SensorMetricOption(key: 'humidity', label: 'Humidity'), - if (telemetry?.pressure != null) - const SensorMetricOption(key: 'pressure', label: 'Pressure'), - if (telemetry?.gpsLocation != null) - const SensorMetricOption(key: 'gps', label: 'GPS'), + 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)}', + ), ]; - final extraSensorData = telemetry?.extraSensorData; 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: _extraFieldKey(key), - label: _formatExtraFieldLabel(key), + key: fieldKey, + label: _selectorMetricLabel( + _resolvedMetricLabel( + fieldKey, + defaultLabel, + labelOverrides: labelOverrides, + ), + metricKey.channel, + ), + defaultLabel: defaultLabel, + channel: metricKey.channel, + valuePreview: _sensorMetricPreviewValue(key, extraSensorData[key]), ), ); } @@ -60,22 +169,275 @@ Map sensorDefaultFieldSpans(Iterable fieldKeys) { 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, @@ -83,6 +445,7 @@ class SensorTelemetryCard extends StatelessWidget { 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 => @@ -96,7 +459,9 @@ class SensorTelemetryCard extends StatelessWidget { final colorScheme = theme.colorScheme; final metrics = contact == null || telemetry == null ? const <_MetricCardData>[] - : _buildMetricCards(l10n, telemetry, contact!); + : _sortMetricsByFieldOrder( + _buildMetricCards(l10n, telemetry, contact!), + ); return Container( margin: margin, @@ -281,9 +646,14 @@ class SensorTelemetryCard extends StatelessWidget { _MetricCardData( fieldKey: 'voltage', icon: Icons.bolt, - label: l10n.voltage, + label: _resolvedMetricLabel( + 'voltage', + l10n.voltage, + labelOverrides: labelOverrides, + ), value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V', accent: const Color(0xFF0A7D61), + channel: _sourceChannelForField(telemetry.extraSensorData, 'voltage'), ), ); } @@ -293,9 +663,14 @@ class SensorTelemetryCard extends StatelessWidget { _MetricCardData( fieldKey: 'battery', icon: Icons.battery_5_bar, - label: l10n.battery, + label: _resolvedMetricLabel( + 'battery', + l10n.battery, + labelOverrides: labelOverrides, + ), value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%', accent: const Color(0xFF4B8E2F), + channel: _sourceChannelForField(telemetry.extraSensorData, 'battery'), ), ); } @@ -305,9 +680,17 @@ class SensorTelemetryCard extends StatelessWidget { _MetricCardData( fieldKey: 'temperature', icon: Icons.thermostat, - label: l10n.temperature, + label: _resolvedMetricLabel( + 'temperature', + l10n.temperature, + labelOverrides: labelOverrides, + ), value: '${telemetry.temperature!.toStringAsFixed(1)}°C', accent: const Color(0xFFC76821), + channel: _sourceChannelForField( + telemetry.extraSensorData, + 'temperature', + ), ), ); } @@ -316,9 +699,17 @@ class SensorTelemetryCard extends StatelessWidget { _MetricCardData( fieldKey: 'humidity', icon: Icons.water_drop, - label: l10n.humidity, + label: _resolvedMetricLabel( + 'humidity', + l10n.humidity, + labelOverrides: labelOverrides, + ), value: '${telemetry.humidity!.toStringAsFixed(1)}%', accent: const Color(0xFF246BB2), + channel: _sourceChannelForField( + telemetry.extraSensorData, + 'humidity', + ), ), ); } @@ -327,9 +718,17 @@ class SensorTelemetryCard extends StatelessWidget { _MetricCardData( fieldKey: 'pressure', icon: Icons.compress, - label: l10n.pressure, + label: _resolvedMetricLabel( + 'pressure', + l10n.pressure, + labelOverrides: labelOverrides, + ), value: '${telemetry.pressure!.toStringAsFixed(1)} hPa', accent: const Color(0xFF6B4BAE), + channel: _sourceChannelForField( + telemetry.extraSensorData, + 'pressure', + ), ), ); } @@ -338,7 +737,11 @@ class SensorTelemetryCard extends StatelessWidget { _MetricCardData( fieldKey: 'gps', icon: Icons.place, - label: l10n.gpsTelemetry, + label: _resolvedMetricLabel( + 'gps', + l10n.gpsTelemetry, + labelOverrides: labelOverrides, + ), value: '${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}', accent: const Color(0xFFAA3F57), @@ -351,11 +754,15 @@ class SensorTelemetryCard extends StatelessWidget { 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; @@ -370,9 +777,37 @@ class SensorTelemetryCard extends StatelessWidget { 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 label = _formatExtraFieldLabel(rawKey); + final fieldKey = _extraFieldKey(rawKey); + final label = _resolvedMetricLabel( + fieldKey, + _formatExtraFieldLabel(rawKey), + labelOverrides: labelOverrides, + ); switch (metricKey.baseKey) { case 'altitude': @@ -384,6 +819,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: '${_formatNumber(meters, maxFractionDigits: 1)} m', accent: const Color(0xFF7A5C3E), + channel: metricKey.channel, ); case 'illuminance': @@ -397,6 +833,7 @@ class SensorTelemetryCard extends StatelessWidget { secondaryValue: '~${_formatNumber(_approxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2 daylight', accent: const Color(0xFFC17B1D), + channel: metricKey.channel, ); case 'presence': @@ -408,6 +845,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: isPresent ? 'Detected' : 'Clear', accent: const Color(0xFFAA3F57), + channel: metricKey.channel, ); case 'digital_input': @@ -419,6 +857,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: isHigh ? 'High' : 'Low', accent: const Color(0xFF3A6D8C), + channel: metricKey.channel, ); case 'digital_output': @@ -430,6 +869,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: isHigh ? 'High' : 'Low', accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, ); case 'analog_input': @@ -441,6 +881,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: _formatNumber(reading, maxFractionDigits: 3), accent: const Color(0xFF5A6C84), + channel: metricKey.channel, ); case 'analog_output': @@ -452,6 +893,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: _formatNumber(reading, maxFractionDigits: 3), accent: const Color(0xFF4B7785), + channel: metricKey.channel, ); case 'accelerometer': @@ -467,6 +909,7 @@ class SensorTelemetryCard extends StatelessWidget { '|a| ${_formatNumber(_vectorMagnitude(vector), maxFractionDigits: 2)} g', accent: const Color(0xFF5A4C99), wide: true, + channel: metricKey.channel, ); case 'gyrometer': @@ -482,6 +925,7 @@ class SensorTelemetryCard extends StatelessWidget { '|w| ${_formatNumber(_vectorMagnitude(vector), maxFractionDigits: 2)} deg/s', accent: const Color(0xFF6C4F96), wide: true, + channel: metricKey.channel, ); case 'generic_sensor': @@ -493,6 +937,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: _formatNumber(reading, maxFractionDigits: 2), accent: const Color(0xFF3E657C), + channel: metricKey.channel, ); case 'current': @@ -504,6 +949,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: _formatCurrent(amps), accent: const Color(0xFF1C7C54), + channel: metricKey.channel, ); case 'frequency': @@ -515,6 +961,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: _formatFrequency(hz), accent: const Color(0xFF2C6BA0), + channel: metricKey.channel, ); case 'percentage': @@ -526,6 +973,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: '${_formatNumber(reading, maxFractionDigits: 1)}%', accent: const Color(0xFF4B8E2F), + channel: metricKey.channel, ); case 'concentration': @@ -537,6 +985,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: '${_formatNumber(reading, maxFractionDigits: 0)} ppm', accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, ); case 'power': @@ -548,6 +997,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: _formatPower(watts), accent: const Color(0xFFB5622E), + channel: metricKey.channel, ); case 'speed': @@ -559,6 +1009,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', accent: const Color(0xFF2B78A0), + channel: metricKey.channel, ); case 'distance': @@ -570,6 +1021,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: _formatDistance(meters), accent: const Color(0xFF577590), + channel: metricKey.channel, ); case 'energy': @@ -581,6 +1033,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: _formatEnergy(kwh), accent: const Color(0xFF9C6644), + channel: metricKey.channel, ); case 'direction': @@ -593,6 +1046,7 @@ class SensorTelemetryCard extends StatelessWidget { value: '${_formatNumber(degrees, maxFractionDigits: 0)} deg', secondaryValue: _formatCardinalDirection(degrees), accent: const Color(0xFF8A5A44), + channel: metricKey.channel, ); case 'unixtime': @@ -610,6 +1064,7 @@ class SensorTelemetryCard extends StatelessWidget { secondaryValue: _formatTelemetryTime(timestamp), accent: const Color(0xFF6B7280), wide: true, + channel: metricKey.channel, ); case 'colour': @@ -624,6 +1079,7 @@ class SensorTelemetryCard extends StatelessWidget { 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': @@ -635,6 +1091,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: isOn ? 'On' : 'Off', accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, ); case 'voltage': @@ -646,6 +1103,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: '${_formatNumber(volts, maxFractionDigits: 3)} V', accent: const Color(0xFF0A7D61), + channel: metricKey.channel, ); } @@ -660,6 +1118,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: '${_formatNumber(reading, maxFractionDigits: 0)} ppm', accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, ); case 'pm25': @@ -672,6 +1131,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: '${_formatNumber(reading, maxFractionDigits: 1)} ug/m3', accent: const Color(0xFF7A6C5D), + channel: metricKey.channel, ); case 'uv': @@ -683,6 +1143,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: _formatNumber(reading, maxFractionDigits: 1), accent: const Color(0xFFC17B1D), + channel: metricKey.channel, ); } @@ -693,6 +1154,7 @@ class SensorTelemetryCard extends StatelessWidget { label: label, value: _formatNumber(value, maxFractionDigits: 2), accent: const Color(0xFF3E657C), + channel: metricKey.channel, ); } @@ -703,6 +1165,7 @@ class SensorTelemetryCard extends StatelessWidget { value: '$value', accent: const Color(0xFF3E657C), wide: value is Map, + channel: metricKey.channel, ); } @@ -986,6 +1449,7 @@ class _MetricTile extends StatelessWidget { @override Widget build(BuildContext context) { return Container( + key: ValueKey('sensor_metric_${data.fieldKey}'), width: width, padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -1133,14 +1597,39 @@ class _MetricText extends StatelessWidget { 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, - ), + 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( @@ -1178,6 +1667,7 @@ class _MetricCardData { final Color accent; final bool wide; final LatLng? mapLocation; + final int? channel; const _MetricCardData({ required this.fieldKey, @@ -1188,6 +1678,7 @@ class _MetricCardData { required this.accent, this.wide = false, this.mapLocation, + this.channel, }); } @@ -1214,19 +1705,57 @@ class _RgbColor { 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); - final label = - _knownMetricLabels[metricKey.baseKey] ?? + return _knownMetricLabels[metricKey.baseKey] ?? _fallbackMetricLabel(metricKey.baseKey); - if (metricKey.channel != null) { - return '$label (ch ${metricKey.channel})'; - } - return label; } const List _knownMetricBaseKeys = [ 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/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/widgets/contact_tile_test.dart b/test/widgets/contact_tile_test.dart index c68358c..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( @@ -125,6 +133,9 @@ void main() { 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, @@ -151,11 +162,29 @@ void main() { expect(find.text('21.5°C'), findsOneWidget); expect(find.text('CO2'), findsOneWidget); expect(find.text('415 ppm'), findsOneWidget); - expect(find.text('Illuminance (ch 2)'), findsOneWidget); + expect(find.text('Illuminance'), findsOneWidget); expect(find.text('~4.2 W/m2 daylight'), findsOneWidget); - expect(find.text('Current (ch 2)'), findsOneWidget); + expect(find.text('Current'), findsOneWidget); expect(find.text('15 mA'), findsOneWidget); - expect(find.text('Power (ch 2)'), findsOneWidget); - expect(find.text('Distance (ch 2)'), 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_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)); + }); +}