import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; import '../connector/meshcore_connector.dart'; import '../connector/meshcore_protocol.dart'; import '../l10n/l10n.dart'; import '../models/contact.dart'; import 'signal_ui.dart'; Contact? _getRepeaterPrefixMatchNearLocation( List contacts, int pubkeyFirstByte, { LatLng? searchPoint, bool preferFavorites = false, }) { final candidates = contacts .where( (c) => c.publicKey.isNotEmpty && c.publicKey.first == pubkeyFirstByte && (c.type == advTypeRepeater || c.type == advTypeRoom), ) .toList(); if (candidates.isEmpty) return null; candidates.sort((a, b) { if (preferFavorites) { final favA = a.isFavorite ? 1 : 0; final favB = b.isFavorite ? 1 : 0; final favCompare = favB.compareTo(favA); if (favCompare != 0) return favCompare; } final seenCompare = b.lastSeen.compareTo(a.lastSeen); if (seenCompare != 0) return seenCompare; return a.publicKeyHex.compareTo(b.publicKeyHex); }); if (searchPoint == null) { return candidates.first; } final distance = Distance(); Contact best = candidates.first; var bestDistance = double.infinity; for (final c in candidates) { if (c.hasLocation && c.latitude != null && c.longitude != null) { final d = distance(searchPoint, LatLng(c.latitude!, c.longitude!)); if (d < bestDistance) { bestDistance = d; best = c; } } } return best; } class SNRUi { final IconData icon; final Color color; final String text; const SNRUi(this.icon, this.color, this.text); } List getSNRfromSF(int spreadingFactor) { switch (spreadingFactor) { case 7: return [4.0, -2.0, -4.0, -6.0]; case 8: return [4.0, -4.0, -6.0, -8.0]; case 9: return [4.0, -6.0, -8.0, -10.0]; case 10: return [4.0, -8.0, -10.0, -13.0]; case 11: return [4.0, -10.0, -12.5, -15.0]; case 12: return [4.0, -12.5, -15.0, -18.0]; default: return []; // Or throw Exception('Invalid SF: $spreadingFactor'); } } SNRUi snrUiFromSNR(double? snr, int? spreadingFactor) { if (snr == null || spreadingFactor == null || spreadingFactor < 7 || spreadingFactor > 12) { return const SNRUi(Icons.signal_cellular_off, Colors.grey, '—'); } final snrLevels = getSNRfromSF(spreadingFactor); String text = '${snr.toStringAsFixed(1)} dB'; final tier = snr >= snrLevels[0] ? 0 : snr >= snrLevels[1] ? 1 : snr >= snrLevels[2] ? 2 : snr >= snrLevels[3] ? 3 : 4; final signalUi = signalUiForStrengthTier(tier); return SNRUi(signalUi.icon, signalUi.color, text); } class SNRIndicator extends StatefulWidget { final MeshCoreConnector connector; const SNRIndicator({super.key, required this.connector}); @override State createState() => _SNRIndicatorState(); } class _SNRIndicatorState extends State { bool _isValidSelfLocation(double lat, double lon) { const double epsilon = 1e-6; return (lat.abs() > epsilon || lon.abs() > epsilon) && lat >= -90.0 && lat <= 90.0 && lon >= -180.0 && lon <= 180.0; } @override Widget build(BuildContext context) { final directRepeaters = widget.connector.directRepeaters; final directBestRepeaters = List.of(directRepeaters) ..sort((a, b) => (b.ranking).compareTo(a.ranking)); final directRepeater = directBestRepeaters.isEmpty ? null : directBestRepeaters.first; final snrUi = snrUiFromSNR( directBestRepeaters.isNotEmpty ? directRepeater!.snr : null, widget.connector.currentSf, ); return ConstrainedBox( constraints: const BoxConstraints(minWidth: 40, minHeight: 40), child: InkWell( onTap: directRepeater != null ? () => _showFullPathDialog(context, directBestRepeaters) : null, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(snrUi.icon, size: 18, color: snrUi.color), Text( snrUi.text, style: TextStyle(fontSize: 12, color: snrUi.color), ), if (directRepeater != null) Text( '${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: Colors.grey, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ), ); } String _formatLastUpdated(DateTime lastSeen) { final now = DateTime.now(); final diff = now.difference(lastSeen); if (diff.isNegative) { return "0s"; } if (diff.inMinutes < 1) { return "${diff.inSeconds}s"; } if (diff.inMinutes < 60) { return "${diff.inMinutes}m"; } if (diff.inHours < 24) { final hours = diff.inHours; return "${hours}h"; } final days = diff.inDays; return "${days}d"; } void _showFullPathDialog( BuildContext context, List directBestRepeaters, ) { final l10n = context.l10n; showDialog( context: context, builder: (context) => AlertDialog( title: Text(l10n.snrIndicator_nearByRepeaters), content: SizedBox( width: double.maxFinite, child: Scrollbar( child: ListView.separated( shrinkWrap: true, padding: const EdgeInsets.symmetric(vertical: 4), itemCount: directBestRepeaters.length, separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final repeater = directBestRepeaters[index]; final snrUi = snrUiFromSNR( repeater.snr, widget.connector.currentSf, ); final allContacts = widget.connector.allContacts; final selfLat = widget.connector.selfLatitude; final selfLon = widget.connector.selfLongitude; LatLng? selfPoint; if (selfLat != null && selfLon != null && _isValidSelfLocation(selfLat, selfLon)) { selfPoint = LatLng(selfLat, selfLon); } final contact = _getRepeaterPrefixMatchNearLocation( allContacts, repeater.pubkeyFirstByte, searchPoint: selfPoint, preferFavorites: true, ); final name = contact?.name; return Column( children: [ ListTile( leading: Icon(snrUi.icon, color: snrUi.color), title: Text( name ?? repeater.pubkeyFirstByte .toRadixString(16) .padLeft(2, '0'), ), subtitle: Text( 'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}', ), ), ], ); }, ), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(l10n.common_close), ), ], ), ); } }