chore: Initial commit

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3
This commit is contained in:
Janez Troha
2026-09-04 16:53:52 +02:00
commit 7924803351
516 changed files with 207265 additions and 0 deletions

View File

@@ -0,0 +1,820 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import 'package:meshcore_client/meshcore_client.dart' show Message;
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../services/bthome_met_history.dart';
Future<void> showBTHomeMetHistorySheet(
BuildContext context, {
required Contact contact,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => _BTHomeMetHistorySheet(contact: contact),
);
}
class _BTHomeMetHistorySheet extends StatefulWidget {
const _BTHomeMetHistorySheet({required this.contact});
final Contact contact;
@override
State<_BTHomeMetHistorySheet> createState() => _BTHomeMetHistorySheetState();
}
class _BTHomeMetHistorySheetState extends State<_BTHomeMetHistorySheet> {
late final List<BTHomeMetMeasurement> _availableMeasurements;
late BTHomeMetMeasurement _selectedMeasurement;
BTHomeMetHistoryPage? _history;
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_availableMeasurements = bTHomeMetMeasurementsForContact(widget.contact);
_selectedMeasurement = _availableMeasurements.isEmpty
? BTHomeMetMeasurement.temperature
: _availableMeasurements.first;
if (_availableMeasurements.isEmpty) {
_loading = false;
_error = 'No BTHome MET-compatible telemetry is available for this node.';
return;
}
unawaited(_loadHistory(measurement: _selectedMeasurement, page: 0));
}
Future<void> _loadHistory({
required BTHomeMetMeasurement measurement,
required int page,
}) async {
final connectionProvider = context.read<ConnectionProvider>();
final previousOnMessageReceived = connectionProvider.onMessageReceived;
String? responseText;
void onMessage(Message message) {
previousOnMessageReceived?.call(message);
if (_matchesContact(message)) {
responseText = message.text;
}
}
connectionProvider.onMessageReceived = onMessage;
if (mounted) {
setState(() {
_loading = true;
_error = null;
});
}
try {
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: widget.contact.publicKey,
text: 'bthome met history ${measurement.id} $page',
);
if (!sent) {
throw Exception('Failed to send MET history request.');
}
for (var i = 0; i < 30; i++) {
await Future.delayed(const Duration(milliseconds: 500));
if (responseText != null) {
break;
}
}
final text = responseText?.trim();
if (text == null || text.isEmpty) {
throw TimeoutException('No response from sensor.');
}
if (_looksLikeError(text)) {
throw Exception(text);
}
final history = BTHomeMetHistoryParser.parse(text);
if (!mounted) {
return;
}
setState(() {
_selectedMeasurement = measurement;
_history = history;
_loading = false;
_error = null;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_selectedMeasurement = measurement;
_history = null;
_loading = false;
_error = _formatError(error);
});
} finally {
if (identical(connectionProvider.onMessageReceived, onMessage)) {
connectionProvider.onMessageReceived = previousOnMessageReceived;
}
}
}
bool _matchesContact(Message message) {
final prefix = message.senderPublicKeyPrefix;
if (prefix == null ||
prefix.length < 6 ||
widget.contact.publicKey.length < 6) {
return false;
}
for (var i = 0; i < 6; i++) {
if (prefix[i] != widget.contact.publicKey[i]) {
return false;
}
}
return true;
}
bool _looksLikeError(String text) {
final lower = text.toLowerCase();
return lower.startsWith('err') ||
lower.contains('unknown') ||
lower.contains('unsupported');
}
String _formatError(Object error) {
if (error is TimeoutException) {
return error.message ?? 'Timed out waiting for MET history.';
}
return error.toString().replaceFirst('Exception: ', '');
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final height = MediaQuery.of(context).size.height * 0.8;
final history = _history;
return SafeArea(
child: SizedBox(
height: height,
child: 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(
'MET history',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
widget.contact.displayName,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _availableMeasurements
.map(
(measurement) => ChoiceChip(
label: Text(measurement.label),
selected: measurement == _selectedMeasurement,
onSelected: (selected) {
if (!selected || _loading) {
return;
}
unawaited(
_loadHistory(measurement: measurement, page: 0),
);
},
),
)
.toList(growable: false),
),
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
onPressed: _loading || (history?.page ?? 0) == 0
? null
: () => unawaited(
_loadHistory(
measurement: _selectedMeasurement,
page: history!.page - 1,
),
),
icon: const Icon(Icons.chevron_left),
label: Text(AppLocalizations.of(context)!.newer),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed:
_loading ||
history == null ||
history.values.length < 12
? null
: () => unawaited(
_loadHistory(
measurement: _selectedMeasurement,
page: history.page + 1,
),
),
icon: const Icon(Icons.chevron_right),
label: Text(AppLocalizations.of(context)!.older),
),
const Spacer(),
if (_loading)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Text(
'Page ${history?.page ?? 0}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 12),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_error != null)
_HistoryMessageCard(
icon: Icons.error_outline,
title: AppLocalizations.of(context)!.couldNotLoadMetHistory,
body: _error!,
)
else if (_loading && history == null)
_HistoryMessageCard(
icon: Icons.hourglass_top,
title: AppLocalizations.of(context)!.loading,
body: 'Waiting for the sensor to reply.',
)
else if (history != null) ...[
_HistoryChartCard(history: history),
const SizedBox(height: 12),
_HistoryStatsGrid(history: history),
const SizedBox(height: 12),
_HistorySamplesCard(history: history),
],
],
),
),
),
],
),
),
),
);
}
}
class _HistoryChartCard extends StatelessWidget {
const _HistoryChartCard({required this.history});
final BTHomeMetHistoryPage history;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
history.measurement.label,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Samples are shown oldest to newest. Firmware replies do not include timestamps.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: LineChart(_historyLineChartData(context, history: history)),
),
],
),
);
}
}
class _HistoryStatsGrid extends StatelessWidget {
const _HistoryStatsGrid({required this.history});
final BTHomeMetHistoryPage history;
@override
Widget build(BuildContext context) {
final measurement = history.measurement;
return LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 520;
final tiles = <Widget>[
_HistoryStatTile(
label: AppLocalizations.of(context)!.latest,
value: _formatMeasurementValue(measurement, history.latest),
),
_HistoryStatTile(
label: AppLocalizations.of(context)!.min,
value: _formatMeasurementValue(measurement, history.minimum),
),
_HistoryStatTile(
label: AppLocalizations.of(context)!.max,
value: _formatMeasurementValue(measurement, history.maximum),
),
_HistoryStatTile(
label: AppLocalizations.of(context)!.samples,
value: history.values.length.toString(),
),
];
if (compact) {
return Column(
children: [
Row(
children: [
Expanded(child: tiles[0]),
const SizedBox(width: 8),
Expanded(child: tiles[1]),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: tiles[2]),
const SizedBox(width: 8),
Expanded(child: tiles[3]),
],
),
],
);
}
return Row(
children: [
Expanded(child: tiles[0]),
const SizedBox(width: 8),
Expanded(child: tiles[1]),
const SizedBox(width: 8),
Expanded(child: tiles[2]),
const SizedBox(width: 8),
Expanded(child: tiles[3]),
],
);
},
);
}
}
class _HistoryStatTile extends StatelessWidget {
const _HistoryStatTile({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
value,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
class _HistorySamplesCard extends StatelessWidget {
const _HistorySamplesCard({required this.history});
final BTHomeMetHistoryPage history;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Samples',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: history.values
.asMap()
.entries
.map((entry) {
final sampleIndex = entry.key + 1;
final isLatest = entry.key == history.values.length - 1;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: isLatest
? _historyColor(
history.measurement,
).withValues(alpha: 0.12)
: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(14),
),
child: Text(
'$sampleIndex. ${_formatMeasurementValue(history.measurement, entry.value)}${isLatest ? ' latest' : ''}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: isLatest
? FontWeight.w700
: FontWeight.w500,
),
),
);
})
.toList(growable: false),
),
],
),
);
}
}
class _HistoryMessageCard extends StatelessWidget {
const _HistoryMessageCard({
required this.icon,
required this.title,
required this.body,
});
final IconData icon;
final String title;
final String body;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(body),
],
),
),
],
),
);
}
}
Color _historyColor(BTHomeMetMeasurement measurement) {
switch (measurement) {
case BTHomeMetMeasurement.temperature:
return const Color(0xFFC76821);
case BTHomeMetMeasurement.humidity:
return const Color(0xFF246BB2);
case BTHomeMetMeasurement.windSpeed:
return const Color(0xFF2B78A0);
case BTHomeMetMeasurement.gust:
return const Color(0xFF1E88A8);
case BTHomeMetMeasurement.rain:
return const Color(0xFF2C6BA0);
}
}
String _formatMeasurementValue(BTHomeMetMeasurement measurement, num? value) {
if (value == null) {
return '--';
}
final digits = switch (measurement) {
BTHomeMetMeasurement.humidity => 0,
BTHomeMetMeasurement.temperature => 1,
BTHomeMetMeasurement.windSpeed => 1,
BTHomeMetMeasurement.gust => 1,
BTHomeMetMeasurement.rain => 1,
};
final text = value
.toStringAsFixed(digits)
.replaceFirst(RegExp(r'\.?0+$'), '');
return '$text${measurement.unit}';
}
LineChartData _historyLineChartData(
BuildContext context, {
required BTHomeMetHistoryPage history,
}) {
final theme = Theme.of(context);
final color = _historyColor(history.measurement);
final values = history.values;
final spots = values
.asMap()
.entries
.map((entry) => FlSpot(entry.key.toDouble(), entry.value))
.toList(growable: false);
final chartMinY = _historyChartMinY(history);
final chartMaxY = _historyChartMaxY(history);
final yInterval = _historyYAxisInterval(
measurement: history.measurement,
minY: chartMinY,
maxY: chartMaxY,
);
return LineChartData(
minX: 0,
maxX: values.length <= 1 ? 1.0 : (values.length - 1).toDouble(),
minY: chartMinY,
maxY: chartMaxY,
clipData: const FlClipData.all(),
lineTouchData: const LineTouchData(enabled: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: yInterval,
getDrawingHorizontalLine: (value) => FlLine(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.28),
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: 42,
interval: yInterval,
getTitlesWidget: (value, meta) => SideTitleWidget(
meta: meta,
space: 8,
child: Text(
_formatHistoryAxisValue(history.measurement, value),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 24,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.round();
if (value != index.toDouble() ||
!_shouldShowBottomSampleLabel(index, values.length)) {
return const SizedBox.shrink();
}
return SideTitleWidget(
meta: meta,
space: 6,
child: Text(
'${index + 1}',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
);
},
),
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
color: color,
barWidth: 2.8,
isCurved: false,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
checkToShowDot: (spot, barData) =>
barData.spots.length <= 10 || spot == barData.spots.last,
getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
radius: spot == barData.spots.last ? 4 : 2.5,
color: color,
strokeColor: theme.colorScheme.surface,
strokeWidth: 1.6,
),
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color.withValues(alpha: 0.24),
color.withValues(alpha: 0.03),
],
),
),
),
],
);
}
double _historyChartMinY(BTHomeMetHistoryPage history) {
final minValue = history.minimum ?? 0;
final maxValue = history.maximum ?? 0;
final spread = maxValue - minValue;
final padding = spread == 0
? _historyMinimumPadding(history.measurement, minValue)
: math.max(
spread * 0.18,
_historyMinimumPadding(history.measurement, minValue),
);
return minValue - padding;
}
double _historyChartMaxY(BTHomeMetHistoryPage history) {
final minValue = history.minimum ?? 0;
final maxValue = history.maximum ?? 0;
final spread = maxValue - minValue;
final padding = spread == 0
? _historyMinimumPadding(history.measurement, maxValue)
: math.max(
spread * 0.18,
_historyMinimumPadding(history.measurement, maxValue),
);
return maxValue + padding;
}
double _historyMinimumPadding(
BTHomeMetMeasurement measurement,
double reference,
) {
final scaled = math.max(reference.abs() * 0.05, 0.1);
return switch (measurement) {
BTHomeMetMeasurement.temperature => math.max(0.4, scaled),
BTHomeMetMeasurement.humidity => math.max(2.0, scaled),
BTHomeMetMeasurement.windSpeed => math.max(0.4, scaled),
BTHomeMetMeasurement.gust => math.max(0.4, scaled),
BTHomeMetMeasurement.rain => math.max(0.4, scaled),
};
}
double _historyYAxisInterval({
required BTHomeMetMeasurement measurement,
required double minY,
required double maxY,
}) {
final span = maxY - minY;
if (span <= 0) {
return 1;
}
final rough = span / 3;
return switch (measurement) {
BTHomeMetMeasurement.humidity => math.max(1, rough.round()).toDouble(),
BTHomeMetMeasurement.temperature => _niceStep(rough, minStep: 0.5),
BTHomeMetMeasurement.windSpeed => _niceStep(rough, minStep: 0.5),
BTHomeMetMeasurement.gust => _niceStep(rough, minStep: 0.5),
BTHomeMetMeasurement.rain => _niceStep(rough, minStep: 0.5),
};
}
double _niceStep(double value, {required double minStep}) {
if (value <= minStep) {
return minStep;
}
final exponent = math
.pow(10.0, (math.log(value) / math.ln10).floor())
.toDouble();
final normalized = value / exponent;
final stepped = switch (normalized) {
< 1.5 => 1.0,
< 3.0 => 2.0,
< 7.0 => 5.0,
_ => 10.0,
};
return math.max(minStep, stepped * exponent);
}
String _formatHistoryAxisValue(BTHomeMetMeasurement measurement, double value) {
final digits = measurement == BTHomeMetMeasurement.humidity ? 0 : 1;
return value.toStringAsFixed(digits).replaceFirst(RegExp(r'\.?0+$'), '');
}
bool _shouldShowBottomSampleLabel(int index, int sampleCount) {
if (index < 0 || index >= sampleCount) {
return false;
}
if (sampleCount <= 3) {
return true;
}
final middle = (sampleCount - 1) ~/ 2;
return index == 0 || index == middle || index == sampleCount - 1;
}

View File

@@ -0,0 +1,646 @@
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';
enum SensorHistoryRange { day, week, month, all }
Future<void> showSensorHistorySheet(
BuildContext context, {
required String publicKeyHex,
String? initialFieldKey,
}) {
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (pageContext) => SensorHistoryScreen(
publicKeyHex: publicKeyHex,
initialFieldKey: initialFieldKey,
),
),
);
}
class SensorHistoryScreen extends StatefulWidget {
const SensorHistoryScreen({
super.key,
required this.publicKeyHex,
this.initialFieldKey,
});
final String publicKeyHex;
final String? initialFieldKey;
@override
State<SensorHistoryScreen> createState() => _SensorHistoryScreenState();
}
class _SensorHistoryScreenState extends State<SensorHistoryScreen> {
late SensorHistoryRange _selectedRange;
@override
void initState() {
super.initState();
_selectedRange = SensorHistoryRange.day;
}
@override
Widget build(BuildContext context) {
return 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);
});
final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: widget.initialFieldKey,
availableFieldKeys: availableFieldKeys,
);
final selectedOption = selectedFieldKey == null
? null
: optionByKey[selectedFieldKey];
final selectedCardData = selectedOption?.previewCardData;
final selectedSamples = selectedFieldKey == null
? const <SensorHistorySample>[]
: history
.where(
(sample) =>
sample.values.containsKey(selectedFieldKey),
)
.toList(growable: false);
final rangeSamples = filterSensorHistorySamples(
samples: selectedSamples,
range: _selectedRange,
);
final title =
selectedCardData?.label ??
selectedOption?.defaultLabel ??
'Sensor history';
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title),
Text(
contact?.displayName ?? 'Unavailable node',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
bottom: const TabBar(
tabs: [
Tab(text: 'Graph'),
Tab(text: 'Values'),
],
),
),
body: selectedFieldKey == null
? _SensorHistoryEmptyState(
message:
'No history recorded yet. Enable auto refresh for this sensor and leave the app running to collect samples.',
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: SensorHistoryRange.values
.map(
(range) => ChoiceChip(
label: Text(
sensorHistoryRangeLabel(range),
),
selected: _selectedRange == range,
onSelected: (selected) {
if (!selected) {
return;
}
setState(() {
_selectedRange = range;
});
},
),
)
.toList(growable: false),
),
),
Expanded(
child: TabBarView(
children: [
_SensorHistoryGraphTab(
samples: rangeSamples,
fieldKey: selectedFieldKey,
cardData: selectedCardData,
totalCount: selectedSamples.length,
range: _selectedRange,
),
_SensorHistoryValuesTab(
samples: rangeSamples,
fieldKey: selectedFieldKey,
cardData: selectedCardData,
range: _selectedRange,
),
],
),
),
],
),
),
);
},
);
}
}
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;
}
List<SensorHistorySample> filterSensorHistorySamples({
required List<SensorHistorySample> samples,
required SensorHistoryRange range,
}) {
if (samples.isEmpty || range == SensorHistoryRange.all) {
return List<SensorHistorySample>.from(samples);
}
final latestTimestamp = samples.last.timestamp;
final cutoff = switch (range) {
SensorHistoryRange.day => latestTimestamp.subtract(const Duration(days: 1)),
SensorHistoryRange.week => latestTimestamp.subtract(const Duration(days: 7)),
SensorHistoryRange.month => latestTimestamp.subtract(
const Duration(days: 30),
),
SensorHistoryRange.all => DateTime.fromMillisecondsSinceEpoch(0),
};
return samples
.where((sample) => !sample.timestamp.isBefore(cutoff))
.toList(growable: false);
}
String sensorHistoryRangeLabel(SensorHistoryRange range) {
return switch (range) {
SensorHistoryRange.day => '24h',
SensorHistoryRange.week => '7d',
SensorHistoryRange.month => '30d',
SensorHistoryRange.all => 'All',
};
}
class _SensorHistoryGraphTab extends StatelessWidget {
const _SensorHistoryGraphTab({
required this.samples,
required this.fieldKey,
required this.cardData,
required this.totalCount,
required this.range,
});
final List<SensorHistorySample> samples;
final String fieldKey;
final SensorMetricCardData? cardData;
final int totalCount;
final SensorHistoryRange range;
@override
Widget build(BuildContext context) {
if (samples.isEmpty) {
return _SensorHistoryEmptyState(
message:
'No samples are available for ${sensorHistoryRangeLabel(range)}.',
);
}
final latestValue = samples.last.values[fieldKey]!;
final minValue = samples
.map((sample) => sample.values[fieldKey]!)
.reduce(math.min);
final maxValue = samples
.map((sample) => sample.values[fieldKey]!)
.reduce(math.max);
final accent = cardData?.accent ?? Theme.of(context).colorScheme.primary;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
children: [
Expanded(
child: _SensorHistoryStatTile(
label: 'Visible',
value: samples.length.toString(),
accent: accent,
),
),
const SizedBox(width: 8),
Expanded(
child: _SensorHistoryStatTile(
label: 'Latest',
value: _formatHistoryValue(cardData, latestValue),
accent: accent,
),
),
const SizedBox(width: 8),
Expanded(
child: _SensorHistoryStatTile(
label: 'Min',
value: _formatHistoryValue(cardData, minValue),
accent: accent,
),
),
const SizedBox(width: 8),
Expanded(
child: _SensorHistoryStatTile(
label: 'Max',
value: _formatHistoryValue(cardData, maxValue),
accent: accent,
),
),
],
),
const SizedBox(height: 8),
Text(
'$totalCount total readings',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
Container(
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: SizedBox(
height: 280,
child: LineChart(
_historyLineChartData(
context,
samples: samples,
fieldKey: fieldKey,
color: accent,
),
duration: Duration.zero,
),
),
),
],
);
}
}
class _SensorHistoryValuesTab extends StatelessWidget {
const _SensorHistoryValuesTab({
required this.samples,
required this.fieldKey,
required this.cardData,
required this.range,
});
final List<SensorHistorySample> samples;
final String fieldKey;
final SensorMetricCardData? cardData;
final SensorHistoryRange range;
@override
Widget build(BuildContext context) {
if (samples.isEmpty) {
return _SensorHistoryEmptyState(
message:
'No values are available for ${sensorHistoryRangeLabel(range)}.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: samples.length,
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final sample = samples[samples.length - index - 1];
final value = sample.values[fieldKey]!;
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: Row(
children: [
Expanded(
child: Text(
_formatSampleTimestamp(sample.timestamp),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
Text(
_formatHistoryValue(cardData, value),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color:
cardData?.accent ?? Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
],
),
);
},
);
}
}
class _SensorHistoryEmptyState extends StatelessWidget {
const _SensorHistoryEmptyState({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
),
);
}
}
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),
),
],
),
);
}
}
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 month = local.month.toString().padLeft(2, '0');
final day = local.day.toString().padLeft(2, '0');
return '$month/$day';
}

File diff suppressed because it is too large Load Diff