mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-14 22:55:12 +10:00
3981 lines
136 KiB
Dart
3981 lines
136 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:meshcore_open/screens/path_trace_map.dart';
|
|
import 'package:meshcore_open/widgets/app_bar.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../connector/meshcore_connector.dart';
|
|
import '../l10n/l10n.dart';
|
|
import '../connector/meshcore_protocol.dart';
|
|
import '../models/app_settings.dart';
|
|
import '../models/channel.dart';
|
|
import '../models/contact.dart';
|
|
import '../l10n/contact_localization.dart';
|
|
import '../services/app_settings_service.dart';
|
|
import '../services/path_history_service.dart';
|
|
import '../services/map_marker_service.dart';
|
|
import '../services/map_tile_cache_service.dart';
|
|
import '../utils/contact_search.dart';
|
|
import '../utils/battery_utils.dart';
|
|
import '../utils/route_transitions.dart';
|
|
import '../widgets/quick_switch_bar.dart';
|
|
import '../widgets/sync_progress_overlay.dart';
|
|
import '../widgets/themed_map_tile_layer.dart';
|
|
import '../icons/los_icon.dart';
|
|
import 'channels_screen.dart';
|
|
import 'chat_screen.dart';
|
|
import 'contacts_screen.dart';
|
|
import '../theme/mesh_theme.dart';
|
|
import '../widgets/mesh_ui.dart';
|
|
import '../widgets/repeater_login_dialog.dart';
|
|
import '../widgets/room_login_dialog.dart';
|
|
import '../helpers/snack_bar_builder.dart';
|
|
import 'repeater_hub_screen.dart';
|
|
import 'settings_screen.dart';
|
|
import 'line_of_sight_map_screen.dart';
|
|
|
|
class MapScreen extends StatefulWidget {
|
|
final LatLng? highlightPosition;
|
|
final String? highlightLabel;
|
|
final String? highlightMarkerKey;
|
|
final double highlightZoom;
|
|
final bool hideBackButton;
|
|
|
|
const MapScreen({
|
|
super.key,
|
|
this.highlightPosition,
|
|
this.highlightLabel,
|
|
this.highlightMarkerKey,
|
|
this.highlightZoom = 15.0,
|
|
this.hideBackButton = false,
|
|
});
|
|
|
|
@override
|
|
State<MapScreen> createState() => _MapScreenState();
|
|
}
|
|
|
|
class _MapScreenState extends State<MapScreen> {
|
|
// Zoom level at which node labels start to appear
|
|
static const double _labelZoomThreshold = 14.0;
|
|
// Below this zoom, nearby nodes collapse into clusters.
|
|
static const double _clusterOffZoom = 12.5;
|
|
// Guessed (estimated) locations only render at closer zooms to avoid a
|
|
// carpet of approximate markers at city-wide scale.
|
|
static const double _guessedZoomThreshold = 12.0;
|
|
static const double _mapMinZoom = 2.0;
|
|
static const double _mapMaxZoom = 18.0;
|
|
|
|
final MapController _mapController = MapController();
|
|
final MapMarkerService _markerService = MapMarkerService();
|
|
final Set<String> _hiddenMarkerIds = {};
|
|
Set<String> _removedMarkerIds = {};
|
|
bool _isBuildingPathTrace = false;
|
|
bool _isSelectingPoi = false;
|
|
bool _hasInitializedMap = false;
|
|
bool _removedMarkersLoaded = false;
|
|
final List<int> _pathTrace = [];
|
|
final List<Contact> _pathTraceContacts = [];
|
|
final List<LatLng> _points = [];
|
|
final List<Polyline> _polylines = [];
|
|
bool _statsExpanded = false;
|
|
bool _showNodeLabels = true;
|
|
double _zoom = 10.0;
|
|
String? _selectedKey;
|
|
LatLng? _selectedGuessPos;
|
|
_Freshness _freshness = _Freshness.all;
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final FocusNode _searchFocus = FocusNode();
|
|
String _searchQuery = '';
|
|
List<_GuessedLocation> _cachedGuessedLocations = [];
|
|
String _guessedLocationsCacheKey = '';
|
|
int? _sharedMarkersCacheSignature;
|
|
Locale? _sharedMarkersCacheLocale;
|
|
List<_SharedMarker> _cachedSharedMarkers = const [];
|
|
_NodeMarkersCacheKey? _nodeMarkersCacheKey;
|
|
List<Marker> _cachedNodeMarkers = const [];
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
_searchFocus.dispose();
|
|
_mapController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
_NodeAge _ageOf(Contact contact) {
|
|
final d = DateTime.now().difference(contact.lastSeen);
|
|
if (d.inMinutes <= 60) return _NodeAge.online;
|
|
if (d.inHours <= 24) return _NodeAge.recent;
|
|
return _NodeAge.stale;
|
|
}
|
|
|
|
void _selectNode(Contact contact, {LatLng? guessedPosition}) {
|
|
HapticFeedback.selectionClick();
|
|
setState(() {
|
|
_selectedKey = contact.publicKeyHex;
|
|
_selectedGuessPos = guessedPosition;
|
|
_searchQuery = '';
|
|
_searchController.clear();
|
|
_searchFocus.unfocus();
|
|
});
|
|
}
|
|
|
|
void _clearSelection() {
|
|
setState(() {
|
|
_selectedKey = null;
|
|
_selectedGuessPos = null;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadRemovedMarkers();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
context.read<MeshCoreConnector>().getChannels();
|
|
if (widget.highlightPosition != null) {
|
|
_mapController.move(widget.highlightPosition!, widget.highlightZoom);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _loadRemovedMarkers() async {
|
|
final ids = await _markerService.loadRemovedIds();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_removedMarkerIds = ids;
|
|
_removedMarkersLoaded = true;
|
|
});
|
|
// If this screen was opened to highlight a marker, and that marker
|
|
// was previously removed, re-enable it now that we've loaded the saved
|
|
// removed IDs.
|
|
if (widget.highlightMarkerKey != null &&
|
|
_removedMarkerIds.contains(widget.highlightMarkerKey)) {
|
|
final updated = Set<String>.from(_removedMarkerIds);
|
|
updated.remove(widget.highlightMarkerKey);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_removedMarkerIds = updated;
|
|
});
|
|
await _markerService.saveRemovedIds(updated);
|
|
}
|
|
}
|
|
|
|
bool _checkLocationPlausibility(double lat, double lon) {
|
|
const double epsilon = 1e-6;
|
|
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
|
|
lat >= -90.0 &&
|
|
lat <= 90.0 &&
|
|
lon >= -180.0 &&
|
|
lon <= 180.0;
|
|
}
|
|
|
|
double _standardDeviation(List<double> values) {
|
|
if (values.length <= 1) {
|
|
return 0.0;
|
|
}
|
|
|
|
final mean = values.reduce((a, b) => a + b) / values.length;
|
|
|
|
double sumSquaredDiff = 0.0;
|
|
for (final value in values) {
|
|
final diff = value - mean;
|
|
sumSquaredDiff += diff * diff;
|
|
}
|
|
|
|
// Sample standard deviation (n-1) — most appropriate here
|
|
final variance = sumSquaredDiff / (values.length - 1);
|
|
|
|
return sqrt(variance);
|
|
}
|
|
|
|
// Calculate zoom level based on the spread of points (std deviation in degrees)
|
|
double _zoomFromStdDev(double latStdDev, double lonStdDev) {
|
|
final maxSpread = max(latStdDev, lonStdDev);
|
|
if (maxSpread <= 0) return 13.0;
|
|
// Approximate: each zoom level halves the visible area
|
|
// ~0.01 degrees spread -> zoom 13, ~0.1 -> zoom 10, ~1.0 -> zoom 7
|
|
final zoom = 10.0 - log(maxSpread * 10 + 1) / ln10 * 3;
|
|
return zoom.clamp(4.0, 15.0);
|
|
}
|
|
|
|
bool _isDesktopPlatform(TargetPlatform platform) {
|
|
return platform == TargetPlatform.linux ||
|
|
platform == TargetPlatform.windows ||
|
|
platform == TargetPlatform.macOS;
|
|
}
|
|
|
|
void _zoomMapBy(double delta) {
|
|
final camera = _mapController.camera;
|
|
final nextZoom = (camera.zoom + delta)
|
|
.clamp(_mapMinZoom, _mapMaxZoom)
|
|
.toDouble();
|
|
_mapController.move(camera.center, nextZoom);
|
|
}
|
|
|
|
Widget _buildControlRail(
|
|
BuildContext context, {
|
|
required LatLng center,
|
|
required double zoom,
|
|
required MeshCoreConnector connector,
|
|
}) {
|
|
final hasSelf =
|
|
connector.selfLatitude != null && connector.selfLongitude != null;
|
|
return Positioned(
|
|
left: 12,
|
|
bottom: 96,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: MapPalette.panelDark,
|
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
|
border: Border.all(color: MapPalette.border),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: MapPalette.markerShadow,
|
|
blurRadius: 8,
|
|
offset: Offset(0, 3),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
color: MapPalette.textPrimary,
|
|
icon: const Icon(Icons.add),
|
|
visualDensity: VisualDensity.standard,
|
|
tooltip: context.l10n.map_zoomIn,
|
|
onPressed: () => _zoomMapBy(1),
|
|
),
|
|
IconButton(
|
|
color: MapPalette.textPrimary,
|
|
icon: const Icon(Icons.remove),
|
|
tooltip: context.l10n.map_zoomOut,
|
|
onPressed: () => _zoomMapBy(-1),
|
|
),
|
|
IconButton(
|
|
color: MapPalette.textPrimary,
|
|
icon: const Icon(Icons.crop_free),
|
|
tooltip: context.l10n.map_centerMap,
|
|
onPressed: () => _mapController.move(center, zoom),
|
|
),
|
|
if (hasSelf)
|
|
IconButton(
|
|
color: MapPalette.selected,
|
|
icon: const Icon(Icons.my_location),
|
|
tooltip: context.l10n.map_setAsMyLocation,
|
|
onPressed: () => _mapController.move(
|
|
LatLng(connector.selfLatitude!, connector.selfLongitude!),
|
|
max(_zoom, 14),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Builder(
|
|
builder: (context) {
|
|
final connectorSnapshot = context
|
|
.select<MeshCoreConnector, _MapConnectorSnapshot>(
|
|
_MapConnectorSnapshot.fromConnector,
|
|
);
|
|
final connector = connectorSnapshot.connector;
|
|
final settings = context.select<AppSettingsService, AppSettings>(
|
|
(service) => service.settings,
|
|
);
|
|
final pathHistoryVersion = context.select<PathHistoryService, int>(
|
|
(service) => service.version,
|
|
);
|
|
final settingsService = context.read<AppSettingsService>();
|
|
final pathHistory = context.read<PathHistoryService>();
|
|
final tileCache = context.read<MapTileCacheService>();
|
|
final isDesktop = _isDesktopPlatform(defaultTargetPlatform);
|
|
final allContacts = connector.allContacts;
|
|
|
|
final contacts = settings.mapShowDiscoveryContacts
|
|
? allContacts
|
|
: allContacts.where((c) => c.isActive).toList();
|
|
|
|
final highlightPosition = widget.highlightPosition;
|
|
final sharedMarkers = settings.mapShowMarkers
|
|
? _collectSharedMarkers(
|
|
connector,
|
|
connectorSnapshot.markerSignature,
|
|
)
|
|
.where(
|
|
(marker) =>
|
|
!_hiddenMarkerIds.contains(marker.id) &&
|
|
!_removedMarkerIds.contains(marker.id),
|
|
)
|
|
.toList()
|
|
: <_SharedMarker>[];
|
|
|
|
// Filter by time
|
|
final now = DateTime.now();
|
|
final filteredByTime = settings.mapTimeFilterHours == 0
|
|
? contacts
|
|
: contacts.where((c) {
|
|
final hoursSinceLastSeen = now.difference(c.lastSeen).inHours;
|
|
return hoursSinceLastSeen <= settings.mapTimeFilterHours;
|
|
}).toList();
|
|
|
|
// Quick activity filter (search bar chips)
|
|
final filteredByFreshness = switch (_freshness) {
|
|
_Freshness.all => filteredByTime,
|
|
_Freshness.online =>
|
|
filteredByTime.where((c) => _ageOf(c) == _NodeAge.online).toList(),
|
|
_Freshness.recent =>
|
|
filteredByTime.where((c) => _ageOf(c) != _NodeAge.stale).toList(),
|
|
_Freshness.stale =>
|
|
filteredByTime.where((c) => _ageOf(c) == _NodeAge.stale).toList(),
|
|
};
|
|
|
|
// Filter by key prefix
|
|
final keyPrefix = settings.mapKeyPrefix.trim();
|
|
final filteredByKeyPrefix =
|
|
(settings.mapKeyPrefixEnabled && keyPrefix.isNotEmpty)
|
|
? filteredByFreshness.where((c) {
|
|
return c.publicKeyHex.toLowerCase().startsWith(
|
|
keyPrefix.toLowerCase(),
|
|
);
|
|
}).toList()
|
|
: filteredByFreshness;
|
|
|
|
// Filter by location
|
|
final contactsWithLocation = filteredByKeyPrefix.where((c) {
|
|
return c.hasLocation;
|
|
}).toList();
|
|
|
|
// All contacts with a known location — used as anchors regardless of
|
|
// time/key-prefix filters so that repeaters are always available.
|
|
final allContactsWithLocation = allContacts
|
|
.where((c) => c.hasLocation)
|
|
.toList();
|
|
|
|
// Guessed markers represent the same node types as known-location
|
|
// markers, so apply the node-type filters before estimating positions.
|
|
final guessCandidates = _filterContactsBySettings(
|
|
filteredByKeyPrefix,
|
|
settings,
|
|
noLocations: true,
|
|
);
|
|
|
|
// Compute guessed locations with caching
|
|
final maxRangeKm = _estimateLoRaRangeKm(connector);
|
|
final filteredKeys = guessCandidates
|
|
.map((c) => '${c.publicKeyHex}:${c.path.join("-")}')
|
|
.join(',');
|
|
final anchorKeys = allContactsWithLocation
|
|
.map(
|
|
(c) =>
|
|
'${c.publicKeyHex}:${c.latitude}:${c.longitude}:${c.path.isNotEmpty ? c.path.last : ""}',
|
|
)
|
|
.join(',');
|
|
final cacheKey =
|
|
'$filteredKeys|$anchorKeys|$pathHistoryVersion:${connector.currentFreqHz}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}';
|
|
if (cacheKey != _guessedLocationsCacheKey) {
|
|
_guessedLocationsCacheKey = cacheKey;
|
|
_cachedGuessedLocations = settings.mapShowGuessedLocations
|
|
? _computeGuessedLocations(
|
|
guessCandidates,
|
|
allContactsWithLocation,
|
|
pathHistory,
|
|
maxRangeKm,
|
|
)
|
|
: [];
|
|
}
|
|
final guessedLocations = settings.mapShowGuessedLocations
|
|
? _cachedGuessedLocations
|
|
: <_GuessedLocation>[];
|
|
|
|
_polylines.clear();
|
|
_polylines.addAll(
|
|
_points.length > 1
|
|
? [
|
|
Polyline(
|
|
points: _points,
|
|
strokeWidth: 4,
|
|
color: MapPalette.selected,
|
|
),
|
|
]
|
|
: <Polyline>[],
|
|
);
|
|
|
|
// Collect polylines for shared markers' history with dashed lines
|
|
final List<Polyline> sharedMarkerPolylines = [];
|
|
for (final marker in sharedMarkers) {
|
|
if (marker.history.isNotEmpty) {
|
|
final points = List<LatLng>.from(marker.history);
|
|
points.add(marker.position);
|
|
sharedMarkerPolylines.add(
|
|
Polyline(
|
|
points: points,
|
|
color: marker.isChannel
|
|
? (marker.isPublicChannel
|
|
? MapPalette.cluster
|
|
: MapPalette.router)
|
|
: MapPalette.shared,
|
|
strokeWidth: 3,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Calculate center and zoom of all nodes, or default to (0, 0)
|
|
LatLng center = const LatLng(0, 0);
|
|
double initialZoom = 10.0;
|
|
final hasMapContent =
|
|
contactsWithLocation.isNotEmpty ||
|
|
sharedMarkers.isNotEmpty ||
|
|
_isSelectingPoi ||
|
|
highlightPosition != null;
|
|
if (contactsWithLocation.isNotEmpty || sharedMarkers.isNotEmpty) {
|
|
final allPoints = [
|
|
...contactsWithLocation.map(
|
|
(c) => LatLng(c.latitude!, c.longitude!),
|
|
),
|
|
...sharedMarkers.map((m) => m.position),
|
|
];
|
|
if (allPoints.length >= 3) {
|
|
final latValues = allPoints.map((p) => p.latitude).toList();
|
|
final lonValues = allPoints.map((p) => p.longitude).toList();
|
|
|
|
final meanLat =
|
|
latValues.reduce((a, b) => a + b) / latValues.length;
|
|
final meanLon =
|
|
lonValues.reduce((a, b) => a + b) / lonValues.length;
|
|
final latStdDev = _standardDeviation(latValues);
|
|
final lonStdDev = _standardDeviation(lonValues);
|
|
|
|
final filteredPoints = allPoints
|
|
.where(
|
|
(p) =>
|
|
(p.latitude - meanLat).abs() <= latStdDev * 2 &&
|
|
(p.longitude - meanLon).abs() <= lonStdDev * 2,
|
|
)
|
|
.toList();
|
|
|
|
if (filteredPoints.isNotEmpty) {
|
|
final filteredLatValues = filteredPoints
|
|
.map((p) => p.latitude)
|
|
.toList();
|
|
final filteredLonValues = filteredPoints
|
|
.map((p) => p.longitude)
|
|
.toList();
|
|
final avgLat = filteredLatValues.reduce((a, b) => a + b);
|
|
final avgLon = filteredLonValues.reduce((a, b) => a + b);
|
|
center = LatLng(
|
|
avgLat / filteredPoints.length,
|
|
avgLon / filteredPoints.length,
|
|
);
|
|
// Use std deviation of filtered points for zoom
|
|
final filteredLatStdDev = _standardDeviation(filteredLatValues);
|
|
final filteredLonStdDev = _standardDeviation(filteredLonValues);
|
|
initialZoom = _zoomFromStdDev(
|
|
filteredLatStdDev,
|
|
filteredLonStdDev,
|
|
);
|
|
} else {
|
|
center = LatLng(meanLat, meanLon);
|
|
initialZoom = _zoomFromStdDev(latStdDev, lonStdDev);
|
|
}
|
|
} else {
|
|
double avgLat = 0.0;
|
|
double avgLon = 0.0;
|
|
for (final point in allPoints) {
|
|
avgLat += point.latitude;
|
|
avgLon += point.longitude;
|
|
}
|
|
center = LatLng(
|
|
avgLat / allPoints.length,
|
|
avgLon / allPoints.length,
|
|
);
|
|
initialZoom = 12.0;
|
|
}
|
|
}
|
|
if (highlightPosition != null) {
|
|
center = highlightPosition;
|
|
initialZoom = widget.highlightZoom;
|
|
}
|
|
|
|
// Re center map after removed markers have loaded
|
|
if (!_hasInitializedMap && _removedMarkersLoaded) {
|
|
_hasInitializedMap = true;
|
|
_showNodeLabels = initialZoom >= _labelZoomThreshold;
|
|
_zoom = initialZoom;
|
|
if (hasMapContent) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
_mapController.move(center, initialZoom);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
final allowBack = !connector.isConnected;
|
|
|
|
final visibleContacts = _filterContactsBySettings(
|
|
contactsWithLocation,
|
|
settings,
|
|
);
|
|
Contact? selectedContact;
|
|
if (_selectedKey != null) {
|
|
for (final c in allContacts) {
|
|
if (c.publicKeyHex == _selectedKey) {
|
|
selectedContact = c;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
final locatedTotal = allContacts.where((c) => c.hasLocation).length;
|
|
final hiddenCount = max(0, locatedTotal - visibleContacts.length);
|
|
final onlineCount = visibleContacts
|
|
.where((c) => _ageOf(c) == _NodeAge.online)
|
|
.length;
|
|
final repeaterCount = visibleContacts
|
|
.where((c) => c.type == advTypeRepeater)
|
|
.length;
|
|
|
|
return PopScope(
|
|
canPop: allowBack,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
backgroundColor: MapPalette.panelDark,
|
|
foregroundColor: MapPalette.textPrimary,
|
|
title: AppBarTitle(context.l10n.map_title),
|
|
centerTitle: true,
|
|
automaticallyImplyLeading: false,
|
|
bottom: const SyncProgressAppBarBottom(),
|
|
actions: [
|
|
PopupMenuButton(
|
|
itemBuilder: (context) => [
|
|
if (!_isBuildingPathTrace &&
|
|
connector.selfLatitude != null &&
|
|
connector.selfLongitude != null)
|
|
PopupMenuItem(
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.radar),
|
|
const SizedBox(width: 8),
|
|
Text(context.l10n.contacts_pathTrace),
|
|
],
|
|
),
|
|
onTap: () => _startPath(
|
|
LatLng(
|
|
connector.selfLatitude!,
|
|
connector.selfLongitude!,
|
|
),
|
|
),
|
|
),
|
|
if (!_isBuildingPathTrace)
|
|
PopupMenuItem(
|
|
child: Row(
|
|
children: [
|
|
const LosIcon(),
|
|
const SizedBox(width: 8),
|
|
Text(context.l10n.map_lineOfSight),
|
|
],
|
|
),
|
|
onTap: () {
|
|
final candidates = <LineOfSightEndpoint>[];
|
|
if (connector.selfLatitude != null &&
|
|
connector.selfLongitude != null) {
|
|
candidates.add(
|
|
LineOfSightEndpoint(
|
|
label: context.l10n.pathTrace_you,
|
|
point: LatLng(
|
|
connector.selfLatitude!,
|
|
connector.selfLongitude!,
|
|
),
|
|
color: MapPalette.selected,
|
|
icon: Icons.person_pin_circle,
|
|
),
|
|
);
|
|
}
|
|
for (final c in contactsWithLocation) {
|
|
candidates.add(
|
|
LineOfSightEndpoint(
|
|
label: c.name,
|
|
point: LatLng(c.latitude!, c.longitude!),
|
|
color: _getNodeColor(c.type),
|
|
icon: _getNodeIcon(c.type),
|
|
),
|
|
);
|
|
}
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => LineOfSightMapScreen(
|
|
title: context.l10n.map_losScreenTitle,
|
|
candidates: candidates,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
PopupMenuItem(
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.logout,
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(context.l10n.common_disconnect),
|
|
],
|
|
),
|
|
onTap: () => _disconnect(context, connector),
|
|
),
|
|
PopupMenuItem(
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.settings),
|
|
const SizedBox(width: 8),
|
|
Text(context.l10n.settings_title),
|
|
],
|
|
),
|
|
onTap: () => Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const SettingsScreen(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
icon: const Icon(Icons.more_vert),
|
|
),
|
|
],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
FlutterMap(
|
|
mapController: _mapController,
|
|
options: MapOptions(
|
|
initialCenter: center,
|
|
initialZoom: initialZoom,
|
|
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(),
|
|
),
|
|
onTap: (_, latLng) {
|
|
if (_isSelectingPoi) {
|
|
setState(() {
|
|
_isSelectingPoi = false;
|
|
});
|
|
_shareMarker(
|
|
context: context,
|
|
connector: connector,
|
|
position: latLng,
|
|
defaultLabel: context.l10n.map_pointOfInterest,
|
|
flags: 'poi',
|
|
);
|
|
return;
|
|
}
|
|
// Tapping empty map dismisses selection + search.
|
|
if (_selectedKey != null || _searchQuery.isNotEmpty) {
|
|
setState(() {
|
|
_selectedKey = null;
|
|
_selectedGuessPos = null;
|
|
_searchQuery = '';
|
|
_searchController.clear();
|
|
_searchFocus.unfocus();
|
|
});
|
|
}
|
|
},
|
|
onLongPress: (_, latLng) {
|
|
if (_isSelectingPoi) {
|
|
setState(() {
|
|
_isSelectingPoi = false;
|
|
});
|
|
_shareMarker(
|
|
context: context,
|
|
connector: connector,
|
|
position: latLng,
|
|
defaultLabel: context.l10n.map_pointOfInterest,
|
|
flags: 'poi',
|
|
);
|
|
return;
|
|
}
|
|
_showShareMarkerAtPositionSheet(
|
|
context: context,
|
|
connector: connector,
|
|
position: latLng,
|
|
);
|
|
},
|
|
onPositionChanged: (camera, hasGesture) {
|
|
// Track zoom in half-step buckets so cluster/marker
|
|
// detail levels update without rebuilding every frame.
|
|
final bucket = (camera.zoom * 2).roundToDouble() / 2;
|
|
final shouldShow = camera.zoom >= _labelZoomThreshold;
|
|
if ((bucket != _zoom || shouldShow != _showNodeLabels) &&
|
|
mounted) {
|
|
setState(() {
|
|
_zoom = bucket;
|
|
_showNodeLabels = shouldShow;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
children: [
|
|
ThemedMapTileLayer(tileCache: tileCache),
|
|
if (_polylines.isNotEmpty && _isBuildingPathTrace)
|
|
PolylineLayer(polylines: _polylines),
|
|
if (sharedMarkerPolylines.isNotEmpty)
|
|
PolylineLayer(polylines: sharedMarkerPolylines),
|
|
MarkerLayer(
|
|
markers: [
|
|
if (highlightPosition != null)
|
|
Marker(
|
|
point: highlightPosition,
|
|
width: 44,
|
|
height: 44,
|
|
child: IgnorePointer(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: MapPalette.batteryLow,
|
|
border: Border.all(
|
|
color: MapPalette.markerOutline,
|
|
width: 3,
|
|
),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: MapPalette.markerShadow,
|
|
blurRadius: 8,
|
|
offset: Offset(0, 3),
|
|
),
|
|
],
|
|
),
|
|
child: const Icon(
|
|
Icons.location_on,
|
|
color: Colors.white,
|
|
size: 25,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (!settings.mapShowOverlaps &&
|
|
(_zoom >= _guessedZoomThreshold ||
|
|
_isBuildingPathTrace))
|
|
..._buildGuessedMarker(
|
|
guessedLocations,
|
|
showLabels: _showNodeLabels,
|
|
),
|
|
..._buildNodeMarkersCached(
|
|
visibleContacts,
|
|
settings,
|
|
connectorSnapshot.contactsSignature,
|
|
connectorSnapshot.batterySignature,
|
|
_freshness,
|
|
settings.mapTimeFilterHours,
|
|
settings.mapKeyPrefixEnabled,
|
|
settings.mapKeyPrefix,
|
|
settings.mapShowDiscoveryContacts,
|
|
Object.hashAllUnordered(
|
|
settings.batteryChemistryByRepeaterId.entries.map(
|
|
(entry) => Object.hash(entry.key, entry.value),
|
|
),
|
|
),
|
|
showLabels: _showNodeLabels,
|
|
selectedContact: selectedContact,
|
|
),
|
|
...sharedMarkers.map(_buildSharedMarker),
|
|
if (connector.selfLatitude != null &&
|
|
connector.selfLongitude != null)
|
|
Marker(
|
|
point: LatLng(
|
|
connector.selfLatitude!,
|
|
connector.selfLongitude!,
|
|
),
|
|
width: 40,
|
|
height: 40,
|
|
child: IgnorePointer(
|
|
ignoring: true,
|
|
child: Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: MapPalette.panelDark,
|
|
border: Border.all(
|
|
color: MapPalette.markerOutline,
|
|
width: 2.5,
|
|
),
|
|
boxShadow: [
|
|
const BoxShadow(
|
|
color: MapPalette.markerShadow,
|
|
blurRadius: 8,
|
|
offset: Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: const Icon(
|
|
Icons.person_pin_circle,
|
|
color: MapPalette.selected,
|
|
size: 22,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (_showNodeLabels &&
|
|
connector.selfLatitude != null &&
|
|
connector.selfLongitude != null)
|
|
_buildNodeLabelMarker(
|
|
point: LatLng(
|
|
connector.selfLatitude!,
|
|
connector.selfLongitude!,
|
|
),
|
|
label: context.l10n.pathTrace_you,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
if (selectedContact == null)
|
|
_buildControlRail(
|
|
context,
|
|
center: center,
|
|
zoom: initialZoom,
|
|
connector: connector,
|
|
),
|
|
if (!_isBuildingPathTrace)
|
|
_buildTopOverlay(
|
|
context,
|
|
connector: connector,
|
|
settingsService: settingsService,
|
|
allContacts: allContacts,
|
|
guessedLocations: guessedLocations,
|
|
visibleCount:
|
|
visibleContacts.length +
|
|
((settings.mapShowGuessedLocations &&
|
|
_zoom >= _guessedZoomThreshold)
|
|
? guessedLocations.length
|
|
: 0),
|
|
onlineCount: onlineCount,
|
|
repeaterCount: repeaterCount,
|
|
hiddenCount: hiddenCount,
|
|
pinCount: sharedMarkers.length,
|
|
),
|
|
if (_isBuildingPathTrace) _buildPathTraceOverlay(),
|
|
if (selectedContact != null && !_isBuildingPathTrace)
|
|
_buildSelectedNodeCard(context, selectedContact, connector),
|
|
],
|
|
),
|
|
bottomNavigationBar: SafeArea(
|
|
top: false,
|
|
child: QuickSwitchBar(
|
|
selectedIndex: 2,
|
|
onDestinationSelected: (index) =>
|
|
_handleQuickSwitch(index, context),
|
|
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
|
|
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
|
|
highContrast: true,
|
|
),
|
|
),
|
|
floatingActionButton:
|
|
(selectedContact == null && !_isBuildingPathTrace)
|
|
? FloatingActionButton(
|
|
onPressed: () => _showFilterSheet(context, settingsService),
|
|
tooltip: context.l10n.map_filterNodes,
|
|
child: const Icon(Icons.filter_list),
|
|
)
|
|
: null,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
List<Marker> _buildNodeMarkersCached(
|
|
List<Contact> contacts,
|
|
AppSettings settings,
|
|
int contactsSignature,
|
|
int batterySignature,
|
|
_Freshness freshness,
|
|
double timeFilterHours,
|
|
bool keyPrefixEnabled,
|
|
String keyPrefix,
|
|
bool showDiscoveryContacts,
|
|
int batteryChemistrySignature, {
|
|
required bool showLabels,
|
|
Contact? selectedContact,
|
|
}) {
|
|
final visibleContactsSignature = Object.hashAll(
|
|
contacts.map(
|
|
(contact) =>
|
|
Object.hash(_mapContactSignature(contact), _ageOf(contact)),
|
|
),
|
|
);
|
|
final key = _NodeMarkersCacheKey(
|
|
contactsSignature: contactsSignature,
|
|
visibleContactsSignature: visibleContactsSignature,
|
|
batterySignature: batterySignature,
|
|
freshness: freshness,
|
|
timeFilterHours: timeFilterHours,
|
|
keyPrefixEnabled: keyPrefixEnabled,
|
|
keyPrefix: keyPrefix,
|
|
showDiscoveryContacts: showDiscoveryContacts,
|
|
batteryChemistrySignature: batteryChemistrySignature,
|
|
showLabels: showLabels,
|
|
selectedKey: selectedContact?.publicKeyHex,
|
|
zoom: _zoom,
|
|
overlapsMode: settings.mapShowOverlaps,
|
|
showRepeaters: settings.mapShowRepeaters,
|
|
showChatNodes: settings.mapShowChatNodes,
|
|
showOtherNodes: settings.mapShowOtherNodes,
|
|
isBuildingPathTrace: _isBuildingPathTrace,
|
|
);
|
|
if (key != _nodeMarkersCacheKey) {
|
|
_nodeMarkersCacheKey = key;
|
|
_cachedNodeMarkers = List.unmodifiable(
|
|
_buildNodeMarkers(
|
|
contacts,
|
|
settings,
|
|
showLabels: showLabels,
|
|
selectedContact: selectedContact,
|
|
),
|
|
);
|
|
}
|
|
return _cachedNodeMarkers;
|
|
}
|
|
|
|
List<_GuessedLocation> _computeGuessedLocations(
|
|
List<Contact> allContacts,
|
|
List<Contact> withLocation,
|
|
PathHistoryService pathHistory,
|
|
double? maxRangeKm,
|
|
) {
|
|
// Index known-location repeaters by their 1-byte hash.
|
|
// null value = two repeaters share the same hash byte (ambiguous collision).
|
|
final repeaterByHash = <int, Contact?>{};
|
|
|
|
for (final c in withLocation) {
|
|
if (c.type == advTypeRepeater) {
|
|
if (repeaterByHash.containsKey(c.publicKey[0])) {
|
|
repeaterByHash[c.publicKey[0]] =
|
|
null; // collision: can't disambiguate
|
|
} else {
|
|
repeaterByHash[c.publicKey[0]] = c;
|
|
}
|
|
}
|
|
}
|
|
|
|
final result = <_GuessedLocation>[];
|
|
|
|
for (final contact in allContacts) {
|
|
if (contact.hasLocation) continue;
|
|
if (contact.lastSeen.isBefore(
|
|
DateTime.now().subtract(const Duration(hours: 24)),
|
|
)) {
|
|
continue; // skip stale contacts
|
|
}
|
|
|
|
final anchorSet = <LatLng>{};
|
|
|
|
// Collect the contact-side (last-hop) repeater from every known path.
|
|
// path = [device-side hop, ..., contact-side hop]
|
|
// Only path.last is actually within radio range of the contact — using
|
|
// earlier bytes would anchor against our own side of the network.
|
|
final pathSets = <List<int>>[
|
|
contact.path.toList(),
|
|
...pathHistory
|
|
.getRecentPaths(contact.publicKeyHex)
|
|
.map((r) => r.pathBytes),
|
|
];
|
|
final lastHopBytes = <int>{};
|
|
for (final pathBytes in pathSets) {
|
|
if (pathBytes.isEmpty) continue;
|
|
final lastHop = pathBytes.last;
|
|
lastHopBytes.add(lastHop);
|
|
final r = repeaterByHash[lastHop];
|
|
if (r != null) anchorSet.add(LatLng(r.latitude!, r.longitude!));
|
|
}
|
|
|
|
// Filter anchors that are geometrically inconsistent with radio range.
|
|
// Two anchors more than 2 * maxRange apart cannot both be in direct radio
|
|
// range of the same node, so isolated outliers are removed.
|
|
final anchors = maxRangeKm != null && anchorSet.length > 1
|
|
? _filterConsistentAnchors(anchorSet.toList(), maxRangeKm)
|
|
: anchorSet.toList();
|
|
|
|
if (anchors.isEmpty) continue;
|
|
|
|
final LatLng position;
|
|
if (anchors.length == 1) {
|
|
// Spread single-anchor guesses around the anchor so they remain visible.
|
|
position = _offsetGuessedPosition(
|
|
anchors[0],
|
|
contact,
|
|
radiusMeters: 330,
|
|
);
|
|
if (!_checkLocationPlausibility(
|
|
position.latitude,
|
|
position.longitude,
|
|
)) {
|
|
continue; // discard implausible guesses near (0, 0)
|
|
}
|
|
} else {
|
|
double lat = 0, lon = 0, weight = 1.0;
|
|
int counted = 0;
|
|
for (final a in anchors) {
|
|
if (counted == 0) {
|
|
lat = a.latitude;
|
|
lon = a.longitude;
|
|
} else {
|
|
lat += a.latitude * weight;
|
|
lon += a.longitude * weight;
|
|
}
|
|
// weight subsequent anchors less to create a bias towards the first (if more than 2)
|
|
weight = weight / 2;
|
|
counted++;
|
|
}
|
|
position = _offsetGuessedPosition(
|
|
LatLng(lat / anchors.length, lon / anchors.length),
|
|
contact,
|
|
radiusMeters: anchors.length >= 3 ? 80 : 120,
|
|
);
|
|
if (!_checkLocationPlausibility(
|
|
position.latitude,
|
|
position.longitude,
|
|
)) {
|
|
continue; // discard implausible guesses near (0, 0
|
|
}
|
|
}
|
|
result.add(
|
|
_GuessedLocation(
|
|
contact: contact,
|
|
position: position,
|
|
highConfidence: anchors.length >= 2,
|
|
),
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
LatLng _offsetGuessedPosition(
|
|
LatLng anchor,
|
|
Contact contact, {
|
|
required double radiusMeters,
|
|
}) {
|
|
final seed = _guessSeed(contact.publicKey);
|
|
final angle = ((seed & 0xFFFF) / 0x10000) * 2 * pi;
|
|
final latOffsetDeg = (radiusMeters / 111320.0) * cos(angle);
|
|
final lonScale = max(cos(anchor.latitude * pi / 180.0).abs(), 0.2);
|
|
final lonOffsetDeg = (radiusMeters / (111320.0 * lonScale)) * sin(angle);
|
|
return LatLng(
|
|
anchor.latitude + latOffsetDeg,
|
|
anchor.longitude + lonOffsetDeg,
|
|
);
|
|
}
|
|
|
|
int _guessSeed(Uint8List publicKey) {
|
|
var seed = 0x811C9DC5;
|
|
for (final byte in publicKey) {
|
|
seed ^= byte;
|
|
seed = (seed * 0x01000193) & 0x7FFFFFFF;
|
|
}
|
|
return seed;
|
|
}
|
|
|
|
/// Estimates the free-space maximum LoRa range in km from the connected
|
|
/// device's current radio parameters. Returns null if parameters are unknown.
|
|
double? _estimateLoRaRangeKm(MeshCoreConnector connector) {
|
|
final freqHz = connector.currentFreqHz;
|
|
final bwHz = connector.currentBwHz;
|
|
final sf = connector.currentSf;
|
|
final txPower = connector.currentTxPower;
|
|
if (freqHz == null || bwHz == null || sf == null || txPower == null) {
|
|
return null;
|
|
}
|
|
// LoRa receiver sensitivity = thermal noise + NF + required demod SNR
|
|
const noiseFigureDb = 6.0;
|
|
final thermalNoiseDbm = -174.0 + 10 * log(bwHz.toDouble()) / ln10;
|
|
final sensitivityDbm =
|
|
thermalNoiseDbm + noiseFigureDb + _sfToRequiredSnrDb(sf);
|
|
// FSPL at max range equals link budget:
|
|
// FSPL = 20*log10(d_m) + 20*log10(f_hz) - 147.55
|
|
final linkBudgetDb = txPower.toDouble() - sensitivityDbm;
|
|
final exponent =
|
|
(linkBudgetDb + 147.55 - 20 * log(freqHz.toDouble()) / ln10) / 20;
|
|
return pow(10, exponent) / 1000;
|
|
}
|
|
|
|
double _sfToRequiredSnrDb(int sf) {
|
|
switch (sf) {
|
|
case 5:
|
|
return -2.5;
|
|
case 6:
|
|
return -5.0;
|
|
case 7:
|
|
return -7.5;
|
|
case 8:
|
|
return -10.0;
|
|
case 9:
|
|
return -12.5;
|
|
case 10:
|
|
return -15.0;
|
|
case 11:
|
|
return -17.5;
|
|
case 12:
|
|
return -20.0;
|
|
default:
|
|
return -10.0;
|
|
}
|
|
}
|
|
|
|
/// Removes anchors that have no neighbour within 2 * maxRangeKm.
|
|
/// A node cannot be simultaneously in radio range of two points farther apart
|
|
/// than twice the expected maximum range.
|
|
List<LatLng> _filterConsistentAnchors(
|
|
List<LatLng> anchors,
|
|
double maxRangeKm,
|
|
) {
|
|
const distance = Distance();
|
|
final maxDistM = maxRangeKm * 2000;
|
|
return anchors
|
|
.where((a) => anchors.any((b) => b != a && distance(a, b) <= maxDistM))
|
|
.toList();
|
|
}
|
|
|
|
List<Marker> _buildGuessedMarker(
|
|
List<_GuessedLocation> guessed, {
|
|
required bool showLabels,
|
|
}) {
|
|
final markers = <Marker>[];
|
|
|
|
for (final guess in guessed) {
|
|
if (guess.contact.type == advTypeChat && _isBuildingPathTrace) {
|
|
continue;
|
|
}
|
|
|
|
final color = _getNodeColor(guess.contact.type);
|
|
final marker = Marker(
|
|
point: guess.position,
|
|
width: 48,
|
|
height: 48,
|
|
child: GestureDetector(
|
|
onLongPress: () => _isBuildingPathTrace
|
|
? _showNodeInfo(context, guess.contact)
|
|
: null,
|
|
onTap: () => _isBuildingPathTrace
|
|
? _addToPath(context, guess.contact, position: guess.position)
|
|
: _selectNode(guess.contact, guessedPosition: guess.position),
|
|
child: Center(
|
|
child: Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: MapPalette.panelDark,
|
|
border: Border.all(
|
|
color: guess.highConfidence ? color : MapPalette.textMuted,
|
|
width: guess.highConfidence ? 2.5 : 2,
|
|
),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: MapPalette.markerShadow,
|
|
blurRadius: 7,
|
|
offset: Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Icon(
|
|
Icons.not_listed_location,
|
|
color: MapPalette.textPrimary,
|
|
size: 19,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
markers.add(marker);
|
|
|
|
if (showLabels) {
|
|
markers.add(
|
|
_buildNodeLabelMarker(
|
|
point: guess.position,
|
|
label: guess.contact.name,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return markers;
|
|
}
|
|
|
|
List<Contact> _filterContactsBySettings(
|
|
List<Contact> contacts,
|
|
dynamic settings, {
|
|
bool noLocations = false,
|
|
}) {
|
|
List<Contact> filtered = [];
|
|
bool addContact = false;
|
|
|
|
for (final contact in contacts) {
|
|
addContact = false;
|
|
if (!contact.hasLocation && !noLocations) {
|
|
continue;
|
|
}
|
|
|
|
// Apply node type filters. The overlaps toggle is purely a visual
|
|
// highlight (applied in _buildNodeMarkers) and no longer affects which
|
|
// nodes are shown.
|
|
if (contact.type == advTypeRepeater &&
|
|
(settings.mapShowRepeaters || _isBuildingPathTrace)) {
|
|
addContact = true;
|
|
}
|
|
if (contact.type == advTypeChat &&
|
|
(settings.mapShowChatNodes || _isBuildingPathTrace)) {
|
|
addContact = true;
|
|
}
|
|
if (contact.type != advTypeChat &&
|
|
contact.type != advTypeRepeater &&
|
|
(settings.mapShowOtherNodes || _isBuildingPathTrace)) {
|
|
addContact = true;
|
|
}
|
|
|
|
if (contact.type == advTypeChat && _isBuildingPathTrace) {
|
|
addContact = false;
|
|
}
|
|
|
|
if (addContact) {
|
|
filtered.add(contact);
|
|
}
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
List<Marker> _buildNodeMarkers(
|
|
List<Contact> contacts,
|
|
settings, {
|
|
required bool showLabels,
|
|
Contact? selectedContact,
|
|
}) {
|
|
final markers = <Marker>[];
|
|
final overlapsMode = settings.mapShowOverlaps && !_isBuildingPathTrace;
|
|
final selectedKey = selectedContact?.publicKeyHex;
|
|
final items = contacts.where((c) => c.publicKeyHex != selectedKey).toList();
|
|
|
|
// Key-prefix overlaps are a visual highlight only: flag the repeaters/rooms
|
|
// whose first key byte collides with another repeater/room on the map.
|
|
final overlapPrefixes = <int>{};
|
|
if (overlapsMode) {
|
|
final counts = <int, int>{};
|
|
for (final contact in contacts) {
|
|
if (contact.type == advTypeRepeater || contact.type == advTypeRoom) {
|
|
final prefix = contact.publicKey.first;
|
|
counts[prefix] = (counts[prefix] ?? 0) + 1;
|
|
}
|
|
}
|
|
counts.forEach((prefix, count) {
|
|
if (count > 1) overlapPrefixes.add(prefix);
|
|
});
|
|
}
|
|
bool isOverlap(Contact contact) =>
|
|
overlapsMode &&
|
|
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
|
overlapPrefixes.contains(contact.publicKey.first);
|
|
|
|
void addNode(Contact contact, {bool dot = false}) {
|
|
final overlap = isOverlap(contact);
|
|
markers.add(_nodeMarker(contact, overlapsMode: overlap, dot: dot));
|
|
if (showLabels) {
|
|
markers.add(
|
|
_buildNodeLabelMarker(
|
|
point: LatLng(contact.latitude!, contact.longitude!),
|
|
label: overlap
|
|
? "${contact.publicKeyHex.substring(0, 2)}:${contact.name}"
|
|
: contact.name,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (_zoom >= _clusterOffZoom || overlapsMode || _isBuildingPathTrace) {
|
|
for (final contact in items) {
|
|
addNode(contact);
|
|
}
|
|
} else {
|
|
// Grid clustering: bucket markers into ~64px screen cells at the
|
|
// current zoom; cells with 2+ nodes render as a numbered cluster.
|
|
final cellDeg = 360.0 / (256.0 * pow(2.0, _zoom)) * 64.0;
|
|
final cells = <String, List<Contact>>{};
|
|
for (final contact in items) {
|
|
final key =
|
|
'${(contact.latitude! / cellDeg).floor()}:${(contact.longitude! / cellDeg).floor()}';
|
|
(cells[key] ??= []).add(contact);
|
|
}
|
|
for (final cell in cells.values) {
|
|
if (cell.length == 1) {
|
|
addNode(cell.first, dot: true);
|
|
} else {
|
|
markers.add(_clusterMarker(cell));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Selected node always renders individually on top, even when its
|
|
// neighbors are clustered or it is filtered out.
|
|
if (selectedContact != null && selectedContact.hasLocation) {
|
|
markers.add(
|
|
_nodeMarker(
|
|
selectedContact,
|
|
overlapsMode: isOverlap(selectedContact),
|
|
selected: true,
|
|
),
|
|
);
|
|
markers.add(
|
|
_buildNodeLabelMarker(
|
|
point: LatLng(selectedContact.latitude!, selectedContact.longitude!),
|
|
label: selectedContact.name,
|
|
),
|
|
);
|
|
}
|
|
|
|
return markers;
|
|
}
|
|
|
|
Marker _nodeMarker(
|
|
Contact contact, {
|
|
bool overlapsMode = false,
|
|
bool dot = false,
|
|
bool selected = false,
|
|
}) {
|
|
final age = _ageOf(contact);
|
|
final baseColor = overlapsMode
|
|
? MapPalette.batteryLow
|
|
: _markerColor(contact);
|
|
final stale = age == _NodeAge.stale;
|
|
final online = age == _NodeAge.online;
|
|
final batteryLow = _isBatteryLow(contact);
|
|
final size = selected ? 46.0 : (dot ? 22.0 : 40.0);
|
|
return Marker(
|
|
point: LatLng(contact.latitude!, contact.longitude!),
|
|
width: size,
|
|
height: size,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onLongPress: () =>
|
|
_isBuildingPathTrace ? _showNodeInfo(context, contact) : null,
|
|
onTap: () => _isBuildingPathTrace
|
|
? _addToPath(context, contact)
|
|
: _selectNode(contact),
|
|
child: Center(
|
|
child: dot && !selected
|
|
? Container(
|
|
width: 15,
|
|
height: 15,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: baseColor,
|
|
border: Border.all(
|
|
color: MapPalette.markerOutline,
|
|
width: 2,
|
|
),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: MapPalette.markerShadow,
|
|
blurRadius: 6,
|
|
offset: Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: _buildNodeMarkerWidget(
|
|
color: baseColor,
|
|
icon: _getNodeIcon(contact.type),
|
|
selected: selected,
|
|
stale: stale,
|
|
online: online,
|
|
batteryLow: batteryLow,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Marker _clusterMarker(List<Contact> members) {
|
|
final count = members.length;
|
|
double lat = 0, lon = 0;
|
|
var online = 0;
|
|
for (final m in members) {
|
|
lat += m.latitude!;
|
|
lon += m.longitude!;
|
|
if (_ageOf(m) == _NodeAge.online) online++;
|
|
}
|
|
final center = LatLng(lat / count, lon / count);
|
|
final size = count >= 50
|
|
? 54.0
|
|
: count >= 16
|
|
? 50.0
|
|
: count >= 6
|
|
? 46.0
|
|
: 42.0;
|
|
return Marker(
|
|
point: center,
|
|
width: size,
|
|
height: size,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => _zoomToCluster(members),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: MapPalette.cluster,
|
|
border: Border.all(color: MapPalette.markerOutline, width: 3),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: MapPalette.markerShadow,
|
|
blurRadius: 8,
|
|
offset: Offset(0, 3),
|
|
),
|
|
],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
'$count',
|
|
style: MeshTheme.mono(
|
|
fontSize: count >= 100 ? 11.5 : 13.5,
|
|
fontWeight: FontWeight.w800,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
if (online > 0)
|
|
Container(
|
|
width: 7,
|
|
height: 7,
|
|
margin: const EdgeInsets.only(top: 1),
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: MapPalette.online,
|
|
border: Border.all(color: Colors.white, width: 1),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _zoomToCluster(List<Contact> members) {
|
|
HapticFeedback.selectionClick();
|
|
var minLat = double.infinity, maxLat = -double.infinity;
|
|
var minLon = double.infinity, maxLon = -double.infinity;
|
|
for (final m in members) {
|
|
minLat = min(minLat, m.latitude!);
|
|
maxLat = max(maxLat, m.latitude!);
|
|
minLon = min(minLon, m.longitude!);
|
|
maxLon = max(maxLon, m.longitude!);
|
|
}
|
|
_mapController.fitCamera(
|
|
CameraFit.bounds(
|
|
bounds: LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon)),
|
|
padding: const EdgeInsets.all(72),
|
|
maxZoom: 16,
|
|
),
|
|
);
|
|
}
|
|
|
|
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: MapPalette.panelDark,
|
|
borderRadius: BorderRadius.circular(MeshRadii.xs),
|
|
border: Border.all(color: MapPalette.border),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: MapPalette.markerShadow,
|
|
blurRadius: 4,
|
|
offset: Offset(0, 1),
|
|
),
|
|
],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: MeshTheme.mono(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w700,
|
|
color: MapPalette.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _getNodeColor(int type) {
|
|
switch (type) {
|
|
case advTypeChat:
|
|
return MapPalette.selected;
|
|
case advTypeRepeater:
|
|
return MapPalette.repeater;
|
|
case advTypeRoom:
|
|
return MapPalette.router;
|
|
case advTypeSensor:
|
|
return MapPalette.sensor;
|
|
default:
|
|
return MapPalette.offline;
|
|
}
|
|
}
|
|
|
|
Color _markerColor(Contact contact) {
|
|
switch (contact.type) {
|
|
case advTypeRepeater:
|
|
return MapPalette.repeater;
|
|
case advTypeRoom:
|
|
return MapPalette.router;
|
|
case advTypeSensor:
|
|
return MapPalette.sensor;
|
|
default:
|
|
return _ageColor(_ageOf(contact));
|
|
}
|
|
}
|
|
|
|
bool _isBatteryLow(Contact contact) {
|
|
if (contact.type != advTypeRepeater) return false;
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final millivolts = connector.getRepeaterBatteryMillivolts(
|
|
contact.publicKeyHex,
|
|
);
|
|
if (millivolts == null) return false;
|
|
final chemistry = context
|
|
.read<AppSettingsService>()
|
|
.batteryChemistryForRepeater(contact.publicKeyHex);
|
|
return estimateBatteryPercentFromMillivolts(millivolts, chemistry) <= 20;
|
|
}
|
|
|
|
IconData _getNodeIcon(int type) {
|
|
switch (type) {
|
|
case advTypeChat:
|
|
return Icons.person;
|
|
case advTypeRepeater:
|
|
return Icons.router;
|
|
case advTypeRoom:
|
|
return Icons.meeting_room;
|
|
case advTypeSensor:
|
|
return Icons.sensors;
|
|
default:
|
|
return Icons.device_unknown;
|
|
}
|
|
}
|
|
|
|
Widget _buildNodeMarkerWidget({
|
|
required Color color,
|
|
required IconData icon,
|
|
bool selected = false,
|
|
bool stale = false,
|
|
bool online = false,
|
|
bool batteryLow = false,
|
|
}) {
|
|
final statusColor = batteryLow
|
|
? MapPalette.batteryLow
|
|
: online
|
|
? MapPalette.online
|
|
: stale
|
|
? MapPalette.offline
|
|
: MapPalette.stale;
|
|
return Stack(
|
|
clipBehavior: Clip.none,
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Container(
|
|
width: selected ? 44 : 36,
|
|
height: selected ? 44 : 36,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: selected ? MapPalette.selected : color,
|
|
border: Border.all(
|
|
color: MapPalette.markerOutline,
|
|
width: selected ? 3 : 2.5,
|
|
),
|
|
boxShadow: [
|
|
const BoxShadow(
|
|
color: MapPalette.markerShadow,
|
|
blurRadius: 8,
|
|
offset: Offset(0, 3),
|
|
),
|
|
if (selected)
|
|
BoxShadow(
|
|
color: MapPalette.selected.withValues(alpha: 0.75),
|
|
blurRadius: 14,
|
|
spreadRadius: 3,
|
|
),
|
|
],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Icon(icon, color: Colors.white, size: selected ? 22 : 19),
|
|
),
|
|
Positioned(
|
|
right: selected ? -1 : -2,
|
|
bottom: selected ? 0 : -2,
|
|
child: Container(
|
|
width: batteryLow ? 16 : (selected ? 13 : 12),
|
|
height: batteryLow ? 16 : (selected ? 13 : 12),
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: statusColor,
|
|
border: Border.all(color: MapPalette.panelDark, width: 2),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: batteryLow
|
|
? const Icon(Icons.battery_alert, size: 10, color: Colors.white)
|
|
: null,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildLegendItem(IconData icon, String label, Color color) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 1.5),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 15, color: color),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: MapPalette.textSecondary,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _ageColor(_NodeAge age) {
|
|
switch (age) {
|
|
case _NodeAge.online:
|
|
return MapPalette.online;
|
|
case _NodeAge.recent:
|
|
return MapPalette.stale;
|
|
case _NodeAge.stale:
|
|
return MapPalette.textMuted;
|
|
}
|
|
}
|
|
|
|
String _ageLabel(_NodeAge age) {
|
|
switch (age) {
|
|
case _NodeAge.online:
|
|
return context.l10n.map_online;
|
|
case _NodeAge.recent:
|
|
return context.l10n.map_recent;
|
|
case _NodeAge.stale:
|
|
return context.l10n.map_stale;
|
|
}
|
|
}
|
|
|
|
Widget _buildTopOverlay(
|
|
BuildContext context, {
|
|
required MeshCoreConnector connector,
|
|
required AppSettingsService settingsService,
|
|
required List<Contact> allContacts,
|
|
required List<_GuessedLocation> guessedLocations,
|
|
required int visibleCount,
|
|
required int onlineCount,
|
|
required int repeaterCount,
|
|
required int hiddenCount,
|
|
required int pinCount,
|
|
}) {
|
|
final settings = settingsService.settings;
|
|
final hasQuery = _searchQuery.trim().isNotEmpty;
|
|
return Positioned(
|
|
top: 8,
|
|
left: 12,
|
|
right: 12,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Material(
|
|
color: MapPalette.panelDark,
|
|
shape: StadiumBorder(
|
|
side: const BorderSide(color: MapPalette.border),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: TextField(
|
|
controller: _searchController,
|
|
focusNode: _searchFocus,
|
|
decoration: InputDecoration(
|
|
hintText: context.l10n.map_searchHint,
|
|
hintStyle: const TextStyle(
|
|
color: MapPalette.textSecondary,
|
|
),
|
|
prefixIcon: const Icon(
|
|
Icons.search,
|
|
size: 20,
|
|
color: MapPalette.textPrimary,
|
|
),
|
|
suffixIcon: hasQuery
|
|
? IconButton(
|
|
color: MapPalette.textPrimary,
|
|
icon: const Icon(Icons.close, size: 18),
|
|
onPressed: () {
|
|
setState(() {
|
|
_searchQuery = '';
|
|
_searchController.clear();
|
|
});
|
|
},
|
|
)
|
|
: null,
|
|
filled: false,
|
|
border: InputBorder.none,
|
|
enabledBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 4,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
color: MapPalette.textPrimary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
cursorColor: MapPalette.selected,
|
|
onChanged: (value) {
|
|
setState(() => _searchQuery = value);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Material(
|
|
color: MapPalette.panelDark,
|
|
shape: StadiumBorder(
|
|
side: const BorderSide(color: MapPalette.border),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: () => setState(() => _statsExpanded = !_statsExpanded),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 11,
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(
|
|
Icons.hub,
|
|
size: 15,
|
|
color: MapPalette.selected,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'$visibleCount',
|
|
style: MeshTheme.mono(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
color: MapPalette.textPrimary,
|
|
),
|
|
),
|
|
const SizedBox(width: 2),
|
|
AnimatedRotation(
|
|
turns: _statsExpanded ? 0.5 : 0,
|
|
duration: const Duration(milliseconds: 200),
|
|
child: const Icon(
|
|
Icons.expand_more,
|
|
size: 16,
|
|
color: MapPalette.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final chips = <Widget>[
|
|
_mapChip(
|
|
label: context.l10n.time_allTime,
|
|
selected: _freshness == _Freshness.all,
|
|
onTap: () => setState(() => _freshness = _Freshness.all),
|
|
),
|
|
_mapChip(
|
|
label: context.l10n.map_online,
|
|
selected: _freshness == _Freshness.online,
|
|
color: MapPalette.online,
|
|
onTap: () => setState(() => _freshness = _Freshness.online),
|
|
),
|
|
_mapChip(
|
|
label: context.l10n.map_recent,
|
|
selected: _freshness == _Freshness.recent,
|
|
color: MapPalette.stale,
|
|
onTap: () => setState(() => _freshness = _Freshness.recent),
|
|
),
|
|
_mapChip(
|
|
label: context.l10n.map_stale,
|
|
selected: _freshness == _Freshness.stale,
|
|
color: MapPalette.offline,
|
|
onTap: () => setState(() => _freshness = _Freshness.stale),
|
|
),
|
|
_mapChip(
|
|
label: context.l10n.map_repeaters,
|
|
selected: settings.mapShowRepeaters,
|
|
color: MapPalette.repeater,
|
|
onTap: () => settingsService.setMapShowRepeaters(
|
|
!settings.mapShowRepeaters,
|
|
),
|
|
),
|
|
_mapChip(
|
|
label: context.l10n.map_chatNodes,
|
|
selected: settings.mapShowChatNodes,
|
|
color: MapPalette.selected,
|
|
onTap: () => settingsService.setMapShowChatNodes(
|
|
!settings.mapShowChatNodes,
|
|
),
|
|
),
|
|
];
|
|
|
|
if (constraints.maxWidth < 600) {
|
|
return Wrap(runSpacing: 6, children: chips);
|
|
}
|
|
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(children: chips),
|
|
);
|
|
},
|
|
),
|
|
if (hasQuery)
|
|
_buildSearchResults(context, allContacts, guessedLocations)
|
|
else if (_statsExpanded)
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: _buildStatsCard(
|
|
context,
|
|
settings: settings,
|
|
visibleCount: visibleCount,
|
|
onlineCount: onlineCount,
|
|
repeaterCount: repeaterCount,
|
|
hiddenCount: hiddenCount,
|
|
pinCount: pinCount,
|
|
guessedCount: guessedLocations.length,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _mapChip({
|
|
required String label,
|
|
required bool selected,
|
|
required VoidCallback onTap,
|
|
Color? color,
|
|
}) {
|
|
final accent = color ?? MapPalette.selected;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 6),
|
|
child: Material(
|
|
color: selected
|
|
? Color.alphaBlend(
|
|
accent.withValues(alpha: 0.34),
|
|
MapPalette.panelDark,
|
|
)
|
|
: MapPalette.panelDark,
|
|
shape: StadiumBorder(
|
|
side: BorderSide(
|
|
color: selected ? accent : MapPalette.border,
|
|
width: selected ? 1.5 : 1,
|
|
),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: () {
|
|
HapticFeedback.selectionClick();
|
|
onTap();
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (selected) ...[
|
|
const Icon(
|
|
Icons.check,
|
|
size: 13,
|
|
color: MapPalette.textPrimary,
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: selected
|
|
? MapPalette.textPrimary
|
|
: MapPalette.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchResults(
|
|
BuildContext context,
|
|
List<Contact> allContacts,
|
|
List<_GuessedLocation> guessedLocations,
|
|
) {
|
|
final query = _searchQuery.trim().toLowerCase();
|
|
final matches =
|
|
allContacts.where((c) => matchesContactQuery(c, query)).toList()
|
|
..sort((a, b) {
|
|
if (a.hasLocation != b.hasLocation) {
|
|
return a.hasLocation ? -1 : 1;
|
|
}
|
|
return b.lastSeen.compareTo(a.lastSeen);
|
|
});
|
|
final results = matches.take(8).toList();
|
|
return Container(
|
|
margin: const EdgeInsets.only(top: 6),
|
|
constraints: const BoxConstraints(maxHeight: 300),
|
|
decoration: BoxDecoration(
|
|
color: MapPalette.panelDark,
|
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
|
border: Border.all(color: MapPalette.border),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: MapPalette.markerShadow,
|
|
blurRadius: 10,
|
|
offset: Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: results.isEmpty
|
|
? Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text(
|
|
context.l10n.map_noResults,
|
|
style: const TextStyle(
|
|
color: MapPalette.textSecondary,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
)
|
|
: ListView.separated(
|
|
shrinkWrap: true,
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
itemCount: results.length,
|
|
separatorBuilder: (_, _) =>
|
|
const Divider(height: 1, color: MapPalette.border),
|
|
itemBuilder: (context, index) {
|
|
final c = results[index];
|
|
final color = _getNodeColor(c.type);
|
|
return InkWell(
|
|
onTap: () => _onSearchResultTap(c, guessedLocations),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(_getNodeIcon(c.type), size: 18, color: color),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
c.name,
|
|
style: const TextStyle(
|
|
fontSize: 13.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: MapPalette.textPrimary,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
Text(
|
|
c.publicKeyHex.substring(0, 12),
|
|
style: MeshTheme.mono(
|
|
fontSize: 10.5,
|
|
color: MapPalette.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (c.hasLocation)
|
|
Icon(
|
|
Icons.chevron_right,
|
|
size: 18,
|
|
color: MapPalette.textSecondary,
|
|
)
|
|
else
|
|
Text(
|
|
context.l10n.map_noGps.toUpperCase(),
|
|
style: MeshTheme.accentLabel(
|
|
color: MapPalette.textMuted,
|
|
fontSize: 8.5,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _onSearchResultTap(
|
|
Contact contact,
|
|
List<_GuessedLocation> guessedLocations,
|
|
) {
|
|
if (contact.hasLocation) {
|
|
_selectNode(contact);
|
|
_mapController.move(
|
|
LatLng(contact.latitude!, contact.longitude!),
|
|
max(_zoom, 14),
|
|
);
|
|
return;
|
|
}
|
|
_GuessedLocation? guess;
|
|
for (final g in guessedLocations) {
|
|
if (g.contact.publicKeyHex == contact.publicKeyHex) {
|
|
guess = g;
|
|
break;
|
|
}
|
|
}
|
|
if (guess != null) {
|
|
_selectNode(contact, guessedPosition: guess.position);
|
|
_mapController.move(guess.position, max(_zoom, 13));
|
|
} else {
|
|
setState(() {
|
|
_searchQuery = '';
|
|
_searchController.clear();
|
|
_searchFocus.unfocus();
|
|
});
|
|
_showNodeInfo(context, contact);
|
|
}
|
|
}
|
|
|
|
Widget _buildStatsCard(
|
|
BuildContext context, {
|
|
required dynamic settings,
|
|
required int visibleCount,
|
|
required int onlineCount,
|
|
required int repeaterCount,
|
|
required int hiddenCount,
|
|
required int pinCount,
|
|
required int guessedCount,
|
|
}) {
|
|
return Container(
|
|
margin: const EdgeInsets.only(top: 6),
|
|
width: 230,
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
|
decoration: BoxDecoration(
|
|
color: MapPalette.panelDark,
|
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
|
border: Border.all(color: MapPalette.border),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: MapPalette.markerShadow,
|
|
blurRadius: 10,
|
|
offset: Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_statRow(context.l10n.map_visible, visibleCount, MapPalette.selected),
|
|
_statRow(context.l10n.map_online, onlineCount, MapPalette.online),
|
|
_statRow(
|
|
context.l10n.map_repeaters,
|
|
repeaterCount,
|
|
MapPalette.repeater,
|
|
),
|
|
_statRow(context.l10n.map_hidden, hiddenCount, MapPalette.offline),
|
|
_statRow(context.l10n.map_markers, pinCount, MapPalette.shared),
|
|
const Divider(height: 16, color: MapPalette.border),
|
|
_buildLegendItem(
|
|
Icons.person,
|
|
context.l10n.map_chat,
|
|
MapPalette.selected,
|
|
),
|
|
_buildLegendItem(
|
|
Icons.router,
|
|
context.l10n.map_repeater,
|
|
MapPalette.repeater,
|
|
),
|
|
_buildLegendItem(
|
|
Icons.meeting_room,
|
|
context.l10n.map_room,
|
|
MapPalette.router,
|
|
),
|
|
_buildLegendItem(
|
|
Icons.sensors,
|
|
context.l10n.map_sensor,
|
|
MapPalette.sensor,
|
|
),
|
|
_buildLegendItem(
|
|
Icons.flag,
|
|
context.l10n.map_pinDm,
|
|
MapPalette.shared,
|
|
),
|
|
if (settings.mapShowGuessedLocations && guessedCount > 0)
|
|
_buildLegendItem(
|
|
Icons.not_listed_location,
|
|
context.l10n.map_guessedLocation,
|
|
MapPalette.textMuted,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _statRow(String label, int value, Color color) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 7,
|
|
height: 7,
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: TextStyle(fontSize: 12.5, color: MapPalette.textSecondary),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Text(
|
|
'$value',
|
|
style: MeshTheme.mono(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
color: MapPalette.textPrimary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSelectedNodeCard(
|
|
BuildContext context,
|
|
Contact contact,
|
|
MeshCoreConnector connector,
|
|
) {
|
|
final color = _markerColor(contact);
|
|
final age = _ageOf(contact);
|
|
final pos = contact.hasLocation
|
|
? LatLng(contact.latitude!, contact.longitude!)
|
|
: _selectedGuessPos;
|
|
return Positioned(
|
|
left: 12,
|
|
right: 12,
|
|
bottom: 12,
|
|
child: TweenAnimationBuilder<double>(
|
|
tween: Tween(begin: 1, end: 0),
|
|
duration: const Duration(milliseconds: 220),
|
|
curve: Curves.easeOutCubic,
|
|
builder: (context, t, child) => Transform.translate(
|
|
offset: Offset(0, 32 * t),
|
|
child: Opacity(opacity: 1 - t, child: child),
|
|
),
|
|
child: MeshCard(
|
|
margin: EdgeInsets.zero,
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
|
|
color: MapPalette.panelDark,
|
|
borderColor: MapPalette.border,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
AvatarCircle(
|
|
name: contact.name,
|
|
size: 38,
|
|
color: color,
|
|
icon: _getNodeIcon(contact.type),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
contact.name,
|
|
style: const TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: MapPalette.textPrimary,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
if (contact.isFavorite) ...[
|
|
const SizedBox(width: 4),
|
|
const Icon(
|
|
Icons.star,
|
|
size: 14,
|
|
color: MapPalette.stale,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 3),
|
|
Row(
|
|
children: [
|
|
StatusChip(
|
|
label: _ageLabel(age),
|
|
color: _ageColor(age),
|
|
fontSize: 9.5,
|
|
pulse: age == _NodeAge.online,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
contact.typeLabel(context.l10n),
|
|
style: const TextStyle(
|
|
fontSize: 11.5,
|
|
color: MapPalette.textSecondary,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (pos != null)
|
|
IconButton(
|
|
color: MapPalette.textPrimary,
|
|
icon: const Icon(Icons.center_focus_strong, size: 20),
|
|
tooltip: context.l10n.map_centerOnNode,
|
|
onPressed: () => _mapController.move(pos, max(_zoom, 15)),
|
|
),
|
|
IconButton(
|
|
color: MapPalette.textPrimary,
|
|
icon: const Icon(Icons.close, size: 20),
|
|
onPressed: _clearSelection,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 14,
|
|
runSpacing: 4,
|
|
children: [
|
|
_miniMeta(
|
|
context.l10n.map_lastSeen,
|
|
_formatLastSeen(contact.lastSeen),
|
|
),
|
|
_miniMeta(
|
|
context.l10n.map_path,
|
|
contact.pathLabel(context.l10n),
|
|
),
|
|
_miniMeta('ID', contact.publicKeyHex.substring(0, 12)),
|
|
if (pos != null)
|
|
_miniMeta(
|
|
context.l10n.map_location,
|
|
'${contact.hasLocation ? '' : '~'}${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}',
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
..._selectedNodeActions(context, contact, connector),
|
|
TextButton(
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: MapPalette.selected,
|
|
),
|
|
onPressed: () => _showNodeInfo(
|
|
context,
|
|
contact,
|
|
guessedPosition: contact.hasLocation
|
|
? null
|
|
: _selectedGuessPos,
|
|
),
|
|
child: Text(context.l10n.map_details),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _miniMeta(String label, String value) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label.toUpperCase(),
|
|
style: MeshTheme.accentLabel(
|
|
color: MapPalette.textMuted,
|
|
fontSize: 8,
|
|
),
|
|
),
|
|
const SizedBox(height: 1),
|
|
Text(
|
|
value,
|
|
style: MeshTheme.mono(fontSize: 11.5, color: MapPalette.textPrimary),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _selectedNodeActions(
|
|
BuildContext context,
|
|
Contact contact,
|
|
MeshCoreConnector connector,
|
|
) {
|
|
Widget action(String label, IconData icon, VoidCallback onPressed) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: FilledButton.icon(
|
|
style: FilledButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
visualDensity: VisualDensity.compact,
|
|
),
|
|
onPressed: onPressed,
|
|
icon: Icon(icon, size: 16),
|
|
label: Text(label, style: const TextStyle(fontSize: 12.5)),
|
|
),
|
|
);
|
|
}
|
|
|
|
switch (contact.type) {
|
|
case advTypeChat:
|
|
return [
|
|
action(context.l10n.contacts_openChat, Icons.chat_bubble_outline, () {
|
|
if (!contact.isActive) {
|
|
connector.importDiscoveredContact(contact);
|
|
}
|
|
final unread = connector.getUnreadCountForContactKey(
|
|
contact.publicKeyHex,
|
|
);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) =>
|
|
ChatScreen(contact: contact, initialUnreadCount: unread),
|
|
),
|
|
);
|
|
}),
|
|
];
|
|
case advTypeRepeater:
|
|
return [
|
|
action(context.l10n.map_manageRepeater, Icons.cell_tower, () {
|
|
if (!contact.isActive) {
|
|
connector.importDiscoveredContact(contact);
|
|
}
|
|
_showRepeaterLogin(context, contact);
|
|
}),
|
|
];
|
|
case advTypeRoom:
|
|
return [
|
|
action(context.l10n.map_joinRoom, Icons.meeting_room, () {
|
|
if (!contact.isActive) {
|
|
connector.importDiscoveredContact(contact);
|
|
}
|
|
_showRoomLogin(context, contact);
|
|
}),
|
|
];
|
|
default:
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
List<_SharedMarker> _collectSharedMarkers(
|
|
MeshCoreConnector connector,
|
|
int markerSignature,
|
|
) {
|
|
final locale = Localizations.localeOf(context);
|
|
if (_sharedMarkersCacheSignature == markerSignature &&
|
|
_sharedMarkersCacheLocale == locale) {
|
|
return _cachedSharedMarkers;
|
|
}
|
|
|
|
// Build a _SharedMarker per message (history empty), grouped by dedupe key.
|
|
// Afterwards pick the latest per key and fill its history from older ones.
|
|
final updatesByKey = <String, List<_SharedMarker>>{};
|
|
final selfName = connector.selfName ?? 'Me';
|
|
|
|
void addUpdate(_SharedMarker update) {
|
|
(updatesByKey[update.id] ??= <_SharedMarker>[]).add(update);
|
|
}
|
|
|
|
for (final contact in connector.contacts) {
|
|
final messages = connector.getMessages(contact);
|
|
for (final message in messages) {
|
|
final payload = parseMarkerText(message.text);
|
|
if (payload == null) continue;
|
|
final fromName = message.isOutgoing ? selfName : contact.name;
|
|
final key = buildSharedMarkerKey(
|
|
sourceId: contact.publicKeyHex,
|
|
label: payload.label,
|
|
fromName: fromName,
|
|
flags: payload.flags,
|
|
isChannel: false,
|
|
);
|
|
addUpdate(
|
|
_SharedMarker(
|
|
id: key,
|
|
position: payload.position,
|
|
label: payload.label.isEmpty
|
|
? context.l10n.map_sharedPin
|
|
: payload.label,
|
|
flags: payload.flags,
|
|
fromName: fromName,
|
|
sourceLabel: contact.name,
|
|
timestamp: message.timestamp,
|
|
isChannel: false,
|
|
isPublicChannel: false,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (final channel in connector.channels.where((c) => !c.isEmpty)) {
|
|
final isPublic = _isPublicChannel(channel);
|
|
final messages = connector.getChannelMessages(channel);
|
|
for (final message in messages) {
|
|
final payload = parseMarkerText(message.text);
|
|
if (payload == null) continue;
|
|
final key = buildSharedMarkerKey(
|
|
sourceId: 'channel:${channel.index}',
|
|
label: payload.label,
|
|
fromName: message.senderName,
|
|
flags: payload.flags,
|
|
isChannel: true,
|
|
);
|
|
addUpdate(
|
|
_SharedMarker(
|
|
id: key,
|
|
position: payload.position,
|
|
label: payload.label.isEmpty
|
|
? context.l10n.map_sharedPin
|
|
: payload.label,
|
|
flags: payload.flags,
|
|
fromName: message.senderName,
|
|
sourceLabel: channel.name.isEmpty
|
|
? 'Channel ${channel.index}'
|
|
: channel.name,
|
|
timestamp: message.timestamp,
|
|
isChannel: true,
|
|
isPublicChannel: isPublic,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final markers = <_SharedMarker>[];
|
|
updatesByKey.forEach((_, updates) {
|
|
updates.sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
|
final latest = updates.last;
|
|
// History: older positions, drop consecutive duplicates at same position.
|
|
final history = <LatLng>[];
|
|
for (var i = 0; i < updates.length - 1; i++) {
|
|
final p = updates[i].position;
|
|
if (history.isEmpty ||
|
|
history.last.latitude != p.latitude ||
|
|
history.last.longitude != p.longitude) {
|
|
history.add(p);
|
|
}
|
|
}
|
|
markers.add(latest.copyWithHistory(history));
|
|
});
|
|
|
|
markers.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
|
_sharedMarkersCacheSignature = markerSignature;
|
|
_sharedMarkersCacheLocale = locale;
|
|
_cachedSharedMarkers = List.unmodifiable(markers);
|
|
return _cachedSharedMarkers;
|
|
}
|
|
|
|
Marker _buildSharedMarker(_SharedMarker marker) {
|
|
final markerColor = marker.isChannel
|
|
? (marker.isPublicChannel ? MapPalette.cluster : MapPalette.router)
|
|
: MapPalette.shared;
|
|
return Marker(
|
|
point: marker.position,
|
|
width: 60,
|
|
height: 60,
|
|
child: GestureDetector(
|
|
onTap: () async {
|
|
if (_removedMarkerIds.contains(marker.id)) {
|
|
setState(() {
|
|
_removedMarkerIds.remove(marker.id);
|
|
});
|
|
await _markerService.saveRemovedIds(_removedMarkerIds);
|
|
}
|
|
_showMarkerInfo(marker);
|
|
},
|
|
child: Column(
|
|
children: [
|
|
Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: markerColor,
|
|
border: Border.all(color: MapPalette.markerOutline, width: 2.5),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: MapPalette.markerShadow,
|
|
blurRadius: 8,
|
|
offset: Offset(0, 3),
|
|
),
|
|
],
|
|
),
|
|
alignment: Alignment.center,
|
|
child: const Icon(Icons.flag, color: Colors.white, size: 19),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showRepeaterLogin(BuildContext context, Contact repeater) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => RepeaterLoginDialog(
|
|
repeater: repeater,
|
|
onLogin: (password, isAdmin) {
|
|
// Navigate to repeater hub screen after successful login
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => RepeaterHubScreen(
|
|
repeater: repeater,
|
|
password: password,
|
|
isAdmin: isAdmin,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showRoomLogin(BuildContext context, Contact room) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => RoomLoginDialog(
|
|
room: room,
|
|
// onLogin(password, isAdmin) isAdmin not used for room caht screen
|
|
onLogin: (password, _) {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
final unread = connector.getUnreadCountForContactKey(
|
|
room.publicKeyHex,
|
|
);
|
|
connector.markContactRead(room.publicKeyHex);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) =>
|
|
ChatScreen(contact: room, initialUnreadCount: unread),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showNodeInfo(
|
|
BuildContext context,
|
|
Contact contact, {
|
|
LatLng? guessedPosition,
|
|
}) {
|
|
final connector = context.read<MeshCoreConnector>();
|
|
showMeshSheet(
|
|
context,
|
|
builder: (sheetContext) {
|
|
final actions = <Widget>[];
|
|
if (contact.type == advTypeChat) {
|
|
actions.add(
|
|
FilledButton(
|
|
onPressed: () {
|
|
if (!contact.isActive) {
|
|
connector.importDiscoveredContact(contact);
|
|
}
|
|
final unread = connector.getUnreadCountForContactKey(
|
|
contact.publicKeyHex,
|
|
);
|
|
Navigator.pop(sheetContext);
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ChatScreen(
|
|
contact: contact,
|
|
initialUnreadCount: unread,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
child: Text(context.l10n.contacts_openChat),
|
|
),
|
|
);
|
|
}
|
|
if (contact.type == advTypeRepeater) {
|
|
actions.add(
|
|
FilledButton(
|
|
onPressed: () {
|
|
if (!contact.isActive) {
|
|
connector.importDiscoveredContact(contact);
|
|
}
|
|
Navigator.pop(sheetContext);
|
|
_showRepeaterLogin(context, contact);
|
|
},
|
|
child: Text(context.l10n.map_manageRepeater),
|
|
),
|
|
);
|
|
}
|
|
if (contact.type == advTypeRoom) {
|
|
actions.add(
|
|
FilledButton(
|
|
onPressed: () {
|
|
if (!contact.isActive) {
|
|
connector.importDiscoveredContact(contact);
|
|
}
|
|
Navigator.pop(sheetContext);
|
|
_showRoomLogin(context, contact);
|
|
},
|
|
child: Text(context.l10n.map_joinRoom),
|
|
),
|
|
);
|
|
}
|
|
return SafeArea(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
BottomSheetHeader(
|
|
title: contact.name,
|
|
subtitle: contact.typeLabel(context.l10n),
|
|
trailing: Icon(
|
|
_getNodeIcon(contact.type),
|
|
color: _getNodeColor(contact.type),
|
|
size: 20,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildInfoRow(
|
|
context.l10n.map_path,
|
|
contact.pathLabel(context.l10n),
|
|
),
|
|
if (contact.hasLocation)
|
|
_buildInfoRow(
|
|
context.l10n.map_location,
|
|
'${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}',
|
|
)
|
|
else if (guessedPosition != null)
|
|
_buildInfoRow(
|
|
context.l10n.map_estLocation,
|
|
'~${guessedPosition.latitude.toStringAsFixed(6)}, ${guessedPosition.longitude.toStringAsFixed(6)}',
|
|
),
|
|
_buildInfoRow(
|
|
context.l10n.map_lastSeen,
|
|
_formatLastSeen(contact.lastSeen),
|
|
),
|
|
_buildInfoRow(
|
|
context.l10n.map_publicKey,
|
|
contact.publicKeyHex,
|
|
),
|
|
const SizedBox(height: 16),
|
|
...actions,
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _handleQuickSwitch(int index, BuildContext context) {
|
|
if (index == 2) return;
|
|
switch (index) {
|
|
case 0:
|
|
Navigator.pushReplacement(
|
|
context,
|
|
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
|
|
);
|
|
break;
|
|
case 1:
|
|
Navigator.pushReplacement(
|
|
context,
|
|
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
Future<void> _disconnect(
|
|
BuildContext context,
|
|
MeshCoreConnector connector,
|
|
) async {
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(context.l10n.common_disconnect),
|
|
content: Text(context.l10n.map_disconnectConfirm),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
child: Text(context.l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
child: Text(context.l10n.common_disconnect),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed == true) {
|
|
await connector.disconnect();
|
|
}
|
|
}
|
|
|
|
void _showMarkerInfo(_SharedMarker marker) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(
|
|
marker.label.isEmpty ? context.l10n.map_sharedPin : marker.label,
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildInfoRow(context.l10n.map_from, marker.fromName),
|
|
_buildInfoRow(context.l10n.map_source, marker.sourceLabel),
|
|
_buildInfoRow(
|
|
context.l10n.map_sharedAt,
|
|
_formatLastSeen(marker.timestamp),
|
|
),
|
|
_buildInfoRow(
|
|
context.l10n.map_location,
|
|
'${marker.position.latitude.toStringAsFixed(6)}, ${marker.position.longitude.toStringAsFixed(6)}',
|
|
),
|
|
if (marker.flags.isNotEmpty)
|
|
_buildInfoRow(context.l10n.map_flags, marker.flags),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
_hiddenMarkerIds.add(marker.id);
|
|
});
|
|
Navigator.pop(dialogContext);
|
|
},
|
|
child: Text(context.l10n.common_hide),
|
|
),
|
|
TextButton(
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Theme.of(context).colorScheme.error,
|
|
),
|
|
onPressed: () async {
|
|
setState(() {
|
|
_hiddenMarkerIds.add(marker.id);
|
|
_removedMarkerIds.add(marker.id);
|
|
});
|
|
await _markerService.saveRemovedIds(_removedMarkerIds);
|
|
if (dialogContext.mounted) {
|
|
Navigator.pop(dialogContext);
|
|
}
|
|
},
|
|
child: Text(context.l10n.common_remove),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(context.l10n.common_close),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildInfoRow(String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
SelectableText(
|
|
value,
|
|
style: MeshTheme.mono(
|
|
fontSize: 13,
|
|
color: Theme.of(context).colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatLastSeen(DateTime lastSeen) {
|
|
final now = DateTime.now();
|
|
final difference = now.difference(lastSeen);
|
|
|
|
if (difference.inSeconds < 60) {
|
|
return context.l10n.time_justNow;
|
|
} else if (difference.inMinutes < 60) {
|
|
return context.l10n.time_minutesAgo(difference.inMinutes);
|
|
} else if (difference.inHours < 24) {
|
|
return context.l10n.time_hoursAgo(difference.inHours);
|
|
} else {
|
|
return context.l10n.time_daysAgo(difference.inDays);
|
|
}
|
|
}
|
|
|
|
void _showShareMarkerAtPositionSheet({
|
|
required BuildContext context,
|
|
required MeshCoreConnector connector,
|
|
required LatLng position,
|
|
}) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (sheetContext) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.place),
|
|
title: Text(context.l10n.map_shareMarkerHere),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
_shareMarker(
|
|
context: context,
|
|
connector: connector,
|
|
position: position,
|
|
defaultLabel: context.l10n.map_pointOfInterest,
|
|
flags: 'poi',
|
|
);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.my_location),
|
|
title: Text(context.l10n.map_setAsMyLocation),
|
|
onTap: () async {
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final successMsg = context.l10n.settings_locationUpdated;
|
|
Navigator.pop(sheetContext);
|
|
if (!connector.isConnected) return;
|
|
await connector.setNodeLocation(
|
|
lat: position.latitude,
|
|
lon: position.longitude,
|
|
);
|
|
await connector.refreshDeviceInfo();
|
|
if (!mounted) return;
|
|
messenger.showSnackBar(SnackBar(content: Text(successMsg)));
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.close),
|
|
title: Text(context.l10n.common_cancel),
|
|
onTap: () => Navigator.pop(sheetContext),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _shareMarker({
|
|
required BuildContext context,
|
|
required MeshCoreConnector connector,
|
|
required LatLng position,
|
|
required String defaultLabel,
|
|
required String flags,
|
|
}) async {
|
|
if (!connector.isConnected) {
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(context.l10n.map_connectToShareMarkers),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final label = await _promptForLabel(context, defaultLabel);
|
|
if (label == null || !mounted) return;
|
|
|
|
final markerText = _formatMarkerMessage(position, label, flags);
|
|
if (!mounted) return;
|
|
|
|
await _showRecipientSheet(
|
|
// ignore: use_build_context_synchronously
|
|
context: context,
|
|
connector: connector,
|
|
markerText: markerText,
|
|
);
|
|
}
|
|
|
|
Future<String?> _promptForLabel(
|
|
BuildContext context,
|
|
String defaultLabel,
|
|
) async {
|
|
final controller = TextEditingController(text: defaultLabel);
|
|
controller.selection = TextSelection(
|
|
baseOffset: 0,
|
|
extentOffset: controller.text.length,
|
|
);
|
|
return showDialog<String>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(context.l10n.map_pinLabel),
|
|
content: TextField(
|
|
controller: controller,
|
|
decoration: InputDecoration(
|
|
hintText: context.l10n.map_label,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: Text(context.l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
final label = controller.text.trim().replaceAll('|', '/');
|
|
Navigator.pop(
|
|
dialogContext,
|
|
label.isEmpty ? defaultLabel : label,
|
|
);
|
|
},
|
|
child: Text(context.l10n.common_continue),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatMarkerMessage(LatLng position, String label, String flags) {
|
|
final lat = position.latitude.toStringAsFixed(6);
|
|
final lon = position.longitude.toStringAsFixed(6);
|
|
return 'm:$lat,$lon|$label|$flags';
|
|
}
|
|
|
|
Future<void> _showRecipientSheet({
|
|
required BuildContext context,
|
|
required MeshCoreConnector connector,
|
|
required String markerText,
|
|
}) async {
|
|
if (!connector.isLoadingChannels && connector.channels.isEmpty) {
|
|
connector.getChannels();
|
|
}
|
|
String query = '';
|
|
|
|
await showModalBottomSheet(
|
|
context: context,
|
|
builder: (sheetContext) => StatefulBuilder(
|
|
builder: (sheetContext, setSheetState) {
|
|
return Consumer<MeshCoreConnector>(
|
|
builder: (consumerContext, liveConnector, child) {
|
|
final allContacts = liveConnector.contacts
|
|
.where(
|
|
(contact) =>
|
|
contact.type != advTypeRepeater &&
|
|
contact.type != advTypeRoom,
|
|
)
|
|
.toList();
|
|
return SafeArea(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: Text(
|
|
context.l10n.map_sendToContact,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
|
|
child: TextField(
|
|
decoration: InputDecoration(
|
|
hintText:
|
|
context.l10n.contacts_searchContactsNoNumber,
|
|
prefixIcon: const Icon(Icons.search),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
onChanged: (value) {
|
|
setSheetState(() {
|
|
query = value.toLowerCase();
|
|
});
|
|
},
|
|
),
|
|
),
|
|
...allContacts
|
|
.where(
|
|
(contact) =>
|
|
query.isEmpty ||
|
|
matchesContactQuery(contact, query),
|
|
)
|
|
.map((contact) {
|
|
return ListTile(
|
|
leading: const Icon(Icons.person),
|
|
title: Text(contact.name),
|
|
onTap: () {
|
|
Navigator.pop(sheetContext);
|
|
liveConnector.sendMessage(contact, markerText);
|
|
},
|
|
);
|
|
}),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
|
child: Text(
|
|
context.l10n.map_sendToChannel,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
if (liveConnector.isLoadingChannels)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: LinearProgressIndicator(),
|
|
)
|
|
else if (liveConnector.channels
|
|
.where((c) => !c.isEmpty)
|
|
.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 8,
|
|
),
|
|
child: Text(context.l10n.map_noChannelsAvailable),
|
|
)
|
|
else
|
|
...liveConnector.channels.where((c) => !c.isEmpty).map((
|
|
channel,
|
|
) {
|
|
final isPublic = _isPublicChannel(channel);
|
|
final label = channel.name.isEmpty
|
|
? 'Channel ${channel.index}'
|
|
: channel.name;
|
|
return ListTile(
|
|
leading: Icon(
|
|
isPublic ? Icons.public : Icons.tag,
|
|
color: isPublic
|
|
? MapPalette.cluster
|
|
: MapPalette.repeater,
|
|
),
|
|
title: Text(label),
|
|
onTap: () async {
|
|
Navigator.pop(sheetContext);
|
|
final canSend = isPublic
|
|
? await _confirmPublicShare(context, label)
|
|
: true;
|
|
if (canSend) {
|
|
liveConnector.sendChannelMessage(
|
|
channel,
|
|
markerText,
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
bool _isPublicChannel(Channel channel) {
|
|
return channel.isPublicChannel;
|
|
}
|
|
|
|
Future<bool> _confirmPublicShare(
|
|
BuildContext context,
|
|
String channelLabel,
|
|
) async {
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: Text(context.l10n.map_publicLocationShare),
|
|
content: Text(
|
|
context.l10n.map_publicLocationShareConfirm(channelLabel),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, false),
|
|
child: Text(context.l10n.common_cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext, true),
|
|
child: Text(context.l10n.common_share),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return result ?? false;
|
|
}
|
|
|
|
void _showFilterSheet(
|
|
BuildContext context,
|
|
AppSettingsService settingsService,
|
|
) {
|
|
showMeshSheet(
|
|
context,
|
|
builder: (sheetContext) => StatefulBuilder(
|
|
builder: (sheetContext, setSheetState) {
|
|
return Consumer<AppSettingsService>(
|
|
builder: (consumerContext, service, child) {
|
|
final settings = service.settings;
|
|
final scheme = Theme.of(sheetContext).colorScheme;
|
|
|
|
Widget freshnessChip(_Freshness value, String label) {
|
|
final selected = _freshness == value;
|
|
final accent = switch (value) {
|
|
_Freshness.all => MapPalette.selected,
|
|
_Freshness.online => MapPalette.online,
|
|
_Freshness.recent => MapPalette.stale,
|
|
_Freshness.stale => MapPalette.offline,
|
|
};
|
|
return FilterChip(
|
|
label: Text(label),
|
|
selected: selected,
|
|
showCheckmark: true,
|
|
checkmarkColor: accent,
|
|
backgroundColor: scheme.surfaceContainerLow,
|
|
selectedColor: Color.alphaBlend(
|
|
accent.withValues(alpha: 0.22),
|
|
scheme.surfaceContainerHigh,
|
|
),
|
|
side: BorderSide(
|
|
color: selected ? accent : scheme.outline,
|
|
width: selected ? 1.5 : 1,
|
|
),
|
|
labelStyle: TextStyle(
|
|
color: selected
|
|
? scheme.onSurface
|
|
: scheme.onSurfaceVariant,
|
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
|
),
|
|
onSelected: (_) {
|
|
setSheetState(() {});
|
|
setState(() => _freshness = value);
|
|
},
|
|
);
|
|
}
|
|
|
|
return SafeArea(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.sizeOf(sheetContext).height * 0.8,
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
BottomSheetHeader(
|
|
title: sheetContext.l10n.map_filterNodes,
|
|
),
|
|
SectionHeader(sheetContext.l10n.map_activity),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Wrap(
|
|
spacing: 8,
|
|
runSpacing: 4,
|
|
children: [
|
|
freshnessChip(
|
|
_Freshness.all,
|
|
sheetContext.l10n.time_allTime,
|
|
),
|
|
freshnessChip(
|
|
_Freshness.online,
|
|
sheetContext.l10n.map_online,
|
|
),
|
|
freshnessChip(
|
|
_Freshness.recent,
|
|
sheetContext.l10n.map_recent,
|
|
),
|
|
freshnessChip(
|
|
_Freshness.stale,
|
|
sheetContext.l10n.map_stale,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
SectionHeader(
|
|
sheetContext.l10n.map_lastSeenTime,
|
|
trailing: Text(
|
|
_getTimeFilterLabel(settings.mapTimeFilterHours),
|
|
style: MeshTheme.mono(
|
|
fontSize: 11,
|
|
color: scheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Slider(
|
|
value: _hoursToSliderValue(
|
|
settings.mapTimeFilterHours,
|
|
),
|
|
min: 0,
|
|
max: 100,
|
|
divisions: 100,
|
|
onChanged: (value) {
|
|
final hours = _sliderValueToHours(value);
|
|
service.setMapTimeFilterHours(hours);
|
|
},
|
|
),
|
|
),
|
|
SectionHeader(sheetContext.l10n.map_nodeTypes),
|
|
SwitchListTile(
|
|
title: Text(sheetContext.l10n.map_chatNodes),
|
|
value: settings.mapShowChatNodes,
|
|
dense: true,
|
|
onChanged: (value) =>
|
|
service.setMapShowChatNodes(value),
|
|
),
|
|
SwitchListTile(
|
|
title: Text(sheetContext.l10n.map_repeaters),
|
|
value: settings.mapShowRepeaters,
|
|
dense: true,
|
|
onChanged: (value) =>
|
|
service.setMapShowRepeaters(value),
|
|
),
|
|
SwitchListTile(
|
|
title: Text(sheetContext.l10n.map_otherNodes),
|
|
value: settings.mapShowOtherNodes,
|
|
dense: true,
|
|
onChanged: (value) =>
|
|
service.setMapShowOtherNodes(value),
|
|
),
|
|
SectionHeader(sheetContext.l10n.map_markers),
|
|
SwitchListTile(
|
|
title: Text(sheetContext.l10n.map_showSharedMarkers),
|
|
value: settings.mapShowMarkers,
|
|
dense: true,
|
|
onChanged: (value) =>
|
|
service.setMapShowMarkers(value),
|
|
),
|
|
SwitchListTile(
|
|
title: Text(
|
|
sheetContext.l10n.map_showGuessedLocations,
|
|
),
|
|
value: settings.mapShowGuessedLocations,
|
|
dense: true,
|
|
onChanged: (value) =>
|
|
service.setMapShowGuessedLocations(value),
|
|
),
|
|
SwitchListTile(
|
|
title: Text(
|
|
sheetContext.l10n.map_showDiscoveryContacts,
|
|
),
|
|
value: settings.mapShowDiscoveryContacts,
|
|
dense: true,
|
|
onChanged: (value) =>
|
|
service.setMapShowDiscoveryContacts(value),
|
|
),
|
|
SwitchListTile(
|
|
title: Text(sheetContext.l10n.map_showOverlaps),
|
|
value: settings.mapShowOverlaps,
|
|
dense: true,
|
|
onChanged: (value) =>
|
|
service.setMapShowOverlaps(value),
|
|
),
|
|
SectionHeader(sheetContext.l10n.map_keyPrefix),
|
|
SwitchListTile(
|
|
title: Text(sheetContext.l10n.map_filterByKeyPrefix),
|
|
value: settings.mapKeyPrefixEnabled,
|
|
dense: true,
|
|
onChanged: (value) =>
|
|
service.setMapKeyPrefixEnabled(value),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 20),
|
|
child: TextFormField(
|
|
initialValue: settings.mapKeyPrefix,
|
|
enabled: settings.mapKeyPrefixEnabled,
|
|
decoration: InputDecoration(
|
|
labelText: sheetContext.l10n.map_publicKeyPrefix,
|
|
hintText:
|
|
sheetContext.l10n.map_publicKeyPrefixHint,
|
|
isDense: true,
|
|
),
|
|
style: MeshTheme.mono(fontSize: 13),
|
|
onChanged: (value) =>
|
|
service.setMapKeyPrefix(value),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
// Convert hours to slider value (0-100) with exponential scaling
|
|
double _hoursToSliderValue(double hours) {
|
|
if (hours == 0) return 100; // All time
|
|
|
|
// Map hours exponentially
|
|
// 0-24h: 0-40
|
|
// 24h-7d: 40-60
|
|
// 7d-30d: 60-80
|
|
// 30d-6mo: 80-99
|
|
// All time: 100
|
|
|
|
if (hours <= 24) {
|
|
return (hours / 24) * 40;
|
|
} else if (hours <= 168) {
|
|
// 7 days
|
|
return 40 + ((hours - 24) / (168 - 24)) * 20;
|
|
} else if (hours <= 720) {
|
|
// 30 days
|
|
return 60 + ((hours - 168) / (720 - 168)) * 20;
|
|
} else if (hours <= 4380) {
|
|
// 6 months
|
|
return 80 + ((hours - 720) / (4380 - 720)) * 19;
|
|
} else {
|
|
return 100;
|
|
}
|
|
}
|
|
|
|
// Convert slider value (0-100) to hours with exponential scaling
|
|
double _sliderValueToHours(double value) {
|
|
if (value >= 99.5) return 0; // All time
|
|
|
|
if (value <= 40) {
|
|
return (value / 40) * 24; // 0-24 hours
|
|
} else if (value <= 60) {
|
|
return 24 + ((value - 40) / 20) * (168 - 24); // 1-7 days
|
|
} else if (value <= 80) {
|
|
return 168 + ((value - 60) / 20) * (720 - 168); // 7-30 days
|
|
} else {
|
|
return 720 + ((value - 80) / 19) * (4380 - 720); // 30 days - 6 months
|
|
}
|
|
}
|
|
|
|
String _getTimeFilterLabel(double hours) {
|
|
if (hours == 0) return context.l10n.time_allTime;
|
|
|
|
if (hours < 1) {
|
|
return '${(hours * 60).round()} ${context.l10n.time_minutes}';
|
|
} else if (hours < 24) {
|
|
final h = hours.round();
|
|
return '$h ${h == 1 ? context.l10n.time_hour : context.l10n.time_hours}';
|
|
} else if (hours < 168) {
|
|
final days = (hours / 24).round();
|
|
return '$days ${days == 1 ? context.l10n.time_day : context.l10n.time_days}';
|
|
} else if (hours < 720) {
|
|
final weeks = (hours / 168).round();
|
|
return '$weeks ${weeks == 1 ? context.l10n.time_week : context.l10n.time_weeks}';
|
|
} else if (hours < 4380) {
|
|
final months = (hours / 730).round();
|
|
return '$months ${months == 1 ? context.l10n.time_month : context.l10n.time_months}';
|
|
} else {
|
|
return context.l10n.time_allTime;
|
|
}
|
|
}
|
|
|
|
void _addToPath(BuildContext context, Contact contact, {LatLng? position}) {
|
|
setState(() {
|
|
_pathTrace.add(
|
|
contact.publicKey[0],
|
|
); // Add first 16 bytes of public key to path trace
|
|
_pathTraceContacts.add(
|
|
contact.copyWith(
|
|
latitude: position?.latitude ?? contact.latitude,
|
|
longitude: position?.longitude ?? contact.longitude,
|
|
),
|
|
); // Add contact to path trace contacts
|
|
_points.add(position ?? LatLng(contact.latitude!, contact.longitude!));
|
|
});
|
|
}
|
|
|
|
void _startPath(LatLng position) {
|
|
setState(() {
|
|
_isBuildingPathTrace = true;
|
|
_pathTrace.clear();
|
|
_pathTraceContacts.clear();
|
|
_points.clear();
|
|
_polylines.clear();
|
|
_points.add(position);
|
|
});
|
|
}
|
|
|
|
void _removePath() {
|
|
setState(() {
|
|
_pathTraceContacts.removeLast();
|
|
_pathTrace.removeLast(); // Remove last node from path trace
|
|
_points.removeLast(); // Remove last point from points list
|
|
_polylines.clear(); // Clear polylines
|
|
});
|
|
}
|
|
|
|
Widget _buildPathTraceOverlay() {
|
|
final l10n = context.l10n;
|
|
final isImperial =
|
|
context.read<AppSettingsService>().settings.unitSystem ==
|
|
UnitSystem.imperial;
|
|
return Positioned(
|
|
top: 16,
|
|
left: 16,
|
|
right: 16,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: MapPalette.panelDark,
|
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
|
border: Border.all(color: MapPalette.border),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: MapPalette.markerShadow,
|
|
blurRadius: 10,
|
|
offset: Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(MeshRadii.md),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
l10n.contacts_pathTrace,
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
if (_pathTrace.isEmpty) const SizedBox(height: 8),
|
|
if (_pathTrace.isEmpty)
|
|
Text(l10n.map_tapToAdd, style: TextStyle(fontSize: 12)),
|
|
const SizedBox(height: 6),
|
|
if (_pathTrace.isNotEmpty)
|
|
Text(
|
|
"${l10n.path_currentPathLabel} ${formatDistance(getPathDistanceMeters(_points), isImperial: isImperial)}",
|
|
style: MeshTheme.mono(
|
|
fontSize: 12,
|
|
color: MapPalette.textSecondary,
|
|
),
|
|
),
|
|
SelectableText(
|
|
_pathTrace
|
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
.join(','),
|
|
style: MeshTheme.mono(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
color: MapPalette.selected,
|
|
),
|
|
),
|
|
// const SizedBox(height: 6),
|
|
Wrap(
|
|
alignment: WrapAlignment.center,
|
|
spacing: 1,
|
|
runSpacing: 1,
|
|
children: [
|
|
if (_pathTrace.isNotEmpty)
|
|
IconButton(
|
|
onPressed: () {
|
|
final hashW = context
|
|
.read<MeshCoreConnector>()
|
|
.pathHashByteWidth;
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => PathTraceMapScreen(
|
|
title: l10n.contacts_pathTrace,
|
|
path: Uint8List.fromList(_pathTrace),
|
|
pathHashByteWidth: hashW,
|
|
pathContacts: _pathTraceContacts,
|
|
),
|
|
),
|
|
);
|
|
setState(() {
|
|
_isBuildingPathTrace = false;
|
|
});
|
|
},
|
|
tooltip: l10n.map_runTrace,
|
|
icon: const Icon(Icons.arrow_forward_outlined),
|
|
),
|
|
if (_pathTrace.isNotEmpty)
|
|
IconButton(
|
|
onPressed: () {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => PathTraceMapScreen(
|
|
title: l10n.contacts_pathTrace,
|
|
path: Uint8List.fromList(_pathTrace),
|
|
flipPathAround: true,
|
|
),
|
|
),
|
|
);
|
|
setState(() {
|
|
_isBuildingPathTrace = false;
|
|
});
|
|
},
|
|
tooltip: l10n.map_runTraceWithReturnPath,
|
|
icon: const Icon(Icons.replay),
|
|
),
|
|
if (_pathTrace.isNotEmpty)
|
|
IconButton(
|
|
onPressed: _removePath,
|
|
tooltip: l10n.map_removeLast,
|
|
icon: const Icon(Icons.undo),
|
|
),
|
|
if (_pathTrace.isEmpty)
|
|
IconButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
_isBuildingPathTrace = false;
|
|
_pathTrace.clear();
|
|
_points.clear();
|
|
_polylines.clear();
|
|
});
|
|
showDismissibleSnackBar(
|
|
context,
|
|
content: Text(l10n.map_pathTraceCancelled),
|
|
);
|
|
},
|
|
tooltip: l10n.common_cancel,
|
|
icon: const Icon(Icons.close),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _NodeAge { online, recent, stale }
|
|
|
|
enum _Freshness { all, online, recent, stale }
|
|
|
|
int _bytesSignature(Iterable<int>? bytes) {
|
|
if (bytes == null) return 0;
|
|
return Object.hashAll(bytes);
|
|
}
|
|
|
|
int _mapContactSignature(Contact contact) {
|
|
return Object.hash(
|
|
contact.publicKeyHex,
|
|
contact.name,
|
|
contact.type,
|
|
contact.flags,
|
|
contact.pathLength,
|
|
_bytesSignature(contact.path),
|
|
contact.pathOverride,
|
|
_bytesSignature(contact.pathOverrideBytes),
|
|
contact.latitude,
|
|
contact.longitude,
|
|
contact.lastSeen.millisecondsSinceEpoch,
|
|
contact.lastMessageAt.millisecondsSinceEpoch,
|
|
contact.isActive,
|
|
contact.wasPulled,
|
|
);
|
|
}
|
|
|
|
class _MapConnectorSnapshot {
|
|
final MeshCoreConnector connector;
|
|
final int contactsSignature;
|
|
final int markerSignature;
|
|
final int batterySignature;
|
|
final int uiSignature;
|
|
|
|
const _MapConnectorSnapshot({
|
|
required this.connector,
|
|
required this.contactsSignature,
|
|
required this.markerSignature,
|
|
required this.batterySignature,
|
|
required this.uiSignature,
|
|
});
|
|
|
|
factory _MapConnectorSnapshot.fromConnector(MeshCoreConnector connector) {
|
|
final allContacts = connector.allContacts;
|
|
final contactsSignature = Object.hashAll(
|
|
allContacts.map(_mapContactSignature),
|
|
);
|
|
final batterySignature = Object.hashAll(
|
|
allContacts
|
|
.where((contact) => contact.type == advTypeRepeater)
|
|
.map(
|
|
(contact) => Object.hash(
|
|
contact.publicKeyHex,
|
|
connector.getRepeaterBatteryMillivolts(contact.publicKeyHex),
|
|
),
|
|
),
|
|
);
|
|
|
|
final markerParts = <Object?>[connector.selfName];
|
|
for (final contact in connector.contacts) {
|
|
markerParts.add(contact.publicKeyHex);
|
|
markerParts.add(contact.name);
|
|
for (final message in connector.getMessages(contact)) {
|
|
if (!message.text.trimLeft().startsWith('m:')) continue;
|
|
markerParts.add(
|
|
Object.hash(
|
|
message.messageId,
|
|
message.text,
|
|
message.timestamp.millisecondsSinceEpoch,
|
|
message.isOutgoing,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
for (final channel in connector.channels) {
|
|
markerParts.add(
|
|
Object.hash(
|
|
channel.index,
|
|
channel.name,
|
|
channel.isPublicChannel,
|
|
channel.isEmpty,
|
|
),
|
|
);
|
|
for (final message in connector.getChannelMessages(channel)) {
|
|
if (!message.text.trimLeft().startsWith('m:')) continue;
|
|
markerParts.add(
|
|
Object.hash(
|
|
message.messageId,
|
|
message.text,
|
|
message.senderName,
|
|
message.timestamp.millisecondsSinceEpoch,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
return _MapConnectorSnapshot(
|
|
connector: connector,
|
|
contactsSignature: contactsSignature,
|
|
markerSignature: Object.hashAll(markerParts),
|
|
batterySignature: batterySignature,
|
|
uiSignature: Object.hash(
|
|
connector.isConnected,
|
|
connector.selfLatitude,
|
|
connector.selfLongitude,
|
|
connector.currentFreqHz,
|
|
connector.currentBwHz,
|
|
connector.currentSf,
|
|
connector.currentTxPower,
|
|
connector.getTotalContactsUnreadCount(),
|
|
connector.getTotalChannelsUnreadCount(),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
return other is _MapConnectorSnapshot &&
|
|
contactsSignature == other.contactsSignature &&
|
|
markerSignature == other.markerSignature &&
|
|
batterySignature == other.batterySignature &&
|
|
uiSignature == other.uiSignature;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(
|
|
contactsSignature,
|
|
markerSignature,
|
|
batterySignature,
|
|
uiSignature,
|
|
);
|
|
}
|
|
|
|
class _NodeMarkersCacheKey {
|
|
final int contactsSignature;
|
|
final int visibleContactsSignature;
|
|
final int batterySignature;
|
|
final _Freshness freshness;
|
|
final double timeFilterHours;
|
|
final bool keyPrefixEnabled;
|
|
final String keyPrefix;
|
|
final bool showDiscoveryContacts;
|
|
final int batteryChemistrySignature;
|
|
final bool showLabels;
|
|
final String? selectedKey;
|
|
final double zoom;
|
|
final bool overlapsMode;
|
|
final bool showRepeaters;
|
|
final bool showChatNodes;
|
|
final bool showOtherNodes;
|
|
final bool isBuildingPathTrace;
|
|
|
|
const _NodeMarkersCacheKey({
|
|
required this.contactsSignature,
|
|
required this.visibleContactsSignature,
|
|
required this.batterySignature,
|
|
required this.freshness,
|
|
required this.timeFilterHours,
|
|
required this.keyPrefixEnabled,
|
|
required this.keyPrefix,
|
|
required this.showDiscoveryContacts,
|
|
required this.batteryChemistrySignature,
|
|
required this.showLabels,
|
|
required this.selectedKey,
|
|
required this.zoom,
|
|
required this.overlapsMode,
|
|
required this.showRepeaters,
|
|
required this.showChatNodes,
|
|
required this.showOtherNodes,
|
|
required this.isBuildingPathTrace,
|
|
});
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
return other is _NodeMarkersCacheKey &&
|
|
contactsSignature == other.contactsSignature &&
|
|
visibleContactsSignature == other.visibleContactsSignature &&
|
|
batterySignature == other.batterySignature &&
|
|
freshness == other.freshness &&
|
|
timeFilterHours == other.timeFilterHours &&
|
|
keyPrefixEnabled == other.keyPrefixEnabled &&
|
|
keyPrefix == other.keyPrefix &&
|
|
showDiscoveryContacts == other.showDiscoveryContacts &&
|
|
batteryChemistrySignature == other.batteryChemistrySignature &&
|
|
showLabels == other.showLabels &&
|
|
selectedKey == other.selectedKey &&
|
|
zoom == other.zoom &&
|
|
overlapsMode == other.overlapsMode &&
|
|
showRepeaters == other.showRepeaters &&
|
|
showChatNodes == other.showChatNodes &&
|
|
showOtherNodes == other.showOtherNodes &&
|
|
isBuildingPathTrace == other.isBuildingPathTrace;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(
|
|
contactsSignature,
|
|
visibleContactsSignature,
|
|
batterySignature,
|
|
freshness,
|
|
timeFilterHours,
|
|
keyPrefixEnabled,
|
|
keyPrefix,
|
|
showDiscoveryContacts,
|
|
batteryChemistrySignature,
|
|
showLabels,
|
|
selectedKey,
|
|
zoom,
|
|
overlapsMode,
|
|
showRepeaters,
|
|
showChatNodes,
|
|
showOtherNodes,
|
|
isBuildingPathTrace,
|
|
);
|
|
}
|
|
|
|
class _GuessedLocation {
|
|
final Contact contact;
|
|
final LatLng position;
|
|
final bool highConfidence;
|
|
|
|
_GuessedLocation({
|
|
required this.contact,
|
|
required this.position,
|
|
required this.highConfidence,
|
|
});
|
|
}
|
|
|
|
class MarkerPayload {
|
|
final LatLng position;
|
|
final String label;
|
|
final String flags;
|
|
|
|
MarkerPayload({
|
|
required this.position,
|
|
required this.label,
|
|
required this.flags,
|
|
});
|
|
}
|
|
|
|
/// Parse a shared marker text message of the form
|
|
/// `m:<lat>,<lon>|<label>|<flags>` and return a [MarkerPayload].
|
|
MarkerPayload? parseMarkerText(String text) {
|
|
final trimmed = text.trim();
|
|
final match = RegExp(
|
|
r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|(.*)',
|
|
).firstMatch(trimmed);
|
|
if (match == null) return null;
|
|
final lat = double.tryParse(match.group(1) ?? '');
|
|
final lon = double.tryParse(match.group(2) ?? '');
|
|
if (lat == null || lon == null) return null;
|
|
final label = (match.group(3) ?? '').trim();
|
|
final flags = (match.group(4) ?? '').trim();
|
|
return MarkerPayload(position: LatLng(lat, lon), label: label, flags: flags);
|
|
}
|
|
|
|
/// Build a normalized dedupe key for shared markers.
|
|
/// Keeps the same algorithm previously present in both chat and map screens.
|
|
String buildSharedMarkerKey({
|
|
required String sourceId,
|
|
required String label,
|
|
required String fromName,
|
|
required String flags,
|
|
required bool isChannel,
|
|
}) {
|
|
final normalizedLabel = label.trim().toLowerCase();
|
|
final normalizedFrom = fromName.trim().toLowerCase();
|
|
final normalizedFlags = flags.trim().toLowerCase();
|
|
final scope = isChannel ? 'ch' : 'dm';
|
|
return '$scope|$sourceId|$normalizedFrom|$normalizedLabel|$normalizedFlags';
|
|
}
|
|
|
|
class _SharedMarker {
|
|
final String id;
|
|
final LatLng position;
|
|
final String label;
|
|
final String flags;
|
|
final String fromName;
|
|
final String sourceLabel;
|
|
final DateTime timestamp;
|
|
final bool isChannel;
|
|
final bool isPublicChannel;
|
|
final List<LatLng> history;
|
|
|
|
_SharedMarker({
|
|
required this.id,
|
|
required this.position,
|
|
required this.label,
|
|
required this.flags,
|
|
required this.fromName,
|
|
required this.sourceLabel,
|
|
required this.timestamp,
|
|
required this.isChannel,
|
|
required this.isPublicChannel,
|
|
this.history = const [],
|
|
});
|
|
|
|
_SharedMarker copyWithHistory(List<LatLng> newHistory) {
|
|
return _SharedMarker(
|
|
id: id,
|
|
position: position,
|
|
label: label,
|
|
flags: flags,
|
|
fromName: fromName,
|
|
sourceLabel: sourceLabel,
|
|
timestamp: timestamp,
|
|
isChannel: isChannel,
|
|
isPublicChannel: isPublicChannel,
|
|
history: newHistory,
|
|
);
|
|
}
|
|
}
|