From 4557fb4b9e057561e4cbd4f63777937ce924fe85 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 15 Mar 2026 09:13:32 +0100 Subject: [PATCH] feat: Add Sensor telemetry alerts ref: --- lib/providers/contacts_provider.dart | 6 + lib/providers/sensors_provider.dart | 3 +- lib/screens/contacts_tab.dart | 53 +- lib/screens/sensors_tab.dart | 771 +--------- lib/screens/settings_screen.dart | 18 + lib/services/cayenne_lpp_parser.dart | 182 ++- lib/services/notification_service.dart | 13 +- lib/widgets/common/contact_avatar.dart | 4 + lib/widgets/contacts/contact_tile.dart | 60 +- .../sensors/sensor_telemetry_card.dart | 1337 +++++++++++++++++ pubspec.lock | 4 +- pubspec.yaml | 2 +- test/providers/contacts_provider_test.dart | 1 + test/screens/contacts_tab_test.dart | 29 + test/services/cayenne_lpp_parser_test.dart | 72 + test/services/notification_service_test.dart | 25 + test/widgets/contact_avatar_test.dart | 21 +- test/widgets/contact_tile_test.dart | 54 + 18 files changed, 1862 insertions(+), 793 deletions(-) create mode 100644 lib/widgets/sensors/sensor_telemetry_card.dart create mode 100644 test/services/notification_service_test.dart diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index c48bbd4..07dbc25 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -450,6 +450,9 @@ class ContactsProvider with ChangeNotifier { List get repeaters => contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen); + List get sensorContacts => + contacts.where((c) => c.isSensor).toList()..sort(_sortByLastSeen); + List get rooms => contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen); @@ -553,6 +556,7 @@ class ContactsProvider with ChangeNotifier { ); _contacts[contact.publicKeyHex] = updatedContact; + _pendingAdverts.remove(contact.publicKeyHex); debugPrint( ' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}', ); @@ -581,6 +585,7 @@ class ContactsProvider with ChangeNotifier { incomingContact: contact, existingContact: existingContact, ); + _pendingAdverts.remove(contact.publicKeyHex); } if (excluded > 0) { debugPrint( @@ -1526,6 +1531,7 @@ class ContactsProvider with ChangeNotifier { return { 'chat': chatContacts.length, 'repeater': repeaters.length, + 'sensor': sensorContacts.length, 'room': rooms.length, 'total': contacts.length, }; diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index fdc42b4..e785526 100644 --- a/lib/providers/sensors_provider.dart +++ b/lib/providers/sensors_provider.dart @@ -170,7 +170,7 @@ class SensorsProvider with ChangeNotifier { _watchedSensorKeys.contains(publicKeyHex); Future addSensor(Contact contact) async { - if (!contact.isChat && !contact.isRepeater) { + if (!contact.isChat && !contact.isRepeater && !contact.isSensor) { return; } if (_watchedSensorKeys.contains(contact.publicKeyHex)) { @@ -204,6 +204,7 @@ class SensorsProvider with ChangeNotifier { final candidates = [ ...contactsProvider.chatContacts, ...contactsProvider.repeaters, + ...contactsProvider.sensorContacts, ]; candidates.removeWhere((contact) => isWatched(contact.publicKeyHex)); candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime)); diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index ca747d2..ce10038 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -37,6 +37,7 @@ class _ContactsTabState extends State { final Map _sectionFilters = { ContactSection.teamMembers: '', ContactSection.repeaters: '', + ContactSection.sensors: '', ContactSection.rooms: '', ContactSection.channels: '', }; @@ -44,6 +45,7 @@ class _ContactsTabState extends State { final Map _sortModes = { ContactSection.teamMembers: ContactSortMode.lastSeen, ContactSection.repeaters: ContactSortMode.lastSeen, + ContactSection.sensors: ContactSortMode.lastSeen, ContactSection.rooms: ContactSortMode.lastSeen, }; @@ -539,6 +541,10 @@ class _ContactsTabState extends State { contactsProvider.repeaters, ContactSection.repeaters, ); + final allSensors = _sortContacts( + contactsProvider.sensorContacts, + ContactSection.sensors, + ); final allRooms = _sortContacts( contactsProvider.rooms, ContactSection.rooms, @@ -580,6 +586,19 @@ class _ContactsTabState extends State { final showRepeatersOthersGroup = visibleSavedRepeaterGroups.length > 1 && ungroupedRepeaters.isNotEmpty; + final sensors = _filterContactsForSection( + allSensors, + ContactSection.sensors, + ); + final savedSensorGroups = _buildSavedGroupsForSection( + contactsProvider, + allSensors, + ContactSection.sensors, + ); + final visibleSavedSensorGroups = + _showSavedGroupsForSection(ContactSection.sensors) + ? savedSensorGroups + : const <_RenderedSavedGroup>[]; final rooms = _filterContactsForSection( allRooms, ContactSection.rooms, @@ -608,12 +627,14 @@ class _ContactsTabState extends State { : const <_RenderedSavedGroup>[]; final showTeamMembersSection = allChatContacts.isNotEmpty; final showRepeatersSection = allRepeaters.isNotEmpty; + final showSensorsSection = allSensors.isNotEmpty; final showRoomsSection = allRooms.isNotEmpty; final showChannelsSection = allChannels.isNotEmpty; // Check if there are any displayable contacts final hasDisplayableContacts = allChatContacts.isNotEmpty || allRepeaters.isNotEmpty || + allSensors.isNotEmpty || allRooms.isNotEmpty || allChannels.isNotEmpty; @@ -743,6 +764,36 @@ class _ContactsTabState extends State { const Divider(height: 32), ], + // Sensors + if (showSensorsSection) ...[ + _SectionHeader( + title: 'Sensors', + count: sensors.length, + icon: Icons.sensors, + trailing: _buildSortMenu(context, ContactSection.sensors), + ), + _buildSectionFilterField( + context, + ContactSection.sensors, + contactsProvider, + ), + ..._buildSavedGroupCards( + visibleSavedSensorGroups, + ContactSection.sensors, + ), + if (sensors.isEmpty && + _sectionHasActiveFilter(ContactSection.sensors)) + _buildNoFilterResults(context) + else + ..._buildContactSectionItems( + _excludeGroupedContacts( + sensors, + visibleSavedSensorGroups, + ), + ), + const Divider(height: 32), + ], + // Rooms if (showRoomsSection) ...[ _SectionHeader( @@ -1172,7 +1223,7 @@ class _ContactsTabState extends State { enum ContactSortMode { lastSeen, distance } -enum ContactSection { teamMembers, repeaters, rooms, channels } +enum ContactSection { teamMembers, repeaters, sensors, rooms, channels } class _RenderedSavedGroup { final SavedContactGroup group; diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index 4350d72..ad83efd 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -1,16 +1,13 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_map/flutter_map.dart' as flutter_map; -import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; -import '../l10n/app_localizations.dart'; import '../models/contact.dart'; import '../providers/connection_provider.dart'; import '../providers/contacts_provider.dart'; import '../providers/sensors_provider.dart'; -import '../utils/location_formats.dart'; +import '../widgets/sensors/sensor_telemetry_card.dart'; class SensorsTab extends StatefulWidget { const SensorsTab({super.key}); @@ -140,7 +137,7 @@ class _SensorsTabState extends State { builder: (sheetContext) => Consumer( builder: (context, sensorsProvider, child) { final visibleFields = sensorsProvider.visibleFieldsFor(publicKeyHex); - final options = _fieldOptionsFor(contact); + final options = sensorMetricOptionsFor(contact); return SafeArea( child: ListView( shrinkWrap: true, @@ -240,7 +237,7 @@ class _SensorsTabState extends State { break; } } - return _SensorCard( + return SensorTelemetryCard( contact: contact, state: sensorsProvider.stateFor(key), visibleFields: sensorsProvider.visibleFieldsFor(key), @@ -347,769 +344,15 @@ class _EmptySensorsState extends StatelessWidget { } } -class _SensorCard extends StatelessWidget { - final Contact? contact; - final SensorRefreshState state; - final Set visibleFields; - final Map fieldSpans; - final Future Function() onRemove; - final Future Function() onRefresh; - final VoidCallback onCustomize; - - const _SensorCard({ - required this.contact, - required this.state, - required this.visibleFields, - required this.fieldSpans, - required this.onRemove, - required this.onRefresh, - required this.onCustomize, - }); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final telemetry = contact?.telemetry; - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final metrics = contact == null || telemetry == null - ? const <_MetricCardData>[] - : _buildMetricCards(l10n, telemetry, contact!); - - return Container( - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(28), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - colorScheme.surfaceContainerLow, - colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), - ], - ), - border: Border.all( - color: colorScheme.outlineVariant.withValues(alpha: 0.35), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.045), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 8, - runSpacing: 6, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - contact?.displayName ?? 'Unavailable node', - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - if (state == SensorRefreshState.timeout) - const _InlineAlertBadge(label: 'No response'), - ], - ), - if (telemetry != null) ...[ - const SizedBox(height: 2), - Wrap( - spacing: 6, - runSpacing: 4, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text( - '${_formatTelemetryDateTime(telemetry.timestamp)} • ${_formatTelemetryTime(telemetry.timestamp)}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - if (state == SensorRefreshState.refreshing) - const _InlineStateMeta( - label: 'Refreshing', - color: Color(0xFF266AC2), - spinning: true, - ), - if (state == SensorRefreshState.success) - const _InlineStateMeta( - label: 'Updated', - color: Color(0xFF218B63), - icon: Icons.check_circle, - ), - if (state == SensorRefreshState.unavailable) - const _InlineStateMeta( - label: 'Unavailable', - color: Color(0xFFB13B55), - icon: Icons.error_outline, - ), - ], - ), - ], - ], - ), - ), - PopupMenuButton( - onSelected: (value) async { - if (value == 'refresh') { - await onRefresh(); - } else if (value == 'remove') { - await onRemove(); - } else if (value == 'customize') { - onCustomize(); - } - }, - itemBuilder: (context) => [ - PopupMenuItem( - value: 'refresh', - child: Text(l10n.refresh), - ), - const PopupMenuItem( - value: 'customize', - child: Text('Customize fields'), - ), - const PopupMenuItem( - value: 'remove', - child: Text('Remove'), - ), - ], - ), - ], - ), - const SizedBox(height: 12), - if (contact == null) - const Text( - 'This node is no longer available in the contact list.', - ) - else if (telemetry == null) - const Text( - 'No telemetry received yet. Use Refresh from the menu or pull down to fetch it.', - ) - else if (metrics.isEmpty) - const Text( - 'All fields are hidden. Use Visible fields to choose what to show.', - ) - else - LayoutBuilder( - builder: (context, constraints) { - const spacing = 8.0; - final compactWidth = (constraints.maxWidth - spacing) / 2; - - return Wrap( - spacing: spacing, - runSpacing: spacing, - children: metrics - .map( - (metric) => _MetricTile( - data: metric, - width: - (fieldSpans[metric.fieldKey] == 2 || - metric.wide) - ? constraints.maxWidth - : compactWidth, - ), - ) - .toList(), - ); - }, - ), - ], - ), - ), - ); - } - - List<_MetricCardData> _buildMetricCards( - AppLocalizations l10n, - dynamic telemetry, - Contact contact, - ) { - final items = <_MetricCardData>[]; - - if (visibleFields.contains('voltage') && - telemetry.batteryMilliVolts != null) { - items.add( - _MetricCardData( - fieldKey: 'voltage', - icon: Icons.bolt, - label: l10n.voltage, - value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V', - accent: const Color(0xFF0A7D61), - ), - ); - } - if (visibleFields.contains('battery') && - telemetry.batteryPercentage != null) { - items.add( - _MetricCardData( - fieldKey: 'battery', - icon: Icons.battery_5_bar, - label: l10n.battery, - value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%', - accent: const Color(0xFF4B8E2F), - ), - ); - } - if (visibleFields.contains('temperature') && - telemetry.temperature != null) { - items.add( - _MetricCardData( - fieldKey: 'temperature', - icon: Icons.thermostat, - label: l10n.temperature, - value: '${telemetry.temperature!.toStringAsFixed(1)}°C', - accent: const Color(0xFFC76821), - ), - ); - } - if (visibleFields.contains('humidity') && telemetry.humidity != null) { - items.add( - _MetricCardData( - fieldKey: 'humidity', - icon: Icons.water_drop, - label: l10n.humidity, - value: '${telemetry.humidity!.toStringAsFixed(1)}%', - accent: const Color(0xFF246BB2), - ), - ); - } - if (visibleFields.contains('pressure') && telemetry.pressure != null) { - items.add( - _MetricCardData( - fieldKey: 'pressure', - icon: Icons.compress, - label: l10n.pressure, - value: '${telemetry.pressure!.toStringAsFixed(1)} hPa', - accent: const Color(0xFF6B4BAE), - ), - ); - } - if (visibleFields.contains('gps') && telemetry.gpsLocation != null) { - items.add( - _MetricCardData( - fieldKey: 'gps', - icon: Icons.place, - label: l10n.gpsTelemetry, - value: - '${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}', - accent: const Color(0xFFAA3F57), - wide: true, - mapLocation: LatLng( - telemetry.gpsLocation!.latitude, - telemetry.gpsLocation!.longitude, - ), - secondaryValue: formatPlusCode( - telemetry.gpsLocation!.latitude, - telemetry.gpsLocation!.longitude, - ), - ), - ); - } - if (telemetry.extraSensorData != null) { - for (final entry in telemetry.extraSensorData!.entries) { - final fieldKey = _extraFieldKey(entry.key); - if (!visibleFields.contains(fieldKey)) { - continue; - } - items.add( - _MetricCardData( - fieldKey: fieldKey, - icon: Icons.sensors, - label: _formatExtraFieldLabel(entry.key), - value: '${entry.value}', - accent: const Color(0xFF3E657C), - ), - ); - } - } - - return items; - } - - String _formatTelemetryTime(DateTime timestamp) { - final diff = DateTime.now().difference(timestamp); - if (diff.inMinutes < 1) return 'now'; - if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; - if (diff.inHours < 24) return '${diff.inHours}h ago'; - return '${diff.inDays}d ago'; - } - - String _formatTelemetryDateTime(DateTime timestamp) { - final local = timestamp.toLocal(); - final year = local.year.toString().padLeft(4, '0'); - final month = local.month.toString().padLeft(2, '0'); - final day = local.day.toString().padLeft(2, '0'); - final hour = local.hour.toString().padLeft(2, '0'); - final minute = local.minute.toString().padLeft(2, '0'); - return '$year-$month-$day $hour:$minute'; - } -} - -class _InlineStateMeta extends StatelessWidget { - final String label; - final Color color; - final IconData? icon; - final bool spinning; - - const _InlineStateMeta({ - required this.label, - required this.color, - this.icon, - this.spinning = false, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(999), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (spinning) - SizedBox( - width: 11, - height: 11, - child: CircularProgressIndicator( - strokeWidth: 1.7, - valueColor: AlwaysStoppedAnimation(color), - ), - ) - else if (icon != null) - Icon(icon, size: 11, color: color), - const SizedBox(width: 4), - Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: color, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ); - } -} - -class _InlineAlertBadge extends StatelessWidget { - final String label; - - const _InlineAlertBadge({required this.label}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFFC17B1D).withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: const Color(0xFFC17B1D), - fontWeight: FontWeight.w700, - ), - ), - ); - } -} - -class _MetricTile extends StatelessWidget { - final _MetricCardData data; - final double width; - - const _MetricTile({required this.data, required this.width}); - - Future _showExpandedMap(BuildContext context) async { - final location = data.mapLocation; - if (location == null) return; - - await Navigator.of(context).push( - MaterialPageRoute( - builder: (pageContext) { - return Scaffold( - appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(data.label), - Text( - data.value, - style: Theme.of(pageContext).textTheme.bodySmall, - ), - ], - ), - ), - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (data.secondaryValue != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), - child: Text( - data.secondaryValue!, - style: Theme.of(pageContext).textTheme.bodyMedium, - ), - ), - Expanded( - child: flutter_map.FlutterMap( - options: flutter_map.MapOptions( - initialCenter: location, - initialZoom: 15, - ), - children: [ - flutter_map.TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: - 'com.meshcore.sar.meshcore_sar_app', - ), - flutter_map.MarkerLayer( - markers: [ - flutter_map.Marker( - point: location, - width: 40, - height: 40, - child: Icon( - Icons.location_on, - color: data.accent, - size: 34, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ); - }, - fullscreenDialog: true, - ), - ); - } - - @override - Widget build(BuildContext context) { - return Container( - width: width, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: data.accent.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(22), - border: Border.all(color: data.accent.withValues(alpha: 0.14)), - ), - child: data.mapLocation == null - ? Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _MetricIcon(accent: data.accent, icon: data.icon), - const SizedBox(width: 10), - Expanded(child: _MetricText(data: data)), - ], - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _MetricIcon(accent: data.accent, icon: data.icon), - const SizedBox(width: 10), - Expanded(child: _MetricText(data: data)), - ], - ), - const SizedBox(height: 10), - Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(14), - onTap: () => _showExpandedMap(context), - child: ClipRRect( - borderRadius: BorderRadius.circular(14), - child: SizedBox( - height: 104, - width: double.infinity, - child: Stack( - children: [ - flutter_map.FlutterMap( - options: flutter_map.MapOptions( - initialCenter: data.mapLocation!, - initialZoom: 14, - interactionOptions: - const flutter_map.InteractionOptions( - flags: flutter_map.InteractiveFlag.none, - ), - ), - children: [ - flutter_map.TileLayer( - urlTemplate: - 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: - 'com.meshcore.sar.meshcore_sar_app', - ), - flutter_map.MarkerLayer( - markers: [ - flutter_map.Marker( - point: data.mapLocation!, - width: 32, - height: 32, - child: Icon( - Icons.location_on, - color: data.accent, - size: 28, - ), - ), - ], - ), - ], - ), - Positioned( - right: 8, - bottom: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 3, - ), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.55), - borderRadius: BorderRadius.circular(999), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.open_in_full, - size: 12, - color: Colors.white, - ), - SizedBox(width: 4), - Text( - 'Open map', - style: TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } -} - -class _MetricIcon extends StatelessWidget { - final Color accent; - final IconData icon; - - const _MetricIcon({required this.accent, required this.icon}); - - @override - Widget build(BuildContext context) { - return Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: accent.withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, color: accent, size: 18), - ); - } -} - -class _MetricText extends StatelessWidget { - final _MetricCardData data; - - const _MetricText({required this.data}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - data.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: data.accent, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - data.value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w700, - height: 1.1, - ), - ), - if (data.secondaryValue != null) ...[ - const SizedBox(height: 4), - Text( - data.secondaryValue!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ], - ], - ); - } -} - -class _MetricCardData { - final String fieldKey; - final IconData icon; - final String label; - final String value; - final String? secondaryValue; - final Color accent; - final bool wide; - final LatLng? mapLocation; - - const _MetricCardData({ - required this.fieldKey, - required this.icon, - required this.label, - required this.value, - this.secondaryValue, - required this.accent, - this.wide = false, - this.mapLocation, - }); -} - -class _FieldOption { - final String key; - final String label; - - const _FieldOption({required this.key, required this.label}); -} - -List<_FieldOption> _fieldOptionsFor(Contact? contact) { - final telemetry = contact?.telemetry; - final options = <_FieldOption>[ - if (telemetry?.batteryMilliVolts != null) - const _FieldOption(key: 'voltage', label: 'Voltage'), - if (telemetry?.batteryPercentage != null) - const _FieldOption(key: 'battery', label: 'Battery'), - if (telemetry?.temperature != null) - const _FieldOption(key: 'temperature', label: 'Temperature'), - if (telemetry?.humidity != null) - const _FieldOption(key: 'humidity', label: 'Humidity'), - if (telemetry?.pressure != null) - const _FieldOption(key: 'pressure', label: 'Pressure'), - if (telemetry?.gpsLocation != null) - const _FieldOption(key: 'gps', label: 'GPS'), - ]; - - final extraSensorData = telemetry?.extraSensorData; - if (extraSensorData != null) { - for (final key in extraSensorData.keys) { - options.add( - _FieldOption( - key: _extraFieldKey(key), - label: _formatExtraFieldLabel(key), - ), - ); - } - } - - return options; -} - -String _extraFieldKey(String label) { - return 'extra:$label'; -} - -String _formatExtraFieldLabel(String rawKey) { - final knownPrefixes = { - 'altitude': 'Altitude', - 'illuminance': 'Illuminance', - 'presence': 'Presence', - 'digital_input': 'Digital input', - 'digital_output': 'Digital output', - 'analog_input': 'Analog input', - 'analog_output': 'Analog output', - 'accelerometer': 'Accelerometer', - 'gyrometer': 'Gyrometer', - }; - - for (final entry in knownPrefixes.entries) { - final prefix = '${entry.key}_'; - if (rawKey == entry.key) { - return entry.value; - } - if (rawKey.startsWith(prefix)) { - final suffix = rawKey.substring(prefix.length); - final channel = int.tryParse(suffix); - if (channel != null) { - return '${entry.value} (ch $channel)'; - } - return entry.value; - } - } - - final parts = rawKey.split('_'); - if (parts.isEmpty) return rawKey; - final channel = parts.length > 1 ? parts.last : null; - final base = parts.length > 1 - ? parts.sublist(0, parts.length - 1).join(' ') - : rawKey; - final title = base - .split(' ') - .where((part) => part.isNotEmpty) - .map((part) => '${part[0].toUpperCase()}${part.substring(1)}') - .join(' '); - if (channel != null && int.tryParse(channel) != null) { - return '$title (ch $channel)'; - } - return title; -} - IconData _typeIcon(Contact contact) { + if (contact.isSensor) { + return Icons.sensors; + } if (contact.isRepeater) { return Icons.router; } if (contact.isChat) { - return Icons.sensors; + return Icons.person; } return Icons.device_hub; } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f52edd9..cc0ec40 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -80,6 +80,7 @@ class _SettingsScreenState extends State { bool _openMapInFullscreen = false; bool _messageNotificationsEnabled = true; bool _sarNotificationsEnabled = true; + bool _discoveryNotificationsEnabled = true; bool _updateNotificationsEnabled = true; bool _muteForegroundNotifications = true; bool _isDeveloperModeEnabled = false; @@ -149,6 +150,7 @@ class _SettingsScreenState extends State { setState(() { _messageNotificationsEnabled = service.messageNotificationsEnabled; _sarNotificationsEnabled = service.sarNotificationsEnabled; + _discoveryNotificationsEnabled = service.discoveryNotificationsEnabled; _updateNotificationsEnabled = service.updateNotificationsEnabled; _muteForegroundNotifications = service.muteForegroundNotifications; }); @@ -1052,6 +1054,22 @@ class _SettingsScreenState extends State { }, ), SwitchListTile( + SwitchListTile( + secondary: const Icon(Icons.contact_page_outlined), + title: const Text('Discovery notifications'), + subtitle: const Text( + 'Notify when new contacts appear in Discovery', + ), + value: _discoveryNotificationsEnabled, + onChanged: (value) async { + setState(() { + _discoveryNotificationsEnabled = value; + }); + await NotificationService().setDiscoveryNotificationsEnabled( + value, + ); + }, + ), secondary: const Icon(Icons.system_update), title: const Text('Update notifications'), subtitle: const Text( diff --git a/lib/services/cayenne_lpp_parser.dart b/lib/services/cayenne_lpp_parser.dart index 0d69fc9..911844a 100644 --- a/lib/services/cayenne_lpp_parser.dart +++ b/lib/services/cayenne_lpp_parser.dart @@ -5,6 +5,22 @@ import 'package:meshcore_client/meshcore_client.dart'; /// Cayenne LPP (Low Power Payload) data parser /// Used for decoding telemetry sensor data from MeshCore devices class CayenneLppParser { + static const int _selfTelemetryChannel = 1; + static const int _lppGenericSensor = 100; + static const int _lppCurrent = 117; + static const int _lppFrequency = 118; + static const int _lppPercentage = 120; + static const int _lppAltitude = 121; + static const int _lppConcentration = 125; + static const int _lppPower = 128; + static const int _lppSpeed = 129; + static const int _lppDistance = 130; + static const int _lppEnergy = 131; + static const int _lppDirection = 132; + static const int _lppUnixTime = 133; + static const int _lppColour = 135; + static const int _lppSwitch = 142; + /// Parse Cayenne LPP data into ContactTelemetry static ContactTelemetry parse(Uint8List data) { debugPrint(' [CayenneLPP] Parsing LPP data...'); @@ -25,7 +41,8 @@ class CayenneLppParser { int fieldCount = 0; while (reader.hasRemaining) { - if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) { + if (fieldCount > 0 && + _isZeroPaddedTail(data, reader.remainingBytesCount)) { debugPrint( ' Detected zero-padded telemetry tail, stopping parse at position ' '${data.length - reader.remainingBytesCount}', @@ -65,14 +82,14 @@ class CayenneLppParser { final value = rawValue / 100.0; debugPrint(' Analog Input (raw): $rawValue'); debugPrint(' Analog Input (volts): ${value}V'); - extraSensorData['analog_input_$channel'] = value; - // If this is a battery reading - if (channel == 0 || channel == 1) { + if (_isBatteryChannel(channel)) { batteryMilliVolts = value * 1000; batteryPercentage = _calculateBatteryPercentage(value); debugPrint( ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', ); + } else { + extraSensorData['analog_input_$channel'] = value; } break; @@ -85,7 +102,7 @@ class CayenneLppParser { break; case MeshCoreConstants.lppIlluminanceSensor: - final value = reader.readUInt16BE(); + final value = reader.readUInt16BE().toDouble(); debugPrint(' Illuminance: $value lux'); extraSensorData['illuminance_$channel'] = value; break; @@ -98,18 +115,28 @@ class CayenneLppParser { case MeshCoreConstants.lppTemperatureSensor: final rawValue = reader.readInt16BE(); - temperature = rawValue / 10.0; + final value = rawValue / 10.0; debugPrint(' Temperature (raw): $rawValue'); - debugPrint( - ' Temperature: ${temperature.toStringAsFixed(1)}°C', - ); + debugPrint(' Temperature: ${value.toStringAsFixed(1)}°C'); + if (channel == _selfTelemetryChannel) { + temperature = value; + } else { + extraSensorData['temperature_$channel'] = value; + temperature ??= value; + } break; case MeshCoreConstants.lppHumiditySensor: final rawValue = reader.readByte(); - humidity = rawValue / 2.0; + final value = rawValue / 2.0; debugPrint(' Humidity (raw): $rawValue'); - debugPrint(' Humidity: ${humidity.toStringAsFixed(1)}%'); + debugPrint(' Humidity: ${value.toStringAsFixed(1)}%'); + if (channel == _selfTelemetryChannel) { + humidity = value; + } else { + extraSensorData['humidity_$channel'] = value; + humidity ??= value; + } break; case MeshCoreConstants.lppAccelerometer: @@ -126,9 +153,15 @@ class CayenneLppParser { case MeshCoreConstants.lppBarometer: final rawValue = reader.readUInt16BE(); - pressure = rawValue / 10.0; + final value = rawValue / 10.0; debugPrint(' Barometer (raw): $rawValue'); - debugPrint(' Barometer: ${pressure.toStringAsFixed(1)} hPa'); + debugPrint(' Barometer: ${value.toStringAsFixed(1)} hPa'); + if (channel == _selfTelemetryChannel) { + pressure = value; + } else { + extraSensorData['pressure_$channel'] = value; + pressure ??= value; + } break; case MeshCoreConstants.lppVoltageSensor: @@ -136,12 +169,15 @@ class CayenneLppParser { final value = rawValue / 100.0; debugPrint(' Voltage (raw): $rawValue'); debugPrint(' Voltage: ${value}V'); - // Treat voltage sensor as battery reading - batteryMilliVolts = value * 1000; - batteryPercentage = _calculateBatteryPercentage(value); - debugPrint( - ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', - ); + if (_isBatteryChannel(channel)) { + batteryMilliVolts = value * 1000; + batteryPercentage = _calculateBatteryPercentage(value); + debugPrint( + ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', + ); + } else { + extraSensorData['voltage_$channel'] = value; + } break; case MeshCoreConstants.lppGyrometer: @@ -197,6 +233,106 @@ class CayenneLppParser { extraSensorData['altitude_$channel'] = alt; break; + case _lppGenericSensor: + final value = _readUInt32BE(reader).toDouble(); + debugPrint(' Generic Sensor: $value'); + extraSensorData['generic_sensor_$channel'] = value; + break; + + case _lppCurrent: + final rawValue = reader.readInt16BE(); + final value = rawValue / 1000.0; + debugPrint(' Current (raw): $rawValue'); + debugPrint(' Current: ${value}A'); + extraSensorData['current_$channel'] = value; + break; + + case _lppFrequency: + final value = _readUInt32BE(reader).toDouble(); + debugPrint(' Frequency: ${value}Hz'); + extraSensorData['frequency_$channel'] = value; + break; + + case _lppPercentage: + final value = reader.readByte().toDouble(); + debugPrint(' Percentage: $value%'); + if (_isBatteryChannel(channel)) { + batteryPercentage = value; + } else { + extraSensorData['percentage_$channel'] = value; + } + break; + + case _lppAltitude: + final rawValue = reader.readInt16BE(); + final value = rawValue.toDouble(); + debugPrint(' Altitude: ${value}m'); + extraSensorData['altitude_$channel'] = value; + break; + + case _lppConcentration: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Concentration: ${value}ppm'); + extraSensorData['concentration_$channel'] = value; + break; + + case _lppPower: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Power: ${value}W'); + extraSensorData['power_$channel'] = value; + break; + + case _lppSpeed: + final rawValue = reader.readUInt16BE(); + final value = rawValue / 100.0; + debugPrint(' Speed: ${value}m/s'); + extraSensorData['speed_$channel'] = value; + break; + + case _lppDistance: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Distance: ${value}m'); + extraSensorData['distance_$channel'] = value; + break; + + case _lppEnergy: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Energy: ${value}kWh'); + extraSensorData['energy_$channel'] = value; + break; + + case _lppDirection: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Direction: $value°'); + extraSensorData['direction_$channel'] = value; + break; + + case _lppUnixTime: + final value = _readUInt32BE(reader); + debugPrint(' Unix time: $value'); + extraSensorData['unixtime_$channel'] = value; + break; + + case _lppColour: + final red = reader.readByte(); + final green = reader.readByte(); + final blue = reader.readByte(); + debugPrint(' Colour: r=$red, g=$green, b=$blue'); + extraSensorData['colour_$channel'] = { + 'r': red, + 'g': green, + 'b': blue, + }; + break; + + case _lppSwitch: + final value = reader.readByte(); + debugPrint(' Switch: $value'); + extraSensorData['switch_$channel'] = value; + break; + default: debugPrint( ' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes', @@ -258,6 +394,14 @@ class CayenneLppParser { return ((voltage - 3.0) / 1.2) * 100.0; } + static bool _isBatteryChannel(int channel) => + channel == 0 || channel == _selfTelemetryChannel; + + static int _readUInt32BE(BufferReader reader) { + final bytes = reader.readBytes(4); + return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]; + } + static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) { final start = data.length - remainingBytes; for (int i = start; i < data.length; i++) { diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 495642b..7819c2c 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -16,6 +16,7 @@ class NotificationService { FlutterLocalNotificationsPlugin(); static const String _prefMessagesEnabled = 'notifications_messages_enabled'; static const String _prefSarEnabled = 'notifications_sar_enabled'; + static const String _prefDiscoveryEnabled = 'notifications_discovery_enabled'; static const String _prefUpdatesEnabled = 'notifications_updates_enabled'; static const String _prefMuteForeground = 'notifications_mute_foreground'; @@ -23,6 +24,7 @@ class NotificationService { bool _permissionGranted = false; bool _messageNotificationsEnabled = true; bool _sarNotificationsEnabled = true; + bool _discoveryNotificationsEnabled = true; bool _updateNotificationsEnabled = true; bool _muteForegroundNotifications = true; AppLifecycleState _lifecycleState = AppLifecycleState.resumed; @@ -63,6 +65,7 @@ class NotificationService { bool get messageNotificationsEnabled => _messageNotificationsEnabled; bool get sarNotificationsEnabled => _sarNotificationsEnabled; + bool get discoveryNotificationsEnabled => _discoveryNotificationsEnabled; bool get updateNotificationsEnabled => _updateNotificationsEnabled; bool get muteForegroundNotifications => _muteForegroundNotifications; bool get isAppInForeground => _lifecycleState == AppLifecycleState.resumed; @@ -175,6 +178,8 @@ class NotificationService { final prefs = await SharedPreferences.getInstance(); _messageNotificationsEnabled = prefs.getBool(_prefMessagesEnabled) ?? true; _sarNotificationsEnabled = prefs.getBool(_prefSarEnabled) ?? true; + _discoveryNotificationsEnabled = + prefs.getBool(_prefDiscoveryEnabled) ?? true; _updateNotificationsEnabled = prefs.getBool(_prefUpdatesEnabled) ?? true; _muteForegroundNotifications = prefs.getBool(_prefMuteForeground) ?? true; } @@ -191,6 +196,12 @@ class NotificationService { await prefs.setBool(_prefSarEnabled, value); } + Future setDiscoveryNotificationsEnabled(bool value) async { + _discoveryNotificationsEnabled = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_prefDiscoveryEnabled, value); + } + Future setUpdateNotificationsEnabled(bool value) async { _updateNotificationsEnabled = value; final prefs = await SharedPreferences.getInstance(); @@ -854,7 +865,7 @@ class NotificationService { }) async { if (!_isInitialized) return false; if (!_permissionGranted) return false; - if (!_messageNotificationsEnabled) return false; + if (!_discoveryNotificationsEnabled) return false; if (_shouldSuppressForegroundNotifications()) return false; final shortKey = contactKey.length > 12 diff --git a/lib/widgets/common/contact_avatar.dart b/lib/widgets/common/contact_avatar.dart index cdfaf08..28d27d0 100644 --- a/lib/widgets/common/contact_avatar.dart +++ b/lib/widgets/common/contact_avatar.dart @@ -108,6 +108,8 @@ class ContactAvatar extends StatelessWidget { return Colors.orange; case ContactType.room: return Colors.purple; + case ContactType.sensor: + return Colors.green; case ContactType.channel: return Colors.teal; } @@ -130,6 +132,8 @@ class ContactAvatar extends StatelessWidget { return Icons.router; case ContactType.room: return Icons.meeting_room; + case ContactType.sensor: + return Icons.sensors; case ContactType.channel: return Icons.public; } diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 51f3315..e423a5a 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/sensor_telemetry_card.dart'; import '../../utils/toast_logger.dart'; import '../../l10n/app_localizations.dart'; @@ -327,10 +328,13 @@ class ContactTile extends StatelessWidget { final canSetPath = contact.type == ContactType.chat || contact.type == ContactType.room || - contact.type == ContactType.repeater; + contact.type == ContactType.repeater || + contact.type == ContactType.sensor; final canAddToSensors = contact.type == ContactType.chat || - contact.type == ContactType.repeater; + contact.type == ContactType.repeater || + contact.type == ContactType.sensor; + final canPreviewSensor = contact.isSensor; final sensorsProvider = context.read(); final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex); @@ -381,6 +385,17 @@ class ContactTile extends StatelessWidget { _showRoomLoginDialog(context, contact); }, ), + if (canPreviewSensor) + ListTile( + leading: const Icon(Icons.visibility_outlined), + title: const Text('Preview'), + onTap: () async { + Navigator.pop(sheetContext); + await Future.delayed(Duration.zero); + if (!context.mounted) return; + await _showSensorPreviewSheet(context, contact); + }, + ), if (canAddToSensors) ListTile( leading: Icon( @@ -454,6 +469,47 @@ class ContactTile extends StatelessWidget { ); } + Future _showSensorPreviewSheet( + BuildContext context, + Contact contact, + ) async { + final publicKeyHex = contact.publicKeyHex; + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (sheetContext) => Consumer2( + builder: (context, contactsProvider, sensorsProvider, child) { + Contact? liveContact; + for (final entry in contactsProvider.contacts) { + if (entry.publicKeyHex == publicKeyHex) { + liveContact = entry; + break; + } + } + + final previewContact = liveContact ?? contact; + final visibleFields = sensorMetricKeysFor(previewContact); + + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: SensorTelemetryCard( + contact: previewContact, + state: sensorsProvider.stateFor(publicKeyHex), + visibleFields: visibleFields, + fieldSpans: sensorDefaultFieldSpans(visibleFields), + margin: EdgeInsets.zero, + emptyMetricsMessage: 'No telemetry fields available yet.', + ), + ), + ); + }, + ), + ); + } + void _showContactOnMap(BuildContext context, Contact contact) { final location = contact.displayLocation; if (location == null) { diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart new file mode 100644 index 0000000..78091d4 --- /dev/null +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -0,0 +1,1337 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart' as flutter_map; +import 'package:latlong2/latlong.dart'; + +import '../../l10n/app_localizations.dart'; +import '../../models/contact.dart'; +import '../../providers/sensors_provider.dart'; +import '../../utils/location_formats.dart'; + +class SensorMetricOption { + final String key; + final String label; + + const SensorMetricOption({required this.key, required this.label}); +} + +List sensorMetricOptionsFor(Contact? contact) { + final telemetry = contact?.telemetry; + final options = [ + if (telemetry?.batteryMilliVolts != null) + const SensorMetricOption(key: 'voltage', label: 'Voltage'), + if (telemetry?.batteryPercentage != null) + const SensorMetricOption(key: 'battery', label: 'Battery'), + if (telemetry?.temperature != null) + const SensorMetricOption(key: 'temperature', label: 'Temperature'), + if (telemetry?.humidity != null) + const SensorMetricOption(key: 'humidity', label: 'Humidity'), + if (telemetry?.pressure != null) + const SensorMetricOption(key: 'pressure', label: 'Pressure'), + if (telemetry?.gpsLocation != null) + const SensorMetricOption(key: 'gps', label: 'GPS'), + ]; + + final extraSensorData = telemetry?.extraSensorData; + if (extraSensorData != null) { + for (final key in extraSensorData.keys) { + options.add( + SensorMetricOption( + key: _extraFieldKey(key), + label: _formatExtraFieldLabel(key), + ), + ); + } + } + + return options; +} + +Set sensorMetricKeysFor(Contact? contact) { + return sensorMetricOptionsFor(contact).map((option) => option.key).toSet(); +} + +Map sensorDefaultFieldSpans(Iterable fieldKeys) { + final spans = {}; + if (fieldKeys.contains('gps')) { + spans['gps'] = 2; + } + return spans; +} + +class SensorTelemetryCard extends StatelessWidget { + final Contact? contact; + final SensorRefreshState state; + final Set visibleFields; + final Map fieldSpans; + final Future Function()? onRemove; + final Future Function()? onRefresh; + final VoidCallback? onCustomize; + final EdgeInsetsGeometry margin; + final String emptyMetricsMessage; + + const SensorTelemetryCard({ + super.key, + required this.contact, + required this.state, + required this.visibleFields, + required this.fieldSpans, + this.onRemove, + this.onRefresh, + this.onCustomize, + this.margin = const EdgeInsets.only(bottom: 16), + this.emptyMetricsMessage = + 'All fields are hidden. Use Visible fields to choose what to show.', + }); + + bool get _showsMenu => + onRefresh != null || onCustomize != null || onRemove != null; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final telemetry = contact?.telemetry; + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final metrics = contact == null || telemetry == null + ? const <_MetricCardData>[] + : _buildMetricCards(l10n, telemetry, contact!); + + return Container( + margin: margin, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colorScheme.surfaceContainerLow, + colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), + ], + ), + border: Border.all( + color: colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.045), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + contact?.displayName ?? 'Unavailable node', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (state == SensorRefreshState.timeout) + const _InlineAlertBadge(label: 'No response'), + ], + ), + if (telemetry != null) ...[ + const SizedBox(height: 2), + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + '${_formatTelemetryDateTime(telemetry.timestamp)} • ${_formatTelemetryTime(telemetry.timestamp)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + if (state == SensorRefreshState.refreshing) + const _InlineStateMeta( + label: 'Refreshing', + color: Color(0xFF266AC2), + spinning: true, + ), + if (state == SensorRefreshState.success) + const _InlineStateMeta( + label: 'Updated', + color: Color(0xFF218B63), + icon: Icons.check_circle, + ), + if (state == SensorRefreshState.unavailable) + const _InlineStateMeta( + label: 'Unavailable', + color: Color(0xFFB13B55), + icon: Icons.error_outline, + ), + ], + ), + ], + ], + ), + ), + if (_showsMenu) + PopupMenuButton( + onSelected: (value) async { + if (value == 'refresh' && onRefresh != null) { + await onRefresh!(); + } else if (value == 'remove' && onRemove != null) { + await onRemove!(); + } else if (value == 'customize' && onCustomize != null) { + onCustomize!(); + } + }, + itemBuilder: (context) { + final items = >[]; + if (onRefresh != null) { + items.add( + PopupMenuItem( + value: 'refresh', + child: Text(l10n.refresh), + ), + ); + } + if (onCustomize != null) { + items.add( + const PopupMenuItem( + value: 'customize', + child: Text('Customize fields'), + ), + ); + } + if (onRemove != null) { + items.add( + const PopupMenuItem( + value: 'remove', + child: Text('Remove'), + ), + ); + } + return items; + }, + ), + ], + ), + const SizedBox(height: 12), + if (contact == null) + const Text( + 'This node is no longer available in the contact list.', + ) + else if (telemetry == null) + const Text( + 'No telemetry received yet. Use Refresh from the menu or pull down to fetch it.', + ) + else if (metrics.isEmpty) + Text(emptyMetricsMessage) + else + LayoutBuilder( + builder: (context, constraints) { + const spacing = 8.0; + final compactWidth = (constraints.maxWidth - spacing) / 2; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: metrics + .map( + (metric) => _MetricTile( + data: metric, + width: + (fieldSpans[metric.fieldKey] == 2 || + metric.wide) + ? constraints.maxWidth + : compactWidth, + ), + ) + .toList(), + ); + }, + ), + ], + ), + ), + ); + } + + List<_MetricCardData> _buildMetricCards( + AppLocalizations l10n, + dynamic telemetry, + Contact contact, + ) { + final items = <_MetricCardData>[]; + + if (visibleFields.contains('voltage') && + telemetry.batteryMilliVolts != null) { + items.add( + _MetricCardData( + fieldKey: 'voltage', + icon: Icons.bolt, + label: l10n.voltage, + value: '${(telemetry.batteryMilliVolts! / 1000).toStringAsFixed(3)}V', + accent: const Color(0xFF0A7D61), + ), + ); + } + if (visibleFields.contains('battery') && + telemetry.batteryPercentage != null) { + items.add( + _MetricCardData( + fieldKey: 'battery', + icon: Icons.battery_5_bar, + label: l10n.battery, + value: '${telemetry.batteryPercentage!.toStringAsFixed(0)}%', + accent: const Color(0xFF4B8E2F), + ), + ); + } + if (visibleFields.contains('temperature') && + telemetry.temperature != null) { + items.add( + _MetricCardData( + fieldKey: 'temperature', + icon: Icons.thermostat, + label: l10n.temperature, + value: '${telemetry.temperature!.toStringAsFixed(1)}°C', + accent: const Color(0xFFC76821), + ), + ); + } + if (visibleFields.contains('humidity') && telemetry.humidity != null) { + items.add( + _MetricCardData( + fieldKey: 'humidity', + icon: Icons.water_drop, + label: l10n.humidity, + value: '${telemetry.humidity!.toStringAsFixed(1)}%', + accent: const Color(0xFF246BB2), + ), + ); + } + if (visibleFields.contains('pressure') && telemetry.pressure != null) { + items.add( + _MetricCardData( + fieldKey: 'pressure', + icon: Icons.compress, + label: l10n.pressure, + value: '${telemetry.pressure!.toStringAsFixed(1)} hPa', + accent: const Color(0xFF6B4BAE), + ), + ); + } + if (visibleFields.contains('gps') && telemetry.gpsLocation != null) { + items.add( + _MetricCardData( + fieldKey: 'gps', + icon: Icons.place, + label: l10n.gpsTelemetry, + value: + '${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}', + accent: const Color(0xFFAA3F57), + wide: true, + mapLocation: LatLng( + telemetry.gpsLocation!.latitude, + telemetry.gpsLocation!.longitude, + ), + secondaryValue: formatPlusCode( + telemetry.gpsLocation!.latitude, + telemetry.gpsLocation!.longitude, + ), + ), + ); + } + if (telemetry.extraSensorData != null) { + for (final entry in telemetry.extraSensorData!.entries) { + final fieldKey = _extraFieldKey(entry.key); + if (!visibleFields.contains(fieldKey)) { + continue; + } + final metric = _buildExtraMetricCardData(entry.key, entry.value); + if (metric != null) { + items.add(metric); + } + } + } + + return items; + } + + _MetricCardData? _buildExtraMetricCardData(String rawKey, dynamic value) { + final metricKey = _parseMetricKey(rawKey); + final label = _formatExtraFieldLabel(rawKey); + + switch (metricKey.baseKey) { + case 'altitude': + final meters = _asDouble(value); + if (meters == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.terrain_outlined, + label: label, + value: '${_formatNumber(meters, maxFractionDigits: 1)} m', + accent: const Color(0xFF7A5C3E), + ); + + case 'illuminance': + final lux = _asDouble(value); + if (lux == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.light_mode_outlined, + label: label, + value: '${_formatNumber(lux, maxFractionDigits: 0)} lx', + secondaryValue: + '~${_formatNumber(_approxDaylightIrradiance(lux), maxFractionDigits: 1)} W/m2 daylight', + accent: const Color(0xFFC17B1D), + ); + + case 'presence': + final isPresent = _asBool(value); + if (isPresent == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sensor_occupied_outlined, + label: label, + value: isPresent ? 'Detected' : 'Clear', + accent: const Color(0xFFAA3F57), + ); + + case 'digital_input': + final isHigh = _asBool(value); + if (isHigh == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.input_outlined, + label: label, + value: isHigh ? 'High' : 'Low', + accent: const Color(0xFF3A6D8C), + ); + + case 'digital_output': + final isHigh = _asBool(value); + if (isHigh == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.output_outlined, + label: label, + value: isHigh ? 'High' : 'Low', + accent: const Color(0xFF4B7B5A), + ); + + case 'analog_input': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.tune, + label: label, + value: _formatNumber(reading, maxFractionDigits: 3), + accent: const Color(0xFF5A6C84), + ); + + case 'analog_output': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.tune, + label: label, + value: _formatNumber(reading, maxFractionDigits: 3), + accent: const Color(0xFF4B7785), + ); + + case 'accelerometer': + final vector = _asVector3(value); + if (vector == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.vibration_outlined, + label: label, + value: + 'X ${_formatNumber(vector.x)} • Y ${_formatNumber(vector.y)} • Z ${_formatNumber(vector.z)} g', + secondaryValue: + '|a| ${_formatNumber(_vectorMagnitude(vector), maxFractionDigits: 2)} g', + accent: const Color(0xFF5A4C99), + wide: true, + ); + + case 'gyrometer': + final vector = _asVector3(value); + if (vector == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.threed_rotation, + label: label, + value: + 'X ${_formatNumber(vector.x)} • Y ${_formatNumber(vector.y)} • Z ${_formatNumber(vector.z)} deg/s', + secondaryValue: + '|w| ${_formatNumber(_vectorMagnitude(vector), maxFractionDigits: 2)} deg/s', + accent: const Color(0xFF6C4F96), + wide: true, + ); + + case 'generic_sensor': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sensors, + label: label, + value: _formatNumber(reading, maxFractionDigits: 2), + accent: const Color(0xFF3E657C), + ); + + case 'current': + final amps = _asDouble(value); + if (amps == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.electric_bolt, + label: label, + value: _formatCurrent(amps), + accent: const Color(0xFF1C7C54), + ); + + case 'frequency': + final hz = _asDouble(value); + if (hz == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.graphic_eq, + label: label, + value: _formatFrequency(hz), + accent: const Color(0xFF2C6BA0), + ); + + case 'percentage': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.percent, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 1)}%', + accent: const Color(0xFF4B8E2F), + ); + + case 'concentration': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.bubble_chart_outlined, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 0)} ppm', + accent: const Color(0xFF4D6D9A), + ); + + case 'power': + final watts = _asDouble(value); + if (watts == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.flash_on_outlined, + label: label, + value: _formatPower(watts), + accent: const Color(0xFFB5622E), + ); + + case 'speed': + final metersPerSecond = _asDouble(value); + if (metersPerSecond == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.air, + label: label, + value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', + accent: const Color(0xFF2B78A0), + ); + + case 'distance': + final meters = _asDouble(value); + if (meters == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.straighten, + label: label, + value: _formatDistance(meters), + accent: const Color(0xFF577590), + ); + + case 'energy': + final kwh = _asDouble(value); + if (kwh == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.battery_charging_full, + label: label, + value: _formatEnergy(kwh), + accent: const Color(0xFF9C6644), + ); + + case 'direction': + final degrees = _asDouble(value); + if (degrees == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.explore_outlined, + label: label, + value: '${_formatNumber(degrees, maxFractionDigits: 0)} deg', + secondaryValue: _formatCardinalDirection(degrees), + accent: const Color(0xFF8A5A44), + ); + + case 'unixtime': + final seconds = _asInt(value); + if (seconds == null) return null; + final timestamp = DateTime.fromMillisecondsSinceEpoch( + seconds * 1000, + isUtc: true, + ).toLocal(); + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.schedule, + label: label, + value: _formatTelemetryDateTime(timestamp), + secondaryValue: _formatTelemetryTime(timestamp), + accent: const Color(0xFF6B7280), + wide: true, + ); + + case 'colour': + final color = _asRgb(value); + if (color == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.palette_outlined, + label: label, + value: + '#${color.r.toRadixString(16).padLeft(2, '0').toUpperCase()}${color.g.toRadixString(16).padLeft(2, '0').toUpperCase()}${color.b.toRadixString(16).padLeft(2, '0').toUpperCase()}', + secondaryValue: 'R ${color.r} • G ${color.g} • B ${color.b}', + accent: Color.fromARGB(255, color.r, color.g, color.b), + wide: true, + ); + + case 'switch': + final isOn = _asBool(value); + if (isOn == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: isOn ? Icons.toggle_on : Icons.toggle_off, + label: label, + value: isOn ? 'On' : 'Off', + accent: const Color(0xFF4B7B5A), + ); + + case 'voltage': + final volts = _asDouble(value); + if (volts == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.bolt, + label: label, + value: '${_formatNumber(volts, maxFractionDigits: 3)} V', + accent: const Color(0xFF0A7D61), + ); + } + + switch (metricKey.baseKey) { + case 'co2': + case 'tvoc': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.bubble_chart_outlined, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 0)} ppm', + accent: const Color(0xFF4D6D9A), + ); + + case 'pm25': + case 'pm10': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.grain, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 1)} ug/m3', + accent: const Color(0xFF7A6C5D), + ); + + case 'uv': + final reading = _asDouble(value); + if (reading == null) return null; + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.wb_sunny_outlined, + label: label, + value: _formatNumber(reading, maxFractionDigits: 1), + accent: const Color(0xFFC17B1D), + ); + } + + if (value is num) { + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sensors, + label: label, + value: _formatNumber(value, maxFractionDigits: 2), + accent: const Color(0xFF3E657C), + ); + } + + return _MetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sensors, + label: label, + value: '$value', + accent: const Color(0xFF3E657C), + wide: value is Map, + ); + } + + double _approxDaylightIrradiance(double lux) { + return lux / 120.0; + } + + String _formatCurrent(double amps) { + final absolute = amps.abs(); + if (absolute < 1.0) { + return '${_formatNumber(amps * 1000, maxFractionDigits: 1)} mA'; + } + return '${_formatNumber(amps, maxFractionDigits: 3)} A'; + } + + String _formatPower(double watts) { + final absolute = watts.abs(); + if (absolute < 1.0) { + return '${_formatNumber(watts * 1000, maxFractionDigits: 1)} mW'; + } + return '${_formatNumber(watts, maxFractionDigits: 2)} W'; + } + + String _formatFrequency(double hertz) { + final absolute = hertz.abs(); + if (absolute >= 1000000) { + return '${_formatNumber(hertz / 1000000, maxFractionDigits: 2)} MHz'; + } + if (absolute >= 1000) { + return '${_formatNumber(hertz / 1000, maxFractionDigits: 2)} kHz'; + } + return '${_formatNumber(hertz, maxFractionDigits: 0)} Hz'; + } + + String _formatDistance(double meters) { + final absolute = meters.abs(); + if (absolute < 1.0) { + return '${_formatNumber(meters * 1000, maxFractionDigits: 0)} mm'; + } + if (absolute >= 1000.0) { + return '${_formatNumber(meters / 1000, maxFractionDigits: 2)} km'; + } + return '${_formatNumber(meters, maxFractionDigits: 2)} m'; + } + + String _formatEnergy(double kilowattHours) { + final absolute = kilowattHours.abs(); + if (absolute < 1.0) { + return '${_formatNumber(kilowattHours * 1000, maxFractionDigits: 1)} Wh'; + } + return '${_formatNumber(kilowattHours, maxFractionDigits: 3)} kWh'; + } + + String _formatCardinalDirection(double degrees) { + const points = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; + final normalized = ((degrees % 360) + 360) % 360; + final index = ((normalized + 22.5) ~/ 45) % points.length; + return points[index]; + } + + String _formatNumber(num value, {int maxFractionDigits = 2}) { + final absolute = value.abs(); + final digits = absolute >= 100 + ? 0 + : absolute >= 10 + ? math.min(maxFractionDigits, 1) + : maxFractionDigits; + final text = value.toStringAsFixed(digits); + return text.replaceFirst(RegExp(r'\.?0+$'), ''); + } + + _Vector3? _asVector3(dynamic value) { + if (value is! Map) return null; + final x = _asDouble(value['x']); + final y = _asDouble(value['y']); + final z = _asDouble(value['z']); + if (x == null || y == null || z == null) return null; + return _Vector3(x: x, y: y, z: z); + } + + _RgbColor? _asRgb(dynamic value) { + if (value is! Map) return null; + final red = _asInt(value['r']); + final green = _asInt(value['g']); + final blue = _asInt(value['b']); + if (red == null || green == null || blue == null) return null; + return _RgbColor(r: red, g: green, b: blue); + } + + double? _asDouble(dynamic value) { + if (value is num) return value.toDouble(); + return null; + } + + int? _asInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.round(); + return null; + } + + bool? _asBool(dynamic value) { + if (value is bool) return value; + if (value is num) return value != 0; + return null; + } + + double _vectorMagnitude(_Vector3 vector) { + return math.sqrt( + vector.x * vector.x + vector.y * vector.y + vector.z * vector.z, + ); + } + + String _formatTelemetryTime(DateTime timestamp) { + final diff = DateTime.now().difference(timestamp); + if (diff.inMinutes < 1) return 'now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } + + String _formatTelemetryDateTime(DateTime timestamp) { + final local = timestamp.toLocal(); + final year = local.year.toString().padLeft(4, '0'); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '$year-$month-$day $hour:$minute'; + } +} + +class _InlineStateMeta extends StatelessWidget { + final String label; + final Color color; + final IconData? icon; + final bool spinning; + + const _InlineStateMeta({ + required this.label, + required this.color, + this.icon, + this.spinning = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (spinning) + SizedBox( + width: 11, + height: 11, + child: CircularProgressIndicator( + strokeWidth: 1.7, + valueColor: AlwaysStoppedAnimation(color), + ), + ) + else if (icon != null) + Icon(icon, size: 11, color: color), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _InlineAlertBadge extends StatelessWidget { + final String label; + + const _InlineAlertBadge({required this.label}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFC17B1D).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: const Color(0xFFC17B1D), + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _MetricTile extends StatelessWidget { + final _MetricCardData data; + final double width; + + const _MetricTile({required this.data, required this.width}); + + Future _showExpandedMap(BuildContext context) async { + final location = data.mapLocation; + if (location == null) return; + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (pageContext) { + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(data.label), + Text( + data.value, + style: Theme.of(pageContext).textTheme.bodySmall, + ), + ], + ), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (data.secondaryValue != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Text( + data.secondaryValue!, + style: Theme.of(pageContext).textTheme.bodyMedium, + ), + ), + Expanded( + child: flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCenter: location, + initialZoom: 15, + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: + 'com.meshcore.sar.meshcore_sar_app', + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: location, + width: 40, + height: 40, + child: Icon( + Icons.location_on, + color: data.accent, + size: 34, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + }, + fullscreenDialog: true, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Container( + width: width, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: data.accent.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: data.accent.withValues(alpha: 0.14)), + ), + child: data.mapLocation == null + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MetricIcon(accent: data.accent, icon: data.icon), + const SizedBox(width: 10), + Expanded(child: _MetricText(data: data)), + ], + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MetricIcon(accent: data.accent, icon: data.icon), + const SizedBox(width: 10), + Expanded(child: _MetricText(data: data)), + ], + ), + const SizedBox(height: 10), + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () => _showExpandedMap(context), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: SizedBox( + height: 104, + width: double.infinity, + child: Stack( + children: [ + flutter_map.FlutterMap( + options: flutter_map.MapOptions( + initialCenter: data.mapLocation!, + initialZoom: 14, + interactionOptions: + const flutter_map.InteractionOptions( + flags: flutter_map.InteractiveFlag.none, + ), + ), + children: [ + flutter_map.TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: + 'com.meshcore.sar.meshcore_sar_app', + ), + flutter_map.MarkerLayer( + markers: [ + flutter_map.Marker( + point: data.mapLocation!, + width: 32, + height: 32, + child: Icon( + Icons.location_on, + color: data.accent, + size: 28, + ), + ), + ], + ), + ], + ), + Positioned( + right: 8, + bottom: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.55), + borderRadius: BorderRadius.circular(999), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.open_in_full, + size: 12, + color: Colors.white, + ), + SizedBox(width: 4), + Text( + 'Open map', + style: TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _MetricIcon extends StatelessWidget { + final Color accent; + final IconData icon; + + const _MetricIcon({required this.accent, required this.icon}); + + @override + Widget build(BuildContext context) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: accent, size: 18), + ); + } +} + +class _MetricText extends StatelessWidget { + final _MetricCardData data; + + const _MetricText({required this.data}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: data.accent, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + data.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + if (data.secondaryValue != null) ...[ + const SizedBox(height: 4), + Text( + data.secondaryValue!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ); + } +} + +class _MetricCardData { + final String fieldKey; + final IconData icon; + final String label; + final String value; + final String? secondaryValue; + final Color accent; + final bool wide; + final LatLng? mapLocation; + + const _MetricCardData({ + required this.fieldKey, + required this.icon, + required this.label, + required this.value, + this.secondaryValue, + required this.accent, + this.wide = false, + this.mapLocation, + }); +} + +class _ParsedMetricKey { + final String baseKey; + final int? channel; + + const _ParsedMetricKey({required this.baseKey, this.channel}); +} + +class _Vector3 { + final double x; + final double y; + final double z; + + const _Vector3({required this.x, required this.y, required this.z}); +} + +class _RgbColor { + final int r; + final int g; + final int b; + + const _RgbColor({required this.r, required this.g, required this.b}); +} + +String _extraFieldKey(String label) { + return 'extra:$label'; +} + +String _formatExtraFieldLabel(String rawKey) { + final metricKey = _parseMetricKey(rawKey); + final label = + _knownMetricLabels[metricKey.baseKey] ?? + _fallbackMetricLabel(metricKey.baseKey); + if (metricKey.channel != null) { + return '$label (ch ${metricKey.channel})'; + } + return label; +} + +const List _knownMetricBaseKeys = [ + 'generic_sensor', + 'digital_output', + 'digital_input', + 'analog_output', + 'analog_input', + 'accelerometer', + 'illuminance', + 'concentration', + 'percentage', + 'direction', + 'frequency', + 'distance', + 'altitude', + 'humidity', + 'pressure', + 'temperature', + 'gyrometer', + 'unixtime', + 'presence', + 'current', + 'voltage', + 'colour', + 'switch', + 'energy', + 'power', + 'speed', + 'pm25', + 'pm10', + 'tvoc', + 'co2', + 'rpm', + 'cond', + 'uv', +]; + +const Map _knownMetricLabels = { + 'accelerometer': 'Accelerometer', + 'altitude': 'Altitude', + 'analog_input': 'Analog input', + 'analog_output': 'Analog output', + 'co2': 'CO2', + 'colour': 'Color', + 'concentration': 'Concentration', + 'cond': 'Conductivity', + 'current': 'Current', + 'digital_input': 'Digital input', + 'digital_output': 'Digital output', + 'direction': 'Direction', + 'distance': 'Distance', + 'energy': 'Energy', + 'frequency': 'Frequency', + 'generic_sensor': 'Generic sensor', + 'gyrometer': 'Gyrometer', + 'humidity': 'Humidity', + 'illuminance': 'Illuminance', + 'percentage': 'Percentage', + 'pm10': 'PM10', + 'pm25': 'PM2.5', + 'power': 'Power', + 'presence': 'Presence', + 'pressure': 'Pressure', + 'rpm': 'RPM', + 'speed': 'Speed', + 'switch': 'Switch', + 'temperature': 'Temperature', + 'tvoc': 'TVOC', + 'unixtime': 'Time', + 'uv': 'UV index', + 'voltage': 'Voltage', +}; + +_ParsedMetricKey _parseMetricKey(String rawKey) { + for (final baseKey in _knownMetricBaseKeys) { + if (rawKey == baseKey) { + return _ParsedMetricKey(baseKey: baseKey); + } + if (rawKey.startsWith('${baseKey}_')) { + final channel = int.tryParse(rawKey.substring(baseKey.length + 1)); + if (channel != null) { + return _ParsedMetricKey(baseKey: baseKey, channel: channel); + } + } + } + + final parts = rawKey.split('_'); + if (parts.length > 1) { + final channel = int.tryParse(parts.last); + if (channel != null) { + return _ParsedMetricKey( + baseKey: parts.sublist(0, parts.length - 1).join('_'), + channel: channel, + ); + } + } + + return _ParsedMetricKey(baseKey: rawKey); +} + +String _fallbackMetricLabel(String rawKey) { + return rawKey + .split('_') + .where((part) => part.isNotEmpty) + .map((part) => '${part[0].toUpperCase()}${part.substring(1)}') + .join(' '); +} diff --git a/pubspec.lock b/pubspec.lock index af69e4f..3d666ed 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -795,8 +795,8 @@ packages: dependency: "direct main" description: path: "." - ref: "813f5b3" - resolved-ref: "813f5b3e0b9d2ea6b85a428be443bf5e6a38c6c5" + ref: eafe9a88fb12193820e8abbf5a096e408117b53d + resolved-ref: eafe9a88fb12193820e8abbf5a096e408117b53d url: "https://github.com/dz0ny/meshcore_client.git" source: git version: "0.1.0" diff --git a/pubspec.yaml b/pubspec.yaml index f967c03..a21ee0a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,7 +44,7 @@ dependencies: meshcore_client: git: url: https://github.com/dz0ny/meshcore_client.git - ref: "813f5b3" + ref: "eafe9a88fb12193820e8abbf5a096e408117b53d" # Codec2 ultra-low-bitrate speech codec (FFI plugin) codec2_flutter: diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index 688b86a..384a17f 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -142,6 +142,7 @@ void main() { final contactTypes = [ ContactType.chat, ContactType.repeater, + ContactType.sensor, ContactType.room, ContactType.channel, ]; diff --git a/test/screens/contacts_tab_test.dart b/test/screens/contacts_tab_test.dart index 66e9033..23541a9 100644 --- a/test/screens/contacts_tab_test.dart +++ b/test/screens/contacts_tab_test.dart @@ -57,6 +57,25 @@ void main() { ); } + Contact buildSensor({required int seed, required String name}) { + final publicKey = Uint8List(32); + publicKey[0] = seed; + publicKey[1] = seed + 1; + + return Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: -1, + outPath: Uint8List(0), + advName: name, + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 46056000 + seed, + advLon: 14505000 + seed, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ); + } + Future pumpContactsTab( WidgetTester tester, { List contacts = const [], @@ -192,4 +211,14 @@ void main() { expect(find.text('Others'), findsNothing); expect(find.text('Lone Relay'), findsOneWidget); }); + + testWidgets('sensor contacts render in their own section', (tester) async { + await pumpContactsTab( + tester, + contacts: [buildSensor(seed: 60, name: 'WX Station')], + ); + + expect(find.text('Sensors'), findsOneWidget); + expect(find.text('WX Station'), findsOneWidget); + }); } diff --git a/test/services/cayenne_lpp_parser_test.dart b/test/services/cayenne_lpp_parser_test.dart index 1a45ae2..82379e3 100644 --- a/test/services/cayenne_lpp_parser_test.dart +++ b/test/services/cayenne_lpp_parser_test.dart @@ -360,6 +360,78 @@ void main() { expect(accel['z'], closeTo(2.0, 0.001)); }); + test('extended MeshCore LPP types decode to structured extra data', () { + const lppGenericSensor = 100; + const lppCurrent = 117; + const lppFrequency = 118; + const lppAltitude = 121; + const lppConcentration = 125; + const lppPower = 128; + const lppSpeed = 129; + const lppDistance = 130; + const lppEnergy = 131; + const lppDirection = 132; + const lppUnixTime = 133; + const lppColour = 135; + const lppSwitch = 142; + + final payload = Uint8List.fromList([ + 2, lppGenericSensor, 0x00, 0x00, 0x01, 0x2C, // 300 + 3, lppCurrent, 0x00, 0x0F, // 0.015 A + 4, lppFrequency, 0x00, 0x00, 0x03, 0xE8, // 1000 Hz + 5, lppAltitude, 0x01, 0xF4, // 500 m + 6, lppConcentration, 0x01, 0x9F, // 415 ppm + 7, lppPower, 0x00, 0xFA, // 250 W + 8, lppSpeed, 0x04, 0xD2, // 12.34 m/s + 9, lppDistance, 0x00, 0x00, 0x04, 0xD2, // 1.234 m + 10, lppEnergy, 0x00, 0x00, 0x04, 0xD2, // 1.234 kWh + 11, lppDirection, 0x01, 0x0E, // 270 deg + 12, lppUnixTime, 0x65, 0xF0, 0x00, 0x00, // 1710221312 + 13, lppColour, 0xFF, 0x80, 0x40, // #FF8040 + 14, lppSwitch, 0x01, // on + ]); + + final decoded = CayenneLppParser.parse(payload); + + expect(decoded.extraSensorData, isNotNull); + expect(decoded.extraSensorData!['generic_sensor_2'], equals(300.0)); + expect(decoded.extraSensorData!['current_3'], closeTo(0.015, 0.0001)); + expect(decoded.extraSensorData!['frequency_4'], equals(1000.0)); + expect(decoded.extraSensorData!['altitude_5'], equals(500.0)); + expect(decoded.extraSensorData!['concentration_6'], equals(415.0)); + expect(decoded.extraSensorData!['power_7'], equals(250.0)); + expect(decoded.extraSensorData!['speed_8'], closeTo(12.34, 0.001)); + expect(decoded.extraSensorData!['distance_9'], closeTo(1.234, 0.0001)); + expect(decoded.extraSensorData!['energy_10'], closeTo(1.234, 0.0001)); + expect(decoded.extraSensorData!['direction_11'], equals(270.0)); + expect(decoded.extraSensorData!['unixtime_12'], equals(1710227456)); + expect( + decoded.extraSensorData!['colour_13'], + equals({'r': 255, 'g': 128, 'b': 64}), + ); + expect(decoded.extraSensorData!['switch_14'], equals(1)); + }); + + test( + 'percentage battery and non-battery voltage channels are preserved separately', + () { + const lppPercentage = 120; + + final payload = Uint8List.fromList([ + 1, lppPercentage, 66, // battery % + 2, MeshCoreConstants.lppVoltageSensor, 0x01, 0x81, // 3.85 V + 2, MeshCoreConstants.lppTemperatureSensor, 0x00, 0xEB, // 23.5 C + ]); + + final decoded = CayenneLppParser.parse(payload); + + expect(decoded.batteryPercentage, equals(66.0)); + expect(decoded.extraSensorData!['voltage_2'], closeTo(3.85, 0.001)); + expect(decoded.temperature, closeTo(23.5, 0.1)); + expect(decoded.extraSensorData!['temperature_2'], closeTo(23.5, 0.1)); + }, + ); + test('unknown sensor type is skipped gracefully', () { final buffer = ByteData(5); buffer.setUint8(0, 0); diff --git a/test/services/notification_service_test.dart b/test/services/notification_service_test.dart new file mode 100644 index 0000000..cfd45c2 --- /dev/null +++ b/test/services/notification_service_test.dart @@ -0,0 +1,25 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/services/notification_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('discovery notification preference persists independently', () async { + final service = NotificationService(); + + await service.setMessageNotificationsEnabled(true); + await service.setDiscoveryNotificationsEnabled(false); + + final prefs = await SharedPreferences.getInstance(); + + expect(service.messageNotificationsEnabled, isTrue); + expect(service.discoveryNotificationsEnabled, isFalse); + expect(prefs.getBool('notifications_messages_enabled'), isTrue); + expect(prefs.getBool('notifications_discovery_enabled'), isFalse); + }); +} diff --git a/test/widgets/contact_avatar_test.dart b/test/widgets/contact_avatar_test.dart index 4859cb5..842524e 100644 --- a/test/widgets/contact_avatar_test.dart +++ b/test/widgets/contact_avatar_test.dart @@ -31,7 +31,9 @@ void main() { Future pumpAvatar(WidgetTester tester, Contact contact) async { await tester.pumpWidget( MaterialApp( - home: Scaffold(body: Center(child: ContactAvatar(contact: contact))), + home: Scaffold( + body: Center(child: ContactAvatar(contact: contact)), + ), ), ); } @@ -85,7 +87,11 @@ void main() { testWidgets('renders non-hash label avatar for channels', (tester) async { await pumpAvatar( tester, - buildContact(name: 'Command Net', type: ContactType.channel, secondByte: 3), + buildContact( + name: 'Command Net', + type: ContactType.channel, + secondByte: 3, + ), ); expect(find.text('CN'), findsOneWidget); @@ -103,4 +109,15 @@ void main() { expect(find.byType(CircleAvatar), findsOneWidget); expect(find.byIcon(Icons.person), findsNothing); }); + + testWidgets('renders sensor icon avatar for sensor contacts', (tester) async { + await pumpAvatar( + tester, + buildContact(name: 'WX Station', type: ContactType.sensor), + ); + + expect(find.byType(CircleAvatar), findsOneWidget); + expect(find.byIcon(Icons.sensors), findsOneWidget); + expect(find.text('WS'), findsNothing); + }); } diff --git a/test/widgets/contact_tile_test.dart b/test/widgets/contact_tile_test.dart index df7a906..c68358c 100644 --- a/test/widgets/contact_tile_test.dart +++ b/test/widgets/contact_tile_test.dart @@ -104,4 +104,58 @@ void main() { expect(find.text(contact.publicKeyShort), findsNothing); expect(find.byIcon(Icons.key_outlined), findsNothing); }); + + testWidgets('sensor contacts can be added to sensors', (tester) async { + await pumpTile( + tester, + buildContact(name: 'WX Station', type: ContactType.sensor), + ); + + await tester.tap(find.text('WX Station')); + await tester.pumpAndSettle(); + + expect(find.text('Add to Sensors'), findsOneWidget); + }); + + testWidgets('sensor preview shows telemetry card', (tester) async { + final contact = buildContact(name: 'WX Station', type: ContactType.sensor) + .copyWith( + telemetry: ContactTelemetry( + batteryPercentage: 84, + temperature: 21.5, + humidity: 58.0, + extraSensorData: const { + 'co2': 415.0, + 'illuminance_2': 500.0, + 'current_2': 0.015, + 'power_2': 0.25, + 'distance_2': 1.234, + }, + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + ), + ); + + await pumpTile(tester, contact); + + await tester.tap(find.text('WX Station')); + await tester.pumpAndSettle(); + + expect(find.text('Preview'), findsOneWidget); + + await tester.tap(find.text('Preview')); + await tester.pumpAndSettle(); + + expect(find.text('Battery'), findsOneWidget); + expect(find.text('84%'), findsOneWidget); + expect(find.text('Temperature'), findsOneWidget); + expect(find.text('21.5°C'), findsOneWidget); + expect(find.text('CO2'), findsOneWidget); + expect(find.text('415 ppm'), findsOneWidget); + expect(find.text('Illuminance (ch 2)'), findsOneWidget); + expect(find.text('~4.2 W/m2 daylight'), findsOneWidget); + expect(find.text('Current (ch 2)'), findsOneWidget); + expect(find.text('15 mA'), findsOneWidget); + expect(find.text('Power (ch 2)'), findsOneWidget); + expect(find.text('Distance (ch 2)'), findsOneWidget); + }); }