import 'dart:collection'; import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:meshcore_open/screens/path_trace_map.dart'; import 'package: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 '../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/route_transitions.dart'; import '../widgets/quick_switch_bar.dart'; import '../icons/los_icon.dart'; import 'channels_screen.dart'; import 'chat_screen.dart'; import 'contacts_screen.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 double highlightZoom; final bool hideBackButton; const MapScreen({ super.key, this.highlightPosition, this.highlightLabel, 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; 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 _legendExpanded = false; bool _showNodeLabels = true; List<_GuessedLocation> _cachedGuessedLocations = []; String _guessedLocationsCacheKey = ''; @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; }); } 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); } @override Widget build(BuildContext context) { return Consumer3( builder: (context, connector, settingsService, pathHistory, child) { final tileCache = context.read(); 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(); // Filter by key prefix final keyPrefix = settings.mapKeyPrefix.trim(); final filteredByKeyPrefix = (settings.mapKeyPrefixEnabled && keyPrefix.isNotEmpty) ? filteredByTime.where((c) { return c.publicKeyHex.toLowerCase().startsWith( keyPrefix.toLowerCase(), ); }).toList() : filteredByTime; // 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: Colors.blueAccent, ), ] : [], ); // 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; if (hasMapContent) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { _mapController.move(center, initialZoom); } }); } } final allowBack = !connector.isConnected; return PopScope( canPop: allowBack, child: Scaffold( appBar: AppBar( title: AppBarTitle(context.l10n.map_title), centerTitle: true, automaticallyImplyLeading: false, actions: [ if (!_isBuildingPathTrace) IconButton( icon: const Icon(Icons.radar), onPressed: () => _startPath( LatLng(connector.selfLatitude!, connector.selfLongitude!), ), tooltip: context.l10n.contacts_pathTrace, ), if (!_isBuildingPathTrace) IconButton( icon: const LosIcon(), onPressed: () { final candidates = []; if (connector.selfLatitude != null && connector.selfLongitude != null) { candidates.add( LineOfSightEndpoint( label: context.l10n.pathTrace_you, point: LatLng( connector.selfLatitude!, connector.selfLongitude!, ), color: Colors.teal, 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, ), ), ); }, tooltip: context.l10n.map_lineOfSight, ), PopupMenuButton( itemBuilder: (context) => [ PopupMenuItem( child: Row( children: [ const Icon(Icons.logout, color: Colors.red), 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: 2.0, maxZoom: 18.0, interactionOptions: InteractionOptions( flags: ~InteractiveFlag.rotate, ), onTap: (_, latLng) { if (_isSelectingPoi) { setState(() { _isSelectingPoi = false; }); _shareMarker( context: context, connector: connector, position: latLng, defaultLabel: context.l10n.map_pointOfInterest, flags: 'poi', ); } }, 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) { final shouldShow = camera.zoom >= _labelZoomThreshold; if (shouldShow != _showNodeLabels && mounted) { setState(() { _showNodeLabels = shouldShow; }); } }, ), children: [ TileLayer( urlTemplate: kMapTileUrlTemplate, tileProvider: tileCache.tileProvider, userAgentPackageName: MapTileCacheService.userAgentPackageName, maxZoom: 19, ), if (_polylines.isNotEmpty && _isBuildingPathTrace) PolylineLayer(polylines: _polylines), MarkerLayer( markers: [ if (highlightPosition != null) Marker( point: highlightPosition, width: 40, height: 40, child: IgnorePointer( child: Icon( Icons.location_on_outlined, color: Colors.red[600], size: 34, ), ), ), if (!settings.mapShowOverlaps) ..._buildGuessedMarker( guessedLocations, showLabels: _showNodeLabels, ), ..._buildMarkers( contactsWithLocation, settings, showLabels: _showNodeLabels, ), ...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( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.teal, shape: BoxShape.circle, border: Border.all( color: Colors.white, width: 2, ), boxShadow: [ BoxShadow( color: Colors.black.withValues( alpha: 0.3, ), blurRadius: 4, offset: const Offset(0, 2), ), ], ), alignment: Alignment.center, child: const Icon( Icons.person_pin_circle, color: Colors.white, size: 20, ), ), ), ), if (_showNodeLabels && connector.selfLatitude != null && connector.selfLongitude != null) _buildNodeLabelMarker( point: LatLng( connector.selfLatitude!, connector.selfLongitude!, ), label: context.l10n.pathTrace_you, ), ], ), ], ), if (!_isBuildingPathTrace) _buildLegend( contacts, contactsWithLocation, settings, sharedMarkers.length, guessedLocations.length, ), if (_isBuildingPathTrace) _buildPathTraceOverlay(), ], ), bottomNavigationBar: SafeArea( top: false, child: QuickSwitchBar( selectedIndex: 2, onDestinationSelected: (index) => _handleQuickSwitch(index, context), ), ), floatingActionButton: FloatingActionButton( onPressed: () => _showFilterDialog(context, settingsService), tooltip: context.l10n.map_filterNodes, child: const Icon(Icons.filter_list), ), ), ); }, ); } 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: 35, height: 35, child: GestureDetector( onLongPress: () => _isBuildingPathTrace ? _showNodeInfo(context, guess.contact) : null, onTap: () => _isBuildingPathTrace ? _addToPath(context, guess.contact, position: guess.position) : _showNodeInfo( context, guess.contact, guessedPosition: guess.position, ), child: Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: color.withValues( alpha: guess.highConfidence ? 0.55 : 0.30, ), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: const Icon( Icons.not_listed_location, color: Colors.white, size: 20, ), ), ), ); 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 _buildMarkers( List contacts, settings, { required bool showLabels, }) { final markers = []; final filteredContacts = _filterContactsBySettings(contacts, settings); for (final contact in filteredContacts) { final marker = Marker( point: LatLng(contact.latitude!, contact.longitude!), width: 35, height: 35, child: GestureDetector( onLongPress: () => _isBuildingPathTrace ? _showNodeInfo(context, contact) : null, onTap: () => _isBuildingPathTrace ? _addToPath(context, contact) : _showNodeInfo(context, contact), child: Column( children: [ Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: settings.mapShowOverlaps && !_isBuildingPathTrace ? Colors.red : _getNodeColor(contact.type), shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: Icon( _getNodeIcon(contact.type), color: Colors.white, size: 20, ), ), ], ), ), ); markers.add(marker); if (showLabels) { markers.add( _buildNodeLabelMarker( point: LatLng(contact.latitude!, contact.longitude!), label: settings.mapShowOverlaps && !_isBuildingPathTrace ? "${contact.publicKeyHex.substring(0, 2)}:${contact.name}" : contact.name, ), ); } } return markers; } Marker _buildNodeLabelMarker({required LatLng point, required String label}) { return Marker( point: point, width: 120, height: 24, alignment: Alignment.topCenter, child: IgnorePointer( child: Transform.translate( offset: const Offset(0, -20), child: FittedBox( fit: BoxFit.contain, child: Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(8), ), alignment: Alignment.center, child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.w500, ), ), ), ), ), ), ); } Color _getNodeColor(int type) { switch (type) { case advTypeChat: return Colors.blue; case advTypeRepeater: return Colors.green; case advTypeRoom: return Colors.purple; case advTypeSensor: return Colors.orange; default: return Colors.grey; } } 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 _buildLegend( List contacts, List contactsWithLocation, settings, int markerCount, int guessedCount, ) { final filteredContacts = _filterContactsBySettings( contacts, settings, noLocations: false, ); final filteredContactsAll = _filterContactsBySettings( contacts, settings, noLocations: true, ); final nodeCount = filteredContacts.length; final nodeCountAll = filteredContactsAll.length; return Positioned( top: 16, right: 16, child: Card( child: Column( mainAxisSize: MainAxisSize.min, children: [ InkWell( borderRadius: BorderRadius.circular(12), onTap: () { setState(() { _legendExpanded = !_legendExpanded; }); }, child: Padding( padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), child: Row( mainAxisSize: MainAxisSize.min, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( context.l10n.map_nodesCount( nodeCount + (settings.mapShowGuessedLocations ? guessedCount : 0), ), style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 14, ), ), Row( children: [ Icon( Icons.location_on, size: 16, color: Colors.grey, ), Text( ": $nodeCount", style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 14, ), ), ], ), Row( children: [ const Icon( Icons.wrong_location, size: 16, color: Colors.grey, ), Text( ": ${nodeCountAll - nodeCount}", style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 14, ), ), ], ), Row( children: [ const Icon( Icons.add_outlined, size: 16, color: Colors.grey, ), Text( ": $nodeCountAll", style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 14, ), ), ], ), Text( context.l10n.map_pinsCount(markerCount), style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 12, ), ), ], ), const SizedBox(width: 8), AnimatedRotation( turns: _legendExpanded ? 0.5 : 0, duration: const Duration(milliseconds: 200), child: const Icon(Icons.expand_more, size: 20), ), ], ), ), ), AnimatedCrossFade( firstChild: const SizedBox.shrink(), secondChild: Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 6), _buildLegendItem( Icons.person, context.l10n.map_chat, Colors.blue, ), _buildLegendItem( Icons.router, context.l10n.map_repeater, Colors.green, ), _buildLegendItem( Icons.meeting_room, context.l10n.map_room, Colors.purple, ), _buildLegendItem( Icons.sensors, context.l10n.map_sensor, Colors.orange, ), _buildLegendItem( Icons.flag, context.l10n.map_pinDm, Colors.blue, ), _buildLegendItem( Icons.flag, context.l10n.map_pinPrivate, Colors.purple, ), _buildLegendItem( Icons.flag, context.l10n.map_pinPublic, Colors.orange, ), if (settings.mapShowGuessedLocations && guessedCount > 0) _buildLegendItem( Icons.not_listed_location, context.l10n.map_guessedLocation, Colors.grey, ), ], ), ), crossFadeState: _legendExpanded ? CrossFadeState.showSecond : CrossFadeState.showFirst, duration: const Duration(milliseconds: 200), ), ], ), ), ); } Widget _buildLegendItem(IconData icon, String label, Color color) { return Padding( padding: const EdgeInsets.symmetric(vertical: 1.0), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 16, color: color), const SizedBox(width: 8), Text(label, style: const TextStyle(fontSize: 12)), ], ), ); } List<_SharedMarker> _collectSharedMarkers(MeshCoreConnector connector) { final markers = <_SharedMarker>[]; final selfName = connector.selfName ?? 'Me'; 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 id = _buildMarkerId( sourceId: contact.publicKeyHex, timestamp: message.timestamp, text: message.text, ); markers.add( _SharedMarker( id: id, position: payload.position, label: payload.label, flags: payload.flags, fromName: fromName, sourceLabel: contact.name, 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 id = _buildMarkerId( sourceId: 'channel:${channel.index}', timestamp: message.timestamp, text: message.text, ); markers.add( _SharedMarker( id: id, position: payload.position, label: payload.label, flags: payload.flags, fromName: message.senderName, sourceLabel: channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name, isChannel: true, isPublicChannel: isPublic, ), ); } } return markers; } _MarkerPayload? _parseMarkerText(String text) { final trimmed = text.trim(); if (!trimmed.startsWith('m:')) return null; final parts = trimmed.substring(2).split('|'); if (parts.isEmpty) return null; final coords = parts[0].split(','); if (coords.length != 2) return null; final lat = double.tryParse(coords[0].trim()); final lon = double.tryParse(coords[1].trim()); if (lat == null || lon == null) return null; final label = parts.length > 1 ? parts[1].trim() : ''; final flags = parts.length > 2 ? parts[2].trim() : ''; return _MarkerPayload( position: LatLng(lat, lon), label: label.isEmpty ? context.l10n.map_sharedPin : label, flags: flags, ); } String _buildMarkerId({ required String sourceId, required DateTime timestamp, required String text, }) { return '$sourceId|${timestamp.millisecondsSinceEpoch}|$text'; } Marker _buildSharedMarker(_SharedMarker marker) { final markerColor = marker.isChannel ? (marker.isPublicChannel ? Colors.orange : Colors.purple) : Colors.blue; return Marker( point: marker.position, width: 60, height: 60, child: GestureDetector( onTap: () => _showMarkerInfo(marker), child: Column( children: [ Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: markerColor, shape: BoxShape.circle, border: Border.all(color: Colors.white, width: 2), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.3), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: const Icon(Icons.flag, color: Colors.white, size: 20), ), ], ), ), ); } 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, _) { // Navigate to chat screen after successful login context.read().markContactRead(room.publicKeyHex); Navigator.push( context, MaterialPageRoute(builder: (context) => ChatScreen(contact: room)), ); }, ), ); } void _showNodeInfo( BuildContext context, Contact contact, { LatLng? guessedPosition, }) { final connector = context.read(); showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Row( children: [ Icon( _getNodeIcon(contact.type), color: _getNodeColor(contact.type), ), const SizedBox(width: 8), Expanded(child: SelectableText(contact.name)), ], ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildInfoRow('Type', contact.typeLabel), _buildInfoRow('Path', contact.pathLabel), if (contact.hasLocation) _buildInfoRow( 'Location', '${contact.latitude!.toStringAsFixed(6)}, ${contact.longitude!.toStringAsFixed(6)}', ) else if (guessedPosition != null) _buildInfoRow( 'Est. Location', '~${guessedPosition.latitude.toStringAsFixed(6)}, ${guessedPosition.longitude.toStringAsFixed(6)}', ), _buildInfoRow( context.l10n.map_lastSeen, _formatLastSeen(contact.lastSeen), ), _buildInfoRow('Public Key', contact.publicKeyHex), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(context.l10n.common_close), ), if (contact.type == advTypeChat) // Only show chat button for chat nodes TextButton( onPressed: () { if (!contact.isActive) { connector.importDiscoveredContact(contact); } Navigator.pop(dialogContext); Navigator.push( context, MaterialPageRoute( builder: (context) => ChatScreen(contact: contact), ), ); }, child: Text(context.l10n.contacts_openChat), ), if (contact.type == advTypeRepeater) TextButton( onPressed: () { if (!contact.isActive) { connector.importDiscoveredContact(contact); } Navigator.pop(dialogContext); _showRepeaterLogin(context, contact); }, child: Text(context.l10n.map_manageRepeater), ), if (contact.type == advTypeRoom) TextButton( onPressed: () { if (!contact.isActive) { connector.importDiscoveredContact(contact); } Navigator.pop(dialogContext); _showRoomLogin(context, contact); }, child: Text(context.l10n.map_joinRoom), ), ], ), ); } 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), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildInfoRow(context.l10n.map_from, marker.fromName), _buildInfoRow(context.l10n.map_source, marker.sourceLabel), _buildInfoRow( '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( 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: Colors.grey[600], fontWeight: FontWeight.w500, ), ), const SizedBox(height: 2), SelectableText(value, style: const TextStyle(fontSize: 14)), ], ), ); } 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; showDismissibleSnackBar( messenger.context, 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); 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 ? Colors.orange : Colors.blue, ), title: Text(label), subtitle: isPublic ? Text(context.l10n.channels_publicChannel) : null, 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 _showFilterDialog( BuildContext context, AppSettingsService settingsService, ) { showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(context.l10n.map_filterNodes), contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), content: SingleChildScrollView( child: Consumer( builder: (consumerContext, service, child) { final settings = service.settings; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( context.l10n.map_nodeTypes, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), const SizedBox(height: 8), CheckboxListTile( title: Text(context.l10n.map_chatNodes), value: settings.mapShowChatNodes, onChanged: (value) { service.setMapShowChatNodes(value ?? true); }, contentPadding: EdgeInsets.zero, ), CheckboxListTile( title: Text(context.l10n.map_repeaters), value: settings.mapShowRepeaters, onChanged: (value) { service.setMapShowRepeaters(value ?? true); }, contentPadding: EdgeInsets.zero, ), CheckboxListTile( title: Text(context.l10n.map_otherNodes), value: settings.mapShowOtherNodes, onChanged: (value) { service.setMapShowOtherNodes(value ?? true); }, contentPadding: EdgeInsets.zero, ), CheckboxListTile( title: Text(context.l10n.map_showGuessedLocations), value: settings.mapShowGuessedLocations, onChanged: (value) { service.setMapShowGuessedLocations(value ?? true); }, contentPadding: EdgeInsets.zero, ), CheckboxListTile( title: Text(context.l10n.map_showDiscoveryContacts), value: settings.mapShowDiscoveryContacts, onChanged: (value) { service.setMapShowDiscoveryContacts(value ?? true); }, contentPadding: EdgeInsets.zero, ), CheckboxListTile( title: Text(context.l10n.map_showOverlaps), value: settings.mapShowOverlaps, onChanged: (value) { service.setMapShowOverlaps(value ?? true); }, contentPadding: EdgeInsets.zero, ), const SizedBox(height: 16), Text( context.l10n.map_keyPrefix, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), const SizedBox(height: 8), CheckboxListTile( title: Text(context.l10n.map_filterByKeyPrefix), value: settings.mapKeyPrefixEnabled, onChanged: (value) { service.setMapKeyPrefixEnabled(value ?? false); }, contentPadding: EdgeInsets.zero, ), TextFormField( initialValue: settings.mapKeyPrefix, enabled: settings.mapKeyPrefixEnabled, decoration: InputDecoration( labelText: context.l10n.map_publicKeyPrefix, hintText: 'e.g. ab12', border: const OutlineInputBorder(), isDense: true, ), onChanged: (value) { service.setMapKeyPrefix(value); }, ), const SizedBox(height: 16), Text( context.l10n.map_markers, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), const SizedBox(height: 8), CheckboxListTile( title: Text(context.l10n.map_showSharedMarkers), value: settings.mapShowMarkers, onChanged: (value) { service.setMapShowMarkers(value ?? true); }, contentPadding: EdgeInsets.zero, ), const SizedBox(height: 16), Text( context.l10n.map_lastSeenTime, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 16, ), ), const SizedBox(height: 8), Text( _getTimeFilterLabel(settings.mapTimeFilterHours), style: TextStyle(fontSize: 14, color: Colors.grey[700]), ), Slider( value: _hoursToSliderValue(settings.mapTimeFilterHours), min: 0, max: 100, divisions: 100, onChanged: (value) { final hours = _sliderValueToHours(value); service.setMapTimeFilterHours(hours); }, ), ], ); }, ), ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(context.l10n.common_close), ), ], ), ); } // 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: Card( 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: TextStyle(fontSize: 12, color: Colors.grey[700]), ), SelectableText( _pathTrace .map((b) => b.toRadixString(16).padLeft(2, '0')) .join(','), style: TextStyle(fontSize: 18), ), // 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), ), ], ), ], ), ), ), ); } } 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, }); } class _SharedMarker { final String id; final LatLng position; final String label; final String flags; final String fromName; final String sourceLabel; final bool isChannel; final bool isPublicChannel; _SharedMarker({ required this.id, required this.position, required this.label, required this.flags, required this.fromName, required this.sourceLabel, required this.isChannel, required this.isPublicChannel, }); }