mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
275 lines
7.8 KiB
Dart
275 lines
7.8 KiB
Dart
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<Contact> 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<double> 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<SNRIndicator> createState() => _SNRIndicatorState();
|
|
}
|
|
|
|
class _SNRIndicatorState extends State<SNRIndicator> {
|
|
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<DirectRepeater> 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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|