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);
+ });
}