diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 7f3dcbf..e169d7d 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -7,6 +7,7 @@ import '../models/contact_group.dart'; import '../models/message_contact_location.dart'; import '../services/cayenne_lpp_parser.dart'; import '../services/contact_storage_service.dart'; +import '../services/profiles_feature_service.dart'; import '../utils/fast_gps_packet.dart'; import '../utils/rssi_location_estimator.dart'; import '../utils/key_comparison.dart'; @@ -140,7 +141,8 @@ class ContactsProvider with ChangeNotifier { String? _storageNamespace; // Add default public channel on initialization - ContactsProvider() { + ContactsProvider() + : _storageNamespace = ProfileStorageScope.effectiveNamespace { _ensurePublicChannelExists(); } @@ -1638,6 +1640,8 @@ class ContactsProvider with ChangeNotifier { _contacts.clear(); _savedContactGroups.clear(); _pendingAdverts.clear(); + _estimatedLocations.clear(); + _rssiObservations.clear(); } Map _pendingAdvertToJson(PendingAdvert advert) { diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index 6cfb3e2..d94b081 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -7,6 +7,7 @@ import '../models/contact.dart'; import '../providers/connection_provider.dart'; import '../providers/contacts_provider.dart'; import '../providers/sensors_provider.dart'; +import '../widgets/sensors/bthome_met_history_sheet.dart'; import '../widgets/sensors/sensor_telemetry_card.dart'; import '../l10n/app_localizations.dart'; @@ -337,6 +338,11 @@ class _SensorsTabState extends State { : null, onCustomize: () => _showMetricSelector(context, key, contact), + onShowMetHistory: (contact) => + showBTHomeMetHistorySheet( + context, + contact: contact, + ), onRefresh: () => sensorsProvider.refreshSensor( publicKeyHex: key, contactsProvider: contactsProvider, @@ -427,6 +433,12 @@ class _SensorCustomizeView extends StatelessWidget { labelOverrides: sensorsProvider.labelOverridesFor( publicKeyHex, ), + onShowMetHistory: contact == null + ? null + : (contact) => showBTHomeMetHistorySheet( + context, + contact: contact, + ), fieldSpans: { for (final field in visibleFields) field: sensorsProvider.fieldSpanFor(publicKeyHex, field), diff --git a/lib/services/bthome_met_history.dart b/lib/services/bthome_met_history.dart new file mode 100644 index 0000000..69aa1c0 --- /dev/null +++ b/lib/services/bthome_met_history.dart @@ -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 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 = []; + 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.unmodifiable(values), + ); + } +} + +List bTHomeMetMeasurementsForContact(Contact? contact) { + final telemetry = contact?.telemetry; + if (telemetry == null) { + return const []; + } + + final measurements = [ + 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.unmodifiable(measurements); +} + +bool supportsBTHomeMetHistory(Contact? contact) => + bTHomeMetMeasurementsForContact(contact).isNotEmpty; diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 46993ca..ab3c8f6 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -16,6 +16,7 @@ import 'contact_route_dialog.dart'; import 'contact_trace_sheet.dart'; import 'room_login_sheet.dart'; import '../common/contact_avatar.dart'; +import '../sensors/bthome_met_history_sheet.dart'; import '../sensors/sensor_telemetry_card.dart'; import '../../utils/link_quality.dart'; import '../../utils/time_ago_extensions.dart'; @@ -1153,6 +1154,8 @@ class _SensorPreviewView extends StatelessWidget { visibleFields: visibleFields, fieldOrder: fieldOrder, labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex), + onShowMetHistory: (contact) => + showBTHomeMetHistorySheet(context, contact: contact), fieldSpans: sensorFullWidthFieldSpans(visibleFields), margin: EdgeInsets.zero, emptyMetricsMessage: 'No telemetry fields available yet.', diff --git a/lib/widgets/sensors/bthome_met_history_sheet.dart b/lib/widgets/sensors/bthome_met_history_sheet.dart new file mode 100644 index 0000000..26c7610 --- /dev/null +++ b/lib/widgets/sensors/bthome_met_history_sheet.dart @@ -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 showBTHomeMetHistorySheet( + BuildContext context, { + required Contact contact, +}) { + return showModalBottomSheet( + 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 _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 _loadHistory({ + required BTHomeMetMeasurement measurement, + required int page, + }) async { + final connectionProvider = context.read(); + 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 = [ + _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; +} diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart index 5b57ea5..22b0ce8 100644 --- a/lib/widgets/sensors/sensor_telemetry_card.dart +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -7,6 +7,7 @@ import 'package:latlong2/latlong.dart'; import '../../l10n/app_localizations.dart'; import '../../models/contact.dart'; import '../../providers/sensors_provider.dart'; +import '../../services/bthome_met_history.dart'; import '../../utils/location_formats.dart'; class SensorMetricOption { @@ -1028,6 +1029,7 @@ class SensorTelemetryCard extends StatelessWidget { final Future Function()? onRemove; final Future Function()? onRefresh; final VoidCallback? onCustomize; + final Future Function(Contact contact)? onShowMetHistory; final EdgeInsetsGeometry margin; final String emptyMetricsMessage; final Map labelOverrides; @@ -1042,6 +1044,7 @@ class SensorTelemetryCard extends StatelessWidget { this.onRemove, this.onRefresh, this.onCustomize, + this.onShowMetHistory, this.margin = const EdgeInsets.only(bottom: 16), this.emptyMetricsMessage = 'All fields are hidden. Use Visible fields to choose what to show.', @@ -1049,7 +1052,12 @@ class SensorTelemetryCard extends StatelessWidget { }); bool get _showsMenu => - onRefresh != null || onCustomize != null || onRemove != null; + onRefresh != null || + onCustomize != null || + onRemove != null || + (contact != null && + onShowMetHistory != null && + supportsBTHomeMetHistory(contact)); @override Widget build(BuildContext context) { @@ -1159,6 +1167,10 @@ class SensorTelemetryCard extends StatelessWidget { await onRemove!(); } else if (value == 'customize' && onCustomize != null) { onCustomize!(); + } else if (value == 'met_history' && + contact != null && + onShowMetHistory != null) { + await onShowMetHistory!(contact!); } }, itemBuilder: (context) { @@ -1179,6 +1191,16 @@ class SensorTelemetryCard extends StatelessWidget { ), ); } + if (contact != null && + onShowMetHistory != null && + supportsBTHomeMetHistory(contact)) { + items.add( + const PopupMenuItem( + value: 'met_history', + child: Text('MET history'), + ), + ); + } if (onRemove != null) { items.add( PopupMenuItem( diff --git a/pubspec.lock b/pubspec.lock index 048bd44..a71cbb1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -226,6 +226,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.3" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" exif: dependency: transitive description: @@ -306,6 +314,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 80ba6e8..fdf0933 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -71,6 +71,7 @@ dependencies: url: https://github.com/fleaflet/flutter_map.git ref: master latlong2: ^0.9.0 + fl_chart: ^1.2.0 http: ^1.2.0 diff --git a/test/providers/contacts_provider_profile_scope_test.dart b/test/providers/contacts_provider_profile_scope_test.dart new file mode 100644 index 0000000..94455a6 --- /dev/null +++ b/test/providers/contacts_provider_profile_scope_test.dart @@ -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.filled(32, 1)), + name: 'Default Contact', + ); + final alphaContact = createContact( + key: Uint8List.fromList(List.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, ['Alpha Contact']); + expect(provider.storageNamespace, 'alpha'); + }); +} diff --git a/test/services/bthome_met_history_test.dart b/test/services/bthome_met_history_test.dart new file mode 100644 index 0000000..9331b84 --- /dev/null +++ b/test/services/bthome_met_history_test.dart @@ -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, [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()), + ); + }); + + 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.temperature, + BTHomeMetMeasurement.humidity, + BTHomeMetMeasurement.windSpeed, + BTHomeMetMeasurement.gust, + BTHomeMetMeasurement.rain, + ]); + expect(supportsBTHomeMetHistory(contact), isTrue); + }); +}