Update sensor customization view

This commit is contained in:
Janez T
2026-03-15 10:40:16 +01:00
parent 650f3c66aa
commit e355f28ca9
9 changed files with 1079 additions and 341 deletions

View File

@@ -18,7 +18,16 @@ class SensorsProvider with ChangeNotifier {
static const String _metricLabelKey = 'sensor_metric_labels'; static const String _metricLabelKey = 'sensor_metric_labels';
static const String _metricOrderKey = 'sensor_metric_order'; static const String _metricOrderKey = 'sensor_metric_order';
static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes'; static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes';
static const List<int> supportedAutoRefreshIntervals = <int>[0, 1, 5, 15]; static const List<int> supportedAutoRefreshIntervals = <int>[
0,
5,
15,
30,
60,
360,
720,
1440,
];
static const Set<String> _defaultVisibleFields = <String>{ static const Set<String> _defaultVisibleFields = <String>{
'voltage', 'voltage',
'battery', 'battery',
@@ -118,7 +127,9 @@ class SensorsProvider with ChangeNotifier {
final decoded = final decoded =
jsonDecode(storedAutoRefreshJson) as Map<String, dynamic>; jsonDecode(storedAutoRefreshJson) as Map<String, dynamic>;
for (final entry in decoded.entries) { for (final entry in decoded.entries) {
final minutes = (entry.value as num).toInt(); final minutes = _normalizeAutoRefreshMinutes(
(entry.value as num).toInt(),
);
if (minutes > 0) { if (minutes > 0) {
_autoRefreshMinutesBySensor[entry.key] = minutes; _autoRefreshMinutesBySensor[entry.key] = minutes;
} }
@@ -258,7 +269,7 @@ class SensorsProvider with ChangeNotifier {
_autoRefreshMinutesBySensor[publicKeyHex] ?? 0; _autoRefreshMinutesBySensor[publicKeyHex] ?? 0;
Future<void> setAutoRefreshMinutes(String publicKeyHex, int minutes) async { Future<void> setAutoRefreshMinutes(String publicKeyHex, int minutes) async {
final normalizedMinutes = minutes <= 0 ? 0 : minutes; final normalizedMinutes = _normalizeAutoRefreshMinutes(minutes);
final currentMinutes = autoRefreshMinutesFor(publicKeyHex); final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
if (currentMinutes == normalizedMinutes) { if (currentMinutes == normalizedMinutes) {
return; return;
@@ -273,6 +284,32 @@ class SensorsProvider with ChangeNotifier {
notifyListeners(); 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<String> dueAutoRefreshSensorKeys({DateTime? now}) { List<String> dueAutoRefreshSensorKeys({DateTime? now}) {
final refreshTime = now ?? DateTime.now(); final refreshTime = now ?? DateTime.now();
final dueKeys = <String>[]; final dueKeys = <String>[];

View File

@@ -171,135 +171,26 @@ class _SensorsTabState extends State<SensorsTab> {
String publicKeyHex, String publicKeyHex,
Contact? contact, Contact? contact,
) async { ) async {
await showModalBottomSheet<void>( await Navigator.of(context).push(
context: context, MaterialPageRoute<void>(
showDragHandle: true, builder: (pageContext) => _SensorCustomizeView(
builder: (sheetContext) => Consumer<SensorsProvider>( publicKeyHex: publicKeyHex,
builder: (context, sensorsProvider, child) { initialContact: contact,
final visibleFields = sensorsProvider.visibleFieldsFor(publicKeyHex); onRenameMetric:
final autoRefreshMinutes = sensorsProvider.autoRefreshMinutesFor( ({
publicKeyHex, required BuildContext context,
); required String publicKeyHex,
final options = sensorMetricOptionsFor( required SensorMetricOption option,
contact, required SensorsProvider sensorsProvider,
labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex), }) {
); return _showMetricRenameDialog(
final orderedFieldKeys = sensorsProvider.metricOrderFor( context,
publicKeyHex, publicKeyHex: publicKeyHex,
options.map((option) => option.key), option: option,
); sensorsProvider: sensorsProvider,
final optionByKey = <String, SensorMetricOption>{ );
for (final option in options) option.key: option, },
}; ),
final orderedOptions = orderedFieldKeys
.map((fieldKey) => optionByKey[fieldKey])
.whereType<SensorMetricOption>()
.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,
);
},
);
}),
],
),
);
},
), ),
); );
} }
@@ -439,6 +330,273 @@ class _SensorsTabState extends State<SensorsTab> {
} }
} }
class _SensorCustomizeView extends StatelessWidget {
final String publicKeyHex;
final Contact? initialContact;
final Future<void> 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<SensorsProvider, ContactsProvider>(
builder: (context, sensorsProvider, contactsProvider, child) {
Contact? contact = initialContact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
contact = entry;
break;
}
}
final visibleFields = sensorsProvider.visibleFieldsFor(publicKeyHex);
final autoRefreshMinutes = sensorsProvider.autoRefreshMinutesFor(
publicKeyHex,
);
final options = sensorMetricOptionsFor(
contact,
labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex),
);
final orderedFieldKeys = sensorsProvider.metricOrderFor(
publicKeyHex,
options.map((option) => option.key),
);
final optionByKey = <String, SensorMetricOption>{
for (final option in options) option.key: option,
};
final orderedOptions = orderedFieldKeys
.map((fieldKey) => optionByKey[fieldKey])
.whereType<SensorMetricOption>()
.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<int> 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 { class SensorMetricSelectorItem extends StatelessWidget {
final SensorMetricOption option; final SensorMetricOption option;
final bool visible; final bool visible;
@@ -468,84 +626,143 @@ class SensorMetricSelectorItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Padding( final colorScheme = theme.colorScheme;
padding: const EdgeInsets.only(bottom: 12), final previewCardData =
child: Column( option.previewCardData ??
crossAxisAlignment: CrossAxisAlignment.start, SensorMetricCardData(
children: [ fieldKey: option.key,
Row( icon: Icons.sensors,
children: [ label: option.defaultLabel,
Expanded( value: option.valuePreview ?? 'No telemetry yet',
child: FilterChip( accent: colorScheme.primary,
selected: visible, channel: option.channel,
label: Text(option.label), );
onSelected: onToggle,
), return Container(
), margin: const EdgeInsets.only(bottom: 16),
const SizedBox(width: 8), decoration: BoxDecoration(
IconButton( borderRadius: BorderRadius.circular(28),
tooltip: 'Rename', gradient: LinearGradient(
onPressed: onRename, begin: Alignment.topLeft,
icon: const Icon(Icons.edit_outlined), 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: Padding(
child: Wrap( padding: const EdgeInsets.all(12),
crossAxisAlignment: WrapCrossAlignment.center, child: Column(
spacing: 8, crossAxisAlignment: CrossAxisAlignment.start,
runSpacing: 6, children: [
children: [ Row(
if (option.valuePreview != null) children: [
Text( Expanded(
option.valuePreview!, child: Text(
key: ValueKey('sensor_selector_value_${option.key}'), option.defaultLabel,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
),
), ),
if (option.channel != null) ),
Container( ),
key: ValueKey('sensor_selector_channel_${option.key}'), Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, horizontal: 10,
vertical: 3, vertical: 5,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest, color: visible
borderRadius: BorderRadius.circular(999), ? const Color(0xFF218B63).withValues(alpha: 0.12)
), : colorScheme.surfaceContainerHighest,
child: Text( borderRadius: BorderRadius.circular(999),
'ch${option.channel}', ),
style: theme.textTheme.labelSmall?.copyWith( child: Text(
color: theme.colorScheme.onSurfaceVariant, visible ? 'Visible' : 'Hidden',
fontWeight: FontWeight.w800, 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), const SizedBox(height: 8),
Align( Row(
alignment: Alignment.centerRight,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [ children: [
IconButton( Expanded(
tooltip: 'Move up', child: Text(
onPressed: canMoveUp ? onMoveUp : null, 'Show on sensor card',
icon: const Icon(Icons.arrow_upward), style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
), ),
IconButton( Switch.adaptive(value: visible, onChanged: onToggle),
tooltip: 'Move down', ],
onPressed: canMoveDown ? onMoveDown : null, ),
icon: const Icon(Icons.arrow_downward), Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: onRename,
icon: const Icon(Icons.edit_outlined),
label: const Text('Rename'),
), ),
if (option.channel != null)
Container(
key: ValueKey('sensor_selector_channel_${option.key}'),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 10,
),
decoration: BoxDecoration(
color: previewCardData.accent.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'Channel ${option.channel}',
style: theme.textTheme.labelMedium?.copyWith(
color: previewCardData.accent,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Text(
'Card width',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
SegmentedButton<int>( SegmentedButton<int>(
segments: const [ segments: const [
ButtonSegment<int>(value: 1, label: Text('1x')), ButtonSegment<int>(value: 1, label: Text('1x')),
@@ -558,8 +775,30 @@ class SensorMetricSelectorItem extends StatelessWidget {
), ),
], ],
), ),
), const SizedBox(height: 8),
], Row(
children: [
Text(
'Order',
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
IconButton(
tooltip: 'Move up',
onPressed: canMoveUp ? onMoveUp : null,
icon: const Icon(Icons.arrow_upward),
),
IconButton(
tooltip: 'Move down',
onPressed: canMoveDown ? onMoveDown : null,
icon: const Icon(Icons.arrow_downward),
),
],
),
],
),
), ),
); );
} }

View File

@@ -393,7 +393,7 @@ class ContactTile extends StatelessWidget {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
if (!context.mounted) return; if (!context.mounted) return;
await _showSensorPreviewSheet(context, contact); await _showSensorPreviewView(context, contact);
}, },
), ),
if (canAddToSensors) if (canAddToSensors)
@@ -469,49 +469,14 @@ class ContactTile extends StatelessWidget {
); );
} }
Future<void> _showSensorPreviewSheet( Future<void> _showSensorPreviewView(
BuildContext context, BuildContext context,
Contact contact, Contact contact,
) async { ) async {
final publicKeyHex = contact.publicKeyHex; await Navigator.of(context).push(
MaterialPageRoute<void>(
await showModalBottomSheet<void>( fullscreenDialog: true,
context: context, builder: (pageContext) => _SensorPreviewView(contact: contact),
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => Consumer2<ContactsProvider, SensorsProvider>(
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.',
),
),
);
},
), ),
); );
} }
@@ -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<ContactsProvider, SensorsProvider>(
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 { class _ContactNameOverrideSheet extends StatefulWidget {
final String initialValue; final String initialValue;
final String advertisedName; final String advertisedName;

View File

@@ -15,6 +15,7 @@ class SensorMetricOption {
final String defaultLabel; final String defaultLabel;
final int? channel; final int? channel;
final String? valuePreview; final String? valuePreview;
final SensorMetricCardData? previewCardData;
const SensorMetricOption({ const SensorMetricOption({
required this.key, required this.key,
@@ -22,6 +23,7 @@ class SensorMetricOption {
required this.defaultLabel, required this.defaultLabel,
this.channel, this.channel,
this.valuePreview, this.valuePreview,
this.previewCardData,
}); });
} }
@@ -52,6 +54,18 @@ List<SensorMetricOption> sensorMetricOptionsFor(
defaultLabel: 'Voltage', defaultLabel: 'Voltage',
channel: _sourceChannelForField(extraSensorData, 'voltage'), channel: _sourceChannelForField(extraSensorData, 'voltage'),
valuePreview: '${(batteryMilliVolts / 1000).toStringAsFixed(3)}V', 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) if (batteryPercentage != null)
SensorMetricOption( SensorMetricOption(
@@ -67,6 +81,18 @@ List<SensorMetricOption> sensorMetricOptionsFor(
defaultLabel: 'Battery', defaultLabel: 'Battery',
channel: _sourceChannelForField(extraSensorData, 'battery'), channel: _sourceChannelForField(extraSensorData, 'battery'),
valuePreview: '${batteryPercentage.toStringAsFixed(0)}%', 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) if (temperature != null)
SensorMetricOption( SensorMetricOption(
@@ -82,6 +108,18 @@ List<SensorMetricOption> sensorMetricOptionsFor(
defaultLabel: 'Temperature', defaultLabel: 'Temperature',
channel: _sourceChannelForField(extraSensorData, 'temperature'), channel: _sourceChannelForField(extraSensorData, 'temperature'),
valuePreview: '${temperature.toStringAsFixed(1)}°C', 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) if (humidity != null)
SensorMetricOption( SensorMetricOption(
@@ -97,6 +135,18 @@ List<SensorMetricOption> sensorMetricOptionsFor(
defaultLabel: 'Humidity', defaultLabel: 'Humidity',
channel: _sourceChannelForField(extraSensorData, 'humidity'), channel: _sourceChannelForField(extraSensorData, 'humidity'),
valuePreview: '${humidity.toStringAsFixed(1)}%', 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) if (pressure != null)
SensorMetricOption( SensorMetricOption(
@@ -112,6 +162,18 @@ List<SensorMetricOption> sensorMetricOptionsFor(
defaultLabel: 'Pressure', defaultLabel: 'Pressure',
channel: _sourceChannelForField(extraSensorData, 'pressure'), channel: _sourceChannelForField(extraSensorData, 'pressure'),
valuePreview: '${pressure.toStringAsFixed(1)} hPa', 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) if (gpsLocation != null)
SensorMetricOption( SensorMetricOption(
@@ -124,6 +186,24 @@ List<SensorMetricOption> sensorMetricOptionsFor(
channel: _sourceChannelForField(extraSensorData, 'gps'), channel: _sourceChannelForField(extraSensorData, 'gps'),
valuePreview: valuePreview:
'${gpsLocation.latitude.toStringAsFixed(5)}, ${gpsLocation.longitude.toStringAsFixed(5)}', '${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<SensorMetricOption> sensorMetricOptionsFor(
final metricKey = _parseMetricKey(key); final metricKey = _parseMetricKey(key);
final fieldKey = _extraFieldKey(key); final fieldKey = _extraFieldKey(key);
final defaultLabel = _formatExtraFieldLabel(key); final defaultLabel = _formatExtraFieldLabel(key);
final resolvedLabel = _resolvedMetricLabel(
fieldKey,
defaultLabel,
labelOverrides: labelOverrides,
);
options.add( options.add(
SensorMetricOption( SensorMetricOption(
key: fieldKey, key: fieldKey,
label: _selectorMetricLabel( label: _selectorMetricLabel(resolvedLabel, metricKey.channel),
_resolvedMetricLabel(
fieldKey,
defaultLabel,
labelOverrides: labelOverrides,
),
metricKey.channel,
),
defaultLabel: defaultLabel, defaultLabel: defaultLabel,
channel: metricKey.channel, channel: metricKey.channel,
valuePreview: _sensorMetricPreviewValue(key, extraSensorData[key]), valuePreview: _sensorMetricPreviewValue(key, extraSensorData[key]),
previewCardData: _buildOptionPreviewCardData(
key,
extraSensorData[key],
fieldKey: fieldKey,
label: resolvedLabel,
),
), ),
); );
} }
@@ -157,6 +241,278 @@ List<SensorMetricOption> sensorMetricOptionsFor(
return options; return options;
} }
SensorMetricCardData? _buildOptionPreviewCardData(
String rawKey,
dynamic value, {
required String fieldKey,
required String label,
}) {
final metricKey = _parseMetricKey(rawKey);
final previewValue = _sensorMetricPreviewValue(rawKey, value);
if (previewValue == null) {
return null;
}
switch (metricKey.baseKey) {
case 'altitude':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.terrain_outlined,
label: label,
value: previewValue,
accent: const Color(0xFF7A5C3E),
channel: metricKey.channel,
);
case 'illuminance':
final lux = _previewAsDouble(value);
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.light_mode_outlined,
label: label,
value: previewValue,
secondaryValue: lux == null
? null
: '~${_formatPreviewNumber(_previewApproxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2 daylight',
accent: const Color(0xFFC17B1D),
channel: metricKey.channel,
);
case 'presence':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.sensor_occupied_outlined,
label: label,
value: previewValue,
accent: const Color(0xFFAA3F57),
channel: metricKey.channel,
);
case 'digital_input':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.input_outlined,
label: label,
value: previewValue,
accent: const Color(0xFF3A6D8C),
channel: metricKey.channel,
);
case 'digital_output':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.output_outlined,
label: label,
value: previewValue,
accent: const Color(0xFF4B7B5A),
channel: metricKey.channel,
);
case 'analog_input':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.tune,
label: label,
value: previewValue,
accent: const Color(0xFF5A6C84),
channel: metricKey.channel,
);
case 'analog_output':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.tune,
label: label,
value: previewValue,
accent: const Color(0xFF4B7785),
channel: metricKey.channel,
);
case 'accelerometer':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.vibration_outlined,
label: label,
value: previewValue,
accent: const Color(0xFF5A4C99),
wide: true,
channel: metricKey.channel,
);
case 'gyrometer':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.threed_rotation,
label: label,
value: previewValue,
accent: const Color(0xFF6C4F96),
wide: true,
channel: metricKey.channel,
);
case 'generic_sensor':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.sensors,
label: label,
value: previewValue,
accent: const Color(0xFF3E657C),
channel: metricKey.channel,
);
case 'current':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.electric_bolt,
label: label,
value: previewValue,
accent: const Color(0xFF1C7C54),
channel: metricKey.channel,
);
case 'frequency':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.graphic_eq,
label: label,
value: previewValue,
accent: const Color(0xFF2C6BA0),
channel: metricKey.channel,
);
case 'percentage':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.percent,
label: label,
value: previewValue,
accent: const Color(0xFF4B8E2F),
channel: metricKey.channel,
);
case 'concentration':
case 'co2':
case 'tvoc':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.bubble_chart_outlined,
label: label,
value: previewValue,
accent: const Color(0xFF4D6D9A),
channel: metricKey.channel,
);
case 'power':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.flash_on_outlined,
label: label,
value: previewValue,
accent: const Color(0xFFB5622E),
channel: metricKey.channel,
);
case 'speed':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.air,
label: label,
value: previewValue,
accent: const Color(0xFF2B78A0),
channel: metricKey.channel,
);
case 'distance':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.straighten,
label: label,
value: previewValue,
accent: const Color(0xFF577590),
channel: metricKey.channel,
);
case 'energy':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.battery_charging_full,
label: label,
value: previewValue,
accent: const Color(0xFF9C6644),
channel: metricKey.channel,
);
case 'direction':
final degrees = _previewAsDouble(value);
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.explore_outlined,
label: label,
value: previewValue,
secondaryValue: degrees == null
? null
: _previewFormatCardinalDirection(degrees),
accent: const Color(0xFF8A5A44),
channel: metricKey.channel,
);
case 'unixtime':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.schedule,
label: label,
value: previewValue,
accent: const Color(0xFF6B7280),
wide: true,
channel: metricKey.channel,
);
case 'colour':
final color = _previewAsRgb(value);
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.palette_outlined,
label: label,
value: previewValue,
secondaryValue: color == null
? null
: 'R ${color.r} • G ${color.g} • B ${color.b}',
accent: color == null
? const Color(0xFF3E657C)
: Color.fromARGB(255, color.r, color.g, color.b),
wide: true,
channel: metricKey.channel,
);
case 'switch':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: previewValue == 'On' ? Icons.toggle_on : Icons.toggle_off,
label: label,
value: previewValue,
accent: const Color(0xFF4B7B5A),
channel: metricKey.channel,
);
case 'voltage':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.bolt,
label: label,
value: previewValue,
accent: const Color(0xFF0A7D61),
channel: metricKey.channel,
);
case 'pm25':
case 'pm10':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.grain,
label: label,
value: previewValue,
accent: const Color(0xFF7A6C5D),
channel: metricKey.channel,
);
case 'uv':
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.wb_sunny_outlined,
label: label,
value: previewValue,
accent: const Color(0xFFC17B1D),
channel: metricKey.channel,
);
}
return SensorMetricCardData(
fieldKey: fieldKey,
icon: Icons.sensors,
label: label,
value: previewValue,
accent: const Color(0xFF3E657C),
wide: value is Map,
channel: metricKey.channel,
);
}
Set<String> sensorMetricKeysFor(Contact? contact) { Set<String> sensorMetricKeysFor(Contact? contact) {
return sensorMetricOptionsFor(contact).map((option) => option.key).toSet(); return sensorMetricOptionsFor(contact).map((option) => option.key).toSet();
} }
@@ -173,6 +529,17 @@ Map<String, int> sensorFullWidthFieldSpans(Iterable<String> fieldKeys) {
return {for (final fieldKey in fieldKeys) fieldKey: 2}; return {for (final fieldKey in fieldKeys) fieldKey: 2};
} }
double _previewApproxDaylightIrradiance(double lux) {
return lux / 120.0;
}
String _previewFormatCardinalDirection(double degrees) {
const points = <String>['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) { String? _sensorMetricPreviewValue(String rawKey, dynamic value) {
final metricKey = _parseMetricKey(rawKey); final metricKey = _parseMetricKey(rawKey);
@@ -458,7 +825,7 @@ class SensorTelemetryCard extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final metrics = contact == null || telemetry == null final metrics = contact == null || telemetry == null
? const <_MetricCardData>[] ? const <SensorMetricCardData>[]
: _sortMetricsByFieldOrder( : _sortMetricsByFieldOrder(
_buildMetricCards(l10n, telemetry, contact!), _buildMetricCards(l10n, telemetry, contact!),
); );
@@ -614,7 +981,7 @@ class SensorTelemetryCard extends StatelessWidget {
runSpacing: spacing, runSpacing: spacing,
children: metrics children: metrics
.map( .map(
(metric) => _MetricTile( (metric) => SensorMetricTile(
data: metric, data: metric,
width: width:
(fieldSpans[metric.fieldKey] == 2 || (fieldSpans[metric.fieldKey] == 2 ||
@@ -633,17 +1000,17 @@ class SensorTelemetryCard extends StatelessWidget {
); );
} }
List<_MetricCardData> _buildMetricCards( List<SensorMetricCardData> _buildMetricCards(
AppLocalizations l10n, AppLocalizations l10n,
dynamic telemetry, dynamic telemetry,
Contact contact, Contact contact,
) { ) {
final items = <_MetricCardData>[]; final items = <SensorMetricCardData>[];
if (visibleFields.contains('voltage') && if (visibleFields.contains('voltage') &&
telemetry.batteryMilliVolts != null) { telemetry.batteryMilliVolts != null) {
items.add( items.add(
_MetricCardData( SensorMetricCardData(
fieldKey: 'voltage', fieldKey: 'voltage',
icon: Icons.bolt, icon: Icons.bolt,
label: _resolvedMetricLabel( label: _resolvedMetricLabel(
@@ -660,7 +1027,7 @@ class SensorTelemetryCard extends StatelessWidget {
if (visibleFields.contains('battery') && if (visibleFields.contains('battery') &&
telemetry.batteryPercentage != null) { telemetry.batteryPercentage != null) {
items.add( items.add(
_MetricCardData( SensorMetricCardData(
fieldKey: 'battery', fieldKey: 'battery',
icon: Icons.battery_5_bar, icon: Icons.battery_5_bar,
label: _resolvedMetricLabel( label: _resolvedMetricLabel(
@@ -677,7 +1044,7 @@ class SensorTelemetryCard extends StatelessWidget {
if (visibleFields.contains('temperature') && if (visibleFields.contains('temperature') &&
telemetry.temperature != null) { telemetry.temperature != null) {
items.add( items.add(
_MetricCardData( SensorMetricCardData(
fieldKey: 'temperature', fieldKey: 'temperature',
icon: Icons.thermostat, icon: Icons.thermostat,
label: _resolvedMetricLabel( label: _resolvedMetricLabel(
@@ -696,7 +1063,7 @@ class SensorTelemetryCard extends StatelessWidget {
} }
if (visibleFields.contains('humidity') && telemetry.humidity != null) { if (visibleFields.contains('humidity') && telemetry.humidity != null) {
items.add( items.add(
_MetricCardData( SensorMetricCardData(
fieldKey: 'humidity', fieldKey: 'humidity',
icon: Icons.water_drop, icon: Icons.water_drop,
label: _resolvedMetricLabel( label: _resolvedMetricLabel(
@@ -715,7 +1082,7 @@ class SensorTelemetryCard extends StatelessWidget {
} }
if (visibleFields.contains('pressure') && telemetry.pressure != null) { if (visibleFields.contains('pressure') && telemetry.pressure != null) {
items.add( items.add(
_MetricCardData( SensorMetricCardData(
fieldKey: 'pressure', fieldKey: 'pressure',
icon: Icons.compress, icon: Icons.compress,
label: _resolvedMetricLabel( label: _resolvedMetricLabel(
@@ -734,7 +1101,7 @@ class SensorTelemetryCard extends StatelessWidget {
} }
if (visibleFields.contains('gps') && telemetry.gpsLocation != null) { if (visibleFields.contains('gps') && telemetry.gpsLocation != null) {
items.add( items.add(
_MetricCardData( SensorMetricCardData(
fieldKey: 'gps', fieldKey: 'gps',
icon: Icons.place, icon: Icons.place,
label: _resolvedMetricLabel( label: _resolvedMetricLabel(
@@ -777,8 +1144,8 @@ class SensorTelemetryCard extends StatelessWidget {
return items; return items;
} }
List<_MetricCardData> _sortMetricsByFieldOrder( List<SensorMetricCardData> _sortMetricsByFieldOrder(
List<_MetricCardData> metrics, List<SensorMetricCardData> metrics,
) { ) {
final order = fieldOrder; final order = fieldOrder;
if (order == null || order.isEmpty || metrics.length < 2) { if (order == null || order.isEmpty || metrics.length < 2) {
@@ -800,7 +1167,10 @@ class SensorTelemetryCard extends StatelessWidget {
return indexedMetrics.map((entry) => entry.value).toList(growable: false); 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 metricKey = _parseMetricKey(rawKey);
final fieldKey = _extraFieldKey(rawKey); final fieldKey = _extraFieldKey(rawKey);
final label = _resolvedMetricLabel( final label = _resolvedMetricLabel(
@@ -813,7 +1183,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'altitude': case 'altitude':
final meters = _asDouble(value); final meters = _asDouble(value);
if (meters == null) return null; if (meters == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.terrain_outlined, icon: Icons.terrain_outlined,
label: label, label: label,
@@ -825,7 +1195,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'illuminance': case 'illuminance':
final lux = _asDouble(value); final lux = _asDouble(value);
if (lux == null) return null; if (lux == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.light_mode_outlined, icon: Icons.light_mode_outlined,
label: label, label: label,
@@ -839,7 +1209,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'presence': case 'presence':
final isPresent = _asBool(value); final isPresent = _asBool(value);
if (isPresent == null) return null; if (isPresent == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.sensor_occupied_outlined, icon: Icons.sensor_occupied_outlined,
label: label, label: label,
@@ -851,7 +1221,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'digital_input': case 'digital_input':
final isHigh = _asBool(value); final isHigh = _asBool(value);
if (isHigh == null) return null; if (isHigh == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.input_outlined, icon: Icons.input_outlined,
label: label, label: label,
@@ -863,7 +1233,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'digital_output': case 'digital_output':
final isHigh = _asBool(value); final isHigh = _asBool(value);
if (isHigh == null) return null; if (isHigh == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.output_outlined, icon: Icons.output_outlined,
label: label, label: label,
@@ -875,7 +1245,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'analog_input': case 'analog_input':
final reading = _asDouble(value); final reading = _asDouble(value);
if (reading == null) return null; if (reading == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.tune, icon: Icons.tune,
label: label, label: label,
@@ -887,7 +1257,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'analog_output': case 'analog_output':
final reading = _asDouble(value); final reading = _asDouble(value);
if (reading == null) return null; if (reading == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.tune, icon: Icons.tune,
label: label, label: label,
@@ -899,7 +1269,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'accelerometer': case 'accelerometer':
final vector = _asVector3(value); final vector = _asVector3(value);
if (vector == null) return null; if (vector == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.vibration_outlined, icon: Icons.vibration_outlined,
label: label, label: label,
@@ -915,7 +1285,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'gyrometer': case 'gyrometer':
final vector = _asVector3(value); final vector = _asVector3(value);
if (vector == null) return null; if (vector == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.threed_rotation, icon: Icons.threed_rotation,
label: label, label: label,
@@ -931,7 +1301,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'generic_sensor': case 'generic_sensor':
final reading = _asDouble(value); final reading = _asDouble(value);
if (reading == null) return null; if (reading == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.sensors, icon: Icons.sensors,
label: label, label: label,
@@ -943,7 +1313,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'current': case 'current':
final amps = _asDouble(value); final amps = _asDouble(value);
if (amps == null) return null; if (amps == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.electric_bolt, icon: Icons.electric_bolt,
label: label, label: label,
@@ -955,7 +1325,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'frequency': case 'frequency':
final hz = _asDouble(value); final hz = _asDouble(value);
if (hz == null) return null; if (hz == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.graphic_eq, icon: Icons.graphic_eq,
label: label, label: label,
@@ -967,7 +1337,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'percentage': case 'percentage':
final reading = _asDouble(value); final reading = _asDouble(value);
if (reading == null) return null; if (reading == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.percent, icon: Icons.percent,
label: label, label: label,
@@ -979,7 +1349,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'concentration': case 'concentration':
final reading = _asDouble(value); final reading = _asDouble(value);
if (reading == null) return null; if (reading == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.bubble_chart_outlined, icon: Icons.bubble_chart_outlined,
label: label, label: label,
@@ -991,7 +1361,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'power': case 'power':
final watts = _asDouble(value); final watts = _asDouble(value);
if (watts == null) return null; if (watts == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.flash_on_outlined, icon: Icons.flash_on_outlined,
label: label, label: label,
@@ -1003,7 +1373,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'speed': case 'speed':
final metersPerSecond = _asDouble(value); final metersPerSecond = _asDouble(value);
if (metersPerSecond == null) return null; if (metersPerSecond == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.air, icon: Icons.air,
label: label, label: label,
@@ -1015,7 +1385,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'distance': case 'distance':
final meters = _asDouble(value); final meters = _asDouble(value);
if (meters == null) return null; if (meters == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.straighten, icon: Icons.straighten,
label: label, label: label,
@@ -1027,7 +1397,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'energy': case 'energy':
final kwh = _asDouble(value); final kwh = _asDouble(value);
if (kwh == null) return null; if (kwh == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.battery_charging_full, icon: Icons.battery_charging_full,
label: label, label: label,
@@ -1039,7 +1409,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'direction': case 'direction':
final degrees = _asDouble(value); final degrees = _asDouble(value);
if (degrees == null) return null; if (degrees == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.explore_outlined, icon: Icons.explore_outlined,
label: label, label: label,
@@ -1056,7 +1426,7 @@ class SensorTelemetryCard extends StatelessWidget {
seconds * 1000, seconds * 1000,
isUtc: true, isUtc: true,
).toLocal(); ).toLocal();
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.schedule, icon: Icons.schedule,
label: label, label: label,
@@ -1070,7 +1440,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'colour': case 'colour':
final color = _asRgb(value); final color = _asRgb(value);
if (color == null) return null; if (color == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.palette_outlined, icon: Icons.palette_outlined,
label: label, label: label,
@@ -1085,7 +1455,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'switch': case 'switch':
final isOn = _asBool(value); final isOn = _asBool(value);
if (isOn == null) return null; if (isOn == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: isOn ? Icons.toggle_on : Icons.toggle_off, icon: isOn ? Icons.toggle_on : Icons.toggle_off,
label: label, label: label,
@@ -1097,7 +1467,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'voltage': case 'voltage':
final volts = _asDouble(value); final volts = _asDouble(value);
if (volts == null) return null; if (volts == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.bolt, icon: Icons.bolt,
label: label, label: label,
@@ -1112,7 +1482,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'tvoc': case 'tvoc':
final reading = _asDouble(value); final reading = _asDouble(value);
if (reading == null) return null; if (reading == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.bubble_chart_outlined, icon: Icons.bubble_chart_outlined,
label: label, label: label,
@@ -1125,7 +1495,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'pm10': case 'pm10':
final reading = _asDouble(value); final reading = _asDouble(value);
if (reading == null) return null; if (reading == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.grain, icon: Icons.grain,
label: label, label: label,
@@ -1137,7 +1507,7 @@ class SensorTelemetryCard extends StatelessWidget {
case 'uv': case 'uv':
final reading = _asDouble(value); final reading = _asDouble(value);
if (reading == null) return null; if (reading == null) return null;
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.wb_sunny_outlined, icon: Icons.wb_sunny_outlined,
label: label, label: label,
@@ -1148,7 +1518,7 @@ class SensorTelemetryCard extends StatelessWidget {
} }
if (value is num) { if (value is num) {
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.sensors, icon: Icons.sensors,
label: label, label: label,
@@ -1158,7 +1528,7 @@ class SensorTelemetryCard extends StatelessWidget {
); );
} }
return _MetricCardData( return SensorMetricCardData(
fieldKey: _extraFieldKey(rawKey), fieldKey: _extraFieldKey(rawKey),
icon: Icons.sensors, icon: Icons.sensors,
label: label, label: label,
@@ -1370,11 +1740,19 @@ class _InlineAlertBadge extends StatelessWidget {
} }
} }
class _MetricTile extends StatelessWidget { class SensorMetricTile extends StatelessWidget {
final _MetricCardData data; final SensorMetricCardData data;
final double width; 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<void> _showExpandedMap(BuildContext context) async { Future<void> _showExpandedMap(BuildContext context) async {
final location = data.mapLocation; final location = data.mapLocation;
@@ -1449,7 +1827,7 @@ class _MetricTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
key: ValueKey('sensor_metric_${data.fieldKey}'), key: ValueKey('${keyPrefix}_${data.fieldKey}'),
width: width, width: width,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -1457,13 +1835,15 @@ class _MetricTile extends StatelessWidget {
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(22),
border: Border.all(color: data.accent.withValues(alpha: 0.14)), border: Border.all(color: data.accent.withValues(alpha: 0.14)),
), ),
child: data.mapLocation == null child: data.mapLocation == null || !allowMapPreview
? Row( ? Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_MetricIcon(accent: data.accent, icon: data.icon), _MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded(child: _MetricText(data: data)), Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix),
),
], ],
) )
: Column( : Column(
@@ -1474,7 +1854,9 @@ class _MetricTile extends StatelessWidget {
children: [ children: [
_MetricIcon(accent: data.accent, icon: data.icon), _MetricIcon(accent: data.accent, icon: data.icon),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded(child: _MetricText(data: data)), Expanded(
child: _MetricText(data: data, keyPrefix: keyPrefix),
),
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -1588,9 +1970,10 @@ class _MetricIcon extends StatelessWidget {
} }
class _MetricText 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1614,7 +1997,7 @@ class _MetricText extends StatelessWidget {
if (data.channel != null) ...[ if (data.channel != null) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
key: ValueKey('sensor_metric_channel_${data.fieldKey}'), key: ValueKey('${keyPrefix}_channel_${data.fieldKey}'),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: data.accent.withValues(alpha: 0.12), color: data.accent.withValues(alpha: 0.12),
@@ -1658,7 +2041,7 @@ class _MetricText extends StatelessWidget {
} }
} }
class _MetricCardData { class SensorMetricCardData {
final String fieldKey; final String fieldKey;
final IconData icon; final IconData icon;
final String label; final String label;
@@ -1669,7 +2052,7 @@ class _MetricCardData {
final LatLng? mapLocation; final LatLng? mapLocation;
final int? channel; final int? channel;
const _MetricCardData({ const SensorMetricCardData({
required this.fieldKey, required this.fieldKey,
required this.icon, required this.icon,
required this.label, required this.label,

View File

@@ -148,16 +148,31 @@ void main() {
final provider = SensorsProvider(); final provider = SensorsProvider();
await waitUntilLoaded(provider); await waitUntilLoaded(provider);
await provider.addSensor(contact); 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(); final reloadedProvider = SensorsProvider();
await waitUntilLoaded(reloadedProvider); 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 { test('refreshDueSensors respects per-contact interval', () async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
final contact = buildSensorContact(); final contact = buildSensorContact();

View File

@@ -50,9 +50,7 @@ void main() {
); );
} }
testWidgets('customize sheet shows metric value previews and channels', ( testWidgets('customize action opens full customization view', (tester) async {
tester,
) async {
final contact = buildSensorContact(); final contact = buildSensorContact();
final sensorsProvider = SensorsProvider(); final sensorsProvider = SensorsProvider();
final contactsProvider = ContactsProvider(); final contactsProvider = ContactsProvider();
@@ -61,40 +59,37 @@ void main() {
contactsProvider.addOrUpdateContact(contact); contactsProvider.addOrUpdateContact(contact);
await sensorsProvider.addSensor(contact); await sensorsProvider.addSensor(contact);
await tester.pumpWidget( await tester.pumpWidget(
MultiProvider( MultiProvider(
providers: [ providers: [
ChangeNotifierProvider<ContactsProvider>.value( ChangeNotifierProvider<ContactsProvider>.value(
value: contactsProvider, value: contactsProvider,
), ),
ChangeNotifierProvider<SensorsProvider>.value(value: sensorsProvider), ChangeNotifierProvider<SensorsProvider>.value(value: sensorsProvider),
], ],
child: MaterialApp( child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
home: const SensorsTab(), home: const SensorsTab(),
),
), ),
); ),
await tester.pump(); );
await tester.pump(const Duration(milliseconds: 300)); await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.more_vert)); await tester.tap(find.byIcon(Icons.more_vert));
await tester.pump(); await tester.pumpAndSettle();
await tester.pump(const Duration(milliseconds: 300)); await tester.tap(find.text('Customize fields'));
await tester.tap(find.text('Customize fields')); await tester.pumpAndSettle();
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( expect(
find.byKey(const ValueKey('sensor_selector_value_extra:illuminance_2')), find.byKey(
const ValueKey('sensor_selector_metric_channel_extra:illuminance_2'),
),
findsOneWidget, findsOneWidget,
); );
expect(find.text('500 lx'), findsOneWidget); expect(find.text('Channel 2'), findsOneWidget);
expect(
find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')),
findsOneWidget,
);
expect(find.text('ch2'), findsOneWidget);
}); });
} }

View File

@@ -156,6 +156,7 @@ void main() {
await tester.tap(find.text('Preview')); await tester.tap(find.text('Preview'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byIcon(Icons.close), findsOneWidget);
expect(find.text('Battery'), findsOneWidget); expect(find.text('Battery'), findsOneWidget);
expect(find.text('84%'), findsOneWidget); expect(find.text('84%'), findsOneWidget);
expect(find.text('Temperature'), findsOneWidget); expect(find.text('Temperature'), findsOneWidget);
@@ -172,10 +173,7 @@ void main() {
find.byKey(const ValueKey('sensor_metric_channel_battery')), find.byKey(const ValueKey('sensor_metric_channel_battery')),
findsOneWidget, findsOneWidget,
); );
expect( expect(find.text('ch1'), findsWidgets);
find.text('ch1'),
findsWidgets,
);
expect( expect(
find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')), find.byKey(const ValueKey('sensor_metric_channel_extra:illuminance_2')),
findsOneWidget, findsOneWidget,
@@ -186,5 +184,10 @@ void main() {
find.byKey(const ValueKey('sensor_metric_battery')), find.byKey(const ValueKey('sensor_metric_battery')),
); );
expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8)); expect(batteryTileSize.width, greaterThan(sensorCardSize.width * 0.8));
await tester.tap(find.byIcon(Icons.close));
await tester.pumpAndSettle();
expect(find.byType(SensorTelemetryCard), findsNothing);
}); });
} }

View File

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

View File

@@ -15,6 +15,14 @@ void main() {
defaultLabel: 'Illuminance', defaultLabel: 'Illuminance',
channel: 2, channel: 2,
valuePreview: '500 lx', 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, visible: true,
span: 1, span: 1,
@@ -30,11 +38,12 @@ void main() {
), ),
); );
expect(find.text('Show on sensor card'), findsOneWidget);
expect(find.text('500 lx'), findsOneWidget); expect(find.text('500 lx'), findsOneWidget);
expect( expect(
find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')), find.byKey(const ValueKey('sensor_selector_channel_extra:illuminance_2')),
findsOneWidget, findsOneWidget,
); );
expect(find.text('ch2'), findsOneWidget); expect(find.text('Channel 2'), findsOneWidget);
}); });
} }