diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 35edd0f..531cdfa 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -489,7 +489,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 117; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +511,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 117; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -530,7 +530,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 117; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -547,7 +547,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 117; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -679,7 +679,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 117; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +702,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 116; + CURRENT_PROJECT_VERSION = 117; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 793ddf1..3a043da 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -43,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 116 + 117 LSRequiresIPhoneOS ITSAppUsesNonExemptEncryption diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index d952e5e..3938aeb 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index 5076cee..f9a08e1 100644 --- a/lib/providers/sensors_provider.dart +++ b/lib/providers/sensors_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; +import '../widgets/sensors/sensor_telemetry_card.dart'; import 'connection_provider.dart'; import 'contacts_provider.dart'; @@ -18,7 +19,16 @@ class SensorsProvider with ChangeNotifier { 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 List supportedAutoRefreshIntervals = [ + 0, + 5, + 15, + 30, + 60, + 360, + 720, + 1440, + ]; static const Set _defaultVisibleFields = { 'voltage', 'battery', @@ -118,7 +128,9 @@ class SensorsProvider with ChangeNotifier { final decoded = jsonDecode(storedAutoRefreshJson) as Map; for (final entry in decoded.entries) { - final minutes = (entry.value as num).toInt(); + final minutes = _normalizeAutoRefreshMinutes( + (entry.value as num).toInt(), + ); if (minutes > 0) { _autoRefreshMinutesBySensor[entry.key] = minutes; } @@ -212,6 +224,22 @@ class SensorsProvider with ChangeNotifier { _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); @@ -258,7 +286,7 @@ class SensorsProvider with ChangeNotifier { _autoRefreshMinutesBySensor[publicKeyHex] ?? 0; Future setAutoRefreshMinutes(String publicKeyHex, int minutes) async { - final normalizedMinutes = minutes <= 0 ? 0 : minutes; + final normalizedMinutes = _normalizeAutoRefreshMinutes(minutes); final currentMinutes = autoRefreshMinutesFor(publicKeyHex); if (currentMinutes == normalizedMinutes) { return; @@ -273,6 +301,32 @@ class SensorsProvider with ChangeNotifier { 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 = []; @@ -403,14 +457,14 @@ class SensorsProvider with ChangeNotifier { } _watchedSensorKeys.add(contact.publicKeyHex); + final initialVisibleFields = _initialVisibleFieldsForContact(contact); await _persistWatchedSensors(); - _visibleFieldsBySensor[contact.publicKeyHex] = Set.from( - _defaultVisibleFields, - ); + _visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields; _fieldSpansBySensor[contact.publicKeyHex] = {'gps': 2}; _metricLabelsBySensor[contact.publicKeyHex] = {}; - _metricOrderBySensor[contact.publicKeyHex] = List.from( - _defaultMetricOrder, + _metricOrderBySensor[contact.publicKeyHex] = metricOrderFor( + contact.publicKeyHex, + initialVisibleFields, ); await _persistVisibleMetrics(); await _persistFieldSpans(); @@ -419,6 +473,37 @@ class SensorsProvider with ChangeNotifier { 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); diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index ba5eebd..6c7ed0d 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -171,135 +171,26 @@ class _SensorsTabState extends State { String publicKeyHex, Contact? contact, ) async { - await showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) => Consumer( - builder: (context, sensorsProvider, child) { - final visibleFields = sensorsProvider.visibleFieldsFor(publicKeyHex); - 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 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), - ...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 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, - ); - }, - ); - }), - ], - ), - ); - }, + await Navigator.of(context).push( + MaterialPageRoute( + builder: (pageContext) => _SensorCustomizeView( + publicKeyHex: publicKeyHex, + initialContact: contact, + onRenameMetric: + ({ + required BuildContext context, + required String publicKeyHex, + required SensorMetricOption option, + required SensorsProvider sensorsProvider, + }) { + return _showMetricRenameDialog( + context, + publicKeyHex: publicKeyHex, + option: option, + sensorsProvider: sensorsProvider, + ); + }, + ), ), ); } @@ -403,19 +294,20 @@ class _SensorsTabState extends State { break; } } + final availableFieldKeys = sensorMetricKeysFor(contact); + final visibleFields = sensorsProvider + .effectiveVisibleFieldsFor(key, availableFieldKeys); return SensorTelemetryCard( contact: contact, state: sensorsProvider.stateFor(key), - visibleFields: sensorsProvider.visibleFieldsFor(key), + visibleFields: visibleFields, fieldOrder: sensorsProvider.metricOrderFor( key, - sensorsProvider.visibleFieldsFor(key), + availableFieldKeys, ), labelOverrides: sensorsProvider.labelOverridesFor(key), fieldSpans: { - for (final field in sensorsProvider.visibleFieldsFor( - key, - )) + for (final field in visibleFields) field: sensorsProvider.fieldSpanFor(key, field), }, onRemove: () async { @@ -439,6 +331,276 @@ class _SensorsTabState extends State { } } +class _SensorCustomizeView extends StatelessWidget { + final String publicKeyHex; + final Contact? initialContact; + final Future Function({ + required BuildContext context, + required String publicKeyHex, + required SensorMetricOption option, + required SensorsProvider sensorsProvider, + }) + onRenameMetric; + + const _SensorCustomizeView({ + required this.publicKeyHex, + required this.initialContact, + required this.onRenameMetric, + }); + + @override + Widget build(BuildContext context) { + return Consumer2( + builder: (context, sensorsProvider, contactsProvider, child) { + Contact? contact = initialContact; + for (final entry in contactsProvider.contacts) { + if (entry.publicKeyHex == publicKeyHex) { + contact = entry; + break; + } + } + + final options = sensorMetricOptionsFor( + contact, + labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex), + ); + final visibleFields = sensorsProvider.effectiveVisibleFieldsFor( + publicKeyHex, + options.map((option) => option.key), + ); + final autoRefreshMinutes = sensorsProvider.autoRefreshMinutesFor( + 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 Scaffold( + appBar: AppBar( + title: Text('Customize ${contact?.displayName ?? 'Sensor'}'), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + _SensorCustomizeSectionCard( + title: 'Live preview', + subtitle: + 'Changes apply immediately. This card matches the current dashboard layout for this sensor.', + child: SensorTelemetryCard( + contact: contact, + state: sensorsProvider.stateFor(publicKeyHex), + visibleFields: visibleFields, + fieldOrder: sensorsProvider.metricOrderFor( + publicKeyHex, + visibleFields, + ), + labelOverrides: sensorsProvider.labelOverridesFor( + publicKeyHex, + ), + fieldSpans: { + for (final field in visibleFields) + field: sensorsProvider.fieldSpanFor(publicKeyHex, field), + }, + margin: EdgeInsets.zero, + emptyMetricsMessage: 'No telemetry fields available yet.', + ), + ), + _SensorCustomizeSectionCard( + title: 'Refresh schedule', + subtitle: + 'Choose how often this sensor should refresh while your device stays connected.', + child: SensorAutoRefreshOptions( + selectedMinutes: autoRefreshMinutes, + onSelected: (minutes) { + sensorsProvider.setAutoRefreshMinutes( + publicKeyHex, + minutes, + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 4, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Field layout', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + 'Use the same value-card previews shown on the dashboard to control visibility, labels, width, and order.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ...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 SensorMetricSelectorItem( + option: option, + visible: visible, + span: span, + canMoveUp: index > 0, + canMoveDown: index < orderedOptions.length - 1, + onToggle: (value) { + sensorsProvider.toggleMetric( + publicKeyHex, + option.key, + value, + ); + }, + onRename: () => onRenameMetric( + context: 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, + ); + }, + ); + }), + ], + ), + ); + }, + ); + } +} + +class _SensorCustomizeSectionCard extends StatelessWidget { + final String title; + final String subtitle; + final Widget child; + + const _SensorCustomizeSectionCard({ + required this.title, + required this.subtitle, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + 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(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 6), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + child, + ], + ), + ), + ); + } +} + +class SensorAutoRefreshOptions extends StatelessWidget { + final int selectedMinutes; + final ValueChanged onSelected; + + const SensorAutoRefreshOptions({ + super.key, + required this.selectedMinutes, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: SensorsProvider.supportedAutoRefreshIntervals + .map( + (minutes) => ChoiceChip( + label: Text(_formatAutoRefreshIntervalLabel(minutes)), + selected: selectedMinutes == minutes, + onSelected: (_) => onSelected(minutes), + ), + ) + .toList(growable: false), + ); + } +} + +String _formatAutoRefreshIntervalLabel(int minutes) { + if (minutes <= 0) { + return 'Off'; + } + if (minutes >= 360 && minutes % 60 == 0) { + return '${minutes ~/ 60}h'; + } + return '${minutes}m'; +} + class SensorMetricSelectorItem extends StatelessWidget { final SensorMetricOption option; final bool visible; @@ -468,84 +630,167 @@ class SensorMetricSelectorItem extends StatelessWidget { @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), - ), - ], + final colorScheme = theme.colorScheme; + final showChannelChip = option.channel != null && option.channel != 1; + final previewCardData = + option.previewCardData ?? + SensorMetricCardData( + fieldKey: option.key, + icon: Icons.sensors, + label: option.defaultLabel, + value: option.valuePreview ?? 'No telemetry yet', + accent: colorScheme.primary, + channel: option.channel, + ); + + 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), ), - 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, + ], + ), + 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: [ + Text( + option.defaultLabel, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, ), ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + TextButton.icon( + onPressed: onRename, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + foregroundColor: colorScheme.primary, + textStyle: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Rename'), + ), + if (showChannelChip) + Container( + key: ValueKey( + 'sensor_selector_channel_${option.key}', + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: previewCardData.accent.withValues( + alpha: 0.10, + ), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Channel ${option.channel}', + style: theme.textTheme.labelMedium?.copyWith( + color: previewCardData.accent, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: visible + ? const Color(0xFF218B63).withValues(alpha: 0.12) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + visible ? 'Visible' : 'Hidden', + style: theme.textTheme.labelMedium?.copyWith( + color: visible + ? const Color(0xFF218B63) + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, ), - ], + ), + ), + ], + ), + const SizedBox(height: 12), + Opacity( + opacity: visible ? 1 : 0.55, + child: SensorMetricTile( + data: previewCardData, + width: double.infinity, + keyPrefix: 'sensor_selector_metric', + allowMapPreview: false, ), ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 8, + const SizedBox(height: 8), + Row( children: [ - IconButton( - tooltip: 'Move up', - onPressed: canMoveUp ? onMoveUp : null, - icon: const Icon(Icons.arrow_upward), + Expanded( + child: Text( + 'Show on sensor card', + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), ), - IconButton( - tooltip: 'Move down', - onPressed: canMoveDown ? onMoveDown : null, - icon: const Icon(Icons.arrow_downward), + Switch(value: visible, onChanged: onToggle), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Text( + 'Card width', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), + const Spacer(), SegmentedButton( segments: const [ ButtonSegment(value: 1, label: Text('1x')), @@ -558,8 +803,30 @@ class SensorMetricSelectorItem extends StatelessWidget { ), ], ), - ), - ], + const SizedBox(height: 8), + Row( + children: [ + Text( + 'Order', + style: theme.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + 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), + ), + ], + ), + ], + ), ), ); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index e690dd0..cbb3fd2 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1875,7 +1875,7 @@ class _SettingsScreenState extends State { style: Theme.of(context).textTheme.bodySmall, ), ), - Switch.adaptive( + Switch( value: _showCurrentImagePreview, onChanged: (value) { setState(() => _showCurrentImagePreview = value); diff --git a/lib/services/cayenne_lpp_parser.dart b/lib/services/cayenne_lpp_parser.dart index 332df6c..3dc5e0a 100644 --- a/lib/services/cayenne_lpp_parser.dart +++ b/lib/services/cayenne_lpp_parser.dart @@ -19,7 +19,61 @@ class CayenneLppParser { static const int _lppDirection = 132; static const int _lppUnixTime = 133; static const int _lppColour = 135; + static const int _lppGust = 137; + static const int _lppDewPoint = 138; + static const int _lppRain = 139; static const int _lppSwitch = 142; + static const int _lppBinaryBool = 143; + static const int _lppBinaryPowerSwitch = 144; + static const int _lppBinaryOpen = 145; + static const int _lppBinaryBatteryLow = 146; + static const int _lppBinaryCharging = 147; + static const int _lppBinaryCarbonMonoxide = 148; + static const int _lppBinaryCold = 149; + static const int _lppBinaryConnectivity = 150; + static const int _lppBinaryDoor = 151; + static const int _lppBinaryGarageDoor = 152; + static const int _lppBinaryGas = 153; + static const int _lppBinaryHeat = 154; + static const int _lppBinaryLight = 155; + static const int _lppBinaryLock = 156; + static const int _lppBinaryMoisture = 157; + static const int _lppBinaryMotion = 158; + static const int _lppBinaryMoving = 159; + static const int _lppBinaryOccupancy = 160; + static const int _lppBinaryPlug = 161; + static const int _lppBinaryPresence = 162; + static const int _lppBinaryProblem = 163; + static const int _lppBinaryRunning = 164; + static const int _lppBinarySafety = 165; + static const int _lppBinarySmoke = 166; + static const int _lppBinarySound = 167; + static const int _lppBinaryTamper = 168; + static const int _lppBinaryVibration = 169; + static const int _lppBinaryWindow = 170; + static const int _lppButtonEvent = 171; + static const int _lppDimmer = 172; + static const int _lppUv = 173; + static const int _lppLightLevel = 174; + static const int _lppPm25 = 175; + static const int _lppPm10 = 176; + static const int _lppCo2 = 177; + static const int _lppTvoc = 178; + static const int _lppRpm = 179; + static const int _lppConductivity = 180; + static const int _lppRotation = 181; + static const int _lppDuration = 182; + static const int _lppAcceleration = 183; + static const int _lppGyroRate = 184; + static const int _lppVolume = 185; + static const int _lppFlowRate = 186; + static const int _lppVolumeStorage = 187; + static const int _lppWater = 188; + static const int _lppGasVolume = 189; + static const int _lppMass = 190; + static const int _lppSignedSpeed = 191; + static const int _lppSignedPower = 192; + static const int _lppSignedCurrent = 193; /// Parse Cayenne LPP data into ContactTelemetry static ContactTelemetry parse(Uint8List data) { @@ -345,6 +399,212 @@ class CayenneLppParser { }; break; + case _lppGust: + final rawValue = reader.readUInt16BE(); + final value = rawValue / 100.0; + debugPrint(' Gust: ${value}m/s'); + extraSensorData['gust_$channel'] = value; + break; + + case _lppDewPoint: + final rawValue = reader.readInt16BE(); + final value = rawValue / 10.0; + debugPrint(' Dew point: ${value.toStringAsFixed(1)}°C'); + extraSensorData['dew_$channel'] = value; + break; + + case _lppRain: + final rawValue = reader.readUInt16BE(); + final value = rawValue / 10.0; + debugPrint(' Rain: ${value}mm'); + extraSensorData['rain_$channel'] = value; + break; + + case _lppBinaryBool: + case _lppBinaryPowerSwitch: + case _lppBinaryOpen: + case _lppBinaryBatteryLow: + case _lppBinaryCharging: + case _lppBinaryCarbonMonoxide: + case _lppBinaryCold: + case _lppBinaryConnectivity: + case _lppBinaryDoor: + case _lppBinaryGarageDoor: + case _lppBinaryGas: + case _lppBinaryHeat: + case _lppBinaryLight: + case _lppBinaryLock: + case _lppBinaryMoisture: + case _lppBinaryMotion: + case _lppBinaryMoving: + case _lppBinaryOccupancy: + case _lppBinaryPlug: + case _lppBinaryPresence: + case _lppBinaryProblem: + case _lppBinaryRunning: + case _lppBinarySafety: + case _lppBinarySmoke: + case _lppBinarySound: + case _lppBinaryTamper: + case _lppBinaryVibration: + case _lppBinaryWindow: + final value = reader.readByte(); + debugPrint(' Binary state: $value'); + extraSensorData['${_binaryMetricKeyForType(type)}_$channel'] = + value; + break; + + case _lppButtonEvent: + final value = reader.readByte(); + debugPrint(' Button event: $value'); + extraSensorData['button_event_$channel'] = value; + break; + + case _lppDimmer: + final value = _readInt8(reader); + debugPrint(' Dimmer: $value'); + extraSensorData['dimmer_$channel'] = value; + break; + + case _lppUv: + final value = reader.readByte() / 10.0; + debugPrint(' UV index: $value'); + extraSensorData['uv_$channel'] = value; + break; + + case _lppLightLevel: + final value = reader.readByte(); + debugPrint(' Light level: $value'); + extraSensorData['light_level_$channel'] = value; + break; + + case _lppPm25: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' PM2.5: $value'); + extraSensorData['pm25_$channel'] = value; + break; + + case _lppPm10: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' PM10: $value'); + extraSensorData['pm10_$channel'] = value; + break; + + case _lppCo2: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' CO2: $value'); + extraSensorData['co2_$channel'] = value; + break; + + case _lppTvoc: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' TVOC: $value'); + extraSensorData['tvoc_$channel'] = value; + break; + + case _lppRpm: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' RPM: $value'); + extraSensorData['rpm_$channel'] = value; + break; + + case _lppConductivity: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Conductivity: $value'); + extraSensorData['conductivity_$channel'] = value; + break; + + case _lppRotation: + final rawValue = reader.readInt16BE(); + final value = rawValue / 10.0; + debugPrint(' Rotation: $value'); + extraSensorData['rotation_$channel'] = value; + break; + + case _lppDuration: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Duration: $value s'); + extraSensorData['duration_$channel'] = value; + break; + + case _lppAcceleration: + final rawValue = _readInt32BE(reader); + final value = rawValue / 1000000.0; + debugPrint(' Acceleration: $value'); + extraSensorData['acceleration_$channel'] = value; + break; + + case _lppGyroRate: + final rawValue = _readInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Gyro rate: $value'); + extraSensorData['gyro_rate_$channel'] = value; + break; + + case _lppVolume: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Volume: $value'); + extraSensorData['volume_$channel'] = value; + break; + + case _lppFlowRate: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Flow rate: $value'); + extraSensorData['flow_rate_$channel'] = value; + break; + + case _lppVolumeStorage: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Storage volume: $value'); + extraSensorData['volume_storage_$channel'] = value; + break; + + case _lppWater: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Water: $value'); + extraSensorData['water_$channel'] = value; + break; + + case _lppGasVolume: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Gas volume: $value'); + extraSensorData['gas_volume_$channel'] = value; + break; + + case _lppMass: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Mass: $value'); + extraSensorData['mass_$channel'] = value; + break; + + case _lppSignedSpeed: + final rawValue = _readInt32BE(reader); + final value = rawValue / 1000000.0; + debugPrint(' Signed speed: $value'); + extraSensorData['signed_speed_$channel'] = value; + break; + + case _lppSignedPower: + final rawValue = _readInt32BE(reader); + final value = rawValue / 100.0; + debugPrint(' Signed power: $value'); + extraSensorData['signed_power_$channel'] = value; + break; + + case _lppSignedCurrent: + final rawValue = _readInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Signed current: $value'); + extraSensorData['signed_current_$channel'] = value; + break; + case _lppSwitch: final value = reader.readByte(); debugPrint(' Switch: $value'); @@ -352,11 +612,16 @@ class CayenneLppParser { break; default: - debugPrint( - ' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes', - ); - // Unknown type, skip remaining to avoid parsing errors - reader.skip(reader.remainingBytesCount); + final size = _payloadSizeForType(type); + if (size == null || reader.remainingBytesCount < size) { + debugPrint( + ' ⚠️ Unknown type $type with unsupported size, stopping parse', + ); + reader.skip(reader.remainingBytesCount); + break; + } + debugPrint(' ⚠️ Unknown type $type, skipping $size bytes'); + reader.skip(size); break; } } catch (e) { @@ -420,7 +685,154 @@ class CayenneLppParser { return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]; } - static String _sourceChannelKey(String fieldKey) => '__source_channel:$fieldKey'; + static int _readInt32BE(BufferReader reader) { + final value = _readUInt32BE(reader); + if ((value & 0x80000000) != 0) { + return value - 0x100000000; + } + return value; + } + + static int _readInt8(BufferReader reader) { + final value = reader.readByte(); + if ((value & 0x80) != 0) { + return value - 0x100; + } + return value; + } + + static String _sourceChannelKey(String fieldKey) => + '__source_channel:$fieldKey'; + + static String _binaryMetricKeyForType(int type) { + switch (type) { + case _lppBinaryBool: + return 'binary_bool'; + case _lppBinaryPowerSwitch: + return 'binary_power_switch'; + case _lppBinaryOpen: + return 'binary_open'; + case _lppBinaryBatteryLow: + return 'binary_battery_low'; + case _lppBinaryCharging: + return 'binary_charging'; + case _lppBinaryCarbonMonoxide: + return 'binary_carbon_monoxide'; + case _lppBinaryCold: + return 'binary_cold'; + case _lppBinaryConnectivity: + return 'binary_connectivity'; + case _lppBinaryDoor: + return 'binary_door'; + case _lppBinaryGarageDoor: + return 'binary_garage_door'; + case _lppBinaryGas: + return 'binary_gas'; + case _lppBinaryHeat: + return 'binary_heat'; + case _lppBinaryLight: + return 'binary_light'; + case _lppBinaryLock: + return 'binary_lock'; + case _lppBinaryMoisture: + return 'binary_moisture'; + case _lppBinaryMotion: + return 'binary_motion'; + case _lppBinaryMoving: + return 'binary_moving'; + case _lppBinaryOccupancy: + return 'binary_occupancy'; + case _lppBinaryPlug: + return 'binary_plug'; + case _lppBinaryPresence: + return 'binary_presence'; + case _lppBinaryProblem: + return 'binary_problem'; + case _lppBinaryRunning: + return 'binary_running'; + case _lppBinarySafety: + return 'binary_safety'; + case _lppBinarySmoke: + return 'binary_smoke'; + case _lppBinarySound: + return 'binary_sound'; + case _lppBinaryTamper: + return 'binary_tamper'; + case _lppBinaryVibration: + return 'binary_vibration'; + case _lppBinaryWindow: + return 'binary_window'; + } + return 'binary_state'; + } + + static int? _payloadSizeForType(int type) { + if (type >= _lppBinaryBool && type <= _lppBinaryWindow) { + return 1; + } + switch (type) { + case MeshCoreConstants.lppDigitalInput: + case MeshCoreConstants.lppDigitalOutput: + case MeshCoreConstants.lppPresenceSensor: + case MeshCoreConstants.lppHumiditySensor: + case _lppPercentage: + case _lppSwitch: + case _lppButtonEvent: + case _lppDimmer: + case _lppUv: + case _lppLightLevel: + return 1; + case MeshCoreConstants.lppAnalogInput: + case MeshCoreConstants.lppAnalogOutput: + case MeshCoreConstants.lppIlluminanceSensor: + case MeshCoreConstants.lppTemperatureSensor: + case MeshCoreConstants.lppBarometer: + case MeshCoreConstants.lppVoltageSensor: + case _lppCurrent: + case _lppAltitude: + case _lppConcentration: + case _lppPower: + case _lppSpeed: + case _lppDirection: + case _lppGust: + case _lppDewPoint: + case _lppRain: + case _lppPm25: + case _lppPm10: + case _lppCo2: + case _lppTvoc: + case _lppRpm: + case _lppConductivity: + case _lppRotation: + return 2; + case MeshCoreConstants.lppAccelerometer: + case MeshCoreConstants.lppGyrometer: + return 6; + case MeshCoreConstants.lppGps: + return 9; + case _lppGenericSensor: + case _lppFrequency: + case _lppDistance: + case _lppEnergy: + case _lppUnixTime: + case _lppDuration: + case _lppAcceleration: + case _lppGyroRate: + case _lppVolume: + case _lppFlowRate: + case _lppVolumeStorage: + case _lppWater: + case _lppGasVolume: + case _lppMass: + case _lppSignedSpeed: + case _lppSignedPower: + case _lppSignedCurrent: + return 4; + case _lppColour: + return 3; + } + return null; + } static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) { final start = data.length - remainingBytes; diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index d942e93..b63b1cf 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -1,123 +1,141 @@ import 'package:flutter/material.dart'; -enum AppThemeMode { - light, - dark, - sarRed, - sarGreen, - sarNavyBlue, - system, -} +enum AppThemeMode { light, dark, sarRed, sarGreen, sarNavyBlue, system } class AppTheme { + static ThemeData _withSharedControls(ThemeData theme) { + final colorScheme = theme.colorScheme; + return theme.copyWith( + switchTheme: SwitchThemeData( + thumbColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colorScheme.onSurface.withValues(alpha: 0.38); + } + if (states.contains(WidgetState.selected)) { + return colorScheme.onPrimary; + } + return colorScheme.outline; + }), + trackColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) { + return colorScheme.onSurface.withValues(alpha: 0.12); + } + if (states.contains(WidgetState.selected)) { + return colorScheme.primary; + } + return colorScheme.surfaceContainerHighest; + }), + trackOutlineColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) { + return colorScheme.primary; + } + return colorScheme.outlineVariant; + }), + ), + ); + } + // Light theme (Blue) static ThemeData get lightTheme { - return ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.blue, - brightness: Brightness.light, - ), - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - ), - cardTheme: CardThemeData( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + return _withSharedControls( + ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.light, ), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + filled: true, ), - filled: true, ), ); } // Dark theme (Blue) static ThemeData get darkTheme { - return ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: Colors.blue, - brightness: Brightness.dark, - ), - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - ), - cardTheme: CardThemeData( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + return _withSharedControls( + ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.blue, + brightness: Brightness.dark, ), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0), + cardTheme: CardThemeData( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + filled: true, ), - filled: true, ), ); } // SAR Red theme (Emergency/Alert tones) static ThemeData get sarRedTheme { - return ThemeData( - useMaterial3: true, - colorScheme: const ColorScheme.dark( - brightness: Brightness.dark, - primary: Color(0xFFFF5252), // Bright red - onPrimary: Color(0xFF000000), - primaryContainer: Color(0xFF8B0000), // Dark red - onPrimaryContainer: Color(0xFFFFCDD2), - secondary: Color(0xFFFF8A80), - onSecondary: Color(0xFF000000), - secondaryContainer: Color(0xFFB71C1C), - onSecondaryContainer: Color(0xFFFFCDD2), - tertiary: Color(0xFFFF6E40), - onTertiary: Color(0xFF000000), - error: Color(0xFFCF6679), - onError: Color(0xFF000000), - surface: Color(0xFF1A0000), // Very dark red-tinted - onSurface: Color(0xFFFFEBEE), - surfaceContainerHighest: Color(0xFF2D0000), - onSurfaceVariant: Color(0xFFFFCDD2), - outline: Color(0xFFFF5252), - ), - scaffoldBackgroundColor: const Color(0xFF1A0000), - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - backgroundColor: Color(0xFF2D0000), - foregroundColor: Color(0xFFFFEBEE), - ), - cardTheme: CardThemeData( - elevation: 2, - color: const Color(0xFF2D0000), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: const BorderSide( - color: Color(0xFFFF5252), - width: 1, + return _withSharedControls( + ThemeData( + useMaterial3: true, + colorScheme: const ColorScheme.dark( + brightness: Brightness.dark, + primary: Color(0xFFFF5252), // Bright red + onPrimary: Color(0xFF000000), + primaryContainer: Color(0xFF8B0000), // Dark red + onPrimaryContainer: Color(0xFFFFCDD2), + secondary: Color(0xFFFF8A80), + onSecondary: Color(0xFF000000), + secondaryContainer: Color(0xFFB71C1C), + onSecondaryContainer: Color(0xFFFFCDD2), + tertiary: Color(0xFFFF6E40), + onTertiary: Color(0xFF000000), + error: Color(0xFFCF6679), + onError: Color(0xFF000000), + surface: Color(0xFF1A0000), // Very dark red-tinted + onSurface: Color(0xFFFFEBEE), + surfaceContainerHighest: Color(0xFF2D0000), + onSurfaceVariant: Color(0xFFFFCDD2), + outline: Color(0xFFFF5252), + ), + scaffoldBackgroundColor: const Color(0xFF1A0000), + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + backgroundColor: Color(0xFF2D0000), + foregroundColor: Color(0xFFFFEBEE), + ), + cardTheme: CardThemeData( + elevation: 2, + color: const Color(0xFF2D0000), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Color(0xFFFF5252), width: 1), ), ), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Color(0xFFFF5252)), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFFF5252)), + ), + filled: true, + fillColor: const Color(0xFF2D0000), ), - filled: true, - fillColor: const Color(0xFF2D0000), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFFF5252), - foregroundColor: const Color(0xFF000000), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFF5252), + foregroundColor: const Color(0xFF000000), + ), ), ), ); @@ -125,58 +143,57 @@ class AppTheme { // SAR Green theme (All Clear/Safe tones) static ThemeData get sarGreenTheme { - return ThemeData( - useMaterial3: true, - colorScheme: const ColorScheme.dark( - brightness: Brightness.dark, - primary: Color(0xFF69F0AE), // Bright green - onPrimary: Color(0xFF000000), - primaryContainer: Color(0xFF00695C), // Dark teal-green - onPrimaryContainer: Color(0xFFB9F6CA), - secondary: Color(0xFF64FFDA), - onSecondary: Color(0xFF000000), - secondaryContainer: Color(0xFF004D40), - onSecondaryContainer: Color(0xFFB9F6CA), - tertiary: Color(0xFF1DE9B6), - onTertiary: Color(0xFF000000), - error: Color(0xFFCF6679), - onError: Color(0xFF000000), - surface: Color(0xFF001A12), // Very dark green-tinted - onSurface: Color(0xFFE8F5E9), - surfaceContainerHighest: Color(0xFF002D1F), - onSurfaceVariant: Color(0xFFB9F6CA), - outline: Color(0xFF69F0AE), - ), - scaffoldBackgroundColor: const Color(0xFF001A12), - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - backgroundColor: Color(0xFF002D1F), - foregroundColor: Color(0xFFE8F5E9), - ), - cardTheme: CardThemeData( - elevation: 2, - color: const Color(0xFF002D1F), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: const BorderSide( - color: Color(0xFF69F0AE), - width: 1, + return _withSharedControls( + ThemeData( + useMaterial3: true, + colorScheme: const ColorScheme.dark( + brightness: Brightness.dark, + primary: Color(0xFF69F0AE), // Bright green + onPrimary: Color(0xFF000000), + primaryContainer: Color(0xFF00695C), // Dark teal-green + onPrimaryContainer: Color(0xFFB9F6CA), + secondary: Color(0xFF64FFDA), + onSecondary: Color(0xFF000000), + secondaryContainer: Color(0xFF004D40), + onSecondaryContainer: Color(0xFFB9F6CA), + tertiary: Color(0xFF1DE9B6), + onTertiary: Color(0xFF000000), + error: Color(0xFFCF6679), + onError: Color(0xFF000000), + surface: Color(0xFF001A12), // Very dark green-tinted + onSurface: Color(0xFFE8F5E9), + surfaceContainerHighest: Color(0xFF002D1F), + onSurfaceVariant: Color(0xFFB9F6CA), + outline: Color(0xFF69F0AE), + ), + scaffoldBackgroundColor: const Color(0xFF001A12), + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + backgroundColor: Color(0xFF002D1F), + foregroundColor: Color(0xFFE8F5E9), + ), + cardTheme: CardThemeData( + elevation: 2, + color: const Color(0xFF002D1F), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Color(0xFF69F0AE), width: 1), ), ), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Color(0xFF69F0AE)), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF69F0AE)), + ), + filled: true, + fillColor: const Color(0xFF002D1F), ), - filled: true, - fillColor: const Color(0xFF002D1F), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF69F0AE), - foregroundColor: const Color(0xFF000000), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF69F0AE), + foregroundColor: const Color(0xFF000000), + ), ), ), ); @@ -184,58 +201,57 @@ class AppTheme { // SAR Navy Blue theme (Professional/Operations tones) static ThemeData get sarNavyBlueTheme { - return ThemeData( - useMaterial3: true, - colorScheme: const ColorScheme.dark( - brightness: Brightness.dark, - primary: Color(0xFF5C9FFF), // Bright navy blue - onPrimary: Color(0xFF000000), - primaryContainer: Color(0xFF003366), // Dark navy - onPrimaryContainer: Color(0xFFBBDEFF), - secondary: Color(0xFF80B3FF), - onSecondary: Color(0xFF000000), - secondaryContainer: Color(0xFF002244), - onSecondaryContainer: Color(0xFFBBDEFF), - tertiary: Color(0xFF4DB8FF), - onTertiary: Color(0xFF000000), - error: Color(0xFFCF6679), - onError: Color(0xFF000000), - surface: Color(0xFF00111C), // Very dark blue-tinted - onSurface: Color(0xFFE3F2FD), - surfaceContainerHighest: Color(0xFF001A2D), - onSurfaceVariant: Color(0xFFBBDEFF), - outline: Color(0xFF5C9FFF), - ), - scaffoldBackgroundColor: const Color(0xFF00111C), - appBarTheme: const AppBarTheme( - centerTitle: true, - elevation: 0, - backgroundColor: Color(0xFF001A2D), - foregroundColor: Color(0xFFE3F2FD), - ), - cardTheme: CardThemeData( - elevation: 2, - color: const Color(0xFF001A2D), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: const BorderSide( - color: Color(0xFF5C9FFF), - width: 1, + return _withSharedControls( + ThemeData( + useMaterial3: true, + colorScheme: const ColorScheme.dark( + brightness: Brightness.dark, + primary: Color(0xFF5C9FFF), // Bright navy blue + onPrimary: Color(0xFF000000), + primaryContainer: Color(0xFF003366), // Dark navy + onPrimaryContainer: Color(0xFFBBDEFF), + secondary: Color(0xFF80B3FF), + onSecondary: Color(0xFF000000), + secondaryContainer: Color(0xFF002244), + onSecondaryContainer: Color(0xFFBBDEFF), + tertiary: Color(0xFF4DB8FF), + onTertiary: Color(0xFF000000), + error: Color(0xFFCF6679), + onError: Color(0xFF000000), + surface: Color(0xFF00111C), // Very dark blue-tinted + onSurface: Color(0xFFE3F2FD), + surfaceContainerHighest: Color(0xFF001A2D), + onSurfaceVariant: Color(0xFFBBDEFF), + outline: Color(0xFF5C9FFF), + ), + scaffoldBackgroundColor: const Color(0xFF00111C), + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + backgroundColor: Color(0xFF001A2D), + foregroundColor: Color(0xFFE3F2FD), + ), + cardTheme: CardThemeData( + elevation: 2, + color: const Color(0xFF001A2D), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(color: Color(0xFF5C9FFF), width: 1), ), ), - ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: Color(0xFF5C9FFF)), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF5C9FFF)), + ), + filled: true, + fillColor: const Color(0xFF001A2D), ), - filled: true, - fillColor: const Color(0xFF001A2D), - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF5C9FFF), - foregroundColor: const Color(0xFF000000), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF5C9FFF), + foregroundColor: const Color(0xFF000000), + ), ), ), ); diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 11b9560..c192e59 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -393,7 +393,7 @@ class ContactTile extends StatelessWidget { Navigator.pop(sheetContext); await Future.delayed(Duration.zero); if (!context.mounted) return; - await _showSensorPreviewSheet(context, contact); + await _showSensorPreviewView(context, contact); }, ), if (canAddToSensors) @@ -469,49 +469,14 @@ class ContactTile extends StatelessWidget { ); } - Future _showSensorPreviewSheet( + Future _showSensorPreviewView( 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.', - ), - ), - ); - }, + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (pageContext) => _SensorPreviewView(contact: contact), ), ); } @@ -1044,6 +1009,60 @@ class ContactTile extends StatelessWidget { } } +class _SensorPreviewView extends StatelessWidget { + final Contact contact; + + const _SensorPreviewView({required this.contact}); + + @override + Widget build(BuildContext context) { + final publicKeyHex = contact.publicKeyHex; + return 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 Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + title: Text(previewContact.displayName), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), + children: [ + 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.', + ), + ], + ), + ); + }, + ); + } +} + class _ContactNameOverrideSheet extends StatefulWidget { final String initialValue; final String advertisedName; diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart index 22a6ca5..55bd158 100644 --- a/lib/widgets/sensors/sensor_telemetry_card.dart +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -15,6 +15,7 @@ class SensorMetricOption { final String defaultLabel; final int? channel; final String? valuePreview; + final SensorMetricCardData? previewCardData; const SensorMetricOption({ required this.key, @@ -22,6 +23,7 @@ class SensorMetricOption { required this.defaultLabel, this.channel, this.valuePreview, + this.previewCardData, }); } @@ -52,6 +54,18 @@ List sensorMetricOptionsFor( defaultLabel: 'Voltage', channel: _sourceChannelForField(extraSensorData, 'voltage'), valuePreview: '${(batteryMilliVolts / 1000).toStringAsFixed(3)}V', + previewCardData: SensorMetricCardData( + fieldKey: 'voltage', + icon: Icons.bolt, + label: _resolvedMetricLabel( + 'voltage', + 'Voltage', + labelOverrides: labelOverrides, + ), + value: '${(batteryMilliVolts / 1000).toStringAsFixed(3)}V', + accent: const Color(0xFF0A7D61), + channel: _sourceChannelForField(extraSensorData, 'voltage'), + ), ), if (batteryPercentage != null) SensorMetricOption( @@ -67,6 +81,18 @@ List sensorMetricOptionsFor( defaultLabel: 'Battery', channel: _sourceChannelForField(extraSensorData, 'battery'), valuePreview: '${batteryPercentage.toStringAsFixed(0)}%', + previewCardData: SensorMetricCardData( + fieldKey: 'battery', + icon: Icons.battery_5_bar, + label: _resolvedMetricLabel( + 'battery', + 'Battery', + labelOverrides: labelOverrides, + ), + value: '${batteryPercentage.toStringAsFixed(0)}%', + accent: const Color(0xFF4B8E2F), + channel: _sourceChannelForField(extraSensorData, 'battery'), + ), ), if (temperature != null) SensorMetricOption( @@ -82,6 +108,18 @@ List sensorMetricOptionsFor( defaultLabel: 'Temperature', channel: _sourceChannelForField(extraSensorData, 'temperature'), valuePreview: '${temperature.toStringAsFixed(1)}°C', + previewCardData: SensorMetricCardData( + fieldKey: 'temperature', + icon: Icons.thermostat, + label: _resolvedMetricLabel( + 'temperature', + 'Temperature', + labelOverrides: labelOverrides, + ), + value: '${temperature.toStringAsFixed(1)}°C', + accent: const Color(0xFFC76821), + channel: _sourceChannelForField(extraSensorData, 'temperature'), + ), ), if (humidity != null) SensorMetricOption( @@ -97,6 +135,18 @@ List sensorMetricOptionsFor( defaultLabel: 'Humidity', channel: _sourceChannelForField(extraSensorData, 'humidity'), valuePreview: '${humidity.toStringAsFixed(1)}%', + previewCardData: SensorMetricCardData( + fieldKey: 'humidity', + icon: Icons.water_drop, + label: _resolvedMetricLabel( + 'humidity', + 'Humidity', + labelOverrides: labelOverrides, + ), + value: '${humidity.toStringAsFixed(1)}%', + accent: const Color(0xFF246BB2), + channel: _sourceChannelForField(extraSensorData, 'humidity'), + ), ), if (pressure != null) SensorMetricOption( @@ -112,6 +162,18 @@ List sensorMetricOptionsFor( defaultLabel: 'Pressure', channel: _sourceChannelForField(extraSensorData, 'pressure'), valuePreview: '${pressure.toStringAsFixed(1)} hPa', + previewCardData: SensorMetricCardData( + fieldKey: 'pressure', + icon: Icons.compress, + label: _resolvedMetricLabel( + 'pressure', + 'Pressure', + labelOverrides: labelOverrides, + ), + value: '${pressure.toStringAsFixed(1)} hPa', + accent: const Color(0xFF6B4BAE), + channel: _sourceChannelForField(extraSensorData, 'pressure'), + ), ), if (gpsLocation != null) SensorMetricOption( @@ -124,6 +186,24 @@ List sensorMetricOptionsFor( channel: _sourceChannelForField(extraSensorData, 'gps'), valuePreview: '${gpsLocation.latitude.toStringAsFixed(5)}, ${gpsLocation.longitude.toStringAsFixed(5)}', + previewCardData: SensorMetricCardData( + fieldKey: 'gps', + icon: Icons.place, + label: _resolvedMetricLabel( + 'gps', + 'GPS', + labelOverrides: labelOverrides, + ), + value: + '${gpsLocation.latitude.toStringAsFixed(5)}, ${gpsLocation.longitude.toStringAsFixed(5)}', + secondaryValue: formatPlusCode( + gpsLocation.latitude, + gpsLocation.longitude, + ), + accent: const Color(0xFFAA3F57), + wide: true, + channel: _sourceChannelForField(extraSensorData, 'gps'), + ), ), ]; @@ -135,20 +215,24 @@ List sensorMetricOptionsFor( final metricKey = _parseMetricKey(key); final fieldKey = _extraFieldKey(key); final defaultLabel = _formatExtraFieldLabel(key); + final resolvedLabel = _resolvedMetricLabel( + fieldKey, + defaultLabel, + labelOverrides: labelOverrides, + ); options.add( SensorMetricOption( key: fieldKey, - label: _selectorMetricLabel( - _resolvedMetricLabel( - fieldKey, - defaultLabel, - labelOverrides: labelOverrides, - ), - metricKey.channel, - ), + label: _selectorMetricLabel(resolvedLabel, metricKey.channel), defaultLabel: defaultLabel, channel: metricKey.channel, valuePreview: _sensorMetricPreviewValue(key, extraSensorData[key]), + previewCardData: _buildOptionPreviewCardData( + key, + extraSensorData[key], + fieldKey: fieldKey, + label: resolvedLabel, + ), ), ); } @@ -157,6 +241,405 @@ List sensorMetricOptionsFor( return options; } +SensorMetricCardData? _buildOptionPreviewCardData( + String rawKey, + dynamic value, { + required String fieldKey, + required String label, +}) { + final metricKey = _parseMetricKey(rawKey); + final previewValue = _sensorMetricPreviewValue(rawKey, value); + if (previewValue == null) { + return null; + } + + if (_isBinaryMetricBaseKey(metricKey.baseKey)) { + return SensorMetricCardData( + fieldKey: fieldKey, + icon: previewValue == 'On' ? Icons.toggle_on : Icons.toggle_off, + label: label, + value: previewValue, + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + } + + switch (metricKey.baseKey) { + case 'altitude': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.terrain_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF7A5C3E), + channel: metricKey.channel, + ); + case 'illuminance': + final lux = _previewAsDouble(value); + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.light_mode_outlined, + label: label, + value: previewValue, + secondaryValue: lux == null + ? null + : '~${_formatPreviewNumber(_previewApproxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2 daylight', + accent: const Color(0xFFC17B1D), + channel: metricKey.channel, + ); + case 'presence': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.sensor_occupied_outlined, + label: label, + value: previewValue, + accent: const Color(0xFFAA3F57), + channel: metricKey.channel, + ); + case 'digital_input': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.input_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF3A6D8C), + channel: metricKey.channel, + ); + case 'digital_output': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.output_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + case 'analog_input': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.tune, + label: label, + value: previewValue, + accent: const Color(0xFF5A6C84), + channel: metricKey.channel, + ); + case 'analog_output': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.tune, + label: label, + value: previewValue, + accent: const Color(0xFF4B7785), + channel: metricKey.channel, + ); + case 'accelerometer': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.vibration_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF5A4C99), + wide: true, + channel: metricKey.channel, + ); + case 'gyrometer': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.threed_rotation, + label: label, + value: previewValue, + accent: const Color(0xFF6C4F96), + wide: true, + channel: metricKey.channel, + ); + case 'generic_sensor': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.sensors, + label: label, + value: previewValue, + accent: const Color(0xFF3E657C), + channel: metricKey.channel, + ); + case 'button_event': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.touch_app_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF6C4F96), + channel: metricKey.channel, + ); + case 'dimmer': + case 'light_level': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.tune, + label: label, + value: previewValue, + accent: const Color(0xFF5A6C84), + channel: metricKey.channel, + ); + case 'current': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.electric_bolt, + label: label, + value: previewValue, + accent: const Color(0xFF1C7C54), + channel: metricKey.channel, + ); + case 'signed_current': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.electric_bolt, + label: label, + value: previewValue, + accent: const Color(0xFF1C7C54), + channel: metricKey.channel, + ); + case 'frequency': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.graphic_eq, + label: label, + value: previewValue, + accent: const Color(0xFF2C6BA0), + channel: metricKey.channel, + ); + case 'percentage': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.percent, + label: label, + value: previewValue, + accent: const Color(0xFF4B8E2F), + channel: metricKey.channel, + ); + case 'concentration': + case 'co2': + case 'tvoc': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.bubble_chart_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, + ); + case 'power': + case 'signed_power': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.flash_on_outlined, + label: label, + value: previewValue, + accent: const Color(0xFFB5622E), + channel: metricKey.channel, + ); + case 'speed': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.air, + label: label, + value: previewValue, + accent: const Color(0xFF2B78A0), + channel: metricKey.channel, + ); + case 'signed_speed': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.air, + label: label, + value: previewValue, + accent: const Color(0xFF2B78A0), + channel: metricKey.channel, + ); + case 'gust': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.air, + label: label, + value: previewValue, + accent: const Color(0xFF1E88A8), + channel: metricKey.channel, + ); + case 'dew': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.device_thermostat, + label: label, + value: previewValue, + accent: const Color(0xFF2F7AA1), + channel: metricKey.channel, + ); + case 'rain': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.grain, + label: label, + value: previewValue, + accent: const Color(0xFF2C6BA0), + channel: metricKey.channel, + ); + case 'distance': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.straighten, + label: label, + value: previewValue, + accent: const Color(0xFF577590), + channel: metricKey.channel, + ); + case 'duration': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.timer_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF577590), + channel: metricKey.channel, + ); + case 'energy': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.battery_charging_full, + label: label, + value: previewValue, + accent: const Color(0xFF9C6644), + channel: metricKey.channel, + ); + case 'volume': + case 'flow_rate': + case 'volume_storage': + case 'water': + case 'gas_volume': + case 'mass': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.inventory_2_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF5F7C8A), + channel: metricKey.channel, + ); + case 'direction': + case 'rotation': + final degrees = _previewAsDouble(value); + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.explore_outlined, + label: label, + value: previewValue, + secondaryValue: degrees == null + ? null + : _previewFormatCardinalDirection(degrees), + accent: const Color(0xFF8A5A44), + channel: metricKey.channel, + ); + case 'unixtime': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.schedule, + label: label, + value: previewValue, + accent: const Color(0xFF6B7280), + wide: true, + channel: metricKey.channel, + ); + case 'colour': + final color = _previewAsRgb(value); + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.palette_outlined, + label: label, + value: previewValue, + secondaryValue: color == null + ? null + : 'R ${color.r} • G ${color.g} • B ${color.b}', + accent: color == null + ? const Color(0xFF3E657C) + : Color.fromARGB(255, color.r, color.g, color.b), + wide: true, + channel: metricKey.channel, + ); + case 'switch': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: previewValue == 'On' ? Icons.toggle_on : Icons.toggle_off, + label: label, + value: previewValue, + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + case 'voltage': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.bolt, + label: label, + value: previewValue, + accent: const Color(0xFF0A7D61), + channel: metricKey.channel, + ); + case 'conductivity': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.science_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, + ); + case 'acceleration': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.speed_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF5A4C99), + channel: metricKey.channel, + ); + case 'gyro_rate': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.sync, + label: label, + value: previewValue, + accent: const Color(0xFF6C4F96), + channel: metricKey.channel, + ); + case 'pm25': + case 'pm10': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.grain, + label: label, + value: previewValue, + accent: const Color(0xFF7A6C5D), + channel: metricKey.channel, + ); + case 'uv': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.wb_sunny_outlined, + label: label, + value: previewValue, + accent: const Color(0xFFC17B1D), + channel: metricKey.channel, + ); + } + + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.sensors, + label: label, + value: previewValue, + accent: const Color(0xFF3E657C), + wide: value is Map, + channel: metricKey.channel, + ); +} + Set sensorMetricKeysFor(Contact? contact) { return sensorMetricOptionsFor(contact).map((option) => option.key).toSet(); } @@ -173,9 +656,26 @@ Map sensorFullWidthFieldSpans(Iterable fieldKeys) { return {for (final fieldKey in fieldKeys) fieldKey: 2}; } +double _previewApproxDaylightIrradiance(double lux) { + return lux / 120.0; +} + +String _previewFormatCardinalDirection(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? _sensorMetricPreviewValue(String rawKey, dynamic value) { final metricKey = _parseMetricKey(rawKey); + if (_isBinaryMetricBaseKey(metricKey.baseKey)) { + final isOn = _previewAsBool(value); + if (isOn == null) return null; + return isOn ? 'On' : 'Off'; + } + switch (metricKey.baseKey) { case 'altitude': final meters = _previewAsDouble(value); @@ -201,10 +701,21 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) { case 'analog_input': case 'analog_output': case 'generic_sensor': + case 'light_level': final reading = _previewAsDouble(value); if (reading == null) return null; return _formatPreviewNumber(reading, maxFractionDigits: 3); + case 'button_event': + final eventCode = _previewAsInt(value); + if (eventCode == null) return null; + return _formatButtonEvent(eventCode); + + case 'dimmer': + final reading = _previewAsInt(value); + if (reading == null) return null; + return reading.toString(); + case 'accelerometer': final vector = _previewAsVector3(value); if (vector == null) return null; @@ -224,6 +735,11 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) { if (amps == null) return null; return _formatPreviewCurrent(amps); + case 'signed_current': + final amps = _previewAsDouble(value); + if (amps == null) return null; + return _formatPreviewCurrent(amps); + case 'frequency': final hertz = _previewAsDouble(value); if (hertz == null) return null; @@ -246,26 +762,75 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) { if (watts == null) return null; return _formatPreviewPower(watts); + case 'signed_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 'signed_speed': + final metersPerSecond = _previewAsDouble(value); + if (metersPerSecond == null) return null; + return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; + + case 'gust': + final metersPerSecond = _previewAsDouble(value); + if (metersPerSecond == null) return null; + return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; + + case 'dew': + final degreesCelsius = _previewAsDouble(value); + if (degreesCelsius == null) return null; + return '${_formatPreviewNumber(degreesCelsius, maxFractionDigits: 1)}°C'; + + case 'rain': + final millimeters = _previewAsDouble(value); + if (millimeters == null) return null; + return '${_formatPreviewNumber(millimeters, maxFractionDigits: 1)} mm'; + case 'distance': final meters = _previewAsDouble(value); if (meters == null) return null; return _formatPreviewDistance(meters); + case 'duration': + final seconds = _previewAsDouble(value); + if (seconds == null) return null; + return '${_formatPreviewNumber(seconds, maxFractionDigits: 2)} s'; + case 'energy': final kilowattHours = _previewAsDouble(value); if (kilowattHours == null) return null; return _formatPreviewEnergy(kilowattHours); + case 'volume': + case 'flow_rate': + case 'volume_storage': + case 'water': + case 'gas_volume': + final liters = _previewAsDouble(value); + if (liters == null) return null; + return '${_formatPreviewNumber(liters, maxFractionDigits: 3)} L'; + + case 'mass': + final kilograms = _previewAsDouble(value); + if (kilograms == null) return null; + return '${_formatPreviewNumber(kilograms, maxFractionDigits: 3)} kg'; + case 'direction': final degrees = _previewAsDouble(value); if (degrees == null) return null; return '${_formatPreviewNumber(degrees, maxFractionDigits: 0)} deg'; + case 'rotation': + final degrees = _previewAsDouble(value); + if (degrees == null) return null; + return '${_formatPreviewNumber(degrees, maxFractionDigits: 1)} deg'; + case 'unixtime': final seconds = _previewAsInt(value); if (seconds == null) return null; @@ -292,6 +857,21 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) { if (volts == null) return null; return '${_formatPreviewNumber(volts, maxFractionDigits: 3)} V'; + case 'conductivity': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return _formatPreviewNumber(reading, maxFractionDigits: 0); + + case 'acceleration': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return '${_formatPreviewNumber(reading, maxFractionDigits: 3)} m/s2'; + + case 'gyro_rate': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return '${_formatPreviewNumber(reading, maxFractionDigits: 3)} deg/s'; + case 'pm25': case 'pm10': final reading = _previewAsDouble(value); @@ -458,7 +1038,7 @@ class SensorTelemetryCard extends StatelessWidget { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final metrics = contact == null || telemetry == null - ? const <_MetricCardData>[] + ? const [] : _sortMetricsByFieldOrder( _buildMetricCards(l10n, telemetry, contact!), ); @@ -614,7 +1194,7 @@ class SensorTelemetryCard extends StatelessWidget { runSpacing: spacing, children: metrics .map( - (metric) => _MetricTile( + (metric) => SensorMetricTile( data: metric, width: (fieldSpans[metric.fieldKey] == 2 || @@ -633,17 +1213,17 @@ class SensorTelemetryCard extends StatelessWidget { ); } - List<_MetricCardData> _buildMetricCards( + List _buildMetricCards( AppLocalizations l10n, dynamic telemetry, Contact contact, ) { - final items = <_MetricCardData>[]; + final items = []; if (visibleFields.contains('voltage') && telemetry.batteryMilliVolts != null) { items.add( - _MetricCardData( + SensorMetricCardData( fieldKey: 'voltage', icon: Icons.bolt, label: _resolvedMetricLabel( @@ -660,7 +1240,7 @@ class SensorTelemetryCard extends StatelessWidget { if (visibleFields.contains('battery') && telemetry.batteryPercentage != null) { items.add( - _MetricCardData( + SensorMetricCardData( fieldKey: 'battery', icon: Icons.battery_5_bar, label: _resolvedMetricLabel( @@ -677,7 +1257,7 @@ class SensorTelemetryCard extends StatelessWidget { if (visibleFields.contains('temperature') && telemetry.temperature != null) { items.add( - _MetricCardData( + SensorMetricCardData( fieldKey: 'temperature', icon: Icons.thermostat, label: _resolvedMetricLabel( @@ -696,7 +1276,7 @@ class SensorTelemetryCard extends StatelessWidget { } if (visibleFields.contains('humidity') && telemetry.humidity != null) { items.add( - _MetricCardData( + SensorMetricCardData( fieldKey: 'humidity', icon: Icons.water_drop, label: _resolvedMetricLabel( @@ -715,7 +1295,7 @@ class SensorTelemetryCard extends StatelessWidget { } if (visibleFields.contains('pressure') && telemetry.pressure != null) { items.add( - _MetricCardData( + SensorMetricCardData( fieldKey: 'pressure', icon: Icons.compress, label: _resolvedMetricLabel( @@ -734,7 +1314,7 @@ class SensorTelemetryCard extends StatelessWidget { } if (visibleFields.contains('gps') && telemetry.gpsLocation != null) { items.add( - _MetricCardData( + SensorMetricCardData( fieldKey: 'gps', icon: Icons.place, label: _resolvedMetricLabel( @@ -777,8 +1357,8 @@ class SensorTelemetryCard extends StatelessWidget { return items; } - List<_MetricCardData> _sortMetricsByFieldOrder( - List<_MetricCardData> metrics, + List _sortMetricsByFieldOrder( + List metrics, ) { final order = fieldOrder; if (order == null || order.isEmpty || metrics.length < 2) { @@ -800,7 +1380,10 @@ class SensorTelemetryCard extends StatelessWidget { return indexedMetrics.map((entry) => entry.value).toList(growable: false); } - _MetricCardData? _buildExtraMetricCardData(String rawKey, dynamic value) { + SensorMetricCardData? _buildExtraMetricCardData( + String rawKey, + dynamic value, + ) { final metricKey = _parseMetricKey(rawKey); final fieldKey = _extraFieldKey(rawKey); final label = _resolvedMetricLabel( @@ -809,11 +1392,24 @@ class SensorTelemetryCard extends StatelessWidget { labelOverrides: labelOverrides, ); + if (_isBinaryMetricBaseKey(metricKey.baseKey)) { + final isOn = _asBool(value); + if (isOn == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: isOn ? Icons.toggle_on : Icons.toggle_off, + label: label, + value: isOn ? 'On' : 'Off', + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + } + switch (metricKey.baseKey) { case 'altitude': final meters = _asDouble(value); if (meters == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.terrain_outlined, label: label, @@ -825,7 +1421,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'illuminance': final lux = _asDouble(value); if (lux == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.light_mode_outlined, label: label, @@ -839,7 +1435,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'presence': final isPresent = _asBool(value); if (isPresent == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.sensor_occupied_outlined, label: label, @@ -851,7 +1447,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'digital_input': final isHigh = _asBool(value); if (isHigh == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.input_outlined, label: label, @@ -863,7 +1459,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'digital_output': final isHigh = _asBool(value); if (isHigh == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.output_outlined, label: label, @@ -875,7 +1471,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'analog_input': final reading = _asDouble(value); if (reading == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.tune, label: label, @@ -887,7 +1483,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'analog_output': final reading = _asDouble(value); if (reading == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.tune, label: label, @@ -899,7 +1495,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'accelerometer': final vector = _asVector3(value); if (vector == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.vibration_outlined, label: label, @@ -915,7 +1511,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'gyrometer': final vector = _asVector3(value); if (vector == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.threed_rotation, label: label, @@ -931,7 +1527,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'generic_sensor': final reading = _asDouble(value); if (reading == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.sensors, label: label, @@ -940,10 +1536,47 @@ class SensorTelemetryCard extends StatelessWidget { channel: metricKey.channel, ); + case 'button_event': + final reading = _asInt(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.touch_app_outlined, + label: label, + value: _formatButtonEvent(reading), + accent: const Color(0xFF6C4F96), + channel: metricKey.channel, + ); + + case 'dimmer': + case 'light_level': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.tune, + label: label, + value: _formatNumber(reading, maxFractionDigits: 2), + accent: const Color(0xFF5A6C84), + channel: metricKey.channel, + ); + case 'current': final amps = _asDouble(value); if (amps == null) return null; - return _MetricCardData( + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.electric_bolt, + label: label, + value: _formatCurrent(amps), + accent: const Color(0xFF1C7C54), + channel: metricKey.channel, + ); + + case 'signed_current': + final amps = _asDouble(value); + if (amps == null) return null; + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.electric_bolt, label: label, @@ -955,7 +1588,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'frequency': final hz = _asDouble(value); if (hz == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.graphic_eq, label: label, @@ -967,7 +1600,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'percentage': final reading = _asDouble(value); if (reading == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.percent, label: label, @@ -979,7 +1612,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'concentration': final reading = _asDouble(value); if (reading == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.bubble_chart_outlined, label: label, @@ -991,7 +1624,19 @@ class SensorTelemetryCard extends StatelessWidget { case 'power': final watts = _asDouble(value); if (watts == null) return null; - return _MetricCardData( + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.flash_on_outlined, + label: label, + value: _formatPower(watts), + accent: const Color(0xFFB5622E), + channel: metricKey.channel, + ); + + case 'signed_power': + final watts = _asDouble(value); + if (watts == null) return null; + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.flash_on_outlined, label: label, @@ -1003,7 +1648,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'speed': final metersPerSecond = _asDouble(value); if (metersPerSecond == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.air, label: label, @@ -1012,10 +1657,58 @@ class SensorTelemetryCard extends StatelessWidget { channel: metricKey.channel, ); + case 'signed_speed': + final metersPerSecond = _asDouble(value); + if (metersPerSecond == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.air, + label: label, + value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', + accent: const Color(0xFF2B78A0), + channel: metricKey.channel, + ); + + case 'gust': + final metersPerSecond = _asDouble(value); + if (metersPerSecond == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.air, + label: label, + value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', + accent: const Color(0xFF1E88A8), + channel: metricKey.channel, + ); + + case 'dew': + final degreesCelsius = _asDouble(value); + if (degreesCelsius == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.device_thermostat, + label: label, + value: '${_formatNumber(degreesCelsius, maxFractionDigits: 1)}°C', + accent: const Color(0xFF2F7AA1), + channel: metricKey.channel, + ); + + case 'rain': + final millimeters = _asDouble(value); + if (millimeters == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.grain, + label: label, + value: '${_formatNumber(millimeters, maxFractionDigits: 1)} mm', + accent: const Color(0xFF2C6BA0), + channel: metricKey.channel, + ); + case 'distance': final meters = _asDouble(value); if (meters == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.straighten, label: label, @@ -1024,10 +1717,22 @@ class SensorTelemetryCard extends StatelessWidget { channel: metricKey.channel, ); + case 'duration': + final seconds = _asDouble(value); + if (seconds == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.timer_outlined, + label: label, + value: '${_formatNumber(seconds, maxFractionDigits: 2)} s', + accent: const Color(0xFF577590), + channel: metricKey.channel, + ); + case 'energy': final kwh = _asDouble(value); if (kwh == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.battery_charging_full, label: label, @@ -1036,10 +1741,28 @@ class SensorTelemetryCard extends StatelessWidget { channel: metricKey.channel, ); + case 'volume': + case 'flow_rate': + case 'volume_storage': + case 'water': + case 'gas_volume': + case 'mass': + final reading = _asDouble(value); + if (reading == null) return null; + final unit = metricKey.baseKey == 'mass' ? 'kg' : 'L'; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.inventory_2_outlined, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 3)} $unit', + accent: const Color(0xFF5F7C8A), + channel: metricKey.channel, + ); + case 'direction': final degrees = _asDouble(value); if (degrees == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.explore_outlined, label: label, @@ -1049,6 +1772,18 @@ class SensorTelemetryCard extends StatelessWidget { channel: metricKey.channel, ); + case 'rotation': + final degrees = _asDouble(value); + if (degrees == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.explore_outlined, + label: label, + value: '${_formatNumber(degrees, maxFractionDigits: 1)} deg', + accent: const Color(0xFF8A5A44), + channel: metricKey.channel, + ); + case 'unixtime': final seconds = _asInt(value); if (seconds == null) return null; @@ -1056,7 +1791,7 @@ class SensorTelemetryCard extends StatelessWidget { seconds * 1000, isUtc: true, ).toLocal(); - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.schedule, label: label, @@ -1070,7 +1805,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'colour': final color = _asRgb(value); if (color == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.palette_outlined, label: label, @@ -1085,7 +1820,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'switch': final isOn = _asBool(value); if (isOn == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: isOn ? Icons.toggle_on : Icons.toggle_off, label: label, @@ -1097,7 +1832,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'voltage': final volts = _asDouble(value); if (volts == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.bolt, label: label, @@ -1105,6 +1840,42 @@ class SensorTelemetryCard extends StatelessWidget { accent: const Color(0xFF0A7D61), channel: metricKey.channel, ); + + case 'conductivity': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.science_outlined, + label: label, + value: _formatNumber(reading, maxFractionDigits: 0), + accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, + ); + + case 'acceleration': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.speed_outlined, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 3)} m/s2', + accent: const Color(0xFF5A4C99), + channel: metricKey.channel, + ); + + case 'gyro_rate': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sync, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 3)} deg/s', + accent: const Color(0xFF6C4F96), + channel: metricKey.channel, + ); } switch (metricKey.baseKey) { @@ -1112,7 +1883,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'tvoc': final reading = _asDouble(value); if (reading == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.bubble_chart_outlined, label: label, @@ -1125,7 +1896,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'pm10': final reading = _asDouble(value); if (reading == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.grain, label: label, @@ -1137,7 +1908,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'uv': final reading = _asDouble(value); if (reading == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.wb_sunny_outlined, label: label, @@ -1148,7 +1919,7 @@ class SensorTelemetryCard extends StatelessWidget { } if (value is num) { - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.sensors, label: label, @@ -1158,7 +1929,7 @@ class SensorTelemetryCard extends StatelessWidget { ); } - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.sensors, label: label, @@ -1370,11 +2141,19 @@ class _InlineAlertBadge extends StatelessWidget { } } -class _MetricTile extends StatelessWidget { - final _MetricCardData data; +class SensorMetricTile extends StatelessWidget { + final SensorMetricCardData data; final double width; + final String keyPrefix; + final bool allowMapPreview; - const _MetricTile({required this.data, required this.width}); + const SensorMetricTile({ + super.key, + required this.data, + required this.width, + this.keyPrefix = 'sensor_metric', + this.allowMapPreview = true, + }); Future _showExpandedMap(BuildContext context) async { final location = data.mapLocation; @@ -1449,7 +2228,7 @@ class _MetricTile extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - key: ValueKey('sensor_metric_${data.fieldKey}'), + key: ValueKey('${keyPrefix}_${data.fieldKey}'), width: width, padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -1457,13 +2236,15 @@ class _MetricTile extends StatelessWidget { borderRadius: BorderRadius.circular(22), border: Border.all(color: data.accent.withValues(alpha: 0.14)), ), - child: data.mapLocation == null + child: data.mapLocation == null || !allowMapPreview ? Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _MetricIcon(accent: data.accent, icon: data.icon), const SizedBox(width: 10), - Expanded(child: _MetricText(data: data)), + Expanded( + child: _MetricText(data: data, keyPrefix: keyPrefix), + ), ], ) : Column( @@ -1474,7 +2255,9 @@ class _MetricTile extends StatelessWidget { children: [ _MetricIcon(accent: data.accent, icon: data.icon), const SizedBox(width: 10), - Expanded(child: _MetricText(data: data)), + Expanded( + child: _MetricText(data: data, keyPrefix: keyPrefix), + ), ], ), const SizedBox(height: 10), @@ -1588,9 +2371,10 @@ class _MetricIcon extends StatelessWidget { } class _MetricText extends StatelessWidget { - final _MetricCardData data; + final SensorMetricCardData data; + final String keyPrefix; - const _MetricText({required this.data}); + const _MetricText({required this.data, required this.keyPrefix}); @override Widget build(BuildContext context) { @@ -1614,7 +2398,7 @@ class _MetricText extends StatelessWidget { if (data.channel != null) ...[ const SizedBox(width: 8), Container( - key: ValueKey('sensor_metric_channel_${data.fieldKey}'), + key: ValueKey('${keyPrefix}_channel_${data.fieldKey}'), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: data.accent.withValues(alpha: 0.12), @@ -1658,7 +2442,7 @@ class _MetricText extends StatelessWidget { } } -class _MetricCardData { +class SensorMetricCardData { final String fieldKey; final IconData icon; final String label; @@ -1669,7 +2453,7 @@ class _MetricCardData { final LatLng? mapLocation; final int? channel; - const _MetricCardData({ + const SensorMetricCardData({ required this.fieldKey, required this.icon, required this.label, @@ -1752,6 +2536,32 @@ String _selectorMetricLabel(String label, int? channel) { return '$label (ch $channel)'; } +bool _isBinaryMetricBaseKey(String baseKey) { + return baseKey.startsWith('binary_'); +} + +String _formatButtonEvent(int value) { + switch (value) { + case 0: + return 'None'; + case 1: + return 'Press'; + case 2: + return 'Double press'; + case 3: + return 'Triple press'; + case 4: + return 'Long press'; + case 5: + return 'Long double'; + case 6: + return 'Long triple'; + case 128: + return 'Hold'; + } + return value.toString(); +} + String _formatExtraFieldLabel(String rawKey) { final metricKey = _parseMetricKey(rawKey); return _knownMetricLabels[metricKey.baseKey] ?? @@ -1785,13 +2595,60 @@ const List _knownMetricBaseKeys = [ 'energy', 'power', 'speed', + 'gust', + 'dew', + 'rain', 'pm25', 'pm10', 'tvoc', 'co2', 'rpm', - 'cond', + 'conductivity', 'uv', + 'button_event', + 'dimmer', + 'light_level', + 'rotation', + 'duration', + 'acceleration', + 'gyro_rate', + 'volume', + 'flow_rate', + 'volume_storage', + 'water', + 'gas_volume', + 'mass', + 'signed_speed', + 'signed_power', + 'signed_current', + 'binary_bool', + 'binary_power_switch', + 'binary_open', + 'binary_battery_low', + 'binary_charging', + 'binary_carbon_monoxide', + 'binary_cold', + 'binary_connectivity', + 'binary_door', + 'binary_garage_door', + 'binary_gas', + 'binary_heat', + 'binary_light', + 'binary_lock', + 'binary_moisture', + 'binary_motion', + 'binary_moving', + 'binary_occupancy', + 'binary_plug', + 'binary_presence', + 'binary_problem', + 'binary_running', + 'binary_safety', + 'binary_smoke', + 'binary_sound', + 'binary_tamper', + 'binary_vibration', + 'binary_window', ]; const Map _knownMetricLabels = { @@ -1802,32 +2659,78 @@ const Map _knownMetricLabels = { 'co2': 'CO2', 'colour': 'Color', 'concentration': 'Concentration', - 'cond': 'Conductivity', + 'conductivity': 'Conductivity', 'current': 'Current', + 'button_event': 'Button', + 'dimmer': 'Dimmer', 'digital_input': 'Digital input', 'digital_output': 'Digital output', 'direction': 'Direction', 'distance': 'Distance', + 'duration': 'Duration', 'energy': 'Energy', 'frequency': 'Frequency', + 'flow_rate': 'Flow rate', + 'gas_volume': 'Gas volume', 'generic_sensor': 'Generic sensor', + 'gyro_rate': 'Gyro rate', 'gyrometer': 'Gyrometer', 'humidity': 'Humidity', 'illuminance': 'Illuminance', + 'light_level': 'Light level', + 'mass': 'Mass', 'percentage': 'Percentage', 'pm10': 'PM10', 'pm25': 'PM2.5', 'power': 'Power', 'presence': 'Presence', 'pressure': 'Pressure', + 'rain': 'Rain', + 'rotation': 'Rotation', 'rpm': 'RPM', + 'gust': 'Wind gust', + 'signed_current': 'Signed current', + 'signed_power': 'Signed power', + 'signed_speed': 'Signed speed', 'speed': 'Speed', 'switch': 'Switch', + 'dew': 'Dew point', 'temperature': 'Temperature', 'tvoc': 'TVOC', 'unixtime': 'Time', 'uv': 'UV index', + 'volume': 'Volume', + 'volume_storage': 'Storage volume', 'voltage': 'Voltage', + 'water': 'Water', + 'binary_battery_low': 'Battery low', + 'binary_bool': 'Binary', + 'binary_carbon_monoxide': 'Carbon monoxide', + 'binary_charging': 'Charging', + 'binary_cold': 'Cold', + 'binary_connectivity': 'Connectivity', + 'binary_door': 'Door', + 'binary_garage_door': 'Garage door', + 'binary_gas': 'Gas', + 'binary_heat': 'Heat', + 'binary_light': 'Light', + 'binary_lock': 'Lock', + 'binary_moisture': 'Moisture', + 'binary_motion': 'Motion', + 'binary_moving': 'Moving', + 'binary_occupancy': 'Occupancy', + 'binary_open': 'Open', + 'binary_plug': 'Plug', + 'binary_power_switch': 'Power switch', + 'binary_presence': 'Presence', + 'binary_problem': 'Problem', + 'binary_running': 'Running', + 'binary_safety': 'Safety', + 'binary_smoke': 'Smoke', + 'binary_sound': 'Sound', + 'binary_tamper': 'Tamper', + 'binary_vibration': 'Vibration', + 'binary_window': 'Window', }; _ParsedMetricKey _parseMetricKey(String rawKey) { diff --git a/pubspec.yaml b/pubspec.yaml index 68ed14c..02a3c3f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.0314.1+30 +version: 2026.0315.1+31 environment: sdk: ^3.9.2 diff --git a/test/providers/sensors_provider_test.dart b/test/providers/sensors_provider_test.dart index f5ed43d..f74a107 100644 --- a/test/providers/sensors_provider_test.dart +++ b/test/providers/sensors_provider_test.dart @@ -75,6 +75,42 @@ void main() { ); } + Contact buildTelemetrySensorContact() { + final publicKey = Uint8List(32); + publicKey[0] = 0x55; + + return Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Weather Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + temperature: 11.8, + humidity: 51, + pressure: 921.9, + timestamp: DateTime.now(), + extraSensorData: const { + '__source_channel:temperature': 3, + '__source_channel:humidity': 2, + '__source_channel:pressure': 2, + 'illuminance_2': 19150.0, + 'temperature_2': 1.99, + 'voltage_2': 5.2, + 'switch_2': 0, + 'speed_2': 2.8, + 'speed_3': 3.7, + 'uv_2': 1.0, + }, + ), + ); + } + test('metric label overrides persist across reloads', () async { SharedPreferences.setMockInitialValues({}); final contact = buildSensorContact(); @@ -148,16 +184,31 @@ void main() { final provider = SensorsProvider(); await waitUntilLoaded(provider); await provider.addSensor(contact); - await provider.setAutoRefreshMinutes(contact.publicKeyHex, 5); + await provider.setAutoRefreshMinutes(contact.publicKeyHex, 1440); - expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 5); + expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 1440); final reloadedProvider = SensorsProvider(); await waitUntilLoaded(reloadedProvider); - expect(reloadedProvider.autoRefreshMinutesFor(contact.publicKeyHex), 5); + expect(reloadedProvider.autoRefreshMinutesFor(contact.publicKeyHex), 1440); }); + test( + 'unsupported auto refresh minutes normalize to nearest option', + () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildSensorContact(); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + await provider.setAutoRefreshMinutes(contact.publicKeyHex, 1); + + expect(provider.autoRefreshMinutesFor(contact.publicKeyHex), 5); + }, + ); + test('refreshDueSensors respects per-contact interval', () async { SharedPreferences.setMockInitialValues({}); final contact = buildSensorContact(); @@ -191,4 +242,32 @@ void main() { ); expect(connectionProvider.pingCalls, 2); }); + + test( + 'addSensor includes available extra telemetry fields by default', + () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildTelemetrySensorContact(); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + + expect( + provider.visibleFieldsFor(contact.publicKeyHex), + containsAll({ + 'temperature', + 'humidity', + 'pressure', + 'extra:illuminance_2', + 'extra:temperature_2', + 'extra:voltage_2', + 'extra:switch_2', + 'extra:speed_2', + 'extra:speed_3', + 'extra:uv_2', + }), + ); + }, + ); } diff --git a/test/screens/sensors_tab_test.dart b/test/screens/sensors_tab_test.dart index e55d576..979cfd4 100644 --- a/test/screens/sensors_tab_test.dart +++ b/test/screens/sensors_tab_test.dart @@ -50,9 +50,7 @@ void main() { ); } - testWidgets('customize sheet shows metric value previews and channels', ( - tester, - ) async { + testWidgets('customize action opens full customization view', (tester) async { final contact = buildSensorContact(); final sensorsProvider = SensorsProvider(); final contactsProvider = ContactsProvider(); @@ -61,40 +59,39 @@ void main() { contactsProvider.addOrUpdateContact(contact); await sensorsProvider.addSensor(contact); - await tester.pumpWidget( - MultiProvider( - providers: [ - ChangeNotifierProvider.value( - value: contactsProvider, - ), - ChangeNotifierProvider.value(value: sensorsProvider), + 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.pump(); - 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)); + 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.text('Customize WX Station'), findsOneWidget); + expect(find.text('Live preview'), findsOneWidget); + expect(find.text('Refresh schedule'), findsOneWidget); expect( - find.byKey(const ValueKey('sensor_selector_value_extra:illuminance_2')), + find.byKey( + const ValueKey('sensor_selector_metric_channel_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); + expect(find.text('Channel 2'), findsOneWidget); }); } diff --git a/test/services/cayenne_lpp_parser_test.dart b/test/services/cayenne_lpp_parser_test.dart index 82379e3..e429305 100644 --- a/test/services/cayenne_lpp_parser_test.dart +++ b/test/services/cayenne_lpp_parser_test.dart @@ -373,6 +373,9 @@ void main() { const lppDirection = 132; const lppUnixTime = 133; const lppColour = 135; + const lppGust = 137; + const lppDewPoint = 138; + const lppRain = 139; const lppSwitch = 142; final payload = Uint8List.fromList([ @@ -388,6 +391,9 @@ void main() { 11, lppDirection, 0x01, 0x0E, // 270 deg 12, lppUnixTime, 0x65, 0xF0, 0x00, 0x00, // 1710221312 13, lppColour, 0xFF, 0x80, 0x40, // #FF8040 + 15, lppGust, 0x01, 0x72, // 3.70 m/s + 16, lppDewPoint, 0x00, 0x14, // 2.0 C + 17, lppRain, 0x00, 0x7B, // 12.3 mm 14, lppSwitch, 0x01, // on ]); @@ -409,9 +415,59 @@ void main() { decoded.extraSensorData!['colour_13'], equals({'r': 255, 'g': 128, 'b': 64}), ); + expect(decoded.extraSensorData!['gust_15'], closeTo(3.7, 0.001)); + expect(decoded.extraSensorData!['dew_16'], closeTo(2.0, 0.001)); + expect(decoded.extraSensorData!['rain_17'], closeTo(12.3, 0.001)); expect(decoded.extraSensorData!['switch_14'], equals(1)); }); + test('new MeshCore custom telemetry types decode without truncation', () { + const lppBinaryMoisture = 157; + const lppButtonEvent = 171; + const lppDimmer = 172; + const lppUv = 173; + const lppPm25 = 175; + const lppConductivity = 180; + const lppDuration = 182; + const lppSignedSpeed = 191; + const lppSignedPower = 192; + const lppSignedCurrent = 193; + + final payload = Uint8List.fromList([ + 2, lppBinaryMoisture, 0x01, + 3, lppButtonEvent, 0x04, + 4, lppDimmer, 0xFB, // -5 + 5, lppUv, 0x0A, // 1.0 + 6, lppPm25, 0x00, 0x0C, // 12 + 7, lppConductivity, 0x01, 0xF4, // 500 + 8, lppDuration, 0x00, 0x00, 0x13, 0x88, // 5.0 s + 9, lppSignedSpeed, 0xFF, 0xE1, 0x7B, 0x80, // -2.0 m/s + 10, lppSignedPower, 0xFF, 0xFF, 0xFC, 0x18, // -10.0 W + 11, lppSignedCurrent, 0xFF, 0xFF, 0xFC, 0x18, // -1.0 A + 12, MeshCoreConstants.lppTemperatureSensor, 0x00, 0x76, // 11.8 C + ]); + + final decoded = CayenneLppParser.parse(payload); + + expect(decoded.extraSensorData!['binary_moisture_2'], equals(1)); + expect(decoded.extraSensorData!['button_event_3'], equals(4)); + expect(decoded.extraSensorData!['dimmer_4'], equals(-5)); + expect(decoded.extraSensorData!['uv_5'], closeTo(1.0, 0.001)); + expect(decoded.extraSensorData!['pm25_6'], equals(12.0)); + expect(decoded.extraSensorData!['conductivity_7'], equals(500.0)); + expect(decoded.extraSensorData!['duration_8'], closeTo(5.0, 0.001)); + expect(decoded.extraSensorData!['signed_speed_9'], closeTo(-2.0, 0.001)); + expect( + decoded.extraSensorData!['signed_power_10'], + closeTo(-10.0, 0.001), + ); + expect( + decoded.extraSensorData!['signed_current_11'], + closeTo(-1.0, 0.001), + ); + expect(decoded.temperature, closeTo(11.8, 0.1)); + }); + test( 'percentage battery and non-battery voltage channels are preserved separately', () { diff --git a/test/widgets/contact_tile_test.dart b/test/widgets/contact_tile_test.dart index 3b75b16..b4774b3 100644 --- a/test/widgets/contact_tile_test.dart +++ b/test/widgets/contact_tile_test.dart @@ -156,6 +156,7 @@ void main() { await tester.tap(find.text('Preview')); await tester.pumpAndSettle(); + expect(find.byIcon(Icons.close), findsOneWidget); expect(find.text('Battery'), findsOneWidget); expect(find.text('84%'), findsOneWidget); expect(find.text('Temperature'), findsOneWidget); @@ -172,10 +173,7 @@ void main() { find.byKey(const ValueKey('sensor_metric_channel_battery')), findsOneWidget, ); - expect( - find.text('ch1'), - findsWidgets, - ); + expect(find.text('ch1'), findsWidgets); expect( find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')), findsOneWidget, @@ -186,5 +184,10 @@ void main() { find.byKey(const ValueKey('sensor_metric_battery')), ); expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8)); + + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(); + + expect(find.byType(SensorTelemetryCard), findsNothing); }); } diff --git a/test/widgets/sensor_auto_refresh_options_test.dart b/test/widgets/sensor_auto_refresh_options_test.dart new file mode 100644 index 0000000..4183f8b --- /dev/null +++ b/test/widgets/sensor_auto_refresh_options_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/screens/sensors_tab.dart'; + +void main() { + testWidgets('renders expanded auto refresh intervals and selects value', ( + tester, + ) async { + var selected = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SensorAutoRefreshOptions( + selectedMinutes: selected, + onSelected: (minutes) { + selected = minutes; + }, + ), + ), + ), + ); + + expect(find.text('Off'), findsOneWidget); + expect(find.text('5m'), findsOneWidget); + expect(find.text('15m'), findsOneWidget); + expect(find.text('30m'), findsOneWidget); + expect(find.text('60m'), findsOneWidget); + expect(find.text('6h'), findsOneWidget); + expect(find.text('12h'), findsOneWidget); + expect(find.text('24h'), findsOneWidget); + + await tester.tap(find.text('24h')); + await tester.pump(); + + expect(selected, 1440); + }); +} diff --git a/test/widgets/sensor_metric_selector_item_test.dart b/test/widgets/sensor_metric_selector_item_test.dart index 7e7b3d4..dfb816d 100644 --- a/test/widgets/sensor_metric_selector_item_test.dart +++ b/test/widgets/sensor_metric_selector_item_test.dart @@ -15,6 +15,14 @@ void main() { defaultLabel: 'Illuminance', channel: 2, valuePreview: '500 lx', + previewCardData: SensorMetricCardData( + fieldKey: 'extra:illuminance_2', + icon: Icons.light_mode_outlined, + label: 'Illuminance', + value: '500 lx', + accent: Color(0xFFC17B1D), + channel: 2, + ), ), visible: true, span: 1, @@ -30,11 +38,54 @@ void main() { ), ); + expect(find.text('Show on sensor card'), findsOneWidget); expect(find.text('500 lx'), findsOneWidget); expect( find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')), findsOneWidget, ); - expect(find.text('ch2'), findsOneWidget); + expect(find.text('Channel 2'), findsOneWidget); + }); + + testWidgets('omits duplicate channel chip for channel 1', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SensorMetricSelectorItem( + option: const SensorMetricOption( + key: 'battery', + label: 'Battery', + defaultLabel: 'Battery', + channel: 1, + valuePreview: '84%', + previewCardData: SensorMetricCardData( + fieldKey: 'battery', + icon: Icons.battery_5_bar, + label: 'Battery', + value: '84%', + accent: Color(0xFF4B8E2F), + channel: 1, + ), + ), + visible: true, + span: 1, + canMoveUp: true, + canMoveDown: true, + onToggle: (_) {}, + onRename: () {}, + onMoveUp: () {}, + onMoveDown: () {}, + onSpanChanged: (_) {}, + ), + ), + ), + ); + + expect(find.text('Rename'), findsOneWidget); + expect( + find.byKey(const ValueKey('sensor_selector_channel_battery')), + findsNothing, + ); + expect(find.text('Channel 1'), findsNothing); }); } diff --git a/test/widgets/sensor_telemetry_card_test.dart b/test/widgets/sensor_telemetry_card_test.dart index 0a8e560..262ebe0 100644 --- a/test/widgets/sensor_telemetry_card_test.dart +++ b/test/widgets/sensor_telemetry_card_test.dart @@ -50,9 +50,10 @@ void main() { 'temperature': 'Ambient', 'extra:illuminance_2': 'Light', }, - fieldSpans: sensorFullWidthFieldSpans( - const {'temperature', 'extra:illuminance_2'}, - ), + fieldSpans: sensorFullWidthFieldSpans(const { + 'temperature', + 'extra:illuminance_2', + }), ), ), ), @@ -111,18 +112,12 @@ void main() { 'temperature', 'extra:illuminance_2', }, - fieldOrder: const [ - 'extra:illuminance_2', - 'temperature', + fieldOrder: const ['extra:illuminance_2', 'temperature', 'battery'], + fieldSpans: sensorFullWidthFieldSpans(const { 'battery', - ], - fieldSpans: sensorFullWidthFieldSpans( - const { - 'battery', - 'temperature', - 'extra:illuminance_2', - }, - ), + 'temperature', + 'extra:illuminance_2', + }), ), ), ), @@ -141,4 +136,55 @@ void main() { expect(illuminanceTop.dy, lessThan(temperatureTop.dy)); expect(temperatureTop.dy, lessThan(batteryTop.dy)); }); + + testWidgets('renders MeshCore custom weather metrics', (tester) async { + final publicKey = Uint8List(32); + publicKey[0] = 0x46; + 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( + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + extraSensorData: const {'gust_2': 3.7, 'dew_2': 2.0, 'rain_2': 12.3}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const { + 'extra:gust_2', + 'extra:dew_2', + 'extra:rain_2', + }, + fieldSpans: sensorFullWidthFieldSpans(const { + 'extra:gust_2', + 'extra:dew_2', + 'extra:rain_2', + }), + ), + ), + ), + ); + + expect(find.text('Wind gust'), findsOneWidget); + expect(find.text('Dew point'), findsOneWidget); + expect(find.text('Rain'), findsOneWidget); + expect(find.text('3.7 m/s'), findsOneWidget); + expect(find.text('2°C'), findsOneWidget); + expect(find.text('12.3 mm'), findsOneWidget); + }); }