import 'dart:collection'; 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 createState() => _MapScreenState(); } class _MapScreenState extends State { // 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 _hiddenMarkerIds = {}; Set _removedMarkerIds = {}; bool _isBuildingPathTrace = false; bool _isSelectingPoi = false; bool _hasInitializedMap = false; bool _removedMarkersLoaded = false; final List _pathTrace = []; final List _pathTraceContacts = []; final List _points = []; final List _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 = ''; @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().getChannels(); if (widget.highlightPosition != null) { _mapController.move(widget.highlightPosition!, widget.highlightZoom); } } }); } Future _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.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 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 Consumer3( builder: (context, connector, settingsService, pathHistory, child) { final tileCache = context.read(); final isDesktop = _isDesktopPlatform(defaultTargetPlatform); final settings = settingsService.settings; 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) .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(); // Compute guessed locations with caching final maxRangeKm = _estimateLoRaRangeKm(connector); final filteredKeys = filteredByKeyPrefix .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|${pathHistory.version}:${connector.currentSf}:${connector.currentBwHz}:${connector.currentTxPower}:${settings.mapShowGuessedLocations}'; if (cacheKey != _guessedLocationsCacheKey) { _guessedLocationsCacheKey = cacheKey; _cachedGuessedLocations = settings.mapShowGuessedLocations ? _computeGuessedLocations( filteredByKeyPrefix, allContactsWithLocation, pathHistory, maxRangeKm, ) : []; } final guessedLocations = settings.mapShowGuessedLocations ? _cachedGuessedLocations : <_GuessedLocation>[]; _polylines.clear(); _polylines.addAll( _points.length > 1 ? [ Polyline( points: _points, strokeWidth: 4, color: MapPalette.selected, ), ] : [], ); // Collect polylines for shared markers' history with dashed lines final List sharedMarkerPolylines = []; for (final marker in sharedMarkers) { if (marker.history.isNotEmpty) { final points = List.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 = []; 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, ), ..._buildNodeMarkers( visibleContacts, settings, 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<_GuessedLocation> _computeGuessedLocations( List allContacts, List 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 = {}; 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 = {}; // 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 = >[ contact.path.toList(), ...pathHistory .getRecentPaths(contact.publicKeyHex) .map((r) => r.pathBytes), ]; final lastHopBytes = {}; 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 _filterConsistentAnchors( List 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 _buildGuessedMarker( List<_GuessedLocation> guessed, { required bool showLabels, }) { final markers = []; 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 _filterContactsBySettings( List contacts, dynamic settings, { bool noLocations = false, }) { List filtered = []; bool addContact = false; for (final contact in contacts) { addContact = false; if (!contact.hasLocation && !noLocations) { continue; } // Apply node type filters if (contact.type == advTypeRepeater && (settings.mapShowRepeaters || _isBuildingPathTrace || settings.mapShowOverlaps)) { addContact = true; } if (contact.type == advTypeChat && (settings.mapShowChatNodes || _isBuildingPathTrace)) { addContact = true; } if (contact.type != advTypeChat && contact.type != advTypeRepeater && (settings.mapShowOtherNodes || _isBuildingPathTrace || settings.mapShowOverlaps)) { addContact = true; } if (contact.type == advTypeChat && _isBuildingPathTrace) { addContact = false; } if (settings.mapShowOverlaps) { final hasOverlap = contacts .where( (c) => c.publicKeyHex != contact.publicKeyHex && c.publicKey.first == contact.publicKey.first && (c.type == advTypeRepeater || c.type == advTypeRoom) && (contact.type == advTypeRepeater || contact.type == advTypeRoom), ) .firstOrNull; if (hasOverlap == null && settings.mapShowOverlaps && !_isBuildingPathTrace) { addContact = false; } } if (addContact) { filtered.add(contact); } } return filtered; } List _buildNodeMarkers( List contacts, settings, { required bool showLabels, Contact? selectedContact, }) { final markers = []; final overlapsMode = settings.mapShowOverlaps && !_isBuildingPathTrace; final selectedKey = selectedContact?.publicKeyHex; final items = contacts.where((c) => c.publicKeyHex != selectedKey).toList(); void addNode(Contact contact, {bool dot = false}) { markers.add(_nodeMarker(contact, overlapsMode: overlapsMode, dot: dot)); if (showLabels) { markers.add( _buildNodeLabelMarker( point: LatLng(contact.latitude!, contact.longitude!), label: overlapsMode ? "${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 = >{}; 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: overlapsMode, 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 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 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(); final millivolts = connector.getRepeaterBatteryMillivolts( contact.publicKeyHex, ); if (millivolts == null) return false; final chemistry = context .read() .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 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 = [ _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 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( 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 _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) { // 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 = >{}; 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 = []; 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)); return markers; } 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(); 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(); showMeshSheet( context, builder: (sheetContext) { final actions = []; 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 _disconnect( BuildContext context, MeshCoreConnector connector, ) async { final confirmed = await showDialog( 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 _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 _promptForLabel( BuildContext context, String defaultLabel, ) async { final controller = TextEditingController(text: defaultLabel); controller.selection = TextSelection( baseOffset: 0, extentOffset: controller.text.length, ); return showDialog( 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 _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( 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 _confirmPublicShare( BuildContext context, String channelLabel, ) async { final result = await showDialog( 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( 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().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() .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 } 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:,|