feat: Open per-metric sensor history

This commit is contained in:
Janez T
2026-04-12 21:07:53 +02:00
parent 7245d76e17
commit b333b3737e
25 changed files with 1248 additions and 2025 deletions

View File

@@ -0,0 +1,628 @@
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/sensors_provider.dart';
import 'sensor_telemetry_card.dart';
Future<void> showSensorHistorySheet(
BuildContext context, {
required String publicKeyHex,
String? initialFieldKey,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => _SensorHistorySheet(
publicKeyHex: publicKeyHex,
initialFieldKey: initialFieldKey,
),
);
}
class _SensorHistorySheet extends StatefulWidget {
const _SensorHistorySheet({
required this.publicKeyHex,
this.initialFieldKey,
});
final String publicKeyHex;
final String? initialFieldKey;
@override
State<_SensorHistorySheet> createState() => _SensorHistorySheetState();
}
class _SensorHistorySheetState extends State<_SensorHistorySheet> {
String? _selectedFieldKey;
@override
void initState() {
super.initState();
_selectedFieldKey = widget.initialFieldKey;
}
@override
Widget build(BuildContext context) {
final height = MediaQuery.of(context).size.height * 0.84;
return SafeArea(
child: SizedBox(
height: height,
child: Consumer3<SensorsProvider, ContactsProvider, ConnectionProvider>(
builder:
(
context,
sensorsProvider,
contactsProvider,
connectionProvider,
child,
) {
final contact = sensorsProvider.contactForDisplay(
widget.publicKeyHex,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final history = sensorsProvider.historyFor(widget.publicKeyHex);
final options = sensorMetricOptionsFor(
contact,
labelOverrides: sensorsProvider.labelOverridesFor(
widget.publicKeyHex,
),
);
final optionByKey = <String, SensorMetricOption>{
for (final option in options) option.key: option,
};
final availableFieldKeys = <String>{
for (final sample in history) ...sample.values.keys,
}.toList()
..sort((a, b) {
final aIndex = options.indexWhere(
(option) => option.key == a,
);
final bIndex = options.indexWhere(
(option) => option.key == b,
);
if (aIndex == -1 && bIndex == -1) {
return a.compareTo(b);
}
if (aIndex == -1) {
return 1;
}
if (bIndex == -1) {
return -1;
}
return aIndex.compareTo(bIndex);
});
_selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: _selectedFieldKey,
availableFieldKeys: availableFieldKeys,
);
final selectedFieldKey = _selectedFieldKey;
final selectedSamples = selectedFieldKey == null
? const <SensorHistorySample>[]
: history
.where(
(sample) =>
sample.values.containsKey(selectedFieldKey),
)
.toList(growable: false);
final selectedOption = selectedFieldKey == null
? null
: optionByKey[selectedFieldKey];
final selectedCardData = selectedOption?.previewCardData;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sensor history',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
contact?.displayName ?? 'Unavailable node',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 12),
if (availableFieldKeys.isEmpty)
Expanded(
child: Center(
child: Text(
'No history recorded yet. Enable auto refresh for this sensor and leave the app running to collect samples.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
),
)
else ...[
Wrap(
spacing: 8,
runSpacing: 8,
children: availableFieldKeys
.map((fieldKey) {
final option = optionByKey[fieldKey];
return ChoiceChip(
label: Text(
option?.defaultLabel ?? fieldKey,
),
selected: fieldKey == selectedFieldKey,
onSelected: (selected) {
if (!selected) {
return;
}
setState(() {
_selectedFieldKey = fieldKey;
});
},
);
})
.toList(growable: false),
),
const SizedBox(height: 16),
_SensorHistorySummaryCard(
historyCount: history.length,
samples: selectedSamples,
cardData: selectedCardData,
fieldKey: selectedFieldKey!,
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedCardData?.label ??
selectedOption?.defaultLabel ??
selectedFieldKey,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
SizedBox(
height: 220,
child: LineChart(
_historyLineChartData(
context,
samples: selectedSamples,
fieldKey: selectedFieldKey,
color:
selectedCardData?.accent ??
Theme.of(context).colorScheme.primary,
),
duration: Duration.zero,
),
),
],
),
),
const SizedBox(height: 16),
Expanded(
child: _SensorHistoryLogList(
history: history.reversed.toList(growable: false),
selectedFieldKey: selectedFieldKey,
optionByKey: optionByKey,
),
),
],
],
),
);
},
),
),
);
}
}
String? resolveInitialSensorHistoryField({
required String? requestedFieldKey,
required List<String> availableFieldKeys,
}) {
if (requestedFieldKey != null &&
availableFieldKeys.contains(requestedFieldKey)) {
return requestedFieldKey;
}
if (availableFieldKeys.isEmpty) {
return null;
}
return availableFieldKeys.first;
}
class _SensorHistorySummaryCard extends StatelessWidget {
const _SensorHistorySummaryCard({
required this.historyCount,
required this.samples,
required this.cardData,
required this.fieldKey,
});
final int historyCount;
final List<SensorHistorySample> samples;
final SensorMetricCardData? cardData;
final String fieldKey;
@override
Widget build(BuildContext context) {
final latestValue = samples.isEmpty ? null : samples.last.values[fieldKey];
final minValue = samples.isEmpty
? null
: samples
.map((sample) => sample.values[fieldKey]!)
.reduce(math.min);
final maxValue = samples.isEmpty
? null
: samples
.map((sample) => sample.values[fieldKey]!)
.reduce(math.max);
final theme = Theme.of(context);
final accent = cardData?.accent ?? theme.colorScheme.primary;
return Row(
children: [
Expanded(
child: _SensorHistoryStatTile(
label: 'Total',
value: historyCount.toString(),
accent: accent,
),
),
const SizedBox(width: 8),
Expanded(
child: _SensorHistoryStatTile(
label: 'Latest',
value: latestValue == null
? '--'
: _formatHistoryValue(cardData, latestValue),
accent: accent,
),
),
const SizedBox(width: 8),
Expanded(
child: _SensorHistoryStatTile(
label: 'Min',
value: minValue == null ? '--' : _formatHistoryValue(cardData, minValue),
accent: accent,
),
),
const SizedBox(width: 8),
Expanded(
child: _SensorHistoryStatTile(
label: 'Max',
value: maxValue == null ? '--' : _formatHistoryValue(cardData, maxValue),
accent: accent,
),
),
],
);
}
}
class _SensorHistoryStatTile extends StatelessWidget {
const _SensorHistoryStatTile({
required this.label,
required this.value,
required this.accent,
});
final String label;
final String value;
final Color accent;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
],
),
);
}
}
class _SensorHistoryLogList extends StatelessWidget {
const _SensorHistoryLogList({
required this.history,
required this.selectedFieldKey,
required this.optionByKey,
});
final List<SensorHistorySample> history;
final String selectedFieldKey;
final Map<String, SensorMetricOption> optionByKey;
@override
Widget build(BuildContext context) {
return ListView.separated(
itemCount: history.length,
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final sample = history[index];
final selectedValue = sample.values[selectedFieldKey];
final selectedOption = optionByKey[selectedFieldKey];
final secondaryMetrics = sample.values.entries
.where((entry) => entry.key != selectedFieldKey)
.take(3)
.map((entry) {
final option = optionByKey[entry.key];
final cardData = option?.previewCardData;
return TextSpan(
text:
'${option?.defaultLabel ?? entry.key} ${_formatHistoryValue(cardData, entry.value)} ',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: cardData?.accent ?? Theme.of(context).colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
);
})
.toList(growable: false);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_formatSampleTimestamp(sample.timestamp),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'${selectedOption?.defaultLabel ?? selectedFieldKey} ${selectedValue == null ? '--' : _formatHistoryValue(selectedOption?.previewCardData, selectedValue)}',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color:
selectedOption?.previewCardData?.accent ??
Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
if (secondaryMetrics.isNotEmpty) ...[
const SizedBox(height: 4),
RichText(
text: TextSpan(children: secondaryMetrics),
),
],
],
),
);
},
);
}
}
LineChartData _historyLineChartData(
BuildContext context, {
required List<SensorHistorySample> samples,
required String fieldKey,
required Color color,
}) {
final theme = Theme.of(context);
final values = samples
.map((sample) => sample.values[fieldKey]!)
.toList(growable: false);
final spots = values
.asMap()
.entries
.map((entry) => FlSpot(entry.key.toDouble(), entry.value))
.toList(growable: false);
final minValue = values.reduce(math.min);
final maxValue = values.reduce(math.max);
final spread = maxValue - minValue;
final padding = spread == 0 ? math.max(maxValue.abs() * 0.1, 1.0) : spread * 0.15;
final minY = minValue - padding;
final maxY = maxValue + padding;
final interval = spread <= 0 ? math.max(maxValue.abs() / 3, 1.0) : spread / 3;
return LineChartData(
minX: 0,
maxX: values.length <= 1 ? 1.0 : (values.length - 1).toDouble(),
minY: minY,
maxY: maxY,
clipData: const FlClipData.all(),
lineTouchData: const LineTouchData(enabled: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: interval,
getDrawingHorizontalLine: (value) => FlLine(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.24),
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: interval,
getTitlesWidget: (value, meta) => SideTitleWidget(
meta: meta,
space: 8,
child: Text(
value.toStringAsFixed(value.abs() >= 10 ? 0 : 1),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 24,
interval: math.max((values.length / 4).floorToDouble(), 1),
getTitlesWidget: (value, meta) {
final index = value.round();
if (index < 0 || index >= samples.length || value != index.toDouble()) {
return const SizedBox.shrink();
}
return SideTitleWidget(
meta: meta,
space: 6,
child: Text(
_formatChartTimestamp(samples[index].timestamp),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
);
},
),
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
color: color,
barWidth: 2.8,
isCurved: false,
dotData: FlDotData(
show: true,
checkToShowDot: (spot, barData) =>
barData.spots.length <= 10 || spot == barData.spots.last,
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color.withValues(alpha: 0.20),
color.withValues(alpha: 0.03),
],
),
),
),
],
);
}
String _formatHistoryValue(SensorMetricCardData? cardData, double value) {
final template = cardData?.value;
if (template == null || template.isEmpty) {
return value.toStringAsFixed(value.abs() >= 10 ? 0 : 1);
}
if (template.endsWith('%')) {
return '${value.toStringAsFixed(1)}%';
}
if (template.endsWith('V')) {
return '${value.toStringAsFixed(3)}V';
}
if (template.contains(' hPa')) {
return '${value.toStringAsFixed(1)} hPa';
}
if (template.contains('°C')) {
return '${value.toStringAsFixed(1)}°C';
}
if (template.contains(' mph')) {
return '${value.toStringAsFixed(2)} mph';
}
if (template.contains(' km/h')) {
return '${value.toStringAsFixed(2)} km/h';
}
return value.toStringAsFixed(value.abs() >= 10 ? 0 : 1);
}
String _formatSampleTimestamp(DateTime timestamp) {
final local = timestamp.toLocal();
final month = local.month.toString().padLeft(2, '0');
final day = local.day.toString().padLeft(2, '0');
final year = local.year.toString().substring(2);
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
return '$month/$day/$year $hour:$minute';
}
String _formatChartTimestamp(DateTime timestamp) {
final local = timestamp.toLocal();
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}

View File

@@ -1,7 +1,6 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
@@ -1067,6 +1066,7 @@ class SensorTelemetryCard extends StatelessWidget {
final Future<void> Function()? onPing;
final VoidCallback? onCustomize;
final Future<void> Function(Contact contact)? onShowMetHistory;
final Future<void> Function(String fieldKey)? onMetricTap;
final Future<void> Function()? onMoveUp;
final Future<void> Function()? onMoveDown;
final EdgeInsetsGeometry margin;
@@ -1086,6 +1086,7 @@ class SensorTelemetryCard extends StatelessWidget {
this.onPing,
this.onCustomize,
this.onShowMetHistory,
this.onMetricTap,
this.onMoveUp,
this.onMoveDown,
this.margin = const EdgeInsets.only(bottom: 16),
@@ -1112,7 +1113,6 @@ class SensorTelemetryCard extends StatelessWidget {
onRemove != null ||
onMoveUp != null ||
onMoveDown != null ||
_rawTelemetryHex(contact?.telemetry) != null ||
(contact != null &&
onShowMetHistory != null &&
supportsBTHomeMetHistory(contact));
@@ -1134,18 +1134,6 @@ class SensorTelemetryCard extends StatelessWidget {
await onMoveDown!();
return;
}
if (value == 'copy_raw') {
final rawTelemetry = _rawTelemetryHex(contact?.telemetry);
if (rawTelemetry != null && context.mounted) {
await Clipboard.setData(ClipboardData(text: rawTelemetry));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.rawResponseCopied)),
);
}
}
return;
}
if (value == 'remove' && onRemove != null) {
await onRemove!();
return;
@@ -1176,18 +1164,24 @@ class SensorTelemetryCard extends StatelessWidget {
label: AppLocalizations.of(context)!.ping,
onTap: () => _handleAction(context, 'ping'),
),
if (_rawTelemetryHex(contact?.telemetry) != null)
_SensorSheetAction(
icon: Icons.copy_all_outlined,
label: AppLocalizations.of(context)!.copyRawResponse,
onTap: () => _handleAction(context, 'copy_raw'),
),
if (onCustomize != null)
_SensorSheetAction(
icon: Icons.tune,
label: l10n.customizeFields,
onTap: () => _handleAction(context, 'customize'),
),
if (onMoveUp != null)
_SensorSheetAction(
icon: Icons.arrow_upward,
label: l10n.moveUp,
onTap: () => _handleAction(context, 'move_up'),
),
if (onMoveDown != null)
_SensorSheetAction(
icon: Icons.arrow_downward,
label: l10n.moveDown,
onTap: () => _handleAction(context, 'move_down'),
),
if (contact != null &&
onShowMetHistory != null &&
supportsBTHomeMetHistory(contact))
@@ -1327,11 +1321,14 @@ class SensorTelemetryCard extends StatelessWidget {
),
),
if (_showsMenu)
Icon(
showActionSheetOnTap
? Icons.chevron_right_rounded
: Icons.more_horiz,
color: colorScheme.onSurfaceVariant,
IconButton(
onPressed: () => _showActionSheet(context),
icon: Icon(
Icons.more_vert,
color: colorScheme.onSurfaceVariant,
),
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
visualDensity: VisualDensity.compact,
),
],
),
@@ -1364,6 +1361,11 @@ class SensorTelemetryCard extends StatelessWidget {
metric.wide)
? constraints.maxWidth
: compactWidth,
onTap: onMetricTap == null
? null
: () async {
await onMetricTap!(metric.fieldKey);
},
onLongPress: onRefresh == null
? null
: () async {
@@ -1380,15 +1382,7 @@ class SensorTelemetryCard extends StatelessWidget {
),
);
if (!_showsMenu || !showActionSheetOnTap) {
return card;
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _showActionSheet(context),
child: card,
);
return card;
}
List<SensorMetricCardData> _buildMetricCards(
@@ -2326,6 +2320,7 @@ class SensorMetricTile extends StatefulWidget {
final double width;
final String keyPrefix;
final bool allowMapPreview;
final GestureTapCallback? onTap;
final GestureLongPressCallback? onLongPress;
const SensorMetricTile({
@@ -2334,6 +2329,7 @@ class SensorMetricTile extends StatefulWidget {
required this.width,
this.keyPrefix = 'sensor_metric',
this.allowMapPreview = true,
this.onTap,
this.onLongPress,
});
@@ -2450,6 +2446,7 @@ class _SensorMetricTileState extends State<SensorMetricTile> {
child: InkWell(
key: ValueKey('${widget.keyPrefix}_${data.fieldKey}'),
borderRadius: BorderRadius.circular(22),
onTap: widget.onTap,
onLongPress: widget.onLongPress,
child: Container(
width: widget.width,
@@ -3102,11 +3099,6 @@ bool _isTelemetryMetadataKey(String key) {
key == _rawTelemetryHexKey;
}
String? _rawTelemetryHex(ContactTelemetry? telemetry) {
final value = telemetry?.extraSensorData?[_rawTelemetryHexKey];
return value is String && value.trim().isNotEmpty ? value : null;
}
String _telemetrySourceChannelKey(String fieldKey) {
return '$_telemetrySourceChannelPrefix$fieldKey';
}