Files
meshcore-open/lib/screens/channel_message_path_screen.dart

1887 lines
60 KiB
Dart

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/screens/path_trace_map.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../helpers/path_hop_resolver.dart';
import '../services/map_tile_cache_service.dart';
import '../services/app_settings_service.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n.dart';
import '../models/channel_message.dart';
import '../models/app_settings.dart';
import '../models/contact.dart';
import '../models/display_path.dart';
import '../models/path_playback.dart';
import '../theme/mesh_theme.dart';
import '../widgets/adaptive_app_bar_title.dart';
import '../widgets/mesh_ui.dart';
import '../widgets/path_map_ui.dart';
import '../widgets/themed_map_tile_layer.dart';
class ChannelMessagePathScreen extends StatelessWidget {
final ChannelMessage message;
final bool channelMessage;
const ChannelMessagePathScreen({
super.key,
required this.message,
this.channelMessage = false,
});
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final l10n = context.l10n;
final primaryPathTmp = _selectPrimaryPath(
message.pathBytes,
message.pathVariants,
);
final primaryPath = !channelMessage && !message.isOutgoing
? Uint8List.fromList(primaryPathTmp.reversed.toList())
: primaryPathTmp;
final hops = _buildPathHops(
primaryPath,
connector,
l10n,
resolveFromEnd: !message.isOutgoing,
);
final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
primaryPath.length,
message.pathLength,
l10n,
);
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
return Scaffold(
appBar: AppBar(
title: AdaptiveAppBarTitle(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: primaryPath,
flipPathAround: true,
reversePathAround:
!(!channelMessage && !message.isOutgoing),
pathHashByteWidth: context
.read<MeshCoreConnector>()
.pathHashByteWidth,
),
),
),
),
IconButton(
icon: const Icon(Icons.map_outlined),
tooltip: l10n.channelPath_viewMap,
onPressed: hasHopDetails
? () {
_openPathMap(context, channelMessage: channelMessage);
}
: null,
),
],
),
body: SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
_buildSummaryCard(context, observedLabel: observedLabel),
if (extraPaths.isNotEmpty) ...[
SectionHeader(
l10n.channelPath_otherObservedPaths,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
),
_buildPathVariants(context, extraPaths),
],
SectionHeader(
l10n.channelPath_repeaterHops,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
),
if (!hasHopDetails)
_buildNoHopCard(context, l10n)
else
_buildHopTimeline(context, hops, l10n),
const SizedBox(height: 16),
],
),
),
);
},
);
}
Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) {
final l10n = context.l10n;
final scheme = Theme.of(context).colorScheme;
final routeChip = message.pathLength == null
? null
: message.pathLength! < 0
? const RouteChip(isDirect: false)
: RouteChip(isDirect: true, hops: message.pathLength);
return MeshCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: SectionHeader(
l10n.channelPath_messageDetails,
padding: EdgeInsets.zero,
),
),
?routeChip,
],
),
const SizedBox(height: 10),
_buildDetailRow(
context,
l10n.channelPath_senderLabel,
message.senderName,
scheme: scheme,
),
_buildDetailRow(
context,
l10n.channelPath_timeLabel,
_formatTime(message.timestamp, l10n),
scheme: scheme,
),
if (message.repeatCount > 0)
_buildDetailRow(
context,
l10n.channelPath_repeatsLabel,
message.repeatCount.toString(),
scheme: scheme,
),
_buildDetailRow(
context,
l10n.channelPath_pathLabelTitle,
_formatPathLabel(message.pathLength, l10n),
scheme: scheme,
),
if (observedLabel != null)
_buildDetailRow(
context,
l10n.channelPath_observedLabel,
observedLabel,
scheme: scheme,
),
],
),
);
}
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++)
MeshCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
onTap: () => _openPathMap(
context,
initialPath: variants[i],
channelMessage: channelMessage,
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.channelPath_observedPathTitle(
i + 1,
_formatHopCount(variants[i].length, l10n),
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
_formatPathPrefixes(variants[i]),
style: MeshTheme.mono(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.map_outlined,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
],
);
}
Widget _buildNoHopCard(BuildContext context, AppLocalizations l10n) {
final scheme = Theme.of(context).colorScheme;
return MeshCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.all(14),
child: Row(
children: [
Icon(Icons.route_outlined, size: 20, color: scheme.onSurfaceVariant),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.channelPath_noHopDetails,
style: TextStyle(color: scheme.onSurfaceVariant),
),
),
],
),
);
}
Widget _buildHopTimeline(
BuildContext context,
List<_PathHop> hops,
AppLocalizations l10n,
) {
if (hops.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
for (int i = 0; i < hops.length; i++)
ListEntrance(
index: i,
child: _buildTimelineNode(
context,
hops[i],
l10n,
isLast: i == hops.length - 1,
),
),
],
),
);
}
Widget _buildTimelineNode(
BuildContext context,
_PathHop hop,
AppLocalizations l10n, {
required bool isLast,
}) {
final scheme = Theme.of(context).colorScheme;
final hexPrefix = _formatPrefix(hop.prefix);
final locationText = hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
: l10n.channelPath_noLocationData;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 48,
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
AvatarCircle(name: hop.displayLabel, size: 36),
Positioned(
right: -4,
top: -4,
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: MeshPalette.blueDim,
shape: BoxShape.circle,
border: Border.all(
color: scheme.surfaceContainerLow,
width: 1.5,
),
),
alignment: Alignment.center,
child: Text(
hop.index.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
if (!isLast)
Expanded(
child: Container(
width: 2,
margin: const EdgeInsets.symmetric(vertical: 4),
color: MeshPalette.blueLine,
),
)
else
const SizedBox(height: 12),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 16, top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
hop.displayLabel,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
hexPrefix,
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
locationText,
style: MeshTheme.mono(
fontSize: 11,
color: scheme.onSurfaceVariant,
),
),
],
),
),
),
],
),
);
}
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(
BuildContext context,
String label,
String value, {
required ColorScheme scheme,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 72,
child: Text(
label.toUpperCase(),
style: MeshTheme.accentLabel(color: scheme.onSurfaceVariant),
),
),
const SizedBox(width: 8),
Expanded(child: Text(value)),
],
),
);
}
void _openPathMap(
BuildContext context, {
Uint8List? initialPath,
bool channelMessage = false,
}) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChannelMessagePathMapScreen(
message: message,
initialPath: initialPath,
channelMessage: channelMessage,
),
),
);
}
}
class ChannelMessagePathMapScreen extends StatefulWidget {
final ChannelMessage message;
final Uint8List? initialPath;
final bool channelMessage;
const ChannelMessagePathMapScreen({
super.key,
required this.message,
this.initialPath,
this.channelMessage = false,
});
@override
State<ChannelMessagePathMapScreen> createState() =>
_ChannelMessagePathMapScreenState();
}
class _ChannelMessagePathMapScreenState
extends State<ChannelMessagePathMapScreen>
with SingleTickerProviderStateMixin {
static const double _labelZoomThreshold = 8.5;
static const double _mapMinZoom = 2.0;
static const double _mapMaxZoom = 18.0;
final MapController _mapController = MapController();
Uint8List? _selectedPath;
double _pathDistance = 0.0;
bool _showNodeLabels = true;
bool _didReceivePositionUpdate = false;
int? _focusedHopIndex;
// Packet-flow animation + multi-path view state.
late final PathPlaybackController _playback;
PathViewMode _viewMode = PathViewMode.single;
final Set<String> _hiddenPathIds = {};
bool _panelCollapsed = false;
bool _animationEnabled = true;
bool _followPacket = false;
@override
void initState() {
super.initState();
_selectedPath = widget.initialPath;
_playback = PathPlaybackController(this);
_playback.addListener(_followPacketCamera);
}
/// Keeps the camera centered on the packet while the follow lock is on.
void _followPacketCamera() {
if (!_followPacket ||
!_animationEnabled ||
!_playback.started ||
!_playback.hasPath ||
!mounted) {
return;
}
_mapController.move(_playback.position, _mapController.camera.zoom);
}
void _toggleFollowPacket() {
setState(() {
_followPacket = !_followPacket;
});
_followPacketCamera();
}
@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;
}
}
@override
void dispose() {
_playback.dispose();
_mapController.dispose();
super.dispose();
}
/// Builds a renderable [DisplayPath] for one observed route, oriented in
/// the direction the packet traveled (sender first, receiver last).
DisplayPath? _buildDisplayPath({
required int index,
required bool isPrimary,
required Uint8List orientedBytes,
required List<_PathHop> hops,
required MeshCoreConnector connector,
}) {
final l10n = context.l10n;
final selfLat = connector.selfLatitude;
final selfLon = connector.selfLongitude;
final points = <LatLng>[];
final labels = <String>[];
final confirmed = <bool>[];
final rowIdx = <int>[];
final gapBefore = <bool>[];
var pendingGap = false;
var locatedHops = 0;
void addSelf() {
if (selfLat == null || selfLon == null) return;
points.add(LatLng(selfLat, selfLon));
labels.add(l10n.pathTrace_you);
confirmed.add(true);
rowIdx.add(-1);
gapBefore.add(pendingGap);
pendingGap = false;
}
final selfFirst = widget.message.isOutgoing;
if (selfFirst) addSelf();
for (var i = 0; i < hops.length; i++) {
final hop = hops[i];
if (!hop.hasLocation) {
pendingGap = true;
continue;
}
locatedHops++;
points.add(hop.position!);
labels.add(hop.contact?.name ?? _formatPrefix(hop.prefix));
confirmed.add(true);
rowIdx.add(i);
gapBefore.add(pendingGap);
pendingGap = false;
}
if (!selfFirst) addSelf();
if (points.length < 2) return null;
final segmentEstimated = <bool>[];
final rowForSegment = <int>[];
for (var i = 0; i < points.length - 1; i++) {
segmentEstimated.add(gapBefore[i + 1]);
final dest = rowIdx[i + 1];
rowForSegment.add(dest >= 0 ? dest : (rowIdx[i] >= 0 ? rowIdx[i] : 0));
}
return DisplayPath(
id: 'op-$index',
label: isPrimary ? l10n.pathMap_primary : l10n.pathMap_alternate(index),
color: isPrimary
? kPrimaryPathColor
: kAlternatePathColors[(index - 1) % kAlternatePathColors.length],
isPrimary: isPrimary,
hopBytes: List<int>.from(orientedBytes),
points: points,
pointLabels: labels,
pointConfirmed: confirmed,
segmentEstimated: segmentEstimated,
rowForSegment: rowForSegment,
totalTransmissions: hops.length,
hasTargetEndpoint: false,
gpsConfirmedHops: locatedHops,
unresolvedHops: hops.length - locatedHops,
distanceMeters: getPathDistanceMeters(points),
record: null,
);
}
/// Updates the playback path after this frame, but only when the selected
/// path's geometry actually changed, so rebuilds don't reset a running
/// animation.
void _schedulePlaybackSync(DisplayPath? selected) {
final points = selected?.points ?? const <LatLng>[];
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (points.length == _playback.points.length) {
var same = true;
for (var i = 0; i < points.length; i++) {
if (points[i] != _playback.points[i]) {
same = false;
break;
}
}
if (same) return;
}
_playback.setPath(points);
});
}
void _selectEntry(_ObservedPathEntry entry) {
setState(() {
_selectedPath = entry.observedBytes;
_hiddenPathIds.remove(entry.display.id);
_focusedHopIndex = null;
});
}
void _togglePathVisibility(
DisplayPath path,
List<_ObservedPathEntry> entries,
DisplayPath? selected,
) {
setState(() {
if (!_hiddenPathIds.remove(path.id)) {
_hiddenPathIds.add(path.id);
if (path.id == selected?.id) {
final visible = entries.where(
(e) => !_hiddenPathIds.contains(e.display.id),
);
if (visible.isNotEmpty) {
_selectedPath = visible.first.observedBytes;
_focusedHopIndex = null;
}
}
}
});
}
bool _isDesktopPlatform(TargetPlatform platform) {
return platform == TargetPlatform.linux ||
platform == TargetPlatform.windows ||
platform == TargetPlatform.macOS;
}
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;
}
void _focusHop(_PathHop hop) {
if (!hop.hasLocation) return;
final targetZoom = _didReceivePositionUpdate
? max(_mapController.camera.zoom, 10.0)
: 12.0;
_mapController.move(hop.position!, targetZoom);
}
void _onHopTapped(_PathHop hop) {
_focusHop(hop);
if (!mounted) return;
setState(() {
_focusedHopIndex = hop.index;
});
}
void _zoomMapBy(double delta) {
final camera = _mapController.camera;
final nextZoom = (camera.zoom + delta)
.clamp(_mapMinZoom, _mapMaxZoom)
.toDouble();
_mapController.move(camera.center, nextZoom);
}
void _resetMapView({
required LatLng initialCenter,
required double initialZoom,
required LatLngBounds? bounds,
}) {
if (bounds != null) {
_mapController.fitCamera(
CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(64),
maxZoom: 16,
),
);
return;
}
_mapController.move(initialCenter, initialZoom);
}
Widget _buildDesktopMapControls({
required LatLng initialCenter,
required double initialZoom,
required LatLngBounds? bounds,
}) {
return Positioned(
left: 16,
top: 16,
child: Card(
elevation: 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: context.l10n.map_zoomIn,
onPressed: () => _zoomMapBy(1),
),
IconButton(
icon: const Icon(Icons.remove),
tooltip: context.l10n.map_zoomOut,
onPressed: () => _zoomMapBy(-1),
),
IconButton(
icon: const Icon(Icons.my_location),
tooltip: context.l10n.map_centerMap,
onPressed: () => _resetMapView(
initialCenter: initialCenter,
initialZoom: initialZoom,
bounds: bounds,
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final settings = context.watch<AppSettingsService>().settings;
final isImperial = settings.unitSystem == UnitSystem.imperial;
final tileCache = context.read<MapTileCacheService>();
final mapScheme = Theme.of(context).colorScheme;
final primaryPath = _selectPrimaryPath(
widget.message.pathBytes,
widget.message.pathVariants,
);
final observedPaths = _buildObservedPaths(
primaryPath,
widget.message.pathVariants,
);
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
final selectedPathTmp = _resolveSelectedPath(
_selectedPath,
observedPaths,
primaryPath,
);
final selectedPath = _orientPath(selectedPathTmp);
// Match on the unoriented bytes — observedPaths stores them as
// recorded, while selectedPath may be reversed for display.
final selectedIndex = _indexForPath(selectedPathTmp, observedPaths);
final hops = _buildPathHops(
selectedPath,
connector,
context.l10n,
resolveFromEnd: !widget.message.isOutgoing,
);
// Renderable paths for the animation and combined view.
final entries = <_ObservedPathEntry>[];
for (var i = 0; i < observedPaths.length; i++) {
final oriented = _orientPath(observedPaths[i].pathBytes);
final pathHops = i == selectedIndex
? hops
: _buildPathHops(
oriented,
connector,
context.l10n,
resolveFromEnd: !widget.message.isOutgoing,
);
final display = _buildDisplayPath(
index: i,
isPrimary: observedPaths[i].isPrimary,
orientedBytes: oriented,
hops: pathHops,
connector: connector,
);
if (display != null) {
entries.add(
_ObservedPathEntry(
index: i,
observedBytes: observedPaths[i].pathBytes,
display: display,
hops: pathHops,
),
);
}
}
final effectiveMode = entries.length > 1
? _viewMode
: PathViewMode.single;
_ObservedPathEntry? selectedEntry;
for (final entry in entries) {
if (entry.index == selectedIndex) {
selectedEntry = entry;
break;
}
}
final selectedDisplay = selectedEntry?.display;
final visibleEntries = effectiveMode == PathViewMode.single
? [?selectedEntry]
: entries
.where((e) => !_hiddenPathIds.contains(e.display.id))
.toList();
final visibleDisplays = visibleEntries.map((e) => e.display).toList();
_schedulePlaybackSync(selectedDisplay);
final points = <LatLng>[];
if ((widget.message.isOutgoing && !widget.channelMessage) ||
(widget.message.isOutgoing && widget.channelMessage)) {
points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
}
for (final hop in hops) {
if (hop.hasLocation) {
points.add(hop.position!);
}
}
if ((!widget.message.isOutgoing && !widget.channelMessage) ||
(!widget.message.isOutgoing && widget.channelMessage)) {
points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
}
final polylines = points.length > 1
? [
Polyline(
points: points,
strokeWidth: 4,
color: MeshPalette.blue,
),
]
: <Polyline>[];
final initialCenter = points.isNotEmpty
? points.first
: const LatLng(0, 0);
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
if (!_didReceivePositionUpdate) {
_showNodeLabels = initialZoom >= _labelZoomThreshold;
}
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: AdaptiveAppBarTitle(context.l10n.channelPath_mapTitle),
),
body: SafeArea(
top: false,
child: Stack(
children: [
FlutterMap(
key: mapKey,
mapController: _mapController,
options: MapOptions(
initialCenter: initialCenter,
initialZoom: initialZoom,
initialCameraFit: bounds == null
? null
: CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(64),
maxZoom: 16,
),
minZoom: _mapMinZoom,
maxZoom: _mapMaxZoom,
interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate,
scrollWheelVelocity: isDesktop ? 0.012 : 0.005,
cursorKeyboardRotationOptions:
CursorKeyboardRotationOptions.disabled(),
keyboardOptions: isDesktop
? const KeyboardOptions(
enableArrowKeysPanning: true,
enableWASDPanning: true,
enableRFZooming: true,
)
: const KeyboardOptions.disabled(),
),
onPositionChanged: (camera, hasGesture) {
if (!mounted) return;
// A manual pan/zoom releases the follow lock.
if (hasGesture && _followPacket) {
setState(() {
_followPacket = false;
});
}
final shouldShow = camera.zoom >= _labelZoomThreshold;
if (!_didReceivePositionUpdate ||
shouldShow != _showNodeLabels) {
setState(() {
_didReceivePositionUpdate = true;
_showNodeLabels = shouldShow;
});
}
},
),
children: [
ThemedMapTileLayer(tileCache: tileCache),
AnimatedBuilder(
animation: _playback,
builder: (context, _) {
List<Polyline> lines;
if (visibleDisplays.isEmpty) {
lines = polylines;
} else {
final animating =
_animationEnabled &&
_playback.started &&
_playback.hasPath;
lines = buildMultiPathPolylines(
visible: visibleDisplays,
selected: selectedDisplay,
combined: effectiveMode == PathViewMode.combined,
animating: animating,
);
if (animating && selectedDisplay != null) {
lines.addAll(
buildPacketTrailPolylines(
_playback,
selectedDisplay.color,
),
);
}
}
if (lines.isEmpty) return const SizedBox.shrink();
return PolylineLayer(polylines: lines);
},
),
if (effectiveMode == PathViewMode.combined)
MarkerLayer(
markers: _buildCombinedHopMarkers(
visibleEntries,
showLabels: _showNodeLabels,
),
)
else
MarkerLayer(
markers: _buildHopMarkers(
hops,
showLabels: _showNodeLabels,
),
),
AnimatedBuilder(
animation: _playback,
builder: (context, _) {
if (!_animationEnabled || selectedDisplay == null) {
return const SizedBox.shrink();
}
final markers = buildPacketMarkers(
_playback,
selectedDisplay.color,
);
if (markers.isEmpty) return const SizedBox.shrink();
return MarkerLayer(markers: markers);
},
),
],
),
if (isDesktop)
_buildDesktopMapControls(
initialCenter: initialCenter,
initialZoom: initialZoom,
bounds: bounds,
),
if (entries.length > 1)
PathViewModeToggle(
mode: effectiveMode,
onChanged: (mode) => setState(() => _viewMode = mode),
),
if (observedPaths.length > 1 &&
effectiveMode == PathViewMode.single)
_buildPathSelector(
context,
observedPaths,
selectedIndex,
(index) {
setState(() {
_selectedPath = observedPaths[index].pathBytes;
_focusedHopIndex = null;
});
},
topOffset: entries.length > 1 ? 60.0 : 16.0,
),
if (points.isEmpty)
Center(
child: Container(
margin: const EdgeInsets.all(24),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: mapScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(MeshRadii.md),
border: Border.all(color: mapScheme.outlineVariant),
),
child: Text(context.l10n.channelPath_noRepeaterLocations),
),
),
_buildLegendCard(
context,
hops,
isImperial,
entries: entries,
selectedEntry: selectedEntry,
effectiveMode: effectiveMode,
),
],
),
),
);
},
);
}
Widget _buildPathSelector(
BuildContext context,
List<_ObservedPath> paths,
int selectedIndex,
ValueChanged<int> onSelected, {
double topOffset = 16,
}) {
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: topOffset,
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: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
),
),
),
),
);
}
List<Marker> _buildHopMarkers(
List<_PathHop> hops, {
required bool showLabels,
}) {
final markers = <Marker>[];
for (final hop in hops) {
if (!hop.hasLocation) continue;
final point = hop.position!;
markers.add(
Marker(
point: point,
width: 48,
height: 48,
child: Center(
child: Container(
width: 35,
height: 35,
decoration: BoxDecoration(
color: MeshPalette.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(
hop.index.toString(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
),
),
);
if (showLabels) {
markers.add(
_buildNodeLabelMarker(
point: point,
label: hop.contact?.name ?? _formatPrefix(hop.prefix),
),
);
}
}
markers.addAll(_buildSelfMarkers(showLabels: showLabels));
return markers;
}
List<Marker> _buildSelfMarkers({required bool showLabels}) {
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
final selfLon = context.read<MeshCoreConnector>().selfLongitude;
if (selfLat == null || selfLon == null) return const [];
final markers = <Marker>[];
final selfPoint = LatLng(selfLat, selfLon);
markers.add(
Marker(
point: selfPoint,
width: 48,
height: 48,
child: Center(
child: Container(
width: 35,
height: 35,
decoration: BoxDecoration(
color: MeshPalette.signal,
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,
),
),
),
),
),
);
if (showLabels) {
markers.add(
_buildNodeLabelMarker(
point: selfPoint,
label: context.l10n.pathTrace_you,
),
);
}
return markers;
}
/// Markers for the union of located hops across all visible paths, with a
/// badge on repeaters used by more than one path.
List<Marker> _buildCombinedHopMarkers(
List<_ObservedPathEntry> visibleEntries, {
required bool showLabels,
}) {
final markers = <Marker>[];
final nodes = <String, _SharedNode>{};
for (final entry in visibleEntries) {
final seenInPath = <String>{};
for (final hop in entry.hops) {
if (!hop.hasLocation) continue;
final key =
'${hop.prefix}|${hop.position!.latitude.toStringAsFixed(5)},'
'${hop.position!.longitude.toStringAsFixed(5)}';
if (!seenInPath.add(key)) continue;
nodes.putIfAbsent(key, () => _SharedNode(hop)).paths.add(entry.display);
}
}
for (final node in nodes.values) {
final hop = node.hop;
final point = hop.position!;
final label = _formatPrefix(hop.prefix);
final shared = node.paths.length > 1;
markers.add(
Marker(
point: point,
width: 48,
height: 48,
child: GestureDetector(
onTap: () => showSharedNodeSheet(
context,
title: '$label: ${_resolveName(hop.contact, context.l10n)}',
paths: node.paths,
onSelect: (display) {
for (final entry in visibleEntries) {
if (entry.display.id == display.id) {
_selectEntry(entry);
break;
}
}
},
),
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: 35,
height: 35,
decoration: BoxDecoration(
color: MeshPalette.blue,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: shared ? 2.5 : 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
if (shared)
Positioned(
top: 0,
right: 0,
child: Container(
width: 17,
height: 17,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: MeshPalette.bg1,
border: Border.all(color: MeshPalette.line3),
),
alignment: Alignment.center,
child: Text(
'${node.paths.length}',
style: MeshTheme.mono(
fontSize: 9,
fontWeight: FontWeight.w700,
color: MeshPalette.ink,
),
),
),
),
],
),
),
),
);
if (showLabels) {
markers.add(
_buildNodeLabelMarker(
point: point,
label: hop.contact?.name ?? label,
),
);
}
}
markers.addAll(_buildSelfMarkers(showLabels: showLabels));
return markers;
}
/// Orients recorded path bytes in the direction the packet traveled.
Uint8List _orientPath(Uint8List bytes) {
final reverse =
(!widget.message.isOutgoing && !widget.channelMessage) ||
(widget.message.isOutgoing && widget.channelMessage);
return reverse ? Uint8List.fromList(bytes.reversed.toList()) : bytes;
}
Marker _buildNodeLabelMarker({required LatLng point, required String label}) {
return Marker(
point: point,
width: 120,
height: 24,
alignment: Alignment.topCenter,
child: IgnorePointer(
child: Transform.translate(
offset: const Offset(0, -20),
child: FittedBox(
fit: BoxFit.contain,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
);
}
Widget _buildLegendCard(
BuildContext context,
List<_PathHop> hops,
bool isImperial, {
required List<_ObservedPathEntry> entries,
required _ObservedPathEntry? selectedEntry,
required PathViewMode effectiveMode,
}) {
final l10n = context.l10n;
final combined = effectiveMode == PathViewMode.combined;
final selectedDisplay = selectedEntry?.display;
final maxHeight =
MediaQuery.of(context).size.height * (combined ? 0.45 : 0.35);
double cardHeight;
if (_panelCollapsed) {
cardHeight = 128;
} else {
final summaryHeight = combined ? 34.0 + entries.length * 36.0 : 0;
final estimatedHeight = 132.0 + summaryHeight + hops.length * 56.0;
cardHeight = max(176.0, min(maxHeight, estimatedHeight));
}
final hopUseCount = <int, int>{};
if (combined) {
for (final entry in entries) {
if (_hiddenPathIds.contains(entry.display.id)) continue;
for (final prefix in entry.hops.map((h) => h.prefix).toSet()) {
hopUseCount.update(prefix, (v) => v + 1, ifAbsent: () => 1);
}
}
}
return Positioned(
left: 16,
right: 16,
bottom: 16,
child: SizedBox(
height: cardHeight,
child: Container(
decoration: BoxDecoration(
color: MeshPalette.bg1,
borderRadius: BorderRadius.circular(MeshRadii.md),
border: Border.all(color: MeshPalette.line2),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 4, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
l10n.channelPath_repeaterHops,
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
),
Text(
formatDistance(
selectedDisplay?.distanceMeters ??
_pathDistance,
isImperial: isImperial,
),
style: MeshTheme.mono(
fontSize: 12,
color: MeshPalette.ink2,
),
),
],
),
const SizedBox(height: 4),
PathMiniLegend(
combined: combined,
showInferred: false,
),
],
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: Icon(
_panelCollapsed ? Icons.expand_less : Icons.expand_more,
size: 20,
),
tooltip: _panelCollapsed
? l10n.pathMap_expandPanel
: l10n.pathMap_collapsePanel,
onPressed: () =>
setState(() => _panelCollapsed = !_panelCollapsed),
),
],
),
),
PathAnimationControls(
playback: _playback,
selected: selectedDisplay,
animationEnabled: _animationEnabled,
onToggleAnimation: () => setState(() {
_animationEnabled = !_animationEnabled;
if (!_animationEnabled) _playback.stop();
}),
followEnabled: _followPacket,
onToggleFollow: _toggleFollowPacket,
),
if (!_panelCollapsed) ...[
if (selectedDisplay != null &&
selectedDisplay.unresolvedHops > 0)
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 4),
child: Text(
l10n.pathMap_partialAnimation(
selectedDisplay.unresolvedHops,
),
style: TextStyle(fontSize: 10.5, color: MeshPalette.warn),
),
),
if (combined)
PathSummaryList(
paths: entries.map((e) => e.display).toList(),
selectedId: selectedDisplay?.id ?? '',
hiddenIds: _hiddenPathIds,
isImperial: isImperial,
onSelect: (display) {
for (final entry in entries) {
if (entry.display.id == display.id) {
_selectEntry(entry);
break;
}
}
},
onToggleVisibility: (display) => _togglePathVisibility(
display,
entries,
selectedDisplay,
),
onShowAll: () => setState(_hiddenPathIds.clear),
),
const Divider(height: 1),
Expanded(
child: _buildHopListView(hops, selectedDisplay, hopUseCount),
),
],
],
),
),
),
);
}
Widget _buildHopListView(
List<_PathHop> hops,
DisplayPath? selectedDisplay,
Map<int, int> hopUseCount,
) {
final l10n = context.l10n;
if (hops.isEmpty) {
return Center(child: Text(l10n.channelPath_noHopDetailsAvailable));
}
return ValueListenableBuilder<int>(
valueListenable: _playback.activeSegment,
builder: (context, activeSegment, _) {
int highlightRow = -1;
if (_animationEnabled &&
selectedDisplay != null &&
activeSegment >= 0 &&
activeSegment < selectedDisplay.rowForSegment.length) {
highlightRow = selectedDisplay.rowForSegment[activeSegment];
}
final highlightColor = (selectedDisplay?.color ?? MeshPalette.blue)
.withValues(alpha: 0.14);
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: hops.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final hop = hops[index];
final isFocused = _focusedHopIndex == hop.index;
final sharedCount = hopUseCount[hop.prefix] ?? 0;
return InkWell(
onTap: hop.hasLocation ? () => _onHopTapped(hop) : null,
child: Container(
color: index == highlightRow
? highlightColor
: isFocused
? MeshPalette.blueBg
: Colors.transparent,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: MeshPalette.blueDim.withValues(alpha: 0.3),
shape: BoxShape.circle,
border: Border.all(
color: MeshPalette.blueDim.withValues(alpha: 0.5),
),
),
alignment: Alignment.center,
child: Text(
hop.index.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
hop.displayLabel,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 13,
),
overflow: TextOverflow.ellipsis,
),
Text(
[
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
: context.l10n.channelPath_noLocationData,
if (sharedCount > 1)
context.l10n.pathMap_sharedNodeCount(
sharedCount,
),
].join(' · '),
style: MeshTheme.mono(
fontSize: 10,
color: MeshPalette.ink3,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
},
);
},
);
}
}
/// One observed route paired with its renderable form and resolved hops.
class _ObservedPathEntry {
final int index;
final Uint8List observedBytes;
final DisplayPath display;
final List<_PathHop> hops;
const _ObservedPathEntry({
required this.index,
required this.observedBytes,
required this.display,
required this.hops,
});
}
/// A located hop shared across one or more visible paths.
class _SharedNode {
final _PathHop hop;
final List<DisplayPath> paths = [];
_SharedNode(this.hop);
}
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,
MeshCoreConnector connector,
AppLocalizations l10n, {
bool resolveFromEnd = false,
}) {
if (pathBytes.isEmpty) return const [];
final endpoint =
(connector.selfLatitude != null && connector.selfLongitude != null)
? LatLng(connector.selfLatitude!, connector.selfLongitude!)
: null;
final resolvedContacts = PathHopResolver.resolve(
pathBytes: pathBytes,
contacts: connector.allContacts,
endpoint: endpoint,
resolveFromEnd: resolveFromEnd,
);
final hops = <_PathHop>[];
for (var i = 0; i < pathBytes.length; i++) {
final contact = resolvedContacts[i];
final resolvedPosition = _resolvePosition(contact);
hops.add(
_PathHop(
index: i + 1,
prefix: pathBytes[i],
contact: contact,
position: resolvedPosition,
l10n: l10n,
),
);
}
return hops;
}
LatLng? _resolvePosition(Contact? contact) {
if (contact == null) return null;
if (!contact.hasLocation) return null;
final latitude = contact.latitude;
final longitude = contact.longitude;
if (latitude == null || longitude == null) return null;
return LatLng(latitude, longitude);
}
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;
}