mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
feat: Add MET history isolation
This commit is contained in:
@@ -7,6 +7,7 @@ import '../models/contact_group.dart';
|
|||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
import '../services/contact_storage_service.dart';
|
import '../services/contact_storage_service.dart';
|
||||||
|
import '../services/profiles_feature_service.dart';
|
||||||
import '../utils/fast_gps_packet.dart';
|
import '../utils/fast_gps_packet.dart';
|
||||||
import '../utils/rssi_location_estimator.dart';
|
import '../utils/rssi_location_estimator.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
@@ -140,7 +141,8 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
String? _storageNamespace;
|
String? _storageNamespace;
|
||||||
|
|
||||||
// Add default public channel on initialization
|
// Add default public channel on initialization
|
||||||
ContactsProvider() {
|
ContactsProvider()
|
||||||
|
: _storageNamespace = ProfileStorageScope.effectiveNamespace {
|
||||||
_ensurePublicChannelExists();
|
_ensurePublicChannelExists();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1638,6 +1640,8 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
_contacts.clear();
|
_contacts.clear();
|
||||||
_savedContactGroups.clear();
|
_savedContactGroups.clear();
|
||||||
_pendingAdverts.clear();
|
_pendingAdverts.clear();
|
||||||
|
_estimatedLocations.clear();
|
||||||
|
_rssiObservations.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {
|
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../models/contact.dart';
|
|||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
import '../providers/sensors_provider.dart';
|
import '../providers/sensors_provider.dart';
|
||||||
|
import '../widgets/sensors/bthome_met_history_sheet.dart';
|
||||||
import '../widgets/sensors/sensor_telemetry_card.dart';
|
import '../widgets/sensors/sensor_telemetry_card.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
@@ -337,6 +338,11 @@ class _SensorsTabState extends State<SensorsTab> {
|
|||||||
: null,
|
: null,
|
||||||
onCustomize: () =>
|
onCustomize: () =>
|
||||||
_showMetricSelector(context, key, contact),
|
_showMetricSelector(context, key, contact),
|
||||||
|
onShowMetHistory: (contact) =>
|
||||||
|
showBTHomeMetHistorySheet(
|
||||||
|
context,
|
||||||
|
contact: contact,
|
||||||
|
),
|
||||||
onRefresh: () => sensorsProvider.refreshSensor(
|
onRefresh: () => sensorsProvider.refreshSensor(
|
||||||
publicKeyHex: key,
|
publicKeyHex: key,
|
||||||
contactsProvider: contactsProvider,
|
contactsProvider: contactsProvider,
|
||||||
@@ -427,6 +433,12 @@ class _SensorCustomizeView extends StatelessWidget {
|
|||||||
labelOverrides: sensorsProvider.labelOverridesFor(
|
labelOverrides: sensorsProvider.labelOverridesFor(
|
||||||
publicKeyHex,
|
publicKeyHex,
|
||||||
),
|
),
|
||||||
|
onShowMetHistory: contact == null
|
||||||
|
? null
|
||||||
|
: (contact) => showBTHomeMetHistorySheet(
|
||||||
|
context,
|
||||||
|
contact: contact,
|
||||||
|
),
|
||||||
fieldSpans: {
|
fieldSpans: {
|
||||||
for (final field in visibleFields)
|
for (final field in visibleFields)
|
||||||
field: sensorsProvider.fieldSpanFor(publicKeyHex, field),
|
field: sensorsProvider.fieldSpanFor(publicKeyHex, field),
|
||||||
|
|||||||
143
lib/services/bthome_met_history.dart
Normal file
143
lib/services/bthome_met_history.dart
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
enum BTHomeMetMeasurement {
|
||||||
|
temperature(1, 'Temperature', '°C'),
|
||||||
|
humidity(2, 'Humidity', '%'),
|
||||||
|
windSpeed(3, 'Wind speed', 'm/s'),
|
||||||
|
gust(4, 'Wind gust', 'm/s'),
|
||||||
|
rain(5, 'Rain', 'mm');
|
||||||
|
|
||||||
|
const BTHomeMetMeasurement(this.id, this.label, this.unit);
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String label;
|
||||||
|
final String unit;
|
||||||
|
|
||||||
|
static BTHomeMetMeasurement? fromId(int id) {
|
||||||
|
for (final value in BTHomeMetMeasurement.values) {
|
||||||
|
if (value.id == id) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BTHomeMetHistoryPage {
|
||||||
|
const BTHomeMetHistoryPage({
|
||||||
|
required this.measurement,
|
||||||
|
required this.page,
|
||||||
|
required this.values,
|
||||||
|
});
|
||||||
|
|
||||||
|
final BTHomeMetMeasurement measurement;
|
||||||
|
final int page;
|
||||||
|
final List<double> values;
|
||||||
|
|
||||||
|
double? get latest => values.isEmpty ? null : values.last;
|
||||||
|
double? get minimum => values.isEmpty
|
||||||
|
? null
|
||||||
|
: values.reduce((left, right) => left < right ? left : right);
|
||||||
|
double? get maximum => values.isEmpty
|
||||||
|
? null
|
||||||
|
: values.reduce((left, right) => left > right ? left : right);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BTHomeMetHistoryFormatException implements Exception {
|
||||||
|
const BTHomeMetHistoryFormatException(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BTHomeMetHistoryParser {
|
||||||
|
static BTHomeMetHistoryPage parse(String text) {
|
||||||
|
final parts = text
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.toList(growable: false);
|
||||||
|
if (parts.length < 3) {
|
||||||
|
throw const BTHomeMetHistoryFormatException(
|
||||||
|
'MET history response is too short.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final measurementId = int.tryParse(parts[0]);
|
||||||
|
final page = int.tryParse(parts[1]);
|
||||||
|
final count = int.tryParse(parts[2]);
|
||||||
|
if (measurementId == null || page == null || count == null) {
|
||||||
|
throw const BTHomeMetHistoryFormatException(
|
||||||
|
'MET history header is invalid.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final measurement = BTHomeMetMeasurement.fromId(measurementId);
|
||||||
|
if (measurement == null) {
|
||||||
|
throw BTHomeMetHistoryFormatException(
|
||||||
|
'Unsupported MET history measurement id: $measurementId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (count < 0) {
|
||||||
|
throw const BTHomeMetHistoryFormatException(
|
||||||
|
'MET history sample count is invalid.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parts.length != count + 3) {
|
||||||
|
throw BTHomeMetHistoryFormatException(
|
||||||
|
'MET history sample count mismatch: expected $count values, got ${parts.length - 3}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final values = <double>[];
|
||||||
|
for (final part in parts.skip(3)) {
|
||||||
|
final value = double.tryParse(part);
|
||||||
|
if (value == null) {
|
||||||
|
throw BTHomeMetHistoryFormatException(
|
||||||
|
'Invalid MET history sample value: $part',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
values.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BTHomeMetHistoryPage(
|
||||||
|
measurement: measurement,
|
||||||
|
page: page,
|
||||||
|
values: List<double>.unmodifiable(values),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BTHomeMetMeasurement> bTHomeMetMeasurementsForContact(Contact? contact) {
|
||||||
|
final telemetry = contact?.telemetry;
|
||||||
|
if (telemetry == null) {
|
||||||
|
return const <BTHomeMetMeasurement>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final measurements = <BTHomeMetMeasurement>[
|
||||||
|
if (telemetry.temperature != null) BTHomeMetMeasurement.temperature,
|
||||||
|
if (telemetry.humidity != null) BTHomeMetMeasurement.humidity,
|
||||||
|
];
|
||||||
|
|
||||||
|
final extraSensorData = telemetry.extraSensorData;
|
||||||
|
if (extraSensorData != null) {
|
||||||
|
if (extraSensorData.keys.any(
|
||||||
|
(key) => key.startsWith('speed_') || key.startsWith('signed_speed_'),
|
||||||
|
)) {
|
||||||
|
measurements.add(BTHomeMetMeasurement.windSpeed);
|
||||||
|
}
|
||||||
|
if (extraSensorData.keys.any((key) => key.startsWith('gust_'))) {
|
||||||
|
measurements.add(BTHomeMetMeasurement.gust);
|
||||||
|
}
|
||||||
|
if (extraSensorData.keys.any((key) => key.startsWith('rain_'))) {
|
||||||
|
measurements.add(BTHomeMetMeasurement.rain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return List<BTHomeMetMeasurement>.unmodifiable(measurements);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool supportsBTHomeMetHistory(Contact? contact) =>
|
||||||
|
bTHomeMetMeasurementsForContact(contact).isNotEmpty;
|
||||||
@@ -16,6 +16,7 @@ import 'contact_route_dialog.dart';
|
|||||||
import 'contact_trace_sheet.dart';
|
import 'contact_trace_sheet.dart';
|
||||||
import 'room_login_sheet.dart';
|
import 'room_login_sheet.dart';
|
||||||
import '../common/contact_avatar.dart';
|
import '../common/contact_avatar.dart';
|
||||||
|
import '../sensors/bthome_met_history_sheet.dart';
|
||||||
import '../sensors/sensor_telemetry_card.dart';
|
import '../sensors/sensor_telemetry_card.dart';
|
||||||
import '../../utils/link_quality.dart';
|
import '../../utils/link_quality.dart';
|
||||||
import '../../utils/time_ago_extensions.dart';
|
import '../../utils/time_ago_extensions.dart';
|
||||||
@@ -1153,6 +1154,8 @@ class _SensorPreviewView extends StatelessWidget {
|
|||||||
visibleFields: visibleFields,
|
visibleFields: visibleFields,
|
||||||
fieldOrder: fieldOrder,
|
fieldOrder: fieldOrder,
|
||||||
labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex),
|
labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex),
|
||||||
|
onShowMetHistory: (contact) =>
|
||||||
|
showBTHomeMetHistorySheet(context, contact: contact),
|
||||||
fieldSpans: sensorFullWidthFieldSpans(visibleFields),
|
fieldSpans: sensorFullWidthFieldSpans(visibleFields),
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
emptyMetricsMessage: 'No telemetry fields available yet.',
|
emptyMetricsMessage: 'No telemetry fields available yet.',
|
||||||
|
|||||||
819
lib/widgets/sensors/bthome_met_history_sheet.dart
Normal file
819
lib/widgets/sensors/bthome_met_history_sheet.dart
Normal file
@@ -0,0 +1,819 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import 'package:flutter/material.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: const Text('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: const Text('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: 'Could not load MET history',
|
||||||
|
body: _error!,
|
||||||
|
)
|
||||||
|
else if (_loading && history == null)
|
||||||
|
const _HistoryMessageCard(
|
||||||
|
icon: Icons.hourglass_top,
|
||||||
|
title: '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: 'Latest',
|
||||||
|
value: _formatMeasurementValue(measurement, history.latest),
|
||||||
|
),
|
||||||
|
_HistoryStatTile(
|
||||||
|
label: 'Min',
|
||||||
|
value: _formatMeasurementValue(measurement, history.minimum),
|
||||||
|
),
|
||||||
|
_HistoryStatTile(
|
||||||
|
label: 'Max',
|
||||||
|
value: _formatMeasurementValue(measurement, history.maximum),
|
||||||
|
),
|
||||||
|
_HistoryStatTile(
|
||||||
|
label: '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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
import '../../providers/sensors_provider.dart';
|
import '../../providers/sensors_provider.dart';
|
||||||
|
import '../../services/bthome_met_history.dart';
|
||||||
import '../../utils/location_formats.dart';
|
import '../../utils/location_formats.dart';
|
||||||
|
|
||||||
class SensorMetricOption {
|
class SensorMetricOption {
|
||||||
@@ -1028,6 +1029,7 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
final Future<void> Function()? onRemove;
|
final Future<void> Function()? onRemove;
|
||||||
final Future<void> Function()? onRefresh;
|
final Future<void> Function()? onRefresh;
|
||||||
final VoidCallback? onCustomize;
|
final VoidCallback? onCustomize;
|
||||||
|
final Future<void> Function(Contact contact)? onShowMetHistory;
|
||||||
final EdgeInsetsGeometry margin;
|
final EdgeInsetsGeometry margin;
|
||||||
final String emptyMetricsMessage;
|
final String emptyMetricsMessage;
|
||||||
final Map<String, String> labelOverrides;
|
final Map<String, String> labelOverrides;
|
||||||
@@ -1042,6 +1044,7 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
this.onRemove,
|
this.onRemove,
|
||||||
this.onRefresh,
|
this.onRefresh,
|
||||||
this.onCustomize,
|
this.onCustomize,
|
||||||
|
this.onShowMetHistory,
|
||||||
this.margin = const EdgeInsets.only(bottom: 16),
|
this.margin = const EdgeInsets.only(bottom: 16),
|
||||||
this.emptyMetricsMessage =
|
this.emptyMetricsMessage =
|
||||||
'All fields are hidden. Use Visible fields to choose what to show.',
|
'All fields are hidden. Use Visible fields to choose what to show.',
|
||||||
@@ -1049,7 +1052,12 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bool get _showsMenu =>
|
bool get _showsMenu =>
|
||||||
onRefresh != null || onCustomize != null || onRemove != null;
|
onRefresh != null ||
|
||||||
|
onCustomize != null ||
|
||||||
|
onRemove != null ||
|
||||||
|
(contact != null &&
|
||||||
|
onShowMetHistory != null &&
|
||||||
|
supportsBTHomeMetHistory(contact));
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -1159,6 +1167,10 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
await onRemove!();
|
await onRemove!();
|
||||||
} else if (value == 'customize' && onCustomize != null) {
|
} else if (value == 'customize' && onCustomize != null) {
|
||||||
onCustomize!();
|
onCustomize!();
|
||||||
|
} else if (value == 'met_history' &&
|
||||||
|
contact != null &&
|
||||||
|
onShowMetHistory != null) {
|
||||||
|
await onShowMetHistory!(contact!);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
@@ -1179,6 +1191,16 @@ class SensorTelemetryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (contact != null &&
|
||||||
|
onShowMetHistory != null &&
|
||||||
|
supportsBTHomeMetHistory(contact)) {
|
||||||
|
items.add(
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'met_history',
|
||||||
|
child: Text('MET history'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (onRemove != null) {
|
if (onRemove != null) {
|
||||||
items.add(
|
items.add(
|
||||||
PopupMenuItem<String>(
|
PopupMenuItem<String>(
|
||||||
|
|||||||
16
pubspec.lock
16
pubspec.lock
@@ -226,6 +226,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.3"
|
version: "0.3.3"
|
||||||
|
equatable:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.8"
|
||||||
exif:
|
exif:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -306,6 +314,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.1"
|
||||||
|
fl_chart:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: fl_chart
|
||||||
|
sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ dependencies:
|
|||||||
url: https://github.com/fleaflet/flutter_map.git
|
url: https://github.com/fleaflet/flutter_map.git
|
||||||
ref: master
|
ref: master
|
||||||
latlong2: ^0.9.0
|
latlong2: ^0.9.0
|
||||||
|
fl_chart: ^1.2.0
|
||||||
|
|
||||||
http: ^1.2.0
|
http: ^1.2.0
|
||||||
|
|
||||||
|
|||||||
69
test/providers/contacts_provider_profile_scope_test.dart
Normal file
69
test/providers/contacts_provider_profile_scope_test.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/contact_storage_service.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/profiles_feature_service.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
Contact createContact({
|
||||||
|
required Uint8List key,
|
||||||
|
required String name,
|
||||||
|
}) {
|
||||||
|
return Contact(
|
||||||
|
publicKey: key,
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: name,
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: (46.0569 * 1e6).round(),
|
||||||
|
advLon: (14.5058 * 1e6).round(),
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
ProfileStorageScope.setScope(
|
||||||
|
profilesEnabled: true,
|
||||||
|
activeProfileId: 'default',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('initializeEarly respects the active profile storage namespace', () async {
|
||||||
|
final storage = ContactStorageService();
|
||||||
|
final defaultContact = createContact(
|
||||||
|
key: Uint8List.fromList(List<int>.filled(32, 1)),
|
||||||
|
name: 'Default Contact',
|
||||||
|
);
|
||||||
|
final alphaContact = createContact(
|
||||||
|
key: Uint8List.fromList(List<int>.filled(32, 2)),
|
||||||
|
name: 'Alpha Contact',
|
||||||
|
);
|
||||||
|
|
||||||
|
await storage.saveContacts([defaultContact]);
|
||||||
|
await storage.saveContacts([alphaContact], namespace: 'alpha');
|
||||||
|
|
||||||
|
ProfileStorageScope.setScope(
|
||||||
|
profilesEnabled: true,
|
||||||
|
activeProfileId: 'alpha',
|
||||||
|
);
|
||||||
|
|
||||||
|
final provider = ContactsProvider();
|
||||||
|
await provider.initializeEarly();
|
||||||
|
|
||||||
|
final names = provider.contacts
|
||||||
|
.where((contact) => !contact.isChannel)
|
||||||
|
.map((contact) => contact.advName)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
expect(names, <String>['Alpha Contact']);
|
||||||
|
expect(provider.storageNamespace, 'alpha');
|
||||||
|
});
|
||||||
|
}
|
||||||
55
test/services/bthome_met_history_test.dart
Normal file
55
test/services/bthome_met_history_test.dart
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:meshcore_sar_app/models/contact.dart';
|
||||||
|
import 'package:meshcore_sar_app/services/bthome_met_history.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('parses a valid BTHome MET history response', () {
|
||||||
|
final parsed = BTHomeMetHistoryParser.parse('1,0,4,11.2,11.8,12.1,12.3');
|
||||||
|
|
||||||
|
expect(parsed.measurement, BTHomeMetMeasurement.temperature);
|
||||||
|
expect(parsed.page, 0);
|
||||||
|
expect(parsed.values, <double>[11.2, 11.8, 12.1, 12.3]);
|
||||||
|
expect(parsed.latest, 12.3);
|
||||||
|
expect(parsed.minimum, 11.2);
|
||||||
|
expect(parsed.maximum, 12.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed BTHome MET history counts', () {
|
||||||
|
expect(
|
||||||
|
() => BTHomeMetHistoryParser.parse('2,0,3,41,42'),
|
||||||
|
throwsA(isA<BTHomeMetHistoryFormatException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects available BTHome MET measurements from telemetry', () {
|
||||||
|
final contact = Contact(
|
||||||
|
publicKey: Uint8List(32),
|
||||||
|
type: ContactType.sensor,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: 'WX',
|
||||||
|
lastAdvert: 0,
|
||||||
|
advLat: 0,
|
||||||
|
advLon: 0,
|
||||||
|
lastMod: 0,
|
||||||
|
telemetry: ContactTelemetry(
|
||||||
|
temperature: 20.1,
|
||||||
|
humidity: 52,
|
||||||
|
extraSensorData: const {'speed_2': 3.1, 'gust_2': 4.8, 'rain_2': 12.3},
|
||||||
|
timestamp: DateTime(2026, 3, 21, 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bTHomeMetMeasurementsForContact(contact), <BTHomeMetMeasurement>[
|
||||||
|
BTHomeMetMeasurement.temperature,
|
||||||
|
BTHomeMetMeasurement.humidity,
|
||||||
|
BTHomeMetMeasurement.windSpeed,
|
||||||
|
BTHomeMetMeasurement.gust,
|
||||||
|
BTHomeMetMeasurement.rain,
|
||||||
|
]);
|
||||||
|
expect(supportsBTHomeMetHistory(contact), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user