mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Update sensor customization view
This commit is contained in:
@@ -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>[];
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
38
test/widgets/sensor_auto_refresh_options_test.dart
Normal file
38
test/widgets/sensor_auto_refresh_options_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user