import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'dart:typed_data'; import '../l10n/app_localizations.dart'; import '../models/contact.dart'; import '../providers/connection_provider.dart'; import '../providers/contacts_provider.dart'; import '../services/mesh_map_nodes_service.dart'; import '../widgets/compact_signal_indicator.dart' show SignalMetric; enum _DiscoveryListFilter { all, repeaters, sensors, others } class DiscoveryScreen extends StatefulWidget { final bool autoDiscoverRepeatersOnOpen; const DiscoveryScreen({super.key, this.autoDiscoverRepeatersOnOpen = false}); @override State createState() => _DiscoveryScreenState(); } class _DiscoveryScreenState extends State { static const int _repeaterAdvertType = 2; static const int _sensorAdvertType = 4; final Set _resolvingAdvertKeys = {}; final Set _runningDiscoveryTypes = {}; final TextEditingController _searchController = TextEditingController(); bool _isResolvingAll = false; bool _hasQueuedAutoRepeaterDiscovery = false; String _searchQuery = ''; _DiscoveryListFilter _selectedFilter = _DiscoveryListFilter.all; late final Future> _cachedNodesFuture; @override void initState() { super.initState(); _cachedNodesFuture = MeshMapNodesService.loadCachedNodes( cacheTtl: MeshMapNodesService.traceCacheTtl, ); if (widget.autoDiscoverRepeatersOnOpen) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; _autoDiscoverRepeaters(); }); } } @override void dispose() { _searchController.dispose(); super.dispose(); } Future _clearAllDiscoveries() async { final pendingCount = context.read().pendingAdverts.length; if (pendingCount == 0) { return; } final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(AppLocalizations.of(context)!.clearDiscoveries), content: Text( 'Remove all $pendingCount pending discover${pendingCount == 1 ? 'y' : 'ies'} from this device?', ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(AppLocalizations.of(context)!.clearAllLabel), ), ], ), ); if (confirmed != true || !mounted) { return; } await context.read().clearPendingAdverts(); if (!mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries), ), ); } Future _discoverNodeType(int advertType) async { if (_runningDiscoveryTypes.contains(advertType)) return; setState(() { _runningDiscoveryTypes.add(advertType); }); try { await context.read().discoverNodeType( advertType: advertType, ); if (!mounted) return; final l10n = AppLocalizations.of(context)!; final label = switch (advertType) { _repeaterAdvertType => l10n.repeaterDiscoverySent, _sensorAdvertType => l10n.sensorDiscoverySent, _ => 'Discovery sent', }; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(label))); } finally { if (mounted) { setState(() { _runningDiscoveryTypes.remove(advertType); }); } } } Future _autoDiscoverRepeaters() async { if (_hasQueuedAutoRepeaterDiscovery) { return; } _hasQueuedAutoRepeaterDiscovery = true; await _discoverNodeType(_repeaterAdvertType); } Future _resolveAdvert(PendingAdvert advert) async { final keyHex = advert.publicKeyHex; if (_resolvingAdvertKeys.contains(keyHex)) return; setState(() { _resolvingAdvertKeys.add(keyHex); }); try { final connectionProvider = context.read(); final contactsProvider = context.read(); final canAddDirectly = _canAddPendingAdvertDirectly(advert); if (canAddDirectly) { final added = await _addPendingAdvertToRadio( advert, connectionProvider: connectionProvider, contactsProvider: contactsProvider, ); if (!added) { return; } } else { await connectionProvider.getContact(advert.publicKey); if (connectionProvider.error == 'Not found') { connectionProvider.clearError(); final added = await _addPendingAdvertToRadio( advert, connectionProvider: connectionProvider, contactsProvider: contactsProvider, ); if (!added) { return; } } } if ((advert.typeValue ?? 0) == _sensorAdvertType) { await connectionProvider.requestTelemetry(advert.publicKey); } } finally { if (mounted) { setState(() { _resolvingAdvertKeys.remove(keyHex); }); } } } Future _resolveAll(List adverts) async { if (_isResolvingAll || adverts.isEmpty) return; setState(() { _isResolvingAll = true; }); try { final contactsProvider = context.read(); for (final advert in adverts) { if (!mounted) break; // Skip already-resolved contacts if (contactsProvider.findContactByKey(advert.publicKey) != null) { continue; } await _resolveAdvert(advert); } } finally { if (mounted) { setState(() { _isResolvingAll = false; }); } } } String _formatRelativeTime(BuildContext context, DateTime when) { final l10n = AppLocalizations.of(context)!; final diff = DateTime.now().difference(when); if (diff.inMinutes < 1) return l10n.justNow; if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours); return l10n.daysAgo(diff.inDays); } Contact _contactFromPendingAdvert(PendingAdvert advert) { final lastAdvert = advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000); final advName = advert.advName?.trim().isNotEmpty == true ? advert.advName!.trim() : advert.shortDisplayKey; return Contact( publicKey: Uint8List.fromList(advert.publicKey), type: ContactType.fromValue(advert.typeValue ?? 0), flags: advert.flags ?? 0, outPathLen: advert.signedEncodedPathLen ?? -1, outPath: advert.paddedPathBytes == null ? Uint8List(64) : Uint8List.fromList(advert.paddedPathBytes!), advName: advName, lastAdvert: lastAdvert, advLat: advert.advLat ?? 0, advLon: advert.advLon ?? 0, lastMod: lastAdvert, ); } bool _canAddPendingAdvertDirectly(PendingAdvert advert) { return advert.publicKey.length == 32 && advert.typeValue != null && advert.typeValue != 0; } Future _addPendingAdvertToRadio( PendingAdvert advert, { required ConnectionProvider connectionProvider, required ContactsProvider contactsProvider, }) async { final contact = _contactFromPendingAdvert(advert); await connectionProvider.addOrUpdateContact(contact); final addError = connectionProvider.error; if (addError != null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Failed to add contact: $addError')), ); } return false; } contactsProvider.addOrUpdateContact( contact, devicePublicKey: connectionProvider.deviceInfo.publicKey, ); return true; } String _displayNameForAdvert( PendingAdvert advert, ContactsProvider contactsProvider, List cachedNodes, ) { final advertisedName = advert.advName?.trim(); if (advertisedName != null && advertisedName.isNotEmpty) { return advertisedName; } Contact? existingMatch; for (final contact in contactsProvider.contacts) { if (contact.publicKeyHex == advert.publicKeyHex) { existingMatch = contact; break; } } if (existingMatch != null && existingMatch.displayName.trim().isNotEmpty) { return existingMatch.displayName; } MeshMapNode? cachedMatch; for (final node in cachedNodes) { if (node.publicKey == advert.publicKeyHex.toLowerCase()) { cachedMatch = node; break; } } if (cachedMatch != null && cachedMatch.name.trim().isNotEmpty) { return cachedMatch.name; } return advert.shortDisplayKey; } IconData _iconForAdvert(PendingAdvert advert) { return switch (advert.typeValue) { _repeaterAdvertType => Icons.router_outlined, _sensorAdvertType => Icons.sensors_outlined, _ => Icons.campaign_outlined, }; } String? _typeLabelForValue(int? typeValue) { return switch (typeValue) { _repeaterAdvertType => 'Repeater', _sensorAdvertType => 'Sensor', 3 => 'Room', 1 => 'Chat', _ => null, }; } int? _resolvedTypeValueForAdvert( PendingAdvert advert, List cachedNodes, ) { 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 => _repeaterAdvertType, 4 => _sensorAdvertType, 3 => 3, 2 => 1, _ => null, }; } } 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, String? subtitle, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( displayName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.bold), ), if (subtitle != null && subtitle.isNotEmpty) ...[ const SizedBox(height: 2), Text( subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ], ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Scaffold( 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 []; 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); } } 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, 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: 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 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, ), ), ], ); }, ), ), ); } Widget _buildPendingAdvertCard( BuildContext context, { required PendingAdvert advert, required ContactsProvider contactsProvider, required bool isConnected, required bool isResolved, required List cachedNodes, required AppLocalizations l10n, }) { final isResolving = _resolvingAdvertKeys.contains(advert.publicKeyHex); final displayName = _displayNameForAdvert( advert, contactsProvider, cachedNodes, ); final typeLabel = _resolvedTypeLabelForAdvert(advert, cachedNodes); final downMetric = SignalMetric.fromValues( rssiDbm: advert.rxRssiDbm, snrDb: advert.rxSnr, ); final upMetric = SignalMetric.fromValues( rssiDbm: advert.repeaterLastRssi, snrDb: advert.repeaterLastSnr, ); final detailLines = [ '${l10n.publicKey}: ${advert.shortDisplayKey}', ]; final summaryParts = []; final battery = advert.repeaterBatteryPercent; if (battery != null) { summaryParts.add('Battery ${battery.round()}%'); } if (advert.repeaterQueueLen != null) { summaryParts.add('Queue ${advert.repeaterQueueLen}'); } if (summaryParts.isNotEmpty) { detailLines.add(summaryParts.join(' • ')); } detailLines.add( '${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}', ); return Card( margin: const EdgeInsets.only(bottom: 8), child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ CircleAvatar(child: Icon(_iconForAdvert(advert))), const SizedBox(width: 12), Expanded( child: _buildAdvertTitle( context, displayName: displayName, subtitle: typeLabel, ), ), if (downMetric != null || upMetric != null) ...[ const SizedBox(width: 12), _buildSignalSummary( context, downMetric: downMetric, upMetric: upMetric, ), ], const SizedBox(width: 8), if (isResolved) Icon( Icons.check_circle, color: Theme.of(context).colorScheme.primary, size: 24, ) else if (isResolving) const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) else IconButton( visualDensity: VisualDensity.compact, icon: const Icon(Icons.person_add_alt_1), tooltip: 'Resolve contact', onPressed: isConnected ? () => _resolveAdvert(advert) : null, ), ], ), const SizedBox(height: 10), Text( detailLines.join('\n'), style: Theme.of(context).textTheme.bodyMedium, ), ], ), ), ); } 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, SignalMetric? upMetric, }) { if (downMetric == null && upMetric == null) { return const SizedBox.shrink(); } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (downMetric != null) _buildDirectionalSignal( context, icon: Icons.south_west_rounded, metric: downMetric, ), if (downMetric != null && upMetric != null) const SizedBox(height: 6), if (upMetric != null) _buildDirectionalSignal( context, icon: Icons.north_east_rounded, metric: upMetric, ), ], ); } Widget _buildDirectionalSignal( BuildContext context, { required IconData icon, required SignalMetric metric, }) { return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon( icon, size: 11, color: Theme.of(context).colorScheme.onSurfaceVariant, ), const SizedBox(width: 3), _buildMiniSignalBars(context, metric), const SizedBox(width: 4), Text( metric.valueLabel, style: Theme.of( context, ).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700), ), ], ); } Widget _buildMiniSignalBars(BuildContext context, SignalMetric metric) { final inactive = Theme.of(context).colorScheme.outlineVariant; return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ for (var index = 0; index < 3; index++) ...[ if (index > 0) const SizedBox(width: 2), Container( width: 3, height: 6.0 + (index * 4), decoration: BoxDecoration( color: index < metric.activeBars ? metric.color : inactive, borderRadius: BorderRadius.circular(2), ), ), ], ], ); } }