mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-04-20 22:13:48 +00:00
* reduce map marker size reduces map markers from 80 to 60 px to improve visibility with higher density areas * add flutter test to actions * Add GPX export functionality and related UI components * Refactor GPX export constants to use lowercase naming convention and improve export function error handling * ran formating * Enhance GPX export functionality with customizable parameters and improved metadata * Implement PathTraceMapScreen and refactor path tracing functionality across screens * Add localization for missing location error in path tracing * Updated GPX export functionality for contacts and repeaters in multiple languages. * Add scrollbar to path trace details list for improved navigation * Integrate SharePlus plugin for enhanced sharing functionality across platforms * reduce map marker size reduces map markers from 80 to 60 px to improve visibility with higher density areas * reduce marker size to improve map clarity and add path trace navigation to path management --------- Co-authored-by: Winston Lowe <wel97459@gmail.com>
817 lines
25 KiB
Dart
817 lines
25 KiB
Dart
import 'dart:math';
|
|
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';
|
|
import '../services/map_tile_cache_service.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../l10n/l10n.dart';
|
|
import '../models/channel_message.dart';
|
|
import '../models/contact.dart';
|
|
|
|
class ChannelMessagePathScreen extends StatelessWidget {
|
|
final ChannelMessage message;
|
|
|
|
const ChannelMessagePathScreen({super.key, required this.message});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<MeshCoreConnector>(
|
|
builder: (context, connector, _) {
|
|
final l10n = context.l10n;
|
|
final primaryPath = _selectPrimaryPath(
|
|
message.pathBytes,
|
|
message.pathVariants,
|
|
);
|
|
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
|
|
final hasHopDetails = primaryPath.isNotEmpty;
|
|
final observedLabel = _formatObservedHops(
|
|
primaryPath.length,
|
|
message.pathLength,
|
|
l10n,
|
|
);
|
|
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
|
|
|
|
return Scaffold(
|
|
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,
|
|
onPressed: hasHopDetails
|
|
? () {
|
|
_openPathMap(context);
|
|
}
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_buildSummaryCard(context, observedLabel: observedLabel),
|
|
const SizedBox(height: 16),
|
|
if (extraPaths.isNotEmpty) ...[
|
|
Text(
|
|
l10n.channelPath_otherObservedPaths,
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildPathVariants(context, extraPaths),
|
|
const SizedBox(height: 16),
|
|
],
|
|
Text(
|
|
l10n.channelPath_repeaterHops,
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (!hasHopDetails)
|
|
Text(
|
|
l10n.channelPath_noHopDetails,
|
|
style: const TextStyle(color: Colors.grey),
|
|
)
|
|
else
|
|
..._buildHopTiles(context, hops),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) {
|
|
final l10n = context.l10n;
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.channelPath_messageDetails,
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildDetailRow(l10n.channelPath_senderLabel, message.senderName),
|
|
_buildDetailRow(
|
|
l10n.channelPath_timeLabel,
|
|
_formatTime(message.timestamp, l10n),
|
|
),
|
|
if (message.repeatCount > 0)
|
|
_buildDetailRow(
|
|
l10n.channelPath_repeatsLabel,
|
|
message.repeatCount.toString(),
|
|
),
|
|
_buildDetailRow(
|
|
l10n.channelPath_pathLabelTitle,
|
|
_formatPathLabel(message.pathLength, l10n),
|
|
),
|
|
if (observedLabel != null)
|
|
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPathVariants(BuildContext context, List<Uint8List> variants) {
|
|
final l10n = context.l10n;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
for (int i = 0; i < variants.length; i++)
|
|
Card(
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
child: ListTile(
|
|
dense: true,
|
|
title: Text(
|
|
l10n.channelPath_observedPathTitle(
|
|
i + 1,
|
|
_formatHopCount(variants[i].length, l10n),
|
|
),
|
|
),
|
|
subtitle: Text(_formatPathPrefixes(variants[i])),
|
|
trailing: const Icon(Icons.map_outlined, size: 20),
|
|
onTap: () => _openPathMap(context, initialPath: variants[i]),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildHopTiles(BuildContext context, List<_PathHop> hops) {
|
|
final l10n = context.l10n;
|
|
return [
|
|
for (final hop in hops)
|
|
Card(
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
child: ListTile(
|
|
dense: true,
|
|
leading: CircleAvatar(
|
|
radius: 14,
|
|
child: Text(
|
|
hop.index.toString(),
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
title: Text(hop.displayLabel),
|
|
subtitle: Text(
|
|
hop.hasLocation
|
|
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
|
'${hop.position!.longitude.toStringAsFixed(5)}'
|
|
: l10n.channelPath_noLocationData,
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
String _formatTime(DateTime time, AppLocalizations l10n) {
|
|
final now = DateTime.now();
|
|
final diff = now.difference(time);
|
|
|
|
if (diff.inDays > 0) {
|
|
final timeLabel =
|
|
'${time.hour}:${time.minute.toString().padLeft(2, '0')}';
|
|
return l10n.channelPath_timeWithDate(time.day, time.month, timeLabel);
|
|
}
|
|
return l10n.channelPath_timeOnly(
|
|
'${time.hour}:${time.minute.toString().padLeft(2, '0')}',
|
|
);
|
|
}
|
|
|
|
String _formatPathLabel(int? pathLength, AppLocalizations l10n) {
|
|
if (pathLength == null) return l10n.channelPath_unknownPath;
|
|
if (pathLength < 0) return l10n.channelPath_floodPath;
|
|
if (pathLength == 0) return l10n.channelPath_directPath;
|
|
return l10n.chat_hopsCount(pathLength);
|
|
}
|
|
|
|
String? _formatObservedHops(
|
|
int observedCount,
|
|
int? pathLength,
|
|
AppLocalizations l10n,
|
|
) {
|
|
if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) {
|
|
return null;
|
|
}
|
|
if (pathLength == null || pathLength < 0) {
|
|
return observedCount > 0 ? l10n.chat_hopsCount(observedCount) : null;
|
|
}
|
|
if (observedCount == 0) {
|
|
return l10n.channelPath_observedZeroOf(pathLength);
|
|
}
|
|
if (observedCount == pathLength) {
|
|
return l10n.chat_hopsCount(observedCount);
|
|
}
|
|
return l10n.channelPath_observedSomeOf(observedCount, pathLength);
|
|
}
|
|
|
|
Widget _buildDetailRow(String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 70,
|
|
child: Text(label, style: TextStyle(color: Colors.grey[600])),
|
|
),
|
|
Expanded(child: Text(value)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openPathMap(BuildContext context, {Uint8List? initialPath}) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ChannelMessagePathMapScreen(
|
|
message: message,
|
|
initialPath: initialPath,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class ChannelMessagePathMapScreen extends StatefulWidget {
|
|
final ChannelMessage message;
|
|
final Uint8List? initialPath;
|
|
|
|
const ChannelMessagePathMapScreen({
|
|
super.key,
|
|
required this.message,
|
|
this.initialPath,
|
|
});
|
|
|
|
@override
|
|
State<ChannelMessagePathMapScreen> createState() =>
|
|
_ChannelMessagePathMapScreenState();
|
|
}
|
|
|
|
class _ChannelMessagePathMapScreenState
|
|
extends State<ChannelMessagePathMapScreen> {
|
|
Uint8List? _selectedPath;
|
|
double _pathDistance = 0.0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selectedPath = widget.initialPath;
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.message != widget.message ||
|
|
!_pathsEqual(
|
|
oldWidget.initialPath ?? Uint8List(0),
|
|
widget.initialPath ?? Uint8List(0),
|
|
)) {
|
|
_selectedPath = widget.initialPath;
|
|
}
|
|
}
|
|
|
|
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>(
|
|
builder: (context, connector, _) {
|
|
final tileCache = context.read<MapTileCacheService>();
|
|
final primaryPath = _selectPrimaryPath(
|
|
widget.message.pathBytes,
|
|
widget.message.pathVariants,
|
|
);
|
|
final observedPaths = _buildObservedPaths(
|
|
primaryPath,
|
|
widget.message.pathVariants,
|
|
);
|
|
final selectedPath = _resolveSelectedPath(
|
|
_selectedPath,
|
|
observedPaths,
|
|
primaryPath,
|
|
);
|
|
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
|
final hops = _buildPathHops(
|
|
selectedPath,
|
|
connector.contacts,
|
|
context.l10n,
|
|
);
|
|
|
|
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(
|
|
points: points,
|
|
strokeWidth: 4,
|
|
color: Colors.blueAccent,
|
|
),
|
|
]
|
|
: <Polyline>[];
|
|
|
|
final initialCenter = points.isNotEmpty
|
|
? points.first
|
|
: const LatLng(0, 0);
|
|
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
|
|
final bounds = points.length > 1
|
|
? LatLngBounds.fromPoints(points)
|
|
: null;
|
|
final mapKey = ValueKey(
|
|
'${_formatPathPrefixes(selectedPath)},${context.l10n.pathTrace_you}',
|
|
);
|
|
_pathDistance = _getPathDistance(points);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)),
|
|
body: SafeArea(
|
|
top: false,
|
|
child: Stack(
|
|
children: [
|
|
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,
|
|
interactionOptions: InteractionOptions(
|
|
flags: ~InteractiveFlag.rotate,
|
|
),
|
|
),
|
|
children: [
|
|
TileLayer(
|
|
urlTemplate: kMapTileUrlTemplate,
|
|
tileProvider: tileCache.tileProvider,
|
|
userAgentPackageName:
|
|
MapTileCacheService.userAgentPackageName,
|
|
maxZoom: 19,
|
|
),
|
|
if (polylines.isNotEmpty)
|
|
PolylineLayer(polylines: polylines),
|
|
MarkerLayer(markers: _buildHopMarkers(hops)),
|
|
],
|
|
),
|
|
if (observedPaths.length > 1)
|
|
_buildPathSelector(context, observedPaths, selectedIndex, (
|
|
index,
|
|
) {
|
|
setState(() {
|
|
_selectedPath = observedPaths[index].pathBytes;
|
|
});
|
|
}),
|
|
if (points.isEmpty)
|
|
Center(
|
|
child: Card(
|
|
color: Colors.white.withValues(alpha: 0.9),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(12),
|
|
child: Text(
|
|
context.l10n.channelPath_noRepeaterLocations,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
_buildLegendCard(context, hops),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildPathSelector(
|
|
BuildContext context,
|
|
List<_ObservedPath> paths,
|
|
int selectedIndex,
|
|
ValueChanged<int> onSelected,
|
|
) {
|
|
final l10n = context.l10n;
|
|
final selectedPath = paths[selectedIndex];
|
|
final label = selectedPath.isPrimary
|
|
? l10n.channelPath_primaryPath(selectedIndex + 1)
|
|
: l10n.channelPath_pathLabel(selectedIndex + 1);
|
|
return Positioned(
|
|
left: 16,
|
|
right: 16,
|
|
top: 16,
|
|
child: SafeArea(
|
|
child: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.channelPath_observedPathHeader,
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
),
|
|
const SizedBox(height: 4),
|
|
DropdownButtonHideUnderline(
|
|
child: DropdownButton<int>(
|
|
isExpanded: true,
|
|
value: selectedIndex,
|
|
items: [
|
|
for (int i = 0; i < paths.length; i++)
|
|
DropdownMenuItem(
|
|
value: i,
|
|
child: Text(
|
|
'${paths[i].isPrimary ? l10n.channelPath_primaryPath(i + 1) : l10n.channelPath_pathLabel(i + 1)}'
|
|
' • ${_formatHopCount(paths[i].pathBytes.length, l10n)}',
|
|
),
|
|
),
|
|
],
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
onSelected(value);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.channelPath_selectedPathLabel(
|
|
label,
|
|
_formatPathPrefixes(selectedPath.pathBytes),
|
|
),
|
|
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Marker> _buildHopMarkers(List<_PathHop> hops) {
|
|
return [
|
|
for (final hop in hops)
|
|
if (hop.hasLocation)
|
|
Marker(
|
|
point: hop.position!,
|
|
width: 35,
|
|
height: 35,
|
|
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(
|
|
hop.index.toString(),
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (context.read<MeshCoreConnector>().selfLatitude != null &&
|
|
context.read<MeshCoreConnector>().selfLongitude != null)
|
|
Marker(
|
|
point: LatLng(
|
|
context.read<MeshCoreConnector>().selfLatitude!,
|
|
context.read<MeshCoreConnector>().selfLongitude!,
|
|
),
|
|
width: 35,
|
|
height: 35,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.teal,
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
Widget _buildLegendCard(BuildContext context, List<_PathHop> hops) {
|
|
final l10n = context.l10n;
|
|
final maxHeight = MediaQuery.of(context).size.height * 0.35;
|
|
final estimatedHeight = 72.0 + (hops.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: hops.isEmpty
|
|
? Center(
|
|
child: Text(l10n.channelPath_noHopDetailsAvailable),
|
|
)
|
|
: ListView.separated(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
itemCount: hops.length,
|
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
|
itemBuilder: (context, index) {
|
|
final hop = hops[index];
|
|
return ListTile(
|
|
dense: true,
|
|
leading: CircleAvatar(
|
|
radius: 14,
|
|
child: Text(
|
|
hop.index.toString(),
|
|
style: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
title: Text(hop.displayLabel),
|
|
subtitle: Text(
|
|
hop.hasLocation
|
|
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
|
'${hop.position!.longitude.toStringAsFixed(5)}'
|
|
: l10n.channelPath_noLocationData,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PathHop {
|
|
final int index;
|
|
final int prefix;
|
|
final Contact? contact;
|
|
final LatLng? position;
|
|
final AppLocalizations l10n;
|
|
|
|
const _PathHop({
|
|
required this.index,
|
|
required this.prefix,
|
|
required this.contact,
|
|
required this.position,
|
|
required this.l10n,
|
|
});
|
|
|
|
bool get hasLocation => position != null;
|
|
|
|
String get displayLabel {
|
|
final prefixLabel = _formatPrefix(prefix);
|
|
return '($prefixLabel) ${_resolveName(contact, l10n)}';
|
|
}
|
|
}
|
|
|
|
class _ObservedPath {
|
|
final Uint8List pathBytes;
|
|
final bool isPrimary;
|
|
|
|
const _ObservedPath({required this.pathBytes, required this.isPrimary});
|
|
}
|
|
|
|
List<_PathHop> _buildPathHops(
|
|
Uint8List pathBytes,
|
|
List<Contact> contacts,
|
|
AppLocalizations l10n,
|
|
) {
|
|
final hops = <_PathHop>[];
|
|
for (var i = 0; i < pathBytes.length; i++) {
|
|
final prefix = pathBytes[i];
|
|
final contact = _matchContactForPrefix(contacts, prefix);
|
|
hops.add(
|
|
_PathHop(
|
|
index: i + 1,
|
|
prefix: prefix,
|
|
contact: contact,
|
|
position: _resolvePosition(contact),
|
|
l10n: l10n,
|
|
),
|
|
);
|
|
}
|
|
return hops;
|
|
}
|
|
|
|
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
|
|
final matches = contacts
|
|
.where(
|
|
(contact) =>
|
|
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
|
contact.publicKey.isNotEmpty &&
|
|
contact.publicKey[0] == prefix,
|
|
)
|
|
.toList();
|
|
if (matches.isEmpty) return null;
|
|
|
|
Contact? pickWhere(bool Function(Contact) predicate) {
|
|
for (final contact in matches) {
|
|
if (predicate(contact)) return contact;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return pickWhere((c) => c.type == advTypeRepeater && _hasValidLocation(c)) ??
|
|
pickWhere((c) => c.type == advTypeRepeater) ??
|
|
pickWhere(_hasValidLocation) ??
|
|
matches.first;
|
|
}
|
|
|
|
LatLng? _resolvePosition(Contact? contact) {
|
|
if (contact == null) return null;
|
|
if (!_hasValidLocation(contact)) return null;
|
|
return LatLng(contact.latitude!, contact.longitude!);
|
|
}
|
|
|
|
bool _hasValidLocation(Contact contact) {
|
|
final lat = contact.latitude;
|
|
final lon = contact.longitude;
|
|
if (lat == null || lon == null) return false;
|
|
if (lat == 0 && lon == 0) return false;
|
|
return true;
|
|
}
|
|
|
|
String _formatPrefix(int prefix) {
|
|
return prefix.toRadixString(16).padLeft(2, '0').toUpperCase();
|
|
}
|
|
|
|
String _formatPathPrefixes(Uint8List pathBytes) {
|
|
return pathBytes
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
|
.join(',');
|
|
}
|
|
|
|
String _formatHopCount(int count, AppLocalizations l10n) {
|
|
return l10n.chat_hopsCount(count);
|
|
}
|
|
|
|
String _resolveName(Contact? contact, AppLocalizations l10n) {
|
|
if (contact == null) return l10n.channelPath_unknownRepeater;
|
|
final name = contact.name.trim();
|
|
if (name.isEmpty || name.toLowerCase() == 'unknown') {
|
|
return l10n.channelPath_unknownRepeater;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
Uint8List _selectPrimaryPath(Uint8List pathBytes, List<Uint8List> variants) {
|
|
Uint8List primary = pathBytes;
|
|
for (final variant in variants) {
|
|
if (variant.length > primary.length) {
|
|
primary = variant;
|
|
}
|
|
}
|
|
return primary;
|
|
}
|
|
|
|
List<Uint8List> _otherPaths(Uint8List primary, List<Uint8List> variants) {
|
|
final others = <Uint8List>[];
|
|
for (final variant in variants) {
|
|
if (variant.isEmpty) continue;
|
|
if (!_pathsEqual(primary, variant)) {
|
|
others.add(variant);
|
|
}
|
|
}
|
|
return others;
|
|
}
|
|
|
|
List<_ObservedPath> _buildObservedPaths(
|
|
Uint8List primary,
|
|
List<Uint8List> variants,
|
|
) {
|
|
final observed = <_ObservedPath>[];
|
|
|
|
void addPath(Uint8List pathBytes, bool isPrimary) {
|
|
if (pathBytes.isEmpty) return;
|
|
for (final existing in observed) {
|
|
if (_pathsEqual(existing.pathBytes, pathBytes)) return;
|
|
}
|
|
observed.add(_ObservedPath(pathBytes: pathBytes, isPrimary: isPrimary));
|
|
}
|
|
|
|
addPath(primary, true);
|
|
for (final variant in variants) {
|
|
addPath(variant, false);
|
|
}
|
|
|
|
return observed;
|
|
}
|
|
|
|
Uint8List _resolveSelectedPath(
|
|
Uint8List? selected,
|
|
List<_ObservedPath> observedPaths,
|
|
Uint8List fallback,
|
|
) {
|
|
if (selected != null) {
|
|
for (final path in observedPaths) {
|
|
if (_pathsEqual(path.pathBytes, selected)) {
|
|
return path.pathBytes;
|
|
}
|
|
}
|
|
}
|
|
if (observedPaths.isNotEmpty) {
|
|
return observedPaths.first.pathBytes;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
int _indexForPath(Uint8List selected, List<_ObservedPath> paths) {
|
|
for (int i = 0; i < paths.length; i++) {
|
|
if (_pathsEqual(paths[i].pathBytes, selected)) {
|
|
return i;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
bool _pathsEqual(Uint8List a, Uint8List b) {
|
|
if (a.length != b.length) return false;
|
|
for (var i = 0; i < a.length; i++) {
|
|
if (a[i] != b[i]) return false;
|
|
}
|
|
return true;
|
|
}
|