diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index 5076cee..4a0e1ab 100644 --- a/lib/providers/sensors_provider.dart +++ b/lib/providers/sensors_provider.dart @@ -18,7 +18,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 +127,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; } @@ -258,7 +269,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 +284,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 = []; diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index ba5eebd..f22c7e4 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, + ); + }, + ), ), ); } @@ -439,6 +330,273 @@ 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 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 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 +626,143 @@ 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 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, - ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + option.defaultLabel, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, ), - 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, - ), - ), + ), + ), + 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.adaptive(value: visible, onChanged: onToggle), + ], + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlinedButton.icon( + onPressed: onRename, + icon: const Icon(Icons.edit_outlined), + label: const Text('Rename'), ), + if (option.channel != null) + Container( + key: ValueKey('sensor_selector_channel_${option.key}'), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 10, + ), + 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(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 +775,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/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..d9deb28 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,278 @@ 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; + } + + 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 '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': + 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 'distance': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.straighten, + 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 'direction': + 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 '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,6 +529,17 @@ 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); @@ -458,7 +825,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 +981,7 @@ class SensorTelemetryCard extends StatelessWidget { runSpacing: spacing, children: metrics .map( - (metric) => _MetricTile( + (metric) => SensorMetricTile( data: metric, width: (fieldSpans[metric.fieldKey] == 2 || @@ -633,17 +1000,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 +1027,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 +1044,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 +1063,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 +1082,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 +1101,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 +1144,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 +1167,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( @@ -813,7 +1183,7 @@ class SensorTelemetryCard extends StatelessWidget { 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 +1195,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 +1209,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 +1221,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 +1233,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 +1245,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 +1257,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 +1269,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 +1285,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 +1301,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, @@ -943,7 +1313,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'current': final amps = _asDouble(value); if (amps == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.electric_bolt, label: label, @@ -955,7 +1325,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 +1337,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 +1349,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 +1361,7 @@ 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, @@ -1003,7 +1373,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, @@ -1015,7 +1385,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'distance': final meters = _asDouble(value); if (meters == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.straighten, label: label, @@ -1027,7 +1397,7 @@ class SensorTelemetryCard extends StatelessWidget { 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, @@ -1039,7 +1409,7 @@ class SensorTelemetryCard extends StatelessWidget { case 'direction': final degrees = _asDouble(value); if (degrees == null) return null; - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.explore_outlined, label: label, @@ -1056,7 +1426,7 @@ class SensorTelemetryCard extends StatelessWidget { seconds * 1000, isUtc: true, ).toLocal(); - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.schedule, label: label, @@ -1070,7 +1440,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 +1455,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 +1467,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, @@ -1112,7 +1482,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 +1495,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 +1507,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 +1518,7 @@ class SensorTelemetryCard extends StatelessWidget { } if (value is num) { - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.sensors, label: label, @@ -1158,7 +1528,7 @@ class SensorTelemetryCard extends StatelessWidget { ); } - return _MetricCardData( + return SensorMetricCardData( fieldKey: _extraFieldKey(rawKey), icon: Icons.sensors, label: label, @@ -1370,11 +1740,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 +1827,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 +1835,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 +1854,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 +1970,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 +1997,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 +2041,7 @@ class _MetricText extends StatelessWidget { } } -class _MetricCardData { +class SensorMetricCardData { final String fieldKey; final IconData icon; final String label; @@ -1669,7 +2052,7 @@ class _MetricCardData { final LatLng? mapLocation; final int? channel; - const _MetricCardData({ + const SensorMetricCardData({ required this.fieldKey, required this.icon, required this.label, diff --git a/test/providers/sensors_provider_test.dart b/test/providers/sensors_provider_test.dart index f5ed43d..071e3f6 100644 --- a/test/providers/sensors_provider_test.dart +++ b/test/providers/sensors_provider_test.dart @@ -148,16 +148,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(); diff --git a/test/screens/sensors_tab_test.dart b/test/screens/sensors_tab_test.dart index e55d576..cfd6d42 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,37 @@ 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.pumpAndSettle(); - 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.pumpAndSettle(); + await tester.tap(find.text('Customize fields')); + await tester.pumpAndSettle(); + 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/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..56a52b3 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,12 @@ 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); }); }