diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 2adf8bb..f7349f1 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/providers/contacts_provider.dart b/lib/providers/contacts_provider.dart index 1bc2af4..d6311c4 100644 --- a/lib/providers/contacts_provider.dart +++ b/lib/providers/contacts_provider.dart @@ -127,11 +127,13 @@ class _RetainedRoute { class ContactsProvider with ChangeNotifier { static const double _firstHopFallbackOffsetMeters = 100.0; static const String autoGroupIdPrefix = 'auto_group_'; + static const String _rawTelemetryHexKey = '__raw_lpp_hex'; final Map _contacts = {}; final List _savedContactGroups = []; final Map _pendingAdverts = {}; final Map _estimatedLocations = {}; final Map> _rssiObservations = {}; + final Map _retainedContactsForSync = {}; final ContactStorageService _storageService = ContactStorageService(); bool _isInitialized = false; bool _isPersisting = false; @@ -279,7 +281,6 @@ class ContactsProvider with ChangeNotifier { /// empty list while sync is in progress. Future prepareForDeviceContactSync({Uint8List? devicePublicKey}) async { _setSelfDevicePublicKey(devicePublicKey); - _selfTelemetry = null; if (!_isInitialized) { final storedGroups = await _storageService.loadContactGroups( namespace: _storageNamespace, @@ -293,6 +294,13 @@ class ContactsProvider with ChangeNotifier { debugPrint( '🧹 [ContactsProvider] Clearing runtime contacts before device sync', ); + _retainedContactsForSync + ..clear() + ..addEntries( + _contactsForStorage().map( + (contact) => MapEntry(contact.publicKeyHex, contact), + ), + ); _contacts.clear(); _pendingAdverts.clear(); _estimatedLocations.clear(); @@ -676,7 +684,9 @@ class ContactsProvider with ChangeNotifier { } // Check if this is a new contact - final existingContact = _contacts[contact.publicKeyHex]; + final existingContact = + _contacts[contact.publicKeyHex] ?? + _retainedContactsForSync[contact.publicKeyHex]; final isNewContact = existingContact == null; debugPrint( ' isNew: $isNewContact, total contacts before: ${_contacts.length}', @@ -688,6 +698,7 @@ class ContactsProvider with ChangeNotifier { ); _contacts[contact.publicKeyHex] = updatedContact; + _retainedContactsForSync.remove(contact.publicKeyHex); // Keep repeaters and sensors in pending adverts so they remain visible // in the discovery list (with a checkmark). Remove others. if (contact.type != ContactType.repeater && @@ -717,11 +728,14 @@ class ContactsProvider with ChangeNotifier { excluded++; continue; } - final existingContact = _contacts[contact.publicKeyHex]; + final existingContact = + _contacts[contact.publicKeyHex] ?? + _retainedContactsForSync[contact.publicKeyHex]; _contacts[contact.publicKeyHex] = _mergeIncomingContact( incomingContact: contact, existingContact: existingContact, ); + _retainedContactsForSync.remove(contact.publicKeyHex); if (contact.type != ContactType.repeater && contact.type != ContactType.sensor) { _pendingAdverts.remove(contact.publicKeyHex); @@ -1012,7 +1026,10 @@ class ContactsProvider with ChangeNotifier { try { // Parse Cayenne LPP data - var telemetry = CayenneLppParser.parse(lppData); + var telemetry = _withRawTelemetryHex( + CayenneLppParser.parse(lppData), + lppData, + ); debugPrint(' ✅ Parsed new telemetry'); debugPrint(' New telemetry timestamp: ${telemetry.timestamp}'); @@ -1056,7 +1073,7 @@ class ContactsProvider with ChangeNotifier { timestamp: mergedTelemetry.timestamp, humidity: mergedTelemetry.humidity, pressure: mergedTelemetry.pressure, - extraSensorData: telemetry.extraSensorData, + extraSensorData: mergedTelemetry.extraSensorData, ); } @@ -1205,6 +1222,28 @@ class ContactsProvider with ChangeNotifier { ); } + ContactTelemetry _withRawTelemetryHex( + ContactTelemetry telemetry, + Uint8List lppData, + ) { + final extraSensorData = {...?telemetry.extraSensorData}; + extraSensorData[_rawTelemetryHexKey] = _bytesToHex(lppData); + return ContactTelemetry( + gpsLocation: telemetry.gpsLocation, + batteryPercentage: telemetry.batteryPercentage, + batteryMilliVolts: telemetry.batteryMilliVolts, + temperature: telemetry.temperature, + timestamp: telemetry.timestamp, + humidity: telemetry.humidity, + pressure: telemetry.pressure, + extraSensorData: extraSensorData, + ); + } + + String _bytesToHex(Uint8List bytes) { + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' '); + } + Map _mergeExtraSensorData( Map? existingExtraSensorData, Map? incomingExtraSensorData, @@ -1621,6 +1660,7 @@ class ContactsProvider with ChangeNotifier { void clearContacts() { _contacts.clear(); _pendingAdverts.clear(); + _retainedContactsForSync.clear(); _ensurePublicChannelExists(); _persistContacts(); _persistPendingAdverts(); @@ -1645,6 +1685,7 @@ class ContactsProvider with ChangeNotifier { // Then remove from local storage _contacts.remove(publicKeyHex); _pendingAdverts.remove(publicKeyHex); + _retainedContactsForSync.remove(publicKeyHex); _persistContacts(); _persistPendingAdverts(); notifyListeners(); @@ -1729,6 +1770,7 @@ class ContactsProvider with ChangeNotifier { _pendingAdverts.clear(); _estimatedLocations.clear(); _rssiObservations.clear(); + _retainedContactsForSync.clear(); _selfTelemetry = null; } diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index 5bd5fff..c1768a8 100644 --- a/lib/providers/sensors_provider.dart +++ b/lib/providers/sensors_provider.dart @@ -14,6 +14,7 @@ enum SensorRefreshState { idle, refreshing, success, timeout, unavailable } class SensorsProvider with ChangeNotifier { static const Duration _successStateRetention = Duration(minutes: 1); + static const int selfAutoRefreshMinutes = 1; static const String _watchedSensorsKey = 'watched_sensor_keys'; static const String _visibleSensorMetricsKey = 'visible_sensor_metrics'; static const String _fieldSpanKey = 'sensor_field_spans'; @@ -373,6 +374,20 @@ class SensorsProvider with ChangeNotifier { return List.unmodifiable(dueKeys); } + bool _isRefreshDue( + String publicKeyHex, { + required int minutes, + required DateTime refreshTime, + }) { + if (minutes <= 0) { + return false; + } + + final lastRefreshAt = _lastRefreshAttemptAt[publicKeyHex]; + return lastRefreshAt == null || + refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes); + } + Future toggleMetric( String publicKeyHex, String fieldKey, @@ -636,6 +651,17 @@ class SensorsProvider with ChangeNotifier { return [self.publicKeyHex]; } + String? displaySelfKey({ + required ContactsProvider contactsProvider, + required ConnectionProvider connectionProvider, + }) { + final self = selfContact(contactsProvider, connectionProvider); + if (self == null || _watchedSensorKeys.contains(self.publicKeyHex)) { + return null; + } + return self.publicKeyHex; + } + Contact? contactForDisplay( String publicKeyHex, { required ContactsProvider contactsProvider, @@ -659,7 +685,18 @@ class SensorsProvider with ChangeNotifier { required ContactsProvider contactsProvider, required ConnectionProvider connectionProvider, }) async { - if (_isRefreshingAll || _watchedSensorKeys.isEmpty) { + if (_isRefreshingAll) { + return; + } + + final self = selfContact(contactsProvider, connectionProvider); + final keysToRefresh = [ + if (self != null) self.publicKeyHex, + ..._watchedSensorKeys.where( + (key) => self == null || key != self.publicKeyHex, + ), + ]; + if (keysToRefresh.isEmpty) { return; } @@ -667,7 +704,7 @@ class SensorsProvider with ChangeNotifier { notifyListeners(); try { - for (final key in _watchedSensorKeys) { + for (final key in keysToRefresh) { await refreshSensor( publicKeyHex: key, contactsProvider: contactsProvider, @@ -728,7 +765,17 @@ class SensorsProvider with ChangeNotifier { } final refreshTime = now ?? DateTime.now(); - final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime); + final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime).toList(); + final self = selfContact(contactsProvider, connectionProvider); + if (self != null && + !dueKeys.contains(self.publicKeyHex) && + _isRefreshDue( + self.publicKeyHex, + minutes: selfAutoRefreshMinutes, + refreshTime: refreshTime, + )) { + dueKeys.insert(0, self.publicKeyHex); + } if (dueKeys.isEmpty) { return; } diff --git a/lib/screens/contacts_tab.dart b/lib/screens/contacts_tab.dart index d8a17c9..acbe59a 100644 --- a/lib/screens/contacts_tab.dart +++ b/lib/screens/contacts_tab.dart @@ -519,61 +519,58 @@ class _ContactsTabState extends State { final canExportHashChannelPsk = Channel.isHashChannelName( channel.advName.trim(), ); - - showModalBottomSheet( - context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + final actions = <_ChannelSheetAction>[ + _ChannelSheetAction( + icon: Icons.message_outlined, + label: l10n.messages, + onTap: () async { + Navigator.pop(context); + await _openMessagesForChannel(context, channel); + }, ), - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon(Icons.message_outlined), - title: Text(l10n.messages), - onTap: () async { - Navigator.pop(sheetContext); - await _openMessagesForChannel(context, channel); - }, - ), - if (channel.displayLocation != null) - ListTile( - leading: Icon(Icons.map_outlined), - title: Text(l10n.viewOnMap), - onTap: () { - Navigator.pop(sheetContext); - _showChannelOnMap(context, channel); - }, - ), - if (canExportHashChannelPsk) - ListTile( - leading: const Icon(Icons.key_outlined), - title: Text('${l10n.exportToClipboard} psk_base64'), - onTap: () async { - Navigator.pop(sheetContext); - await _exportHashChannelPskBase64(context, channel); - }, - ), - if (!channel.isPublicChannel) - ListTile( - leading: Icon(Icons.delete, color: Colors.red), - title: Text( - l10n.deleteChannel, - style: const TextStyle(color: Colors.red), - ), - onTap: () async { - Navigator.pop(sheetContext); - await Future.delayed(Duration.zero); - if (!context.mounted) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!context.mounted) return; - _showDeleteChannelDialog(context, channel); - }); - }, - ), - ], + if (channel.displayLocation != null) + _ChannelSheetAction( + icon: Icons.map_outlined, + label: l10n.viewOnMap, + onTap: () async { + Navigator.pop(context); + _showChannelOnMap(context, channel); + }, ), + if (canExportHashChannelPsk) + _ChannelSheetAction( + icon: Icons.key_outlined, + label: '${l10n.exportToClipboard} psk_base64', + onTap: () async { + Navigator.pop(context); + await _exportHashChannelPskBase64(context, channel); + }, + ), + if (!channel.isPublicChannel) + _ChannelSheetAction( + icon: Icons.delete_outline_rounded, + label: l10n.deleteChannel, + destructive: true, + onTap: () async { + Navigator.pop(context); + await Future.delayed(Duration.zero); + if (!context.mounted) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + _showDeleteChannelDialog(context, channel); + }); + }, + ), + ]; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) => _ChannelActionSheet( + channel: channel, + actions: actions, + onClose: () => Navigator.pop(sheetContext), ), ); } @@ -1980,6 +1977,302 @@ class _ExpandableParticipantStackState } } +class _ChannelSheetAction { + final IconData icon; + final String label; + final Future Function() onTap; + final bool destructive; + + const _ChannelSheetAction({ + required this.icon, + required this.label, + required this.onTap, + this.destructive = false, + }); +} + +class _ChannelActionSheet extends StatelessWidget { + final Contact channel; + final List<_ChannelSheetAction> actions; + final VoidCallback onClose; + + const _ChannelActionSheet({ + required this.channel, + required this.actions, + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context)!; + final title = channel.getLocalizedDisplayName(context); + final subtitle = channel.isPublicChannel + ? l10n.broadcastToAllNearby + : '${l10n.channel} ${channel.publicKey[1]}'; + final bottomInset = MediaQuery.of(context).viewPadding.bottom; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.82, + ), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(32)), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.12), + blurRadius: 24, + offset: const Offset(0, -4), + ), + ], + ), + child: Material( + color: colorScheme.surface, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomInset), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: ContactAvatar( + contact: channel, + radius: 28, + displayName: title, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: -0.45, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + _ChannelSheetChip( + icon: channel.isPublicChannel + ? Icons.broadcast_on_personal_rounded + : Icons.tag_rounded, + label: channel.isPublicChannel + ? l10n.broadcastToAllNearby + : channel.publicKeyShort, + monospace: !channel.isPublicChannel, + ), + ], + ), + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + onPressed: onClose, + tooltip: l10n.close, + icon: const Icon(Icons.close_rounded), + ), + ], + ), + if (actions.isNotEmpty) ...[ + const SizedBox(height: 20), + LayoutBuilder( + builder: (context, constraints) { + const spacing = 12.0; + const minTileWidth = 132.0; + final actionCount = actions.length; + final maxColumnsByWidth = + ((constraints.maxWidth + spacing) / + (minTileWidth + spacing)) + .floor() + .clamp(1, 3); + var columnCount = actionCount.clamp(1, maxColumnsByWidth); + if (actionCount > 3 && + columnCount > 2 && + actionCount % columnCount == 1) { + columnCount -= 1; + } + final itemWidth = + (constraints.maxWidth - (spacing * (columnCount - 1))) / + columnCount; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + for (final action in actions) + SizedBox( + width: itemWidth, + child: _ChannelPrimaryActionButton(action: action), + ), + ], + ); + }, + ), + ], + ], + ), + ), + ), + ); + } +} + +class _ChannelPrimaryActionButton extends StatelessWidget { + final _ChannelSheetAction action; + + const _ChannelPrimaryActionButton({required this.action}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final accent = action.destructive ? colorScheme.error : colorScheme.primary; + final backgroundColor = action.destructive + ? colorScheme.errorContainer.withValues(alpha: 0.82) + : Color.alphaBlend( + accent.withValues(alpha: 0.12), + colorScheme.surfaceContainerLow, + ); + final foregroundColor = action.destructive + ? colorScheme.onErrorContainer + : accent; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: action.onTap, + child: Ink( + height: 100, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: action.destructive + ? colorScheme.error.withValues(alpha: 0.18) + : accent.withValues(alpha: 0.14), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: foregroundColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(11), + ), + child: Icon(action.icon, color: foregroundColor, size: 16), + ), + const SizedBox(height: 6), + Text( + action.label, + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelLarge?.copyWith( + color: action.destructive + ? colorScheme.onErrorContainer + : colorScheme.onSurface, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ChannelSheetChip extends StatelessWidget { + final IconData icon; + final String label; + final bool monospace; + + const _ChannelSheetChip({ + required this.icon, + required this.label, + this.monospace = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + constraints: const BoxConstraints(maxWidth: 260), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + fontFamily: monospace ? 'monospace' : null, + ), + ), + ), + ], + ), + ); + } +} + class _OverflowAvatar extends StatelessWidget { final int count; diff --git a/lib/screens/discovery_screen.dart b/lib/screens/discovery_screen.dart index 9922948..c0528d2 100644 --- a/lib/screens/discovery_screen.dart +++ b/lib/screens/discovery_screen.dart @@ -9,7 +9,7 @@ import '../providers/contacts_provider.dart'; import '../services/mesh_map_nodes_service.dart'; import '../widgets/compact_signal_indicator.dart' show SignalMetric; -enum _DiscoveryMenuAction { repeaters, sensors } +enum _DiscoveryListFilter { all, repeaters, sensors, others } class DiscoveryScreen extends StatefulWidget { const DiscoveryScreen({super.key}); @@ -23,7 +23,10 @@ class _DiscoveryScreenState extends State { static const int _sensorAdvertType = 4; final Set _resolvingAdvertKeys = {}; final Set _runningDiscoveryTypes = {}; + final TextEditingController _searchController = TextEditingController(); bool _isResolvingAll = false; + String _searchQuery = ''; + _DiscoveryListFilter _selectedFilter = _DiscoveryListFilter.all; late final Future> _cachedNodesFuture; @override @@ -34,15 +37,10 @@ class _DiscoveryScreenState extends State { ); } - Future _handleMenuAction(_DiscoveryMenuAction action) async { - switch (action) { - case _DiscoveryMenuAction.repeaters: - await _discoverNodeType(_repeaterAdvertType); - break; - case _DiscoveryMenuAction.sensors: - await _discoverNodeType(_sensorAdvertType); - break; - } + @override + void dispose() { + _searchController.dispose(); + super.dispose(); } Future _clearAllDiscoveries() async { @@ -80,7 +78,9 @@ class _DiscoveryScreenState extends State { return; } ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries)), + SnackBar( + content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries), + ), ); } @@ -293,8 +293,8 @@ class _DiscoveryScreenState extends State { }; } - String? _typeLabelForAdvert(PendingAdvert advert) { - return switch (advert.typeValue) { + String? _typeLabelForValue(int? typeValue) { + return switch (typeValue) { _repeaterAdvertType => 'Repeater', _sensorAdvertType => 'Sensor', 3 => 'Room', @@ -303,22 +303,22 @@ class _DiscoveryScreenState extends State { }; } - String? _resolvedTypeLabelForAdvert( + int? _resolvedTypeValueForAdvert( PendingAdvert advert, List cachedNodes, ) { - final directType = _typeLabelForAdvert(advert); - if (directType != null) { + final directType = advert.typeValue; + if (directType != null && directType != 0) { return directType; } for (final node in cachedNodes) { if (node.publicKey == advert.publicKeyHex.toLowerCase()) { return switch (node.type) { - 1 => 'Repeater', - 4 => 'Sensor', - 3 => 'Room', - 2 => 'Chat', + 1 => _repeaterAdvertType, + 4 => _sensorAdvertType, + 3 => 3, + 2 => 1, _ => null, }; } @@ -327,6 +327,81 @@ class _DiscoveryScreenState extends State { return null; } + String? _resolvedTypeLabelForAdvert( + PendingAdvert advert, + List cachedNodes, + ) { + return _typeLabelForValue(_resolvedTypeValueForAdvert(advert, cachedNodes)); + } + + bool get _hasActiveInlineFilter => + _searchQuery.trim().isNotEmpty || + _selectedFilter != _DiscoveryListFilter.all; + + bool _matchesSelectedFilter( + PendingAdvert advert, + List cachedNodes, + ) { + if (_selectedFilter == _DiscoveryListFilter.all) { + return true; + } + + final resolvedType = _resolvedTypeValueForAdvert(advert, cachedNodes); + return switch (_selectedFilter) { + _DiscoveryListFilter.all => true, + _DiscoveryListFilter.repeaters => resolvedType == _repeaterAdvertType, + _DiscoveryListFilter.sensors => resolvedType == _sensorAdvertType, + _DiscoveryListFilter.others => + resolvedType != _repeaterAdvertType && + resolvedType != _sensorAdvertType, + }; + } + + bool _matchesSearchQuery( + PendingAdvert advert, + ContactsProvider contactsProvider, + List cachedNodes, + ) { + final query = _searchQuery.trim().toLowerCase(); + if (query.isEmpty) { + return true; + } + + final displayName = _displayNameForAdvert( + advert, + contactsProvider, + cachedNodes, + ).toLowerCase(); + final typeLabel = (_resolvedTypeLabelForAdvert(advert, cachedNodes) ?? '') + .toLowerCase(); + + return displayName.contains(query) || + advert.publicKeyHex.toLowerCase().contains(query) || + advert.shortDisplayKey.toLowerCase().contains(query) || + typeLabel.contains(query); + } + + List _filteredPendingAdverts( + List adverts, + ContactsProvider contactsProvider, + List cachedNodes, + ) { + return adverts + .where((advert) => _matchesSelectedFilter(advert, cachedNodes)) + .where( + (advert) => + _matchesSearchQuery(advert, contactsProvider, cachedNodes), + ) + .toList(); + } + + String _summaryTitle({required int totalCount, required int filteredCount}) { + if (filteredCount == totalCount) { + return 'Discovered nodes ($totalCount)'; + } + return 'Discovered nodes ($filteredCount/$totalCount)'; + } + Widget _buildAdvertTitle( BuildContext context, { required String displayName, @@ -362,193 +437,236 @@ class _DiscoveryScreenState extends State { final l10n = AppLocalizations.of(context)!; return Scaffold( - appBar: AppBar( - title: Text(l10n.discovery), - actions: [ - Consumer( - builder: (context, connectionProvider, child) { - final isConnected = connectionProvider.deviceInfo.isConnected; - final repeatersBusy = _runningDiscoveryTypes.contains( - _repeaterAdvertType, - ); - final sensorsBusy = _runningDiscoveryTypes.contains( - _sensorAdvertType, - ); - - return PopupMenuButton<_DiscoveryMenuAction>( - tooltip: 'Discovery tools', - onSelected: _handleMenuAction, - itemBuilder: (context) => [ - PopupMenuItem<_DiscoveryMenuAction>( - value: _DiscoveryMenuAction.repeaters, - enabled: isConnected && !repeatersBusy, - child: Row( - children: [ - repeatersBusy - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.router_outlined), - SizedBox(width: 12), - Text(l10n.discoverRepeaters), - ], - ), - ), - PopupMenuItem<_DiscoveryMenuAction>( - value: _DiscoveryMenuAction.sensors, - enabled: isConnected && !sensorsBusy, - child: Row( - children: [ - sensorsBusy - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon(Icons.sensors_outlined), - SizedBox(width: 12), - Text(l10n.discoverSensors), - ], - ), - ), - ], - ); - }, - ), - ], - ), + appBar: AppBar(title: Text(l10n.discovery)), body: FutureBuilder>( future: _cachedNodesFuture, - builder: (context, nodesSnapshot) => Consumer2( - builder: (context, contactsProvider, connectionProvider, child) { - final pendingAdverts = contactsProvider.pendingAdverts; - final isConnected = connectionProvider.deviceInfo.isConnected; - final cachedNodes = nodesSnapshot.data ?? const []; + builder: (context, nodesSnapshot) => + Consumer2( + builder: (context, contactsProvider, connectionProvider, child) { + final pendingAdverts = contactsProvider.pendingAdverts; + final isConnected = connectionProvider.deviceInfo.isConnected; + final cachedNodes = nodesSnapshot.data ?? const []; + final repeatersBusy = _runningDiscoveryTypes.contains( + _repeaterAdvertType, + ); + final sensorsBusy = _runningDiscoveryTypes.contains( + _sensorAdvertType, + ); - // Track which pending adverts are already in the contacts list - final resolvedKeySet = {}; - for (final advert in pendingAdverts) { - if (contactsProvider.findContactByKey(advert.publicKey) != null) { - resolvedKeySet.add(advert.publicKeyHex); - } - } + // Track which pending adverts are already in the contacts list + final resolvedKeySet = {}; + for (final advert in pendingAdverts) { + if (contactsProvider.findContactByKey(advert.publicKey) != + null) { + resolvedKeySet.add(advert.publicKeyHex); + } + } - final totalCount = pendingAdverts.length; + final totalCount = pendingAdverts.length; + final filteredAdverts = _filteredPendingAdverts( + pendingAdverts, + contactsProvider, + cachedNodes, + ); + final filteredCount = filteredAdverts.length; - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.person_search), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Discovered nodes ($totalCount)', - style: Theme.of(context).textTheme.titleMedium, + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.person_search), + const SizedBox(width: 12), + Expanded( + child: Text( + _summaryTitle( + totalCount: totalCount, + filteredCount: filteredCount, + ), + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Resolve entries manually so they do not auto-populate contacts.', + style: Theme.of(context).textTheme.bodyMedium, + ), + if (totalCount > 0) ...[ + const SizedBox(height: 12), + _buildInlineSearchField(context), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildTypeFilterChip( + context, + label: l10n.all, + filter: _DiscoveryListFilter.all, + ), + _buildTypeFilterChip( + context, + label: l10n.repeatersFilter, + filter: _DiscoveryListFilter.repeaters, + ), + _buildTypeFilterChip( + context, + label: l10n.sensors, + filter: _DiscoveryListFilter.sensors, + ), + _buildTypeFilterChip( + context, + label: l10n.others, + filter: _DiscoveryListFilter.others, + ), + ], ), + ], + const SizedBox(height: 14), + LayoutBuilder( + builder: (context, constraints) { + const spacing = 10.0; + final useTwoColumns = + constraints.maxWidth >= 360; + final buttonWidth = useTwoColumns + ? (constraints.maxWidth - spacing) / 2 + : constraints.maxWidth; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: [ + _buildHeaderActionButton( + width: buttonWidth, + onPressed: isConnected && !repeatersBusy + ? () => _discoverNodeType( + _repeaterAdvertType, + ) + : null, + icon: repeatersBusy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.router_outlined), + label: Text(l10n.discoverRepeaters), + ), + _buildHeaderActionButton( + width: buttonWidth, + onPressed: isConnected && !sensorsBusy + ? () => _discoverNodeType( + _sensorAdvertType, + ) + : null, + icon: sensorsBusy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.sensors_outlined), + label: Text(l10n.discoverSensors), + ), + _buildHeaderActionButton( + width: buttonWidth, + onPressed: + isConnected && + pendingAdverts.isNotEmpty && + !_isResolvingAll + ? () => _resolveAll(pendingAdverts) + : null, + icon: _isResolvingAll + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon( + Icons + .download_for_offline_outlined, + ), + label: Text(l10n.resolveAll), + ), + _buildHeaderActionButton( + width: buttonWidth, + onPressed: pendingAdverts.isNotEmpty + ? _clearAllDiscoveries + : null, + icon: const Icon(Icons.clear_all_rounded), + label: Text(l10n.clearAllLabel), + ), + ], + ); + }, ), ], ), - const SizedBox(height: 12), - Text( - 'Resolve entries manually so they do not auto-populate contacts.', - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 14), - Row( + ), + ), + const SizedBox(height: 16), + if (totalCount == 0) + Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: - isConnected && - pendingAdverts.isNotEmpty && - !_isResolvingAll - ? () => _resolveAll(pendingAdverts) - : null, - icon: _isResolvingAll - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - ), - ) - : const Icon( - Icons.download_for_offline_outlined, - ), - label: Text(l10n.resolveAll), - ), + Icon( + Icons.person_search_outlined, + size: 64, + color: Theme.of(context).disabledColor, ), - const SizedBox(width: 10), - Expanded( - child: OutlinedButton.icon( - onPressed: pendingAdverts.isNotEmpty - ? _clearAllDiscoveries - : null, - icon: Icon(Icons.clear_all_rounded), - label: Text(l10n.clearAllLabel), - ), + const SizedBox(height: 16), + Text( + 'No discovered nodes', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + 'Use the discovery actions above to find repeaters and sensors on the mesh.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, ), ], ), - ], + ), + if (_hasActiveInlineFilter && + totalCount > 0 && + filteredCount == 0) + _buildNoFilterResults(context), + ...filteredAdverts.map( + (advert) => _buildPendingAdvertCard( + context, + advert: advert, + contactsProvider: contactsProvider, + isConnected: isConnected, + isResolved: resolvedKeySet.contains( + advert.publicKeyHex, + ), + cachedNodes: cachedNodes, + l10n: l10n, + ), ), - ), - ), - const SizedBox(height: 16), - if (totalCount == 0) - Padding( - padding: const EdgeInsets.symmetric(vertical: 48), - child: Column( - children: [ - Icon( - Icons.person_search_outlined, - size: 64, - color: Theme.of(context).disabledColor, - ), - const SizedBox(height: 16), - Text( - 'No discovered nodes', - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 8), - Text( - 'Use the menu to discover repeaters and sensors on the mesh.', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ), - ...pendingAdverts.map((advert) => - _buildPendingAdvertCard( - context, - advert: advert, - contactsProvider: contactsProvider, - isConnected: isConnected, - isResolved: resolvedKeySet.contains(advert.publicKeyHex), - cachedNodes: cachedNodes, - l10n: l10n, - ), - ), - ], - ); - }, - ), + ], + ); + }, + ), ), ); } @@ -563,7 +681,11 @@ class _DiscoveryScreenState extends State { required AppLocalizations l10n, }) { final isResolving = _resolvingAdvertKeys.contains(advert.publicKeyHex); - final displayName = _displayNameForAdvert(advert, contactsProvider, cachedNodes); + final displayName = _displayNameForAdvert( + advert, + contactsProvider, + cachedNodes, + ); final typeLabel = _resolvedTypeLabelForAdvert(advert, cachedNodes); final downMetric = SignalMetric.fromValues( rssiDbm: advert.rxRssiDbm, @@ -652,6 +774,180 @@ class _DiscoveryScreenState extends State { ); } + Widget _buildInlineSearchField(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final hasFilter = _searchQuery.trim().isNotEmpty; + + return Material( + color: Colors.transparent, + child: Ink( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: hasFilter + ? colorScheme.primary.withValues(alpha: 0.38) + : colorScheme.outline.withValues(alpha: 0.32), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.025), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SizedBox( + height: 42, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(left: 12, right: 8), + child: Icon( + Icons.search_rounded, + size: 17, + color: hasFilter + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + Expanded( + child: TextField( + controller: _searchController, + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + cursorColor: colorScheme.primary, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + height: 1.1, + ), + decoration: InputDecoration( + hintText: 'Search discovered nodes', + hintStyle: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.85, + ), + ), + filled: true, + fillColor: Colors.transparent, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + ), + ), + ), + if (hasFilter) + Padding( + padding: const EdgeInsets.only(right: 6), + child: Material( + color: colorScheme.primary.withValues(alpha: 0.10), + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + child: Padding( + padding: const EdgeInsets.all(6), + child: Icon( + Icons.close_rounded, + size: 14, + color: colorScheme.primary, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildTypeFilterChip( + BuildContext context, { + required String label, + required _DiscoveryListFilter filter, + }) { + final scheme = Theme.of(context).colorScheme; + final selected = _selectedFilter == filter; + final color = selected ? scheme.primary : scheme.outline; + + return InkWell( + onTap: () { + if (_selectedFilter == filter) { + return; + } + setState(() { + _selectedFilter = filter; + }); + }, + borderRadius: BorderRadius.circular(999), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: selected ? 0.14 : 0.08), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: color.withValues(alpha: selected ? 0.45 : 0.22), + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ), + ); + } + + Widget _buildNoFilterResults(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + child: Text( + 'No matches', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ); + } + + Widget _buildHeaderActionButton({ + required double width, + required VoidCallback? onPressed, + required Widget icon, + required Widget label, + }) { + return SizedBox( + width: width, + child: OutlinedButton.icon( + onPressed: onPressed, + icon: icon, + label: label, + ), + ); + } + Widget _buildSignalSummary( BuildContext context, { SignalMetric? downMetric, diff --git a/lib/screens/messages_tab.dart b/lib/screens/messages_tab.dart index 68089be..5998a67 100644 --- a/lib/screens/messages_tab.dart +++ b/lib/screens/messages_tab.dart @@ -48,6 +48,110 @@ class MessagesTab extends StatefulWidget { State createState() => _MessagesTabState(); } +class _ComposerActionTile extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final Color color; + final bool enabled; + final VoidCallback? onTap; + + const _ComposerActionTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.color, + this.enabled = true, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final effectiveColor = enabled + ? color + : colorScheme.onSurfaceVariant.withValues(alpha: 0.45); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: enabled ? onTap : null, + child: Ink( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + enabled + ? colorScheme.surfaceContainerLow + : colorScheme.surfaceContainerHighest, + enabled + ? effectiveColor.withValues(alpha: 0.08) + : colorScheme.surfaceContainerHigh, + ], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: enabled + ? effectiveColor.withValues(alpha: 0.16) + : colorScheme.outlineVariant.withValues(alpha: 0.16), + ), + boxShadow: [ + BoxShadow( + color: effectiveColor.withValues(alpha: enabled ? 0.08 : 0.03), + blurRadius: 14, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: effectiveColor.withValues(alpha: enabled ? 0.14 : 0.10), + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: Icon(icon, color: effectiveColor, size: 20), + ), + const SizedBox(height: 8), + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + color: enabled + ? colorScheme.onSurface + : colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.15, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + class _MessagesTabState extends State { static const int _maxContactMessageBytes = 156; static const int _maxChannelMessageBytes = 127; @@ -1468,81 +1572,207 @@ class _MessagesTabState extends State { void _showComposerActions() { showModalBottomSheet( context: context, + backgroundColor: Colors.transparent, builder: (sheetContext) { + final theme = Theme.of(sheetContext); + final colorScheme = theme.colorScheme; + final l10n = AppLocalizations.of(context)!; + + Future runAction(Future Function() action) async { + await _runAfterSheetDismissal(sheetContext, action); + } + return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon(Icons.search), - title: Text(AppLocalizations.of(context)!.searchMessages), - onTap: () async { - await _runAfterSheetDismissal(sheetContext, () async { - _showFilteredMessageSearch(); - }); - }, - ), - ListTile( - leading: Icon(Icons.add_location_alt), - title: Text(AppLocalizations.of(context)!.sendSarMarker), - onTap: () async { - await _runAfterSheetDismissal(sheetContext, () async { - _showSarDialog(); - }); - }, - ), - if (_voiceSupported) - ListTile( - enabled: !_isSendingVoice, - leading: Icon(_isRecording ? Icons.stop : Icons.mic), - title: Text(_isRecording ? 'Stop recording' : 'Record voice'), - onTap: _isSendingVoice - ? null - : () async { - await _runAfterSheetDismissal(sheetContext, () async { - if (_isRecording) { - await _stopAndSendVoice(); - } else { - await _startVoiceRecording(); - } - }); - }, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(sheetContext).size.height * 0.72, + ), + child: Container( + margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colorScheme.surface, + colorScheme.surfaceContainerLow, + ], ), - ListTile( - enabled: !_isSendingImage, - leading: Icon(Icons.photo_library), - title: Text(AppLocalizations.of(context)!.sendImageFromGallery), - onTap: _isSendingImage - ? null - : () async { - await _runAfterSheetDismissal(sheetContext, () async { - await _pickAndSendImage(source: ImageSource.gallery); - }); + borderRadius: const BorderRadius.vertical( + top: Radius.circular(32), + bottom: Radius.circular(28), + ), + boxShadow: [ + BoxShadow( + color: colorScheme.shadow.withValues(alpha: 0.14), + blurRadius: 28, + offset: const Offset(0, 10), + ), + ], + ), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 14), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'More actions', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: -0.4, + ), + ), + const SizedBox(height: 4), + Text( + 'Search, share, or start something from this chat.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: colorScheme.outlineVariant.withValues( + alpha: 0.18, + ), + ), + ), + child: IconButton( + onPressed: () => Navigator.pop(sheetContext), + tooltip: l10n.close, + icon: const Icon(Icons.close_rounded), + ), + ), + ], + ), + const SizedBox(height: 18), + LayoutBuilder( + builder: (context, constraints) { + final actions = [ + _ComposerActionTile( + icon: Icons.search_rounded, + title: l10n.searchMessages, + subtitle: 'Find text in the current conversation', + color: const Color(0xFF2B6CB0), + onTap: () => runAction(() async { + _showFilteredMessageSearch(); + }), + ), + _ComposerActionTile( + icon: Icons.add_location_alt_rounded, + title: l10n.sendSarMarker, + subtitle: 'Share a marker with coordinates', + color: const Color(0xFFB45309), + onTap: () => runAction(() async { + _showSarDialog(); + }), + ), + if (_voiceSupported) + _ComposerActionTile( + icon: _isRecording + ? Icons.stop_rounded + : Icons.mic_rounded, + title: _isRecording + ? 'Stop recording' + : 'Record voice', + subtitle: _isSendingVoice + ? 'Voice message is sending' + : _isRecording + ? 'Finish and send your clip' + : 'Capture and send a voice note', + color: const Color(0xFF7C3AED), + enabled: !_isSendingVoice, + onTap: !_isSendingVoice + ? () => runAction(() async { + if (_isRecording) { + await _stopAndSendVoice(); + } else { + await _startVoiceRecording(); + } + }) + : null, + ), + _ComposerActionTile( + icon: Icons.photo_library_rounded, + title: l10n.sendImageFromGallery, + subtitle: 'Choose an image from your library', + color: const Color(0xFF0F766E), + enabled: !_isSendingImage, + onTap: !_isSendingImage + ? () => runAction(() async { + await _pickAndSendImage( + source: ImageSource.gallery, + ); + }) + : null, + ), + _ComposerActionTile( + icon: Icons.camera_alt_rounded, + title: l10n.takePhoto, + subtitle: 'Capture something right now', + color: const Color(0xFF2563EB), + enabled: !_isSendingImage, + onTap: !_isSendingImage + ? () => runAction(() async { + await _pickAndSendImage( + source: ImageSource.camera, + ); + }) + : null, + ), + _ComposerActionTile( + icon: Icons.grid_3x3_rounded, + title: l10n.startTictactoe, + subtitle: l10n.dmOnly, + color: const Color(0xFFBE185D), + onTap: () => runAction(() async { + await _startTicTacToeGame(); + }), + ), + ]; + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: actions.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + mainAxisExtent: 126, + ), + itemBuilder: (context, index) => actions[index], + ); }, + ), + ], + ), ), - ListTile( - enabled: !_isSendingImage, - leading: Icon(Icons.camera_alt), - title: Text(AppLocalizations.of(context)!.takePhoto), - onTap: _isSendingImage - ? null - : () async { - await _runAfterSheetDismissal(sheetContext, () async { - await _pickAndSendImage(source: ImageSource.camera); - }); - }, - ), - ListTile( - leading: Icon(Icons.grid_3x3), - title: Text(AppLocalizations.of(context)!.startTictactoe), - subtitle: Text(AppLocalizations.of(context)!.dmOnly), - onTap: () async { - await _runAfterSheetDismissal(sheetContext, () async { - await _startTicTacToeGame(); - }); - }, - ), - ], + ), ), ); }, diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index 8f06d24..18fb530 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -105,74 +105,120 @@ class _SensorsTabState extends State { contactsProvider, connectionProvider: context.read(), ); + final searchController = TextEditingController(); - await showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) { - if (candidates.isEmpty) { - return const SafeArea( - child: Padding( - padding: EdgeInsets.all(24), - child: Text( - 'No eligible nodes available. Discover a relay or node first.', - ), - ), - ); - } - - return SafeArea( - child: ListView( - shrinkWrap: true, - padding: const EdgeInsets.only(bottom: 20), - children: [ - ListTile( - title: const Text( - 'Add sensor node', - style: TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text( - AppLocalizations.of( - context, - )!.pickARelayOrNodeToWatchInSensors, + try { + await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (sheetContext) { + if (candidates.isEmpty) { + return const SafeArea( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'No eligible nodes available. Discover a relay or node first.', ), ), - ...candidates.map( - (contact) => ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 4, + ); + } + + return StatefulBuilder( + builder: (context, setModalState) { + final query = searchController.text.trim().toLowerCase(); + final filteredCandidates = candidates.where((contact) { + if (query.isEmpty) { + return true; + } + return contact.displayName.toLowerCase().contains(query) || + contact.publicKeyHex.toLowerCase().contains(query); + }).toList(); + + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, ), - leading: CircleAvatar( - radius: 24, - backgroundColor: const Color(0xFFDDEAF8), - child: Icon( - _typeIcon(contact), - color: const Color(0xFF1E4F7A), - ), - ), - title: Text(contact.displayName), - subtitle: _SensorCandidatePreview(contact: contact), - isThreeLine: true, - onTap: () async { - await sensorsProvider.addSensor(contact); - if (!sheetContext.mounted) return; - Navigator.of(sheetContext).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - '${contact.displayName} added to Sensors', + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(bottom: 20), + children: [ + ListTile( + title: const Text( + 'Add sensor node', + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + AppLocalizations.of( + context, + )!.pickARelayOrNodeToWatchInSensors, ), ), - ); - }, + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: TextField( + controller: searchController, + onChanged: (_) => setModalState(() {}), + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + hintText: 'Search sensors', + border: OutlineInputBorder(), + ), + ), + ), + if (filteredCandidates.isEmpty) + const Padding( + padding: EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + child: Text( + 'No sensor candidates match your search.', + ), + ), + ...filteredCandidates.map( + (contact) => ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + leading: CircleAvatar( + radius: 24, + backgroundColor: const Color(0xFFDDEAF8), + child: Icon( + _typeIcon(contact), + color: const Color(0xFF1E4F7A), + ), + ), + title: Text(contact.displayName), + subtitle: _SensorCandidatePreview(contact: contact), + isThreeLine: true, + onTap: () async { + await sensorsProvider.addSensor(contact); + if (!sheetContext.mounted) return; + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${contact.displayName} added to Sensors', + ), + ), + ); + }, + ), + ), + ], + ), ), - ), - ], - ), - ); - }, - ); + ); + }, + ); + }, + ); + } finally { + searchController.dispose(); + } } Future _showMetricSelector( @@ -292,14 +338,21 @@ class _SensorsTabState extends State { connectionProvider, child, ) { - final displayKeys = sensorsProvider.displaySensorKeys( + final watchedKeys = sensorsProvider.watchedSensorKeys; + final hasPersistedSensors = watchedKeys.isNotEmpty; + final selfDisplayKey = sensorsProvider.displaySelfKey( contactsProvider: contactsProvider, connectionProvider: connectionProvider, ); - final hasPersistedSensors = - sensorsProvider.watchedSensorKeys.isNotEmpty; + final hasAnyCards = + selfDisplayKey != null || watchedKeys.isNotEmpty; - Widget buildSensorCard(String key, int index) { + Widget buildSensorCard( + String key, { + required int index, + required int totalCount, + required bool isWatchedCard, + }) { final contact = sensorsProvider.contactForDisplay( key, contactsProvider: contactsProvider, @@ -314,106 +367,104 @@ class _SensorsTabState extends State { return Padding( key: ValueKey('sensor_card_$key'), padding: EdgeInsets.only( - bottom: index == displayKeys.length - 1 ? 0 : 12, + bottom: index == totalCount - 1 ? 0 : 12, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: SensorTelemetryCard( - contact: contact, - state: sensorsProvider.stateFor(key), - visibleFields: visibleFields, - fieldOrder: sensorsProvider.metricOrderFor( - key, - availableFieldKeys, - ), - labelOverrides: sensorsProvider.labelOverridesFor( - key, - ), - fieldSpans: { - for (final field in visibleFields) - field: sensorsProvider.fieldSpanFor(key, field), - }, - onRemove: hasPersistedSensors - ? () async { - await sensorsProvider.removeSensor(key); - } - : null, - onCustomize: () => - _showMetricSelector(context, key, contact), - onShowMetHistory: (contact) => - showBTHomeMetHistorySheet( - context, - contact: contact, - ), - onRefresh: () => sensorsProvider.refreshSensor( - publicKeyHex: key, - contactsProvider: contactsProvider, - connectionProvider: connectionProvider, - ), - ), + child: ReorderableDelayedDragStartListener( + enabled: isWatchedCard, + index: index, + child: SensorTelemetryCard( + contact: contact, + state: sensorsProvider.stateFor(key), + visibleFields: visibleFields, + fieldOrder: sensorsProvider.metricOrderFor( + key, + availableFieldKeys, ), - if (hasPersistedSensors) ...[ - const SizedBox(width: 8), - Padding( - padding: const EdgeInsets.only(top: 20), - child: ReorderableDragStartListener( - index: index, - child: Tooltip( - message: 'Move card', - child: Container( - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 12, - ), - child: Icon( - Icons.drag_indicator, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ), - ], - ], + labelOverrides: sensorsProvider.labelOverridesFor(key), + fieldSpans: { + for (final field in visibleFields) + field: sensorsProvider.fieldSpanFor(key, field), + }, + onRemove: isWatchedCard + ? () async { + await sensorsProvider.removeSensor(key); + } + : null, + onCustomize: () => + _showMetricSelector(context, key, contact), + onShowMetHistory: (contact) => + showBTHomeMetHistorySheet(context, contact: contact), + onMoveUp: isWatchedCard && index > 0 + ? () => + sensorsProvider.reorderSensors(index, index - 1) + : null, + onMoveDown: isWatchedCard && index < totalCount - 1 + ? () => + sensorsProvider.reorderSensors(index, index + 2) + : null, + onRefresh: () => sensorsProvider.refreshSensor( + publicKeyHex: key, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ), + ), ), ); } return RefreshIndicator( onRefresh: () => _refreshAll(context), - child: displayKeys.isEmpty + child: !hasAnyCards ? ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), children: const [_EmptySensorsState()], ) - : hasPersistedSensors + : hasPersistedSensors && selfDisplayKey == null ? ReorderableListView.builder( buildDefaultDragHandles: false, physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), - itemCount: displayKeys.length, + itemCount: watchedKeys.length, onReorder: (oldIndex, newIndex) => sensorsProvider.reorderSensors(oldIndex, newIndex), - itemBuilder: (context, index) => - buildSensorCard(displayKeys[index], index), + itemBuilder: (context, index) => buildSensorCard( + watchedKeys[index], + index: index, + totalCount: watchedKeys.length, + isWatchedCard: true, + ), ) : ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), children: [ - for (var i = 0; i < displayKeys.length; i++) - buildSensorCard(displayKeys[i], i), + if (selfDisplayKey != null) + buildSensorCard( + selfDisplayKey, + index: 0, + totalCount: watchedKeys.isEmpty ? 1 : 2, + isWatchedCard: false, + ), + if (selfDisplayKey != null && watchedKeys.isEmpty) + _SelfOnlySensorsCta( + onAddSensor: () => _showAddSensorSheet(context), + ), + if (watchedKeys.isNotEmpty) + ReorderableListView.builder( + shrinkWrap: true, + buildDefaultDragHandles: false, + physics: const NeverScrollableScrollPhysics(), + itemCount: watchedKeys.length, + onReorder: (oldIndex, newIndex) => sensorsProvider + .reorderSensors(oldIndex, newIndex), + itemBuilder: (context, index) => buildSensorCard( + watchedKeys[index], + index: index, + totalCount: watchedKeys.length, + isWatchedCard: true, + ), + ), ], ), ); @@ -1011,6 +1062,81 @@ class _EmptySensorsStateState extends State<_EmptySensorsState> { } } +class _SelfOnlySensorsCta extends StatelessWidget { + final VoidCallback onAddSensor; + + const _SelfOnlySensorsCta({required this.onAddSensor}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.fromLTRB(18, 18, 18, 20), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + Icons.add_chart_rounded, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Add another device', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Bring in weather stations, repeaters, or other devices to watch them here.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + height: 1.35, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.icon( + onPressed: onAddSensor, + icon: const Icon(Icons.add, size: 18), + label: const Text('Choose'), + ), + ), + ], + ), + ); + } +} + IconData _typeIcon(Contact contact) { if (contact.isSensor) { return Icons.sensors; diff --git a/lib/services/bthome_met_history.dart b/lib/services/bthome_met_history.dart index 69aa1c0..2758535 100644 --- a/lib/services/bthome_met_history.dart +++ b/lib/services/bthome_met_history.dart @@ -110,9 +110,44 @@ class BTHomeMetHistoryParser { } } +bool _isTruthyCapabilityValue(Object? value) { + if (value is num) { + return value > 0; + } + if (value is bool) { + return value; + } + return false; +} + +bool _hasBTHomeMetCapability(Contact? contact) { + final extraSensorData = contact?.telemetry?.extraSensorData; + if (extraSensorData == null) { + return false; + } + + // MET history is only enabled when the node advertises an explicit + // capability marker on channel 1. Accept a small set of channel-1 marker + // keys so the app remains tolerant while firmware-side encoding settles. + const capabilityKeys = [ + 'met_capability', + 'met_capability_1', + 'generic_sensor_1', + 'light_level_1', + ]; + + for (final key in capabilityKeys) { + if (_isTruthyCapabilityValue(extraSensorData[key])) { + return true; + } + } + + return false; +} + List bTHomeMetMeasurementsForContact(Contact? contact) { final telemetry = contact?.telemetry; - if (telemetry == null) { + if (telemetry == null || !_hasBTHomeMetCapability(contact)) { return const []; } diff --git a/lib/services/cayenne_lpp_parser.dart b/lib/services/cayenne_lpp_parser.dart index 3dc5e0a..f1de915 100644 --- a/lib/services/cayenne_lpp_parser.dart +++ b/lib/services/cayenne_lpp_parser.dart @@ -88,10 +88,13 @@ class CayenneLppParser { LatLng? gpsLocation; double? batteryPercentage; double? batteryMilliVolts; + double? deferredBatteryMilliVolts; + double? deferredBatteryPercentage; double? temperature; double? humidity; double? pressure; final extraSensorData = {}; + bool sawNonSelfChannel = false; int fieldCount = 0; while (reader.hasRemaining) { @@ -112,6 +115,9 @@ class CayenneLppParser { final channel = reader.readByte(); debugPrint(' Channel: $channel'); + if (channel != _selfTelemetryChannel) { + sawNonSelfChannel = true; + } final type = reader.readByte(); debugPrint( @@ -136,7 +142,7 @@ class CayenneLppParser { final value = rawValue / 100.0; debugPrint(' Analog Input (raw): $rawValue'); debugPrint(' Analog Input (volts): ${value}V'); - if (_isBatteryChannel(channel)) { + if (_isDedicatedBatteryChannel(channel)) { batteryMilliVolts = value * 1000; batteryPercentage = _calculateBatteryPercentage(value); extraSensorData[_sourceChannelKey('battery')] = channel; @@ -144,6 +150,10 @@ class CayenneLppParser { debugPrint( ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', ); + } else if (_isDeferredBatteryChannel(channel)) { + deferredBatteryMilliVolts = value * 1000; + deferredBatteryPercentage = _calculateBatteryPercentage(value); + extraSensorData['analog_input_$channel'] = value; } else { extraSensorData['analog_input_$channel'] = value; } @@ -237,7 +247,7 @@ class CayenneLppParser { final value = rawValue / 100.0; debugPrint(' Voltage (raw): $rawValue'); debugPrint(' Voltage: ${value}V'); - if (_isBatteryChannel(channel)) { + if (_isDedicatedBatteryChannel(channel)) { batteryMilliVolts = value * 1000; batteryPercentage = _calculateBatteryPercentage(value); extraSensorData[_sourceChannelKey('battery')] = channel; @@ -245,6 +255,10 @@ class CayenneLppParser { debugPrint( ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', ); + } else if (_isDeferredBatteryChannel(channel)) { + deferredBatteryMilliVolts = value * 1000; + deferredBatteryPercentage = _calculateBatteryPercentage(value); + extraSensorData['voltage_$channel'] = value; } else { extraSensorData['voltage_$channel'] = value; } @@ -327,7 +341,7 @@ class CayenneLppParser { case _lppPercentage: final value = reader.readByte().toDouble(); debugPrint(' Percentage: $value%'); - if (_isBatteryChannel(channel)) { + if (_isDedicatedBatteryChannel(channel) || _isDeferredBatteryChannel(channel)) { batteryPercentage = value; extraSensorData[_sourceChannelKey('battery')] = channel; } else { @@ -631,6 +645,21 @@ class CayenneLppParser { } } + if (batteryMilliVolts == null && + batteryPercentage == null && + deferredBatteryMilliVolts != null && + deferredBatteryPercentage != null && + !sawNonSelfChannel) { + batteryMilliVolts = deferredBatteryMilliVolts; + batteryPercentage = deferredBatteryPercentage; + extraSensorData[_sourceChannelKey('battery')] = _selfTelemetryChannel; + extraSensorData[_sourceChannelKey('voltage')] = _selfTelemetryChannel; + debugPrint( + ' Promoted self-channel voltage to battery: ' + '${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', + ); + } + debugPrint(' Parsed $fieldCount fields'); debugPrint(' ✅ [CayenneLPP] Parsing complete'); debugPrint( @@ -677,8 +706,10 @@ class CayenneLppParser { return ((voltage - 3.0) / 1.2) * 100.0; } - static bool _isBatteryChannel(int channel) => - channel == 0 || channel == _selfTelemetryChannel; + static bool _isDedicatedBatteryChannel(int channel) => channel == 0; + + static bool _isDeferredBatteryChannel(int channel) => + channel == _selfTelemetryChannel; static int _readUInt32BE(BufferReader reader) { final bytes = reader.readBytes(4); diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 6ff008e..4e239c5 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -513,7 +513,6 @@ class ContactTile extends StatelessWidget { showModalBottomSheet( context: context, isScrollControlled: true, - showDragHandle: true, backgroundColor: Colors.transparent, builder: (sheetContext) => _ContactActionSheet( contact: contact, @@ -1252,10 +1251,21 @@ class _ContactActionSheetState extends State<_ContactActionSheet> { child: Material( color: colorScheme.surface, child: SingleChildScrollView( - padding: EdgeInsets.fromLTRB(16, 8, 16, 16 + bottomInset), + padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomInset), child: Column( mainAxisSize: MainAxisSize.min, children: [ + Center( + child: Container( + width: 44, + height: 4, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + const SizedBox(height: 12), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1363,17 +1373,26 @@ class _ContactActionSheetState extends State<_ContactActionSheet> { const SizedBox(height: 20), LayoutBuilder( builder: (context, constraints) { - final columnCount = widget.primaryActions.length <= 1 - ? 1 - : widget.primaryActions.length == 2 - ? 2 - : 3; + const spacing = 12.0; + const minTileWidth = 104.0; + final actionCount = widget.primaryActions.length; + final maxColumnsByWidth = + ((constraints.maxWidth + spacing) / + (minTileWidth + spacing)) + .floor() + .clamp(1, 4); + var columnCount = actionCount.clamp(1, maxColumnsByWidth); + if (actionCount > 3 && + columnCount > 2 && + actionCount % columnCount == 1) { + columnCount -= 1; + } final itemWidth = - (constraints.maxWidth - (12 * (columnCount - 1))) / + (constraints.maxWidth - (spacing * (columnCount - 1))) / columnCount; return Wrap( - spacing: 12, - runSpacing: 12, + spacing: spacing, + runSpacing: spacing, children: [ for (final action in widget.primaryActions) SizedBox( diff --git a/lib/widgets/messages/recipient_selector_sheet.dart b/lib/widgets/messages/recipient_selector_sheet.dart index 15999d4..4f9e02b 100644 --- a/lib/widgets/messages/recipient_selector_sheet.dart +++ b/lib/widgets/messages/recipient_selector_sheet.dart @@ -87,6 +87,9 @@ class _RecipientSelectorSheetState extends State { return lastSeenCompare; } } else if (_sortMode == _RecipientSortMode.activity) { + if (a.isFavourite != b.isFavourite) { + return a.isFavourite ? -1 : 1; + } final unreadCompare = _unreadFor(b).compareTo(_unreadFor(a)); if (unreadCompare != 0) { return unreadCompare; @@ -147,6 +150,30 @@ class _RecipientSelectorSheetState extends State { } } + String _sectionDescription(AppLocalizations l10n, String type) { + switch (type) { + case 'channel': + return 'Broadcast lanes for nearby mesh traffic'; + case 'room': + return 'Shared spaces for ongoing team coordination'; + case 'contact': + return 'Direct people and devices you can reach'; + default: + return ''; + } + } + + String _channelSubtitle(BuildContext context, Contact channel) { + final l10n = AppLocalizations.of(context)!; + if (channel.isPublicChannel) { + return l10n.broadcastToAllNearby; + } + + final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0; + final shortKey = channel.publicKeyShort.toUpperCase(); + return '${l10n.channel} $channelIdx • $shortKey'; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -194,21 +221,45 @@ class _RecipientSelectorSheetState extends State { ), const SizedBox(height: 12), Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: Text( - l10n.selectRecipient, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w900, - letterSpacing: -0.5, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.selectRecipient, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith( + fontWeight: FontWeight.w900, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 4), + Text( + 'Pick a channel, room, or direct contact.', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ], ), ), const SizedBox(width: 12), - IconButton.filledTonal( - onPressed: () => Navigator.pop(context), - tooltip: l10n.close, - icon: const Icon(Icons.close_rounded), + Container( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: colorScheme.outlineVariant.withValues( + alpha: 0.18, + ), + ), + ), + child: IconButton( + onPressed: () => Navigator.pop(context), + tooltip: l10n.close, + icon: const Icon(Icons.close_rounded), + ), ), ], ), @@ -278,9 +329,7 @@ class _RecipientSelectorSheetState extends State { type: 'channel', contact: channel, title: channel.getLocalizedDisplayName(context), - subtitle: channel.isPublicChannel - ? l10n.broadcastToAllNearby - : '${l10n.channel} ${channel.publicKey[1]}', + subtitle: _channelSubtitle(context, channel), unreadCount: _unreadFor(channel), isSelected: _isSelected('channel', channel), onTap: () { @@ -511,6 +560,13 @@ class _RecipientSelectorSheetState extends State { ), ], ), + const SizedBox(height: 8), + Text( + _sectionDescription(AppLocalizations.of(context)!, type), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), const SizedBox(height: 12), if (children.isEmpty) Padding( @@ -685,14 +741,42 @@ class _RecipientSelectorSheetState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w800, - letterSpacing: -0.2, - ), + Row( + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith( + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + ), + ), + ), + if (contact.isPublicChannel) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Public', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: accentColor, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ], ), const SizedBox(height: 4), Text( diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart index 935565b..86ec616 100644 --- a/lib/widgets/sensors/sensor_telemetry_card.dart +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:latlong2/latlong.dart'; @@ -547,12 +548,15 @@ SensorMetricCardData? _buildOptionPreviewCardData( fieldKey: fieldKey, icon: Icons.explore_outlined, label: label, - value: previewValue, + value: degrees == null + ? previewValue + : '${_formatPreviewNumber(degrees, maxFractionDigits: 0)}°', secondaryValue: degrees == null ? null : _previewFormatCardinalDirection(degrees), accent: const Color(0xFF8A5A44), channel: metricKey.channel, + directionDegrees: degrees, ); case 'unixtime': return SensorMetricCardData( @@ -1007,6 +1011,13 @@ String _formatPreviewNumber(num value, {int maxFractionDigits = 2}) { ? math.min(maxFractionDigits, 1) : maxFractionDigits; final text = value.toStringAsFixed(digits); + return _trimFractionZeros(text); +} + +String _trimFractionZeros(String text) { + if (!text.contains('.')) { + return text; + } return text.replaceFirst(RegExp(r'\.?0+$'), ''); } @@ -1030,6 +1041,8 @@ class SensorTelemetryCard extends StatelessWidget { final Future Function()? onRefresh; final VoidCallback? onCustomize; final Future Function(Contact contact)? onShowMetHistory; + final Future Function()? onMoveUp; + final Future Function()? onMoveDown; final EdgeInsetsGeometry margin; final String emptyMetricsMessage; final Map labelOverrides; @@ -1045,6 +1058,8 @@ class SensorTelemetryCard extends StatelessWidget { this.onRefresh, this.onCustomize, this.onShowMetHistory, + this.onMoveUp, + this.onMoveDown, this.margin = const EdgeInsets.only(bottom: 16), this.emptyMetricsMessage = 'All fields are hidden. Use Visible fields to choose what to show.', @@ -1055,6 +1070,9 @@ class SensorTelemetryCard extends StatelessWidget { onRefresh != null || onCustomize != null || onRemove != null || + onMoveUp != null || + onMoveDown != null || + _rawTelemetryHex(contact?.telemetry) != null || (contact != null && onShowMetHistory != null && supportsBTHomeMetHistory(contact)); @@ -1136,21 +1154,21 @@ class SensorTelemetryCard extends StatelessWidget { ), if (state == SensorRefreshState.refreshing) _InlineStateMeta( - label: l10n.refreshing, color: Color(0xFF266AC2), spinning: true, + tooltip: l10n.refreshing, ), if (state == SensorRefreshState.success) const _InlineStateMeta( - label: 'Updated', color: Color(0xFF218B63), icon: Icons.check_circle, + tooltip: 'Updated', ), if (state == SensorRefreshState.unavailable) _InlineStateMeta( - label: l10n.unavailable, color: Color(0xFFB13B55), icon: Icons.error_outline, + tooltip: l10n.unavailable, ), ], ), @@ -1163,6 +1181,26 @@ class SensorTelemetryCard extends StatelessWidget { onSelected: (value) async { if (value == 'refresh' && onRefresh != null) { await onRefresh!(); + } else if (value == 'move_up' && onMoveUp != null) { + await onMoveUp!(); + } else if (value == 'move_down' && onMoveDown != null) { + await onMoveDown!(); + } else if (value == 'copy_raw') { + final rawTelemetry = _rawTelemetryHex( + contact?.telemetry, + ); + if (rawTelemetry != null && context.mounted) { + await Clipboard.setData( + ClipboardData(text: rawTelemetry), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Raw response copied'), + ), + ); + } + } } else if (value == 'remove' && onRemove != null) { await onRemove!(); } else if (value == 'customize' && onCustomize != null) { @@ -1183,6 +1221,30 @@ class SensorTelemetryCard extends StatelessWidget { ), ); } + if (onMoveUp != null) { + items.add( + const PopupMenuItem( + value: 'move_up', + child: Text('Move up'), + ), + ); + } + if (onMoveDown != null) { + items.add( + const PopupMenuItem( + value: 'move_down', + child: Text('Move down'), + ), + ); + } + if (_rawTelemetryHex(contact?.telemetry) != null) { + items.add( + const PopupMenuItem( + value: 'copy_raw', + child: Text('Copy raw response'), + ), + ); + } if (onCustomize != null) { items.add( PopupMenuItem( @@ -1811,10 +1873,11 @@ class SensorTelemetryCard extends StatelessWidget { fieldKey: _extraFieldKey(rawKey), icon: Icons.explore_outlined, label: label, - value: '${_formatNumber(degrees, maxFractionDigits: 0)} deg', + value: '${_formatNumber(degrees, maxFractionDigits: 0)}°', secondaryValue: _formatCardinalDirection(degrees), accent: const Color(0xFF8A5A44), channel: metricKey.channel, + directionDegrees: degrees, ); case 'rotation': @@ -2054,7 +2117,7 @@ class SensorTelemetryCard extends StatelessWidget { ? math.min(maxFractionDigits, 1) : maxFractionDigits; final text = value.toStringAsFixed(digits); - return text.replaceFirst(RegExp(r'\.?0+$'), ''); + return _trimFractionZeros(text); } _Vector3? _asVector3(dynamic value) { @@ -2118,31 +2181,28 @@ class SensorTelemetryCard extends StatelessWidget { } class _InlineStateMeta extends StatelessWidget { - final String label; final Color color; final IconData? icon; final bool spinning; + final String? tooltip; const _InlineStateMeta({ - required this.label, required this.color, this.icon, this.spinning = false, + this.tooltip, }); @override Widget build(BuildContext context) { - return Container( + final badge = 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( + child: spinning + ? SizedBox( width: 11, height: 11, child: CircularProgressIndicator( @@ -2150,18 +2210,20 @@ class _InlineStateMeta extends StatelessWidget { 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( + : Icon( + icon ?? Icons.info_outline, + size: 11, color: color, - fontWeight: FontWeight.w700, ), - ), - ], - ), + ); + + if (tooltip == null || tooltip!.isEmpty) { + return badge; + } + + return Tooltip( + message: tooltip!, + child: badge, ); } } @@ -2292,7 +2354,40 @@ class SensorMetricTile extends StatelessWidget { borderRadius: BorderRadius.circular(22), border: Border.all(color: data.accent.withValues(alpha: 0.14)), ), - child: data.mapLocation == null || !allowMapPreview + child: data.directionDegrees != null + ? Stack( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _DirectionDial( + accent: data.accent, + degrees: data.directionDegrees!, + cardinal: data.secondaryValue ?? '', + compact: true, + ), + const SizedBox(width: 10), + Expanded( + child: _MetricText(data: data, keyPrefix: keyPrefix), + ), + ], + ), + if (data.channel != null) + Positioned( + right: 0, + bottom: 0, + child: Text( + 'ch${data.channel}', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: data.accent.withValues(alpha: 0.5), + ), + ), + ), + ], + ) + : data.mapLocation == null || !allowMapPreview ? Stack( children: [ Row( @@ -2456,6 +2551,10 @@ class _MetricText extends StatelessWidget { @override Widget build(BuildContext context) { + if (data.directionDegrees != null) { + return _DirectionMetricText(data: data); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -2495,12 +2594,138 @@ class _MetricText extends StatelessWidget { } } +class _DirectionMetricText extends StatelessWidget { + final SensorMetricCardData data; + + const _DirectionMetricText({required this.data}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final directionLabel = data.secondaryValue ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + data.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith( + color: data.accent, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: 8, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + data.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + if (directionLabel.isNotEmpty) + Text( + directionLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + color: data.accent.withValues(alpha: 0.9), + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + ], + ), + ], + ); + } +} + +class _DirectionDial extends StatelessWidget { + final Color accent; + final double degrees; + final String cardinal; + final bool compact; + + const _DirectionDial({ + required this.accent, + required this.degrees, + required this.cardinal, + this.compact = false, + }); + + @override + Widget build(BuildContext context) { + final normalized = ((degrees % 360) + 360) % 360; + final size = compact ? 34.0 : 44.0; + final innerSize = compact ? 24.0 : 30.0; + final arrowSize = compact ? 14.0 : 16.0; + final northTop = compact ? 1.0 : 3.0; + + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: size, + height: size, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.08), + shape: BoxShape.circle, + border: Border.all(color: accent.withValues(alpha: 0.2)), + ), + ), + Container( + width: innerSize, + height: innerSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: accent.withValues(alpha: 0.2)), + ), + ), + Transform.rotate( + angle: normalized * math.pi / 180, + child: Icon( + Icons.navigation_rounded, + size: arrowSize, + color: accent, + ), + ), + Positioned( + top: northTop, + child: Text( + 'N', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: accent.withValues(alpha: 0.65), + fontWeight: FontWeight.w800, + fontSize: compact ? 7.5 : 9, + ), + ), + ), + ], + ), + ); + } +} + class SensorMetricCardData { final String fieldKey; final IconData icon; final String label; final String value; final String? secondaryValue; + final double? directionDegrees; final Color accent; final bool wide; final LatLng? mapLocation; @@ -2512,6 +2737,7 @@ class SensorMetricCardData { required this.label, required this.value, this.secondaryValue, + this.directionDegrees, required this.accent, this.wide = false, this.mapLocation, @@ -2543,13 +2769,20 @@ class _RgbColor { } const String _telemetrySourceChannelPrefix = '__source_channel:'; +const String _rawTelemetryHexKey = '__raw_lpp_hex'; String _extraFieldKey(String label) { return 'extra:$label'; } bool _isTelemetryMetadataKey(String key) { - return key.startsWith(_telemetrySourceChannelPrefix); + return key.startsWith(_telemetrySourceChannelPrefix) || + key == _rawTelemetryHexKey; +} + +String? _rawTelemetryHex(ContactTelemetry? telemetry) { + final value = telemetry?.extraSensorData?[_rawTelemetryHexKey]; + return value is String && value.trim().isNotEmpty ? value : null; } String _telemetrySourceChannelKey(String fieldKey) { diff --git a/test/providers/contacts_provider_test.dart b/test/providers/contacts_provider_test.dart index 740dbb3..c9bb1ad 100644 --- a/test/providers/contacts_provider_test.dart +++ b/test/providers/contacts_provider_test.dart @@ -907,6 +907,56 @@ void main() { expect(restored.savedGroupsForSection('teamMembers'), hasLength(1)); }, ); + + test( + 'retains cached telemetry when sync re-adds a contact without telemetry', + () async { + final key = createPublicKey(141); + final contact = createContact( + key: key, + type: ContactType.sensor, + name: 'Sparse Sync Sensor', + ).copyWith( + telemetry: ContactTelemetry( + temperature: 18.5, + humidity: 64.0, + timestamp: DateTime(2026, 3, 22, 8, 30), + ), + ); + + provider.addOrUpdateContact(contact); + await provider.prepareForDeviceContactSync(); + + provider.addOrUpdateContact( + createContact( + key: key, + type: ContactType.sensor, + name: 'Sparse Sync Sensor', + ), + ); + + final restored = provider.findContactByKey(key); + expect(restored, isNotNull); + expect(restored!.telemetry, isNotNull); + expect(restored.telemetry!.temperature, closeTo(18.5, 0.01)); + expect(restored.telemetry!.humidity, closeTo(64.0, 0.01)); + }, + ); + + test('keeps self telemetry cached across reconnect for the same device', () async { + final selfKey = createPublicKey(200); + + await provider.initialize(devicePublicKey: selfKey); + provider.updateTelemetry( + selfKey.sublist(0, 6), + CayenneLppParser.createTemperatureData(23.5, channel: 1), + ); + + await provider.prepareForDeviceContactSync(devicePublicKey: selfKey); + + expect(provider.selfTelemetry, isNotNull); + expect(provider.selfTelemetry!.temperature, closeTo(23.5, 0.1)); + }); }); group('ContactsProvider self telemetry', () { @@ -935,6 +985,24 @@ void main() { }, ); }); + + test('stores raw telemetry hex in metadata for verification', () async { + SharedPreferences.setMockInitialValues({}); + final provider = ContactsProvider(); + final telemetryKey = createPublicKey(150); + final contact = createContact( + key: telemetryKey, + type: ContactType.chat, + name: 'Telemetry Node', + ); + provider.addOrUpdateContact(contact); + + final payload = Uint8List.fromList([0x01, 0x67, 0x00, 0xEB]); + provider.updateTelemetry(telemetryKey.sublist(0, 6), payload); + + final updated = provider.findContactByKey(telemetryKey)!; + expect(updated.telemetry?.extraSensorData?['__raw_lpp_hex'], '01 67 00 eb'); + }); } String publicKeyHex(Uint8List publicKey) { diff --git a/test/providers/sensors_provider_test.dart b/test/providers/sensors_provider_test.dart index 1e96291..9412570 100644 --- a/test/providers/sensors_provider_test.dart +++ b/test/providers/sensors_provider_test.dart @@ -356,6 +356,43 @@ void main() { expect(connectionProvider.pingCalls, 2); }); + test('refreshDueSensors refreshes self every minute', () async { + SharedPreferences.setMockInitialValues({}); + final selfKey = Uint8List(32)..[0] = 0x66; + final contactsProvider = ContactsProvider(); + await contactsProvider.initialize(devicePublicKey: selfKey); + final connectionProvider = _FakeConnectionProvider( + isConnected: true, + publicKey: selfKey, + selfName: 'My Device', + ); + final start = DateTime(2026, 3, 22, 9, 0); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + + await provider.refreshDueSensors( + now: start, + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 1); + + await provider.refreshDueSensors( + now: start.add(const Duration(seconds: 30)), + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 1); + + await provider.refreshDueSensors( + now: start.add(const Duration(minutes: 1)), + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ); + expect(connectionProvider.pingCalls, 2); + }); + test( 'addSensor includes available extra telemetry fields by default', () async { @@ -411,4 +448,32 @@ void main() { expect(selfContact!.telemetry, isNotNull); expect(selfContact.telemetry!.temperature, closeTo(19.5, 0.1)); }); + + test( + 'displaySelfKey still returns self when watched sensors exist', + () async { + SharedPreferences.setMockInitialValues({}); + final selfKey = Uint8List(32)..[0] = 0x66; + final sensor = buildSensorContact(firstByte: 0x44, name: 'Weather'); + final contactsProvider = ContactsProvider(); + await contactsProvider.initialize(devicePublicKey: selfKey); + + final connectionProvider = _FakeConnectionProvider( + isConnected: true, + publicKey: selfKey, + selfName: 'My Device', + ); + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(sensor); + + expect( + provider.displaySelfKey( + contactsProvider: contactsProvider, + connectionProvider: connectionProvider, + ), + selfKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(), + ); + }, + ); } diff --git a/test/screens/discovery_screen_test.dart b/test/screens/discovery_screen_test.dart new file mode 100644 index 0000000..10c5b36 --- /dev/null +++ b/test/screens/discovery_screen_test.dart @@ -0,0 +1,190 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_sar_app/l10n/app_localizations.dart'; +import 'package:meshcore_sar_app/models/device_info.dart' as device_info; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/screens/discovery_screen.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeConnectionProvider extends ConnectionProvider { + _FakeConnectionProvider({required bool isConnected}) + : _isConnected = isConnected; + + final bool _isConnected; + final List discoveredAdvertTypes = []; + + @override + device_info.DeviceInfo get deviceInfo => device_info.DeviceInfo( + connectionState: _isConnected + ? device_info.ConnectionState.connected + : device_info.ConnectionState.disconnected, + ); + + @override + Future discoverNodeType({ + required int advertType, + bool prefixOnly = false, + int since = 0, + }) async { + discoveredAdvertTypes.add(advertType); + } +} + +Future _pumpDiscoveryScreen( + WidgetTester tester, { + ContactsProvider? contactsProvider, + ConnectionProvider? connectionProvider, +}) async { + final resolvedContactsProvider = contactsProvider ?? ContactsProvider(); + final resolvedConnectionProvider = + connectionProvider ?? _FakeConnectionProvider(isConnected: true); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: resolvedContactsProvider, + ), + ChangeNotifierProvider.value( + value: resolvedConnectionProvider, + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const DiscoveryScreen(), + ), + ), + ); + + await tester.pumpAndSettle(); +} + +Uint8List _publicKey(int seed) { + final bytes = Uint8List(32); + bytes[0] = seed; + bytes[1] = seed + 1; + bytes[2] = seed + 2; + return bytes; +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets( + 'discovery actions are shown in the summary card instead of overflow menu', + (tester) async { + tester.view.physicalSize = const Size(375, 812); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final connectionProvider = _FakeConnectionProvider(isConnected: true); + + await _pumpDiscoveryScreen( + tester, + connectionProvider: connectionProvider, + ); + + expect(tester.takeException(), isNull); + expect(find.text('Discover repeaters'), findsOneWidget); + expect(find.text('Discover sensors'), findsOneWidget); + expect(find.text('Resolve all'), findsOneWidget); + expect(find.text('Clear all'), findsOneWidget); + expect(find.text('Search discovered nodes'), findsNothing); + expect(find.byIcon(Icons.more_vert), findsNothing); + expect( + find.text( + 'Use the discovery actions above to find repeaters and sensors on the mesh.', + ), + findsOneWidget, + ); + + await tester.tap(find.text('Discover repeaters')); + await tester.pump(); + + expect(connectionProvider.discoveredAdvertTypes, [2]); + expect(find.text('Repeater discovery sent'), findsOneWidget); + }, + ); + + testWidgets('search and inline type filters narrow discovered nodes', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 1400); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + + final contactsProvider = ContactsProvider() + ..addOrUpdatePendingAdvertMetadata( + publicKey: _publicKey(0x10), + typeValue: 2, + advName: 'Relay Alpha', + ) + ..addOrUpdatePendingAdvertMetadata( + publicKey: _publicKey(0x20), + typeValue: 4, + advName: 'WX Station', + ) + ..addOrUpdatePendingAdvertMetadata( + publicKey: _publicKey(0x30), + typeValue: 3, + advName: 'Ops Room', + ); + + await _pumpDiscoveryScreen( + tester, + contactsProvider: contactsProvider, + connectionProvider: _FakeConnectionProvider(isConnected: true), + ); + + expect(find.text('Search discovered nodes'), findsOneWidget); + expect(find.text('All'), findsOneWidget); + expect(find.text('Repeaters'), findsOneWidget); + expect(find.text('Sensors'), findsOneWidget); + expect(find.text('Others'), findsOneWidget); + + expect(find.text('Relay Alpha'), findsOneWidget); + expect(find.text('WX Station'), findsOneWidget); + expect(find.text('Ops Room'), findsOneWidget); + + await tester.tap(find.text('Sensors')); + await tester.pumpAndSettle(); + + expect(find.text('Relay Alpha'), findsNothing); + expect(find.text('WX Station'), findsOneWidget); + expect(find.text('Ops Room'), findsNothing); + expect(find.text('Discovered nodes (1/3)'), findsOneWidget); + + await tester.tap(find.text('Others')); + await tester.pumpAndSettle(); + + expect(find.text('Relay Alpha'), findsNothing); + expect(find.text('WX Station'), findsNothing); + expect(find.text('Ops Room'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'relay'); + await tester.pumpAndSettle(); + + expect(find.text('No matches'), findsOneWidget); + + await tester.tap(find.text('All')); + await tester.pumpAndSettle(); + + expect(find.text('Relay Alpha'), findsOneWidget); + expect(find.text('Ops Room'), findsNothing); + + await tester.enterText(find.byType(TextField), 'ops'); + await tester.pumpAndSettle(); + + expect(find.text('Relay Alpha'), findsNothing); + expect(find.text('WX Station'), findsNothing); + expect(find.text('Ops Room'), findsOneWidget); + expect(find.text('Discovered nodes (1/3)'), findsOneWidget); + }); +} diff --git a/test/services/bthome_met_history_test.dart b/test/services/bthome_met_history_test.dart index 9331b84..58b6a00 100644 --- a/test/services/bthome_met_history_test.dart +++ b/test/services/bthome_met_history_test.dart @@ -38,7 +38,12 @@ void main() { telemetry: ContactTelemetry( temperature: 20.1, humidity: 52, - extraSensorData: const {'speed_2': 3.1, 'gust_2': 4.8, 'rain_2': 12.3}, + extraSensorData: const { + 'generic_sensor_1': 1, + 'speed_2': 3.1, + 'gust_2': 4.8, + 'rain_2': 12.3, + }, timestamp: DateTime(2026, 3, 21, 12), ), ); @@ -52,4 +57,28 @@ void main() { ]); expect(supportsBTHomeMetHistory(contact), isTrue); }); + + test('does not enable BTHome MET history without channel 1 capability', () { + 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), isEmpty); + expect(supportsBTHomeMetHistory(contact), isFalse); + }); } diff --git a/test/services/cayenne_lpp_parser_test.dart b/test/services/cayenne_lpp_parser_test.dart index e429305..814f523 100644 --- a/test/services/cayenne_lpp_parser_test.dart +++ b/test/services/cayenne_lpp_parser_test.dart @@ -488,6 +488,39 @@ void main() { }, ); + test('weather station payload keeps generic percentage and does not invent battery from ch1 voltage', () { + final payload = Uint8List.fromList([ + 0x01, 0x74, 0x00, 0x00, + 0x02, 0x78, 0x64, + 0x02, 0x73, 0x24, 0x32, + 0x02, 0x65, 0x5A, 0x50, + 0x02, 0x8A, 0xFF, 0xE4, + 0x02, 0x74, 0x01, 0xE0, + 0x02, 0x9D, 0x00, + 0x02, 0x68, 0x76, + 0x02, 0x81, 0x01, 0x4A, + 0x02, 0x89, 0x01, 0xB8, + 0x02, 0x67, 0x00, 0x2D, + 0x02, 0xAD, 0x0A, + 0x02, 0x84, 0x00, 0x56, + 0x02, 0x8B, 0x00, 0xB3, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ]); + + final decoded = CayenneLppParser.parse(payload); + + expect(decoded.batteryPercentage, isNull); + expect(decoded.batteryMilliVolts, isNull); + expect(decoded.temperature, closeTo(4.5, 0.1)); + expect(decoded.humidity, closeTo(59.0, 0.1)); + expect(decoded.pressure, closeTo(926.6, 0.1)); + expect(decoded.extraSensorData!['voltage_1'], closeTo(0.0, 0.001)); + expect(decoded.extraSensorData!['percentage_2'], equals(100.0)); + expect(decoded.extraSensorData!['uv_2'], equals(1.0)); + expect(decoded.extraSensorData!['rain_2'], closeTo(17.9, 0.1)); + expect(decoded.extraSensorData!.containsKey('__source_channel:battery'), isFalse); + }); + test('unknown sensor type is skipped gracefully', () { final buffer = ByteData(5); buffer.setUint8(0, 0); diff --git a/test/widgets/sensor_telemetry_card_test.dart b/test/widgets/sensor_telemetry_card_test.dart index 9e2500a..0c767a1 100644 --- a/test/widgets/sensor_telemetry_card_test.dart +++ b/test/widgets/sensor_telemetry_card_test.dart @@ -1,10 +1,12 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meshcore_sar_app/l10n/app_localizations.dart'; import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/providers/sensors_provider.dart'; +import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart'; import 'package:meshcore_sar_app/widgets/sensors/sensor_telemetry_card.dart'; void main() { @@ -190,6 +192,139 @@ void main() { expect(find.text('12.3 mm'), findsOneWidget); }); + testWidgets('renders generic percentage separately from UV for weather payload', (tester) async { + tester.view.physicalSize = const Size(1600, 2600); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + final publicKey = Uint8List(32); + publicKey[0] = 0x49; + final payload = Uint8List.fromList([ + 0x01, 0x74, 0x00, 0x00, + 0x02, 0x78, 0x64, + 0x02, 0x73, 0x24, 0x31, + 0x02, 0x65, 0x67, 0xDE, + 0x02, 0x8A, 0xFF, 0xE6, + 0x02, 0x74, 0x01, 0xE0, + 0x02, 0x9D, 0x00, + 0x02, 0x68, 0x76, + 0x02, 0x81, 0x00, 0xDC, + 0x02, 0x89, 0x01, 0x36, + 0x02, 0x67, 0x00, 0x2F, + 0x02, 0xAD, 0x0A, + 0x02, 0x84, 0x00, 0x50, + 0x02, 0x8B, 0x00, 0xB3, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ]); + + final contact = Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: CayenneLppParser.parse(payload), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const { + 'temperature', + 'humidity', + 'pressure', + 'extra:percentage_2', + 'extra:dew_2', + 'extra:speed_2', + 'extra:gust_2', + 'extra:uv_2', + 'extra:direction_2', + 'extra:rain_2', + }, + fieldSpans: sensorFullWidthFieldSpans(const { + 'temperature', + 'humidity', + 'pressure', + 'extra:percentage_2', + 'extra:dew_2', + 'extra:speed_2', + 'extra:gust_2', + 'extra:uv_2', + 'extra:direction_2', + 'extra:rain_2', + }), + ), + ), + ), + ), + ); + + expect(find.byKey(const ValueKey('sensor_metric_extra:percentage_2')), findsOneWidget); + expect(find.byKey(const ValueKey('sensor_metric_extra:uv_2')), findsOneWidget); + expect(find.text('100%'), findsOneWidget); + expect(find.text('1%'), findsNothing); + expect(find.text('1'), findsOneWidget); + expect(find.text('80°'), findsOneWidget); + expect(find.text('8°'), findsNothing); + }); + + testWidgets('renders direction metric with compact compass layout', ( + tester, + ) async { + final publicKey = Uint8List(32); + publicKey[0] = 0x47; + final contact = Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + extraSensorData: const {'direction_2': 111.0}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'extra:direction_2'}, + fieldSpans: sensorFullWidthFieldSpans(const {'extra:direction_2'}), + ), + ), + ), + ); + + expect(find.text('Direction'), findsOneWidget); + expect(find.text('111°'), findsOneWidget); + expect(find.text('E'), findsOneWidget); + expect(find.byIcon(Icons.navigation_rounded), findsOneWidget); + }); + testWidgets('long pressing a telemetry bubble triggers refresh', ( tester, ) async { @@ -221,4 +356,92 @@ void main() { expect(refreshCount, 1); }); + + testWidgets('overflow menu exposes move actions', (tester) async { + final contact = buildContact(); + var moveUpCount = 0; + var moveDownCount = 0; + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'temperature'}, + fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}), + onMoveUp: () async { + moveUpCount += 1; + }, + onMoveDown: () async { + moveDownCount += 1; + }, + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + expect(find.text('Move up'), findsOneWidget); + expect(find.text('Move down'), findsOneWidget); + + await tester.tap(find.text('Move down')); + await tester.pumpAndSettle(); + + expect(moveUpCount, 0); + expect(moveDownCount, 1); + }); + + testWidgets('overflow menu copies raw response', (tester) async { + final publicKey = Uint8List(32); + publicKey[0] = 0x48; + final contact = Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + temperature: 21.5, + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + extraSensorData: const {'__raw_lpp_hex': '01 67 00 d7'}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const {'temperature'}, + fieldSpans: sensorFullWidthFieldSpans(const {'temperature'}), + ), + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + expect(find.text('Copy raw response'), findsOneWidget); + + await tester.tap(find.text('Copy raw response')); + await tester.pump(); + + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + expect(clipboardData?.text, '01 67 00 d7'); + expect(find.text('Raw response copied'), findsOneWidget); + }); }