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.
This commit is contained in:
Janez T
2026-03-17 10:16:11 +01:00
parent 372224a62c
commit e17f24a6bc

View File

@@ -483,6 +483,15 @@ class ContactTile extends StatelessWidget {
_showTraceSheet(context, contact); _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) if (!contact.isPublicChannel)
ListTile( ListTile(
leading: const Icon(Icons.edit_outlined), 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) { void _showTraceSheet(BuildContext context, Contact contact) {
showModalBottomSheet( showModalBottomSheet(
context: context, 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<void> _fetchNeighbours() async {
final connectionProvider = context.read<ConnectionProvider>();
// 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<ContactsProvider>();
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 = <String>[
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,
});
}