mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
Merge pull request #141 from zjs81/dev-NewPathTracing
Implement PathTraceMapScreen and refactor path tracing functionality
This commit is contained in:
commit
c365b7889b
36 changed files with 765 additions and 279 deletions
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_contactAdvertCopied": "Рекламата е копирана в клипборда.",
|
||||
"contacts_zeroHopContactAdvertFailed": "Неуспешно изпращане на контакт.",
|
||||
"contacts_zeroHopContactAdvertSent": "Изпратен контакт по обява.",
|
||||
"contacts_contactAdvertCopyFailed": "Копирането на обявата в клипборда не успя."
|
||||
"contacts_contactAdvertCopyFailed": "Копирането на обявата в клипборда не успя.",
|
||||
"pathTrace_someHopsNoLocation": "Един или повече от хмелите липсва местоположение!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_zeroHopContactAdvertFailed": "Kontakt konnte nicht gesendet werden.",
|
||||
"contacts_zeroHopContactAdvertSent": "Kontakt über Anzeige gesendet",
|
||||
"contacts_contactAdvertCopied": "Anzeige in die Zwischenablage kopiert.",
|
||||
"contacts_contactAdvertCopyFailed": "Kopieren des Werbeinhalts in die Zwischenablage fehlgeschlagen."
|
||||
"contacts_contactAdvertCopyFailed": "Kopieren des Werbeinhalts in die Zwischenablage fehlgeschlagen.",
|
||||
"pathTrace_someHopsNoLocation": "Eine oder mehrere der Hopfen fehlen einen Standort!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1316,6 +1316,7 @@
|
|||
"pathTrace_failed": "Path trace failed.",
|
||||
"pathTrace_notAvailable": "Path trace not available.",
|
||||
"pathTrace_refreshTooltip": "Refresh Path Trace.",
|
||||
"pathTrace_someHopsNoLocation": "One or more of the hops is missing a location!",
|
||||
"contacts_pathTrace": "Path Trace",
|
||||
"contacts_ping": "Ping",
|
||||
"contacts_repeaterPathTrace": "Path trace to repeater",
|
||||
|
|
|
|||
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_zeroHopContactAdvertFailed": "No se pudo enviar el contacto.",
|
||||
"contacts_zeroHopContactAdvertSent": "Envió contacto por anuncio.",
|
||||
"contacts_contactAdvertCopied": "Anuncio copiado al Portapapeles.",
|
||||
"contacts_contactAdvertCopyFailed": "Copiar anuncio al Portapapeles ha fallado."
|
||||
"contacts_contactAdvertCopyFailed": "Copiar anuncio al Portapapeles ha fallado.",
|
||||
"pathTrace_someHopsNoLocation": "Uno o más de los lúpulos carecen de una ubicación"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_contactAdvertCopied": "Annonce copiée dans le presse-papiers.",
|
||||
"contacts_contactAdvertCopyFailed": "La copie de l'annonce vers le presse-papiers a échoué.",
|
||||
"contacts_zeroHopContactAdvertSent": "Envoyer un contact par annonce.",
|
||||
"contacts_zeroHopContactAdvertFailed": "Échec de l'envoi du contact."
|
||||
"contacts_zeroHopContactAdvertFailed": "Échec de l'envoi du contact.",
|
||||
"pathTrace_someHopsNoLocation": "Une ou plusieurs des houblons manquent d'une localisation !"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_contactAdvertCopyFailed": "Copia dell'annuncio nella Clipboard non riuscita.",
|
||||
"contacts_ShareContactZeroHop": "Condividi contatto tramite annuncio",
|
||||
"contacts_zeroHopContactAdvertFailed": "Invio del contatto non riuscito.",
|
||||
"contacts_contactAdvertCopied": "Annuncio copiato negli Appunti."
|
||||
"contacts_contactAdvertCopied": "Annuncio copiato negli Appunti.",
|
||||
"pathTrace_someHopsNoLocation": "Uno o più dei luppoli mancano di una posizione!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4724,6 +4724,12 @@ abstract class AppLocalizations {
|
|||
/// **'Refresh Path Trace.'**
|
||||
String get pathTrace_refreshTooltip;
|
||||
|
||||
/// No description provided for @pathTrace_someHopsNoLocation.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'One or more of the hops is missing a location!'**
|
||||
String get pathTrace_someHopsNoLocation;
|
||||
|
||||
/// No description provided for @contacts_pathTrace.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -2695,6 +2695,10 @@ class AppLocalizationsBg extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Обнови Path Trace.';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'Един или повече от хмелите липсва местоположение!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Пътен проследяване';
|
||||
|
||||
|
|
|
|||
|
|
@ -2699,6 +2699,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Path Trace aktualisieren.';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'Eine oder mehrere der Hopfen fehlen einen Standort!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Pfadverfolgung';
|
||||
|
||||
|
|
|
|||
|
|
@ -2655,6 +2655,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Refresh Path Trace.';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'One or more of the hops is missing a location!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Path Trace';
|
||||
|
||||
|
|
|
|||
|
|
@ -2694,6 +2694,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Actualizar Path Trace';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'Uno o más de los lúpulos carecen de una ubicación';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Rastreo de caminos';
|
||||
|
||||
|
|
|
|||
|
|
@ -2711,6 +2711,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Actualiser Path Trace';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'Une ou plusieurs des houblons manquent d\'une localisation !';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Traçage de chemin';
|
||||
|
||||
|
|
|
|||
|
|
@ -2695,6 +2695,10 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Aggiorna Path Trace.';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'Uno o più dei luppoli mancano di una posizione!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Traccia Percorso';
|
||||
|
||||
|
|
|
|||
|
|
@ -2685,6 +2685,10 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Path Trace vernieuwen.';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'Een of meer van de hops ontbreken een locatie!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Pad Traceren';
|
||||
|
||||
|
|
|
|||
|
|
@ -2693,6 +2693,10 @@ class AppLocalizationsPl extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Odśwież ścieżkę.';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'Jeden lub więcej z chmieli nie ma określonej lokalizacji!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Śledzenie Ścieżek';
|
||||
|
||||
|
|
|
|||
|
|
@ -2696,6 +2696,10 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Atualizar Path Trace.';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'Um ou mais dos lúpulos estão sem localização!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Traçado de Caminho';
|
||||
|
||||
|
|
|
|||
|
|
@ -2698,6 +2698,10 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Обновить Path Trace';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'Одному или нескольким хмелям не указано местоположение!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Трассировка пути';
|
||||
|
||||
|
|
|
|||
|
|
@ -2681,6 +2681,10 @@ class AppLocalizationsSk extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Obnoviť Path Trace.';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'Jedna alebo viac chmeľov chýba lokalita!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Sledovanie lúčov';
|
||||
|
||||
|
|
|
|||
|
|
@ -2684,6 +2684,10 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Osveži Path Trace.';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'Ena ali več hmelju manjka lokacija!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Sledenje poti';
|
||||
|
||||
|
|
|
|||
|
|
@ -2669,6 +2669,10 @@ class AppLocalizationsSv extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Uppdatera Path Trace';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'En eller flera av humlen saknar en plats!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Path Trace';
|
||||
|
||||
|
|
|
|||
|
|
@ -2705,6 +2705,10 @@ class AppLocalizationsUk extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => 'Оновити Path Trace';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation =>
|
||||
'Одне або більше хмелів відсутнє місце розташування!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => 'Трасування шляхів';
|
||||
|
||||
|
|
|
|||
|
|
@ -2554,6 +2554,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||
@override
|
||||
String get pathTrace_refreshTooltip => '重新绘制路径。';
|
||||
|
||||
@override
|
||||
String get pathTrace_someHopsNoLocation => '其中一个或多个啤酒花缺少位置!';
|
||||
|
||||
@override
|
||||
String get contacts_pathTrace => '路径追踪';
|
||||
|
||||
|
|
|
|||
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_contactAdvertCopyFailed": "Kopiëren van advertentie naar Clipboard is mislukt.",
|
||||
"contacts_ShareContact": "Kontakt naar Klembord kopiëren",
|
||||
"contacts_ShareContactZeroHop": "Contact delen via advertentie",
|
||||
"contacts_zeroHopContactAdvertFailed": "Mislukt om contact te verzenden"
|
||||
"contacts_zeroHopContactAdvertFailed": "Mislukt om contact te verzenden",
|
||||
"pathTrace_someHopsNoLocation": "Een of meer van de hops ontbreken een locatie!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_contactAdvertCopyFailed": "Kopiowanie ogłoszenia do schowka nie powiodło się.",
|
||||
"contacts_ShareContactZeroHop": "Udostępnij kontakt przez ogłoszenie",
|
||||
"contacts_ShareContact": "Kopiuj kontakt do schowka",
|
||||
"contacts_zeroHopContactAdvertFailed": "Nie udało się wysłać kontaktu."
|
||||
"contacts_zeroHopContactAdvertFailed": "Nie udało się wysłać kontaktu.",
|
||||
"pathTrace_someHopsNoLocation": "Jeden lub więcej z chmieli nie ma określonej lokalizacji!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_floodAdvert": "Anúncio de Inundação",
|
||||
"contacts_contactAdvertCopyFailed": "Cópia do anúncio para a Área de Transferência falhou.",
|
||||
"contacts_ShareContactZeroHop": "Compartilhar contato por anúncio",
|
||||
"contacts_zeroHopContactAdvertFailed": "Falha ao enviar contato."
|
||||
"contacts_zeroHopContactAdvertFailed": "Falha ao enviar contato.",
|
||||
"pathTrace_someHopsNoLocation": "Um ou mais dos lúpulos estão sem localização!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -808,5 +808,6 @@
|
|||
"contacts_contactAdvertCopyFailed": "Копирование рекламы в буфер обмена не удалось.",
|
||||
"contacts_addContactFromClipboard": "Добавить контакт из буфера обмена",
|
||||
"contacts_ShareContactZeroHop": "Поделиться контактом по объявлению",
|
||||
"contacts_zeroHopContactAdvertSent": "Отправлено сообщение по объявлению."
|
||||
"contacts_zeroHopContactAdvertSent": "Отправлено сообщение по объявлению.",
|
||||
"pathTrace_someHopsNoLocation": "Одному или нескольким хмелям не указано местоположение!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_contactAdvertCopyFailed": "Kopírovanie inzerátu do schránky zlyhalo.",
|
||||
"contacts_zeroHopContactAdvertFailed": "Zlyhalo odoslanie kontaktu.",
|
||||
"contacts_ShareContactZeroHop": "Zdieľať kontakt cez inzerát",
|
||||
"contacts_ShareContact": "Kopírovať kontakt do schránky"
|
||||
"contacts_ShareContact": "Kopírovať kontakt do schránky",
|
||||
"pathTrace_someHopsNoLocation": "Jedna alebo viac chmeľov chýba lokalita!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_contactAdvertCopied": "Oglas je bil kopiran v odložišče.",
|
||||
"contacts_contactAdvertCopyFailed": "Kopiranje oglasa v odložišče je spodletelo.",
|
||||
"contacts_ShareContactZeroHop": "Deliti kontakt prek oglasa",
|
||||
"contacts_ShareContact": "Kopiraj stik v Odložišče"
|
||||
"contacts_ShareContact": "Kopiraj stik v Odložišče",
|
||||
"pathTrace_someHopsNoLocation": "Ena ali več hmelju manjka lokacija!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_contactAdvertCopyFailed": "Kopiering av annons till Urklipp misslyckades.",
|
||||
"contacts_ShareContact": "Kopiera kontakt till Urklipp",
|
||||
"contacts_zeroHopContactAdvertFailed": "Misslyckades med att skicka kontakt.",
|
||||
"contacts_ShareContactZeroHop": "Dela kontakt via annons"
|
||||
"contacts_ShareContactZeroHop": "Dela kontakt via annons",
|
||||
"pathTrace_someHopsNoLocation": "En eller flera av humlen saknar en plats!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_contactAdvertCopyFailed": "Копіювання оголошення в буфер обміну завершилося невдало",
|
||||
"contacts_zeroHopContactAdvertSent": "Відправлено контакт за оголошенням",
|
||||
"contacts_addContactFromClipboard": "Додати контакт з буфера обміну",
|
||||
"contacts_ShareContactZeroHop": "Поділитися контактом за оголошенням"
|
||||
"contacts_ShareContactZeroHop": "Поділитися контактом за оголошенням",
|
||||
"pathTrace_someHopsNoLocation": "Одне або більше хмелів відсутнє місце розташування!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1568,5 +1568,6 @@
|
|||
"contacts_zeroHopContactAdvertSent": "通过广告获取联系方式。",
|
||||
"contacts_zeroHopContactAdvertFailed": "发送联系方式失败。",
|
||||
"contacts_contactAdvertCopied": "广告内容已复制到剪贴板。",
|
||||
"contacts_contactAdvertCopyFailed": "将广告复制到剪贴板操作失败。"
|
||||
"contacts_contactAdvertCopyFailed": "将广告复制到剪贴板操作失败。",
|
||||
"pathTrace_someHopsNoLocation": "其中一个或多个啤酒花缺少位置!"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
|
|
@ -41,6 +42,21 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||
appBar: AppBar(
|
||||
title: Text(l10n.channelPath_title),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.radar_outlined),
|
||||
tooltip: l10n.channelPath_viewMap,
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: context.l10n.contacts_repeaterPathTrace,
|
||||
path: Uint8List.fromList(primaryPath),
|
||||
flipPathRound: true,
|
||||
reversePathRound: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
tooltip: l10n.channelPath_viewMap,
|
||||
|
|
@ -263,6 +279,7 @@ class ChannelMessagePathMapScreen extends StatefulWidget {
|
|||
class _ChannelMessagePathMapScreenState
|
||||
extends State<ChannelMessagePathMapScreen> {
|
||||
Uint8List? _selectedPath;
|
||||
double _pathDistance = 0.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -282,6 +299,17 @@ class _ChannelMessagePathMapScreenState
|
|||
}
|
||||
}
|
||||
|
||||
double _getPathDistance(List<LatLng> points) {
|
||||
double totalDistance = 0.0;
|
||||
final distanceCalculator = Distance();
|
||||
|
||||
for (int i = 0; i < points.length - 1; i++) {
|
||||
totalDistance += distanceCalculator(points[i], points[i + 1]);
|
||||
}
|
||||
|
||||
return totalDistance;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
|
|
@ -306,10 +334,15 @@ class _ChannelMessagePathMapScreenState
|
|||
connector.contacts,
|
||||
context.l10n,
|
||||
);
|
||||
final points = hops
|
||||
.where((hop) => hop.hasLocation)
|
||||
.map((hop) => hop.position!)
|
||||
.toList();
|
||||
|
||||
final points = <LatLng>[];
|
||||
for (final hop in hops) {
|
||||
if (hop.hasLocation) {
|
||||
points.add(hop.position!);
|
||||
}
|
||||
}
|
||||
points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
||||
|
||||
final polylines = points.length > 1
|
||||
? [
|
||||
Polyline(
|
||||
|
|
@ -327,7 +360,10 @@ class _ChannelMessagePathMapScreenState
|
|||
final bounds = points.length > 1
|
||||
? LatLngBounds.fromPoints(points)
|
||||
: null;
|
||||
final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
|
||||
final mapKey = ValueKey(
|
||||
'${_formatPathPrefixes(selectedPath)},${context.l10n.pathTrace_you}',
|
||||
);
|
||||
_pathDistance = _getPathDistance(points);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)),
|
||||
|
|
@ -487,6 +523,37 @@ class _ChannelMessagePathMapScreenState
|
|||
),
|
||||
),
|
||||
),
|
||||
Marker(
|
||||
point: LatLng(
|
||||
context.read<MeshCoreConnector>().selfLatitude ?? 0.0,
|
||||
context.read<MeshCoreConnector>().selfLongitude ?? 0.0,
|
||||
),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
context.l10n.pathTrace_you,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -509,7 +576,7 @@ class _ChannelMessagePathMapScreenState
|
|||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
l10n.channelPath_repeaterHops,
|
||||
'${l10n.channelPath_repeaterHops} (${(_pathDistance / 1609.34).toStringAsFixed(2)} Miles / ${(_pathDistance / 1000).toStringAsFixed(2)} Km)',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
|
|
@ -701,6 +702,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||
title: Text(context.l10n.chat_fullPath),
|
||||
content: SelectableText(formattedPath),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: context.l10n.contacts_repeaterPathTrace,
|
||||
path: Uint8List.fromList(pathBytes),
|
||||
flipPathRound: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.contacts_pathTrace),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.common_close),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/widgets/path_trace_dialog.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
|
|
@ -982,16 +982,16 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
? Text(context.l10n.contacts_pathTrace)
|
||||
: Text(context.l10n.contacts_ping),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return PathTraceDialog(
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: contact.pathLength > 0
|
||||
? context.l10n.contacts_repeaterPathTrace
|
||||
: context.l10n.contacts_repeaterPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -1010,16 +1010,16 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
? Text(context.l10n.contacts_pathTrace)
|
||||
: Text(context.l10n.contacts_ping),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return PathTraceDialog(
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: contact.pathLength > 0
|
||||
? context.l10n.contacts_roomPathTrace
|
||||
: context.l10n.contacts_roomPing,
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -1052,16 +1052,16 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||
leading: const Icon(Icons.radar, color: Colors.green),
|
||||
title: Text(context.l10n.contacts_chatTraceRoute),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return PathTraceDialog(
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: context.l10n.contacts_pathTraceTo(
|
||||
contact.name,
|
||||
),
|
||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
565
lib/screens/path_trace_map.dart
Normal file
565
lib/screens/path_trace_map.dart
Normal file
|
|
@ -0,0 +1,565 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:meshcore_open/l10n/l10n.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
import 'package:meshcore_open/services/map_tile_cache_service.dart';
|
||||
import 'package:meshcore_open/widgets/snr_indicator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class PathTraceData {
|
||||
final Uint8List pathData;
|
||||
final Uint8List snrData;
|
||||
final Map<int, Contact> pathContacts;
|
||||
|
||||
PathTraceData({
|
||||
required this.pathData,
|
||||
required this.snrData,
|
||||
required this.pathContacts,
|
||||
});
|
||||
}
|
||||
|
||||
class PathTraceMapScreen extends StatefulWidget {
|
||||
final String title;
|
||||
final Uint8List path;
|
||||
final bool flipPathRound;
|
||||
final bool reversePathRound;
|
||||
|
||||
const PathTraceMapScreen({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.path,
|
||||
this.flipPathRound = false,
|
||||
this.reversePathRound = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PathTraceMapScreen> createState() => _PathTraceMapScreenState();
|
||||
}
|
||||
|
||||
class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
Timer? _timeoutTimer;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _failed2Loaded = false;
|
||||
bool _hasData = false;
|
||||
bool _noLocationErr = false;
|
||||
PathTraceData? _traceData;
|
||||
List<LatLng> _points = <LatLng>[];
|
||||
List<Polyline> _polylines = [];
|
||||
LatLng? _initialCenter = LatLng(0, 0);
|
||||
double _initialZoom = 2.0;
|
||||
LatLngBounds? _bounds;
|
||||
ValueKey<String> _mapKey = const ValueKey('initial');
|
||||
double _pathDistance = 0.0;
|
||||
|
||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||
return pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(',');
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupFrameListener();
|
||||
_doPathTrace();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frameSubscription?.cancel();
|
||||
_timeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Uint8List addReturnpath(Uint8List pathBytes) {
|
||||
Uint8List? traceBytes;
|
||||
final len = (pathBytes.length + pathBytes.length - 1);
|
||||
traceBytes = Uint8List(len);
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length - 1) {
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
}
|
||||
}
|
||||
return traceBytes;
|
||||
}
|
||||
|
||||
double getPathDistance() {
|
||||
double totalDistance = 0.0;
|
||||
final distanceCalculator = Distance();
|
||||
|
||||
for (int i = 0; i < _points.length - 1; i++) {
|
||||
totalDistance += distanceCalculator(_points[i], _points[i + 1]);
|
||||
}
|
||||
|
||||
return totalDistance;
|
||||
}
|
||||
|
||||
Future<void> _doPathTrace() async {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_failed2Loaded = false;
|
||||
_noLocationErr = false;
|
||||
});
|
||||
}
|
||||
|
||||
final Uint8List path;
|
||||
|
||||
Uint8List pathTmp = widget.reversePathRound
|
||||
? Uint8List.fromList(widget.path.reversed.toList())
|
||||
: widget.path;
|
||||
|
||||
if (widget.flipPathRound) {
|
||||
path = addReturnpath(pathTmp);
|
||||
} else {
|
||||
path = pathTmp;
|
||||
}
|
||||
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final frame = buildTraceReq(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
0, //flags
|
||||
0, //auth
|
||||
payload: path,
|
||||
);
|
||||
connector.sendFrame(frame);
|
||||
}
|
||||
|
||||
void _setupFrameListener() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
Uint8List tagData = Uint8List(4);
|
||||
// Listen for incoming text messages from the repeater
|
||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty) return;
|
||||
final frameBuffer = BufferReader(frame);
|
||||
final code = frameBuffer.readUInt8();
|
||||
|
||||
if (code == respCodeSent) {
|
||||
frameBuffer.skipBytes(1); //reserved
|
||||
tagData = frameBuffer.readBytes(4);
|
||||
final timeoutSeconds = frameBuffer.readUInt32LE();
|
||||
|
||||
// Start timeout timer for trace response
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = Timer(Duration(milliseconds: timeoutSeconds), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_failed2Loaded = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Check if it's a binary response
|
||||
if (frame.length > 8 &&
|
||||
code == pushCodeTraceData &&
|
||||
listEquals(frame.sublist(4, 8), tagData)) {
|
||||
_timeoutTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
frameBuffer.skipBytes(3); //reserved + path length + flag
|
||||
if (listEquals(frameBuffer.readBytes(4), tagData)) {
|
||||
_handleTraceResponse(frame);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleTraceResponse(Uint8List frame) async {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
final buffer = BufferReader(frame);
|
||||
buffer.skipBytes(2); // Skip push code and reserved byte
|
||||
int pathLength = buffer.readUInt8();
|
||||
buffer.skipBytes(5); // Skip Flag byte and tag data
|
||||
buffer.skipBytes(4); // Skip auth code
|
||||
Uint8List pathData = buffer.readBytes(pathLength);
|
||||
Uint8List snrData = buffer.readRemainingBytes();
|
||||
|
||||
Map<int, Contact> pathContacts = {};
|
||||
|
||||
connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||
for (var repeaterData in pathData) {
|
||||
if (listEquals(
|
||||
repeater.publicKey.sublist(0, 1),
|
||||
Uint8List.fromList([repeaterData]),
|
||||
)) {
|
||||
pathContacts[repeaterData] = repeater;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = true;
|
||||
_traceData = PathTraceData(
|
||||
pathData: pathData,
|
||||
snrData: snrData,
|
||||
pathContacts: pathContacts,
|
||||
);
|
||||
_points = <LatLng>[];
|
||||
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
||||
for (final hop in _traceData!.pathData) {
|
||||
final contact = _traceData!.pathContacts[hop];
|
||||
if (contact != null &&
|
||||
contact.hasLocation &&
|
||||
contact.latitude != null &&
|
||||
contact.longitude != null) {
|
||||
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
||||
} else {
|
||||
_noLocationErr = true;
|
||||
}
|
||||
}
|
||||
_polylines = _points.length > 1
|
||||
? [
|
||||
Polyline(
|
||||
points: _points,
|
||||
strokeWidth: 4,
|
||||
color: Colors.blueAccent,
|
||||
),
|
||||
]
|
||||
: <Polyline>[];
|
||||
|
||||
_initialCenter = _points.isNotEmpty ? _points.first : const LatLng(0, 0);
|
||||
_initialZoom = _points.isNotEmpty ? 13.0 : 2.0;
|
||||
_bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null;
|
||||
_mapKey = ValueKey(
|
||||
'${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}',
|
||||
);
|
||||
_pathDistance = getPathDistance();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _doPathTrace,
|
||||
tooltip: context.l10n.pathTrace_refreshTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_noLocationErr)
|
||||
Center(
|
||||
child: Card(
|
||||
color: Colors.red,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text(
|
||||
context.l10n.pathTrace_someHopsNoLocation,
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_hasData && _noLocationErr)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isLoading) const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isLoading && _failed2Loaded)
|
||||
Text(context.l10n.pathTrace_notAvailable),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_hasData && !_noLocationErr)
|
||||
FlutterMap(
|
||||
key: _mapKey,
|
||||
options: MapOptions(
|
||||
initialCenter: _initialCenter!,
|
||||
initialZoom: _initialZoom,
|
||||
initialCameraFit: _bounds == null
|
||||
? null
|
||||
: CameraFit.bounds(
|
||||
bounds: _bounds!,
|
||||
padding: const EdgeInsets.all(64),
|
||||
maxZoom: 16,
|
||||
),
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName:
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
if (_polylines.isNotEmpty)
|
||||
PolylineLayer(polylines: _polylines),
|
||||
if (_traceData!.pathData.isNotEmpty)
|
||||
MarkerLayer(
|
||||
markers: _buildHopMarkers(_traceData!.pathData),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_points.isEmpty &&
|
||||
!_hasData &&
|
||||
!_isLoading &&
|
||||
!_failed2Loaded &&
|
||||
!_noLocationErr)
|
||||
Center(
|
||||
child: Card(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text(
|
||||
context.l10n.channelPath_noRepeaterLocations,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_hasData && !_noLocationErr)
|
||||
_buildLegendCard(context, _traceData!),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Marker> _buildHopMarkers(List<int> pathData) {
|
||||
return [
|
||||
Marker(
|
||||
point: LatLng(
|
||||
context.read<MeshCoreConnector>().selfLatitude!,
|
||||
context.read<MeshCoreConnector>().selfLongitude!,
|
||||
),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
context.l10n.pathTrace_you,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
for (final hop in pathData)
|
||||
if (_traceData!.pathContacts[hop]!.hasLocation)
|
||||
Marker(
|
||||
point: LatLng(
|
||||
_traceData!.pathContacts[hop]!.latitude!,
|
||||
_traceData!.pathContacts[hop]!.longitude!,
|
||||
),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_traceData!.pathContacts[hop]!.publicKey
|
||||
.sublist(0, 1)
|
||||
.map(
|
||||
(b) => b.toRadixString(16).padLeft(2, '0').toUpperCase(),
|
||||
)
|
||||
.join(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
String formatDirectionText(PathTraceData pathTraceData, int index) {
|
||||
if (index == 0 || index == pathTraceData.snrData.length - 1) {
|
||||
if (index == 0) {
|
||||
return context.l10n.pathTrace_you;
|
||||
} else {
|
||||
final contactName = pathTraceData
|
||||
.pathContacts[pathTraceData.pathData[pathTraceData.pathData.length -
|
||||
1]]
|
||||
?.name;
|
||||
final hex = pathTraceData.pathData[pathTraceData.pathData.length - 1]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase();
|
||||
return contactName != null ? "$hex: $contactName" : hex;
|
||||
}
|
||||
} else {
|
||||
final contactName =
|
||||
pathTraceData.pathContacts[pathTraceData.pathData[index - 1]]?.name;
|
||||
final hex = pathTraceData.pathData[index - 1]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase();
|
||||
return contactName != null ? "$hex: $contactName" : hex;
|
||||
}
|
||||
}
|
||||
|
||||
String formatDirectionSubText(PathTraceData pathTraceData, int index) {
|
||||
if (index == 0 || index == pathTraceData.snrData.length - 1) {
|
||||
if (index == 0) {
|
||||
final contactName =
|
||||
pathTraceData.pathContacts[pathTraceData.pathData[0]]?.name;
|
||||
final hex = pathTraceData.pathData[0]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase();
|
||||
return contactName != null ? "$hex: $contactName" : hex;
|
||||
} else {
|
||||
return context.l10n.pathTrace_you;
|
||||
}
|
||||
} else {
|
||||
final contactName =
|
||||
pathTraceData.pathContacts[pathTraceData.pathData[index]]?.name;
|
||||
final hex = pathTraceData.pathData[index]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase();
|
||||
return contactName != null ? "$hex: $contactName" : hex;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLegendCard(BuildContext context, PathTraceData pathTraceData) {
|
||||
final l10n = context.l10n;
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.35;
|
||||
final estimatedHeight = 72.0 + (pathTraceData.pathData.length * 56.0);
|
||||
final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
|
||||
|
||||
return Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: SizedBox(
|
||||
height: cardHeight,
|
||||
child: Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'${l10n.channelPath_repeaterHops} (${(_pathDistance / 1609.34).toStringAsFixed(2)} Miles / ${(_pathDistance / 1000).toStringAsFixed(2)} Km)',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: pathTraceData.pathData.isEmpty
|
||||
? Center(
|
||||
child: Text(l10n.channelPath_noHopDetailsAvailable),
|
||||
)
|
||||
: Scrollbar(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: pathTraceData.pathData.length + 1,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading:
|
||||
index >= pathTraceData.snrData.length / 2
|
||||
? Icon(Icons.call_received)
|
||||
: Icon(Icons.call_made),
|
||||
title: Text(
|
||||
formatDirectionText(pathTraceData, index),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
formatDirectionSubText(
|
||||
pathTraceData,
|
||||
index,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
trailing: SNRIcon(
|
||||
snr:
|
||||
pathTraceData.snrData[index].toSigned(
|
||||
8,
|
||||
) /
|
||||
4.0,
|
||||
),
|
||||
onTap: () {
|
||||
// Handle item tap
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../widgets/snr_indicator.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
class PathTraceDialog extends StatefulWidget {
|
||||
const PathTraceDialog({super.key, required this.title, required this.path});
|
||||
|
||||
final String title;
|
||||
final Uint8List path;
|
||||
|
||||
@override
|
||||
State<PathTraceDialog> createState() => _PathTraceDialogState();
|
||||
}
|
||||
|
||||
class _PathTraceDialogState extends State<PathTraceDialog> {
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
Timer? _timeoutTimer;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _failed2Loaded = false;
|
||||
bool _hasData = false;
|
||||
Uint8List _pathData = Uint8List(0);
|
||||
Uint8List _snrData = Uint8List(0);
|
||||
Map<int, Contact> _pathContacts = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupFrameListener();
|
||||
_doPathTrace();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frameSubscription?.cancel();
|
||||
_timeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _doPathTrace() async {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_failed2Loaded = false;
|
||||
});
|
||||
}
|
||||
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final frame = buildTraceReq(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
0, //flags
|
||||
0, //auth
|
||||
payload: widget.path,
|
||||
);
|
||||
connector.sendFrame(frame);
|
||||
}
|
||||
|
||||
void _setupFrameListener() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
Uint8List tagData = Uint8List(4);
|
||||
// Listen for incoming text messages from the repeater
|
||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty) return;
|
||||
final frameBuffer = BufferReader(frame);
|
||||
final code = frameBuffer.readUInt8();
|
||||
|
||||
if (code == respCodeSent) {
|
||||
frameBuffer.skipBytes(1); //reserved
|
||||
tagData = frameBuffer.readBytes(4);
|
||||
final timeoutSeconds = frameBuffer.readUInt32LE();
|
||||
|
||||
// Start timeout timer for trace response
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = Timer(Duration(milliseconds: timeoutSeconds), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_failed2Loaded = true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Check if it's a binary response
|
||||
if (code == pushCodeTraceData &&
|
||||
listEquals(frame.sublist(4, 8), tagData)) {
|
||||
_timeoutTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
frameBuffer.skipBytes(3); //reserved + path length + flag
|
||||
if (listEquals(frameBuffer.readBytes(4), tagData)) {
|
||||
_handleTraceResponse(frame);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleTraceResponse(Uint8List frame) async {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
final buffer = BufferReader(frame);
|
||||
buffer.skipBytes(2); // Skip push code and reserved byte
|
||||
int pathLength = buffer.readUInt8();
|
||||
buffer.skipBytes(5); // Skip Flag byte and tag data
|
||||
buffer.skipBytes(4); // Skip auth code
|
||||
Uint8List pathData = buffer.readBytes(pathLength);
|
||||
Uint8List snrData = buffer.readRemainingBytes();
|
||||
|
||||
Map<int, Contact> pathContacts = {};
|
||||
|
||||
connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||
for (var neighbourData in pathData) {
|
||||
if (listEquals(
|
||||
repeater.publicKey.sublist(0, 1),
|
||||
Uint8List.fromList([neighbourData]),
|
||||
)) {
|
||||
pathContacts[neighbourData] = repeater;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = true;
|
||||
_pathData = pathData;
|
||||
_snrData = snrData;
|
||||
_pathContacts = pathContacts;
|
||||
});
|
||||
}
|
||||
|
||||
String formatDirectionText(int index) {
|
||||
if (index == 0 || index == _snrData.length - 1) {
|
||||
if (index == 0) {
|
||||
return context.l10n.pathTrace_you;
|
||||
} else {
|
||||
return _pathContacts[_pathData[_pathData.length - 1]]?.name ??
|
||||
"0x${_pathData[_pathData.length - 1].toRadixString(16).toUpperCase()}";
|
||||
}
|
||||
} else {
|
||||
return _pathContacts[_pathData[index - 1]]?.name ??
|
||||
"0x${_pathData[index - 1].toRadixString(16).toUpperCase()}";
|
||||
}
|
||||
}
|
||||
|
||||
String formatDirectionSubText(int index) {
|
||||
if (index == 0 || index == _snrData.length - 1) {
|
||||
if (index == 0) {
|
||||
return _pathContacts[_pathData[0]]?.name ??
|
||||
"0x${_pathData[0].toRadixString(16).toUpperCase()}";
|
||||
} else {
|
||||
return context.l10n.pathTrace_you;
|
||||
}
|
||||
} else {
|
||||
return _pathContacts[_pathData[index]]?.name ??
|
||||
"0x${_pathData[index].toRadixString(16).toUpperCase()}";
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return AlertDialog(
|
||||
title: Column(
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(widget.title, style: const TextStyle(fontSize: 24)),
|
||||
),
|
||||
if (_failed2Loaded)
|
||||
Text(
|
||||
l10n.pathTrace_failed,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SafeArea(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _doPathTrace,
|
||||
child: !_hasData
|
||||
? Center(child: Text(l10n.pathTrace_notAvailable))
|
||||
: ListView.builder(
|
||||
itemCount: _snrData.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: index >= _snrData.length / 2
|
||||
? Icon(Icons.call_received)
|
||||
: Icon(Icons.call_made),
|
||||
title: Text(
|
||||
formatDirectionText(index),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
formatDirectionSubText(index),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
trailing: SNRIcon(
|
||||
snr: _snrData[index].toSigned(8) / 4.0,
|
||||
),
|
||||
onTap: () {
|
||||
// Handle item tap
|
||||
},
|
||||
),
|
||||
if (index < _snrData.length - 1)
|
||||
const Divider(height: 0.0),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _doPathTrace,
|
||||
tooltip: l10n.pathTrace_refreshTooltip,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.common_close),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue