From e17f24a6bc70438f4167d4cc06c7d272ad565dee Mon Sep 17 00:00:00 2001 From: Janez T Date: Tue, 17 Mar 2026 10:16:11 +0100 Subject: [PATCH] feat: View Neighbours option for repeater contacts Sends 'neighbors' text command to the repeater and displays the response as a list with resolved names and SNR values. --- lib/widgets/contacts/contact_tile.dart | 210 +++++++++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index 159297f..a2ad547 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -483,6 +483,15 @@ class ContactTile extends StatelessWidget { _showTraceSheet(context, contact); }, ), + if (contact.type == ContactType.repeater) + ListTile( + leading: const Icon(Icons.hub_outlined), + title: const Text('View Neighbours'), + onTap: () { + Navigator.pop(sheetContext); + _showNeighbours(context, contact); + }, + ), if (!contact.isPublicChannel) ListTile( leading: const Icon(Icons.edit_outlined), @@ -595,6 +604,14 @@ class ContactTile extends StatelessWidget { ); } + void _showNeighbours(BuildContext context, Contact contact) { + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => _NeighboursDialog(contact: contact), + ); + } + void _showTraceSheet(BuildContext context, Contact contact) { showModalBottomSheet( context: context, @@ -1215,3 +1232,196 @@ class _ContactNameOverrideSheetState extends State<_ContactNameOverrideSheet> { ); } } + +/// Dialog that sends "neighbors" text command to a repeater and shows results. +class _NeighboursDialog extends StatefulWidget { + final Contact contact; + + const _NeighboursDialog({required this.contact}); + + @override + State<_NeighboursDialog> createState() => _NeighboursDialogState(); +} + +class _NeighboursDialogState extends State<_NeighboursDialog> { + bool _loading = true; + String? _error; + List<_Neighbour> _neighbours = const []; + + @override + void initState() { + super.initState(); + _fetchNeighbours(); + } + + Future _fetchNeighbours() async { + final connectionProvider = context.read(); + + // Listen for the response message + String? responseText; + void onMessage(message) { + // The neighbours response comes as a text message from the repeater + if (message.senderPublicKeyPrefix != null && + widget.contact.publicKey + .sublist(0, 6) + .every((b) => message.senderPublicKeyPrefix!.contains(b))) { + responseText = message.text; + } + } + + connectionProvider.onMessageReceived = (message) { + onMessage(message); + // Keep the original callback chain + }; + + try { + await connectionProvider.sendTextMessage( + contactPublicKey: widget.contact.publicKey, + text: 'neighbors', + ); + + // Wait for response (up to 15s) + for (int i = 0; i < 30; i++) { + await Future.delayed(const Duration(milliseconds: 500)); + if (responseText != null) break; + } + + if (!mounted) return; + + if (responseText == null) { + setState(() { + _loading = false; + _error = 'No response from repeater (timeout)'; + }); + return; + } + + final text = responseText!; + if (text.toLowerCase().contains('unknown')) { + setState(() { + _loading = false; + _error = 'Neighbours feature not supported by this firmware'; + }); + return; + } + if (text.toLowerCase().contains('-none-') || text.trim().isEmpty) { + setState(() { + _loading = false; + _neighbours = const []; + }); + return; + } + + final parsed = <_Neighbour>[]; + for (final line in text.split('\n')) { + final trimmed = line.trim(); + if (trimmed.isEmpty) continue; + final parts = trimmed.split(':'); + if (parts.length >= 3) { + final keyHex = parts[0]; + final timestampOrMs = int.tryParse(parts[1]); + final snrRaw = int.tryParse(parts[2]); + final isMs = timestampOrMs != null && timestampOrMs < 1e9; + parsed.add(_Neighbour( + publicKeyHex: keyHex, + lastSeenAt: !isMs && timestampOrMs != null + ? DateTime.fromMillisecondsSinceEpoch(timestampOrMs * 1000) + : null, + lastSeenMs: isMs ? timestampOrMs : null, + snrDb: snrRaw != null ? snrRaw / 4.0 : null, + )); + } + } + + setState(() { + _loading = false; + _neighbours = parsed; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _error = 'Failed: $e'; + }); + } + } + + String _resolveNeighbourName(String keyHex) { + final contactsProvider = context.read(); + for (final contact in contactsProvider.contacts) { + if (contact.publicKeyHex.toLowerCase().startsWith(keyHex.toLowerCase())) { + return contact.displayName; + } + } + return keyHex.length > 12 ? '${keyHex.substring(0, 12)}...' : keyHex; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Neighbours of ${widget.contact.displayName}'), + content: SizedBox( + width: double.maxFinite, + child: _loading + ? const SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator()), + ) + : _error != null + ? Text(_error!) + : _neighbours.isEmpty + ? const Text('No neighbours found') + : ListView.builder( + shrinkWrap: true, + itemCount: _neighbours.length, + itemBuilder: (context, index) { + final n = _neighbours[index]; + final name = _resolveNeighbourName(n.publicKeyHex); + final subtitle = [ + if (n.snrDb != null) + 'SNR ${n.snrDb!.toStringAsFixed(1)} dB', + if (n.lastSeenAt != null) + _formatAge(n.lastSeenAt!), + if (n.lastSeenMs != null) + '${n.lastSeenMs}ms ago', + ].join(' • '); + return ListTile( + dense: true, + leading: const Icon(Icons.router_outlined, size: 20), + title: Text(name), + subtitle: subtitle.isNotEmpty ? Text(subtitle) : null, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Close'), + ), + ], + ); + } + + String _formatAge(DateTime when) { + final diff = DateTime.now().difference(when); + if (diff.inMinutes < 1) return 'just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + return '${diff.inDays}d ago'; + } +} + +class _Neighbour { + final String publicKeyHex; + final DateTime? lastSeenAt; + final int? lastSeenMs; + final double? snrDb; + + const _Neighbour({ + required this.publicKeyHex, + this.lastSeenAt, + this.lastSeenMs, + this.snrDb, + }); +}