mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-07-04 07:50:57 +10:00
Implement PathTraceMapScreen and refactor path tracing functionality across screens
This commit is contained in:
@@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
@@ -41,6 +42,21 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(l10n.channelPath_title),
|
title: Text(l10n.channelPath_title),
|
||||||
actions: [
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.map_outlined),
|
icon: const Icon(Icons.map_outlined),
|
||||||
tooltip: l10n.channelPath_viewMap,
|
tooltip: l10n.channelPath_viewMap,
|
||||||
@@ -263,6 +279,7 @@ class ChannelMessagePathMapScreen extends StatefulWidget {
|
|||||||
class _ChannelMessagePathMapScreenState
|
class _ChannelMessagePathMapScreenState
|
||||||
extends State<ChannelMessagePathMapScreen> {
|
extends State<ChannelMessagePathMapScreen> {
|
||||||
Uint8List? _selectedPath;
|
Uint8List? _selectedPath;
|
||||||
|
double _pathDistance = 0.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<MeshCoreConnector>(
|
return Consumer<MeshCoreConnector>(
|
||||||
@@ -306,10 +334,15 @@ class _ChannelMessagePathMapScreenState
|
|||||||
connector.contacts,
|
connector.contacts,
|
||||||
context.l10n,
|
context.l10n,
|
||||||
);
|
);
|
||||||
final points = hops
|
|
||||||
.where((hop) => hop.hasLocation)
|
final points = <LatLng>[];
|
||||||
.map((hop) => hop.position!)
|
for (final hop in hops) {
|
||||||
.toList();
|
if (hop.hasLocation) {
|
||||||
|
points.add(hop.position!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
||||||
|
|
||||||
final polylines = points.length > 1
|
final polylines = points.length > 1
|
||||||
? [
|
? [
|
||||||
Polyline(
|
Polyline(
|
||||||
@@ -327,7 +360,10 @@ class _ChannelMessagePathMapScreenState
|
|||||||
final bounds = points.length > 1
|
final bounds = points.length > 1
|
||||||
? LatLngBounds.fromPoints(points)
|
? LatLngBounds.fromPoints(points)
|
||||||
: null;
|
: null;
|
||||||
final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
|
final mapKey = ValueKey(
|
||||||
|
'${_formatPathPrefixes(selectedPath)},${context.l10n.pathTrace_you}',
|
||||||
|
);
|
||||||
|
_pathDistance = _getPathDistance(points);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)),
|
appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)),
|
||||||
@@ -487,6 +523,37 @@ class _ChannelMessagePathMapScreenState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,7 +576,7 @@ class _ChannelMessagePathMapScreenState
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Text(
|
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),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||||
|
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
@@ -701,6 +702,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
title: Text(context.l10n.chat_fullPath),
|
title: Text(context.l10n.chat_fullPath),
|
||||||
content: SelectableText(formattedPath),
|
content: SelectableText(formattedPath),
|
||||||
actions: [
|
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(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: Text(context.l10n.common_close),
|
child: Text(context.l10n.common_close),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:meshcore_open/widgets/path_trace_dialog.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
@@ -982,16 +982,16 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
? Text(context.l10n.contacts_pathTrace)
|
? Text(context.l10n.contacts_pathTrace)
|
||||||
: Text(context.l10n.contacts_ping),
|
: Text(context.l10n.contacts_ping),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
showDialog(
|
Navigator.push(
|
||||||
context: context,
|
context,
|
||||||
builder: (context) {
|
MaterialPageRoute(
|
||||||
return PathTraceDialog(
|
builder: (context) => PathTraceMapScreen(
|
||||||
title: contact.pathLength > 0
|
title: contact.pathLength > 0
|
||||||
? context.l10n.contacts_repeaterPathTrace
|
? context.l10n.contacts_repeaterPathTrace
|
||||||
: context.l10n.contacts_repeaterPing,
|
: context.l10n.contacts_repeaterPing,
|
||||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -1010,16 +1010,16 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
? Text(context.l10n.contacts_pathTrace)
|
? Text(context.l10n.contacts_pathTrace)
|
||||||
: Text(context.l10n.contacts_ping),
|
: Text(context.l10n.contacts_ping),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
showDialog(
|
Navigator.push(
|
||||||
context: context,
|
context,
|
||||||
builder: (context) {
|
MaterialPageRoute(
|
||||||
return PathTraceDialog(
|
builder: (context) => PathTraceMapScreen(
|
||||||
title: contact.pathLength > 0
|
title: contact.pathLength > 0
|
||||||
? context.l10n.contacts_roomPathTrace
|
? context.l10n.contacts_roomPathTrace
|
||||||
: context.l10n.contacts_roomPing,
|
: context.l10n.contacts_roomPing,
|
||||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -1052,16 +1052,16 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
leading: const Icon(Icons.radar, color: Colors.green),
|
leading: const Icon(Icons.radar, color: Colors.green),
|
||||||
title: Text(context.l10n.contacts_chatTraceRoute),
|
title: Text(context.l10n.contacts_chatTraceRoute),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
showDialog(
|
Navigator.push(
|
||||||
context: context,
|
context,
|
||||||
builder: (context) {
|
MaterialPageRoute(
|
||||||
return PathTraceDialog(
|
builder: (context) => PathTraceMapScreen(
|
||||||
title: context.l10n.contacts_pathTraceTo(
|
title: context.l10n.contacts_pathTraceTo(
|
||||||
contact.name,
|
contact.name,
|
||||||
),
|
),
|
||||||
path: contact.traceRouteBytes ?? Uint8List(0),
|
path: contact.traceRouteBytes ?? Uint8List(0),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
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;
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (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) {
|
||||||
|
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_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 (!_hasData)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
Center(
|
||||||
|
child: Card(
|
||||||
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: Text(
|
||||||
|
context.l10n.channelPath_noRepeaterLocations,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_hasData) _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),
|
||||||
|
)
|
||||||
|
: 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user