refactor(metrics/map): DRY up charts, decompose MapView monoliths, add test coverage (#5049)

This commit is contained in:
James Rich 2026-04-10 15:54:09 -05:00 committed by GitHub
parent 56332f4d77
commit 520fa717a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 3464 additions and 2169 deletions

View file

@ -23,32 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
/** OSMDroid implementation of [MapViewProvider]. */
@Single
class FdroidMapViewProvider : MapViewProvider {
@Composable
override fun MapView(
modifier: Modifier,
viewModel: Any,
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int?,
nodeTracks: List<Any>?,
tracerouteOverlay: Any?,
tracerouteNodePositions: Map<Int, Any>,
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
waypointId: Int?,
) {
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
@Suppress("UNCHECKED_CAST")
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
focusedNodeNum = focusedNodeNum,
nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
)
}
}

View file

@ -17,10 +17,8 @@
package org.meshtastic.app.map
import android.Manifest
import android.graphics.Paint
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
@ -38,9 +36,11 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -49,6 +49,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -56,8 +57,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@ -79,6 +78,7 @@ import org.meshtastic.app.map.component.CacheLayout
import org.meshtastic.app.map.component.DownloadButton
import org.meshtastic.app.map.component.EditWaypointDialog
import org.meshtastic.app.map.component.MapButton
import org.meshtastic.app.map.component.MapControlsOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.core.common.gpsDisabled
@ -96,6 +96,7 @@ import org.meshtastic.core.resources.delete_for_everyone
import org.meshtastic.core.resources.delete_for_me
import org.meshtastic.core.resources.expires
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.core.resources.location_disabled
import org.meshtastic.core.resources.map_cache_info
import org.meshtastic.core.resources.map_cache_manager
@ -105,7 +106,6 @@ import org.meshtastic.core.resources.map_clear_tiles
import org.meshtastic.core.resources.map_download_complete
import org.meshtastic.core.resources.map_download_errors
import org.meshtastic.core.resources.map_download_region
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_node_popup_details
import org.meshtastic.core.resources.map_offline_manager
import org.meshtastic.core.resources.map_purge_fail
@ -114,10 +114,8 @@ import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.resources.map_subDescription
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.resources.waypoint_delete
import org.meshtastic.core.resources.you
import org.meshtastic.core.ui.component.BasicListItem
@ -126,18 +124,13 @@ import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.LocationDisabled
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MyLocation
import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.core.ui.icon.Tune
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel.MapFilterState
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.Position
import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Waypoint
import org.osmdroid.bonuspack.utils.BonusPackHelper.getBitmapFromVectorDrawable
import org.osmdroid.config.Configuration
@ -156,38 +149,23 @@ import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.MapEventsOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polygon
import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.infowindow.InfoWindow
import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay
import java.io.File
import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.roundToInt
private fun MapView.updateMarkers(
nodeMarkers: List<MarkerWithLabel>,
waypointMarkers: List<MarkerWithLabel>,
trackMarkers: List<Marker>,
trackPolylines: List<Polyline>,
nodeClusterer: RadiusMarkerClusterer,
) {
Logger.d {
"Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints ${trackMarkers.size} tracks"
}
val trackOverlayIds = (trackMarkers + trackPolylines).toSet()
Logger.d { "Showing on map: ${nodeMarkers.size} nodes ${waypointMarkers.size} waypoints" }
overlays.removeAll { overlay ->
overlay is MarkerWithLabel ||
(overlay is Marker && overlay !in nodeClusterer.items && overlay !in trackOverlayIds) ||
(overlay is Polyline && overlay !in trackOverlayIds)
overlay is MarkerWithLabel || (overlay is Marker && overlay !in nodeClusterer.items)
}
overlays.addAll(waypointMarkers)
overlays.addAll(trackPolylines)
overlays.addAll(trackMarkers)
nodeClusterer.items.clear()
nodeClusterer.items.addAll(nodeMarkers)
@ -225,17 +203,12 @@ private fun cacheManagerCallback(onTaskComplete: () -> Unit, onTaskFailed: (Int)
* @param navigateToNodeDetails Callback to navigate to the details screen of a selected node.
*/
@OptIn(ExperimentalPermissionsApi::class) // Added for Accompanist
@Suppress("CyclomaticComplexMethod", "LongParameterList", "LongMethod")
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
fun MapView(
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int? = null,
nodeTracks: List<Position>? = null,
tracerouteOverlay: TracerouteOverlay? = null,
tracerouteNodePositions: Map<Int, Position> = emptyMap(),
onTracerouteMappableCountChanged: (shown: Int, total: Int) -> Unit = { _, _ -> },
) {
var mapFilterExpanded by remember { mutableStateOf(false) }
@ -334,6 +307,16 @@ fun MapView(
}
}
// Keep screen on while location tracking is active
LaunchedEffect(myLocationOverlay) {
val activity = context as? android.app.Activity ?: return@LaunchedEffect
if (myLocationOverlay != null) {
activity.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
activity.window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap())
val selectedWaypointId by mapViewModel.selectedWaypointId.collectAsStateWithLifecycle()
@ -349,77 +332,21 @@ fun MapView(
}
}
val tracerouteSelection =
remember(tracerouteOverlay, tracerouteNodePositions, nodes) {
mapViewModel.tracerouteNodeSelection(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
nodes = nodes,
)
}
val overlayNodeNums = tracerouteSelection.overlayNodeNums
val nodeLookup = tracerouteSelection.nodeLookup
val nodesForMarkers = tracerouteSelection.nodesForMarkers
val tracerouteForwardPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.forwardRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
val tracerouteReturnPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.returnRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
LaunchedEffect(tracerouteOverlay, nodesForMarkers) {
if (tracerouteOverlay != null) {
onTracerouteMappableCountChanged(nodesForMarkers.size, tracerouteOverlay.relatedNodeNums.size)
}
}
val tracerouteHeadingReferencePoints =
remember(tracerouteForwardPoints, tracerouteReturnPoints) {
when {
tracerouteForwardPoints.size >= 2 -> tracerouteForwardPoints
tracerouteReturnPoints.size >= 2 -> tracerouteReturnPoints
else -> emptyList()
}
}
val tracerouteForwardOffsetPoints =
remember(tracerouteForwardPoints, tracerouteHeadingReferencePoints) {
offsetPolyline(
points = tracerouteForwardPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = tracerouteHeadingReferencePoints,
sideMultiplier = 1.0,
)
}
val tracerouteReturnOffsetPoints =
remember(tracerouteReturnPoints, tracerouteHeadingReferencePoints) {
offsetPolyline(
points = tracerouteReturnPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = tracerouteHeadingReferencePoints,
sideMultiplier = -1.0,
)
}
val traceroutePolylines = remember { mutableStateListOf<Polyline>() }
var hasCenteredTraceroute by remember(tracerouteOverlay) { mutableStateOf(false) }
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
fun MapView.onNodesChanged(nodes: Collection<Node>): List<MarkerWithLabel> {
val nodesWithPosition = nodes.filter { it.validPosition != null }
val ourNode = mapViewModel.ourNodeInfo.value
val displayUnits =
mapViewModel.config.display?.units ?: org.meshtastic.proto.Config.DisplayConfig.DisplayUnits.METRIC
val displayUnits = mapViewModel.config.display?.units ?: DisplayUnits.METRIC
val mapFilterStateValue = mapViewModel.mapFilterStateFlow.value // Access mapFilterState directly
return nodesWithPosition.mapNotNull { node ->
if (mapFilterStateValue.onlyFavorites && !node.isFavorite && !node.equals(ourNode)) {
return@mapNotNull null
}
if (
mapFilterStateValue.onlyFavorites &&
!node.isFavorite &&
!overlayNodeNums.contains(node.num) &&
!node.equals(ourNode)
mapFilterStateValue.lastHeardFilter.seconds != 0L &&
(nowSeconds - node.lastHeard) > mapFilterStateValue.lastHeardFilter.seconds &&
node.num != ourNode?.num
) {
return@mapNotNull null
}
@ -580,53 +507,6 @@ fun MapView(
invalidate()
}
fun MapView.updateTracerouteOverlay(forwardPoints: List<GeoPoint>, returnPoints: List<GeoPoint>) {
overlays.removeAll(traceroutePolylines)
traceroutePolylines.clear()
fun buildPolyline(points: List<GeoPoint>, color: Int, strokeWidth: Float): Polyline = Polyline().apply {
setPoints(points)
outlinePaint.apply {
this.color = color
this.strokeWidth = strokeWidth
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
style = Paint.Style.STROKE
}
}
forwardPoints
.takeIf { it.size >= 2 }
?.let { points ->
traceroutePolylines.add(
buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }),
)
}
returnPoints
.takeIf { it.size >= 2 }
?.let { points ->
traceroutePolylines.add(
buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }),
)
}
overlays.addAll(traceroutePolylines)
invalidate()
}
LaunchedEffect(tracerouteOverlay, tracerouteForwardPoints, tracerouteReturnPoints) {
if (tracerouteOverlay == null || hasCenteredTraceroute) return@LaunchedEffect
val allPoints = (tracerouteForwardPoints + tracerouteReturnPoints).distinct()
if (allPoints.isNotEmpty()) {
if (allPoints.size == 1) {
map.controller.setCenter(allPoints.first())
map.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM)
} else {
map.zoomToBoundingBox(BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS), true)
}
hasCenteredTraceroute = true
}
}
fun MapView.generateBoxOverlay() {
overlays.removeAll { it is Polygon }
val zoomFactor = 1.3
@ -689,51 +569,6 @@ fun MapView(
}
}
fun MapView.onTracksChanged(nodeTracks: List<Position>?, focusedNodeNum: Int?): Pair<List<Marker>, List<Polyline>> {
if (nodeTracks == null || focusedNodeNum == null) return emptyList<Marker>() to emptyList<Polyline>()
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
val timeFilteredPositions =
nodeTracks.filter {
lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds
}
val sortedPositions = timeFilteredPositions.sortedBy { it.time }
val focusedNode = nodes.find { it.num == focusedNodeNum } ?: return emptyList<Marker>() to emptyList<Polyline>()
val color = focusedNode.colors.second
val trackPolylines = mutableListOf<Polyline>()
if (sortedPositions.size > 1) {
val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
segments.forEachIndexed { index, segmentPoints ->
val alpha = (index.toFloat() / (segments.size.toFloat() - 1))
val polyline =
Polyline().apply {
setPoints(
segmentPoints.map { GeoPoint((it.latitude_i ?: 0) * 1e-7, (it.longitude_i ?: 0) * 1e-7) },
)
outlinePaint.color = Color(color).copy(alpha = alpha).toArgb()
outlinePaint.strokeWidth = 8f
}
trackPolylines.add(polyline)
}
}
val trackMarkers =
sortedPositions.mapIndexedNotNull { index, position ->
if (index == sortedPositions.lastIndex) return@mapIndexedNotNull null
Marker(this).apply {
this.position = GeoPoint((position.latitude_i ?: 0) * 1e-7, (position.longitude_i ?: 0) * 1e-7)
icon = AppCompatResources.getDrawable(context, R.drawable.ic_map_location_dot)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
title = getString(Res.string.position)
snippet = formatAgo(position.time)
}
}
return trackMarkers to trackPolylines
}
Scaffold(
modifier = modifier,
floatingActionButton = {
@ -750,14 +585,10 @@ fun MapView(
},
modifier = Modifier.fillMaxSize(),
update = { mapView ->
mapView.updateTracerouteOverlay(tracerouteForwardOffsetPoints, tracerouteReturnOffsetPoints)
val (trackMarkers, trackPolylines) = mapView.onTracksChanged(nodeTracks, focusedNodeNum)
with(mapView) {
updateMarkers(
onNodesChanged(nodesForMarkers),
onNodesChanged(nodes),
onWaypointChanged(waypoints.values, selectedWaypointId),
trackMarkers,
trackPolylines,
nodeClusterer,
)
}
@ -776,122 +607,34 @@ fun MapView(
modifier = Modifier.align(Alignment.BottomCenter),
)
} else {
@Suppress("MagicNumber")
Column(
modifier = Modifier.padding(top = 16.dp, end = 16.dp).align(Alignment.TopEnd),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
MapButton(
onClick = { showMapStyleDialog = true },
icon = MeshtasticIcons.Layers,
contentDescription = Res.string.map_style_selection,
)
Box(modifier = Modifier) {
MapButton(
onClick = { mapFilterExpanded = true },
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
)
DropdownMenu(
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
onToggleFilterMenu = { mapFilterExpanded = true },
filterDropdownContent = {
FdroidMainMapFilterDropdown(
expanded = mapFilterExpanded,
onDismissRequest = { mapFilterExpanded = false },
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
) {
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = MeshtasticIcons.Favorite,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(Res.string.only_favorites),
modifier = Modifier.weight(1f),
)
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleOnlyFavorites() },
)
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(Res.string.show_waypoints),
modifier = Modifier.weight(1f),
)
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
)
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = MeshtasticIcons.Lens,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(Res.string.show_precision_circle),
modifier = Modifier.weight(1f),
)
@Suppress("MagicNumber")
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
}
}
MapButton(
icon =
if (myLocationOverlay == null) {
MeshtasticIcons.MyLocation
} else {
MeshtasticIcons.LocationDisabled
},
contentDescription = stringResource(Res.string.toggle_my_position),
) {
mapFilterState = mapFilterState,
mapViewModel = mapViewModel,
)
},
mapTypeContent = {
MapButton(
icon = MeshtasticIcons.Layers,
contentDescription = stringResource(Res.string.map_style_selection),
onClick = { showMapStyleDialog = true },
)
},
isLocationTrackingEnabled = myLocationOverlay != null,
onToggleLocationTracking = {
if (locationPermissionsState.allPermissionsGranted) {
map.toggleMyLocation()
} else {
triggerLocationToggleAfterPermission = true
locationPermissionsState.launchMultiplePermissionRequest()
}
}
}
},
)
}
}
}
@ -970,6 +713,103 @@ fun MapView(
}
}
/** F-Droid main map filter dropdown — favorites, waypoints, precision circle, and last-heard time filter slider. */
@Composable
private fun FdroidMainMapFilterDropdown(
expanded: Boolean,
onDismissRequest: () -> Unit,
mapFilterState: MapFilterState,
mapViewModel: MapViewModel,
) {
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
) {
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Favorite,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleOnlyFavorites() },
)
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
)
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Lens,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
HorizontalDivider()
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
Res.string.last_heard_filter_label,
stringResource(mapFilterState.lastHeardFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
}
@Composable
private fun MapStyleDialog(selectedMapStyle: Int, onDismiss: () -> Unit, onSelectMapStyle: (Int) -> Unit) {
val selected = remember { mutableStateOf(selectedMapStyle) }
@ -1125,57 +965,4 @@ private fun MapsDialog(
}
}
private const val EARTH_RADIUS_METERS = 6_371_000.0
private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
private const val WAYPOINT_ZOOM = 15.0
@Suppress("MagicNumber")
private fun Double.toRad(): Double = this * Math.PI / 180.0
private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
val lat1 = from.latitude.toRad()
val lat2 = to.latitude.toRad()
val dLon = (to.longitude - from.longitude).toRad()
return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon))
}
private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint {
val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS
val lat1 = latitude.toRad()
val lon1 = longitude.toRad()
val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad))
val lon2 =
lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2))
return GeoPoint(Math.toDegrees(lat2), Math.toDegrees(lon2))
}
private fun offsetPolyline(
points: List<GeoPoint>,
offsetMeters: Double,
headingReferencePoints: List<GeoPoint> = points,
sideMultiplier: Double = 1.0,
): List<GeoPoint> {
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
val headings =
headingPoints.mapIndexed { index, _ ->
when (index) {
0 -> bearingRad(headingPoints[0], headingPoints[1])
headingPoints.lastIndex ->
bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex])
else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1])
}
}
return points.mapIndexed { index, point ->
val heading = headings[index.coerceIn(0, headings.lastIndex)]
@Suppress("MagicNumber")
val perpendicularHeading = heading + (Math.PI / 2 * sideMultiplier)
point.offsetPoint(perpendicularHeading, abs(offsetMeters))
}
}

View file

@ -16,9 +16,6 @@
*/
package org.meshtastic.app.map
import android.annotation.SuppressLint
import android.content.Context
import android.os.PowerManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@ -32,7 +29,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import co.touchlab.kermit.Logger
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.ITileSource
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
@ -41,29 +37,6 @@ import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
@SuppressLint("WakelockTimeout")
private fun PowerManager.WakeLock.safeAcquire() {
if (!isHeld) {
try {
acquire()
} catch (e: SecurityException) {
Logger.e { "WakeLock permission exception: ${e.message}" }
} catch (e: IllegalStateException) {
Logger.e { "WakeLock acquire() exception: ${e.message}" }
}
}
}
private fun PowerManager.WakeLock.safeRelease() {
if (isHeld) {
try {
release()
} catch (e: IllegalStateException) {
Logger.e { "WakeLock release() exception: ${e.message}" }
}
}
}
private const val MIN_ZOOM_LEVEL = 1.5
private const val MAX_ZOOM_LEVEL = 20.0
private const val DEFAULT_ZOOM_LEVEL = 15.0
@ -136,22 +109,13 @@ internal fun rememberMapViewWithLifecycle(
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
@Suppress("DEPRECATION")
val wakeLock = powerManager.newWakeLock(PowerManager.FULL_WAKE_LOCK, "Meshtastic:MapViewLock")
wakeLock.safeAcquire()
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
wakeLock.safeRelease()
mapView.onPause()
}
Lifecycle.Event.ON_RESUME -> {
wakeLock.safeAcquire()
mapView.onResume()
}
@ -166,10 +130,7 @@ internal fun rememberMapViewWithLifecycle(
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
wakeLock.safeRelease()
}
onDispose { lifecycle.removeObserver(observer) }
}
return mapView
}

View file

@ -1,61 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.size
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.map_style_selection
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun MapButton(
icon: ImageVector,
contentDescription: StringResource,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
) {
MapButton(
icon = icon,
contentDescription = stringResource(contentDescription),
modifier = modifier,
onClick = onClick,
)
}
@Composable
fun MapButton(icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
FloatingActionButton(onClick = onClick, modifier = modifier) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(24.dp))
}
}
@PreviewLightDark
@Composable
private fun MapButtonPreview() {
AppTheme { MapButton(icon = MeshtasticIcons.Layers, contentDescription = Res.string.map_style_selection) }
}

View file

@ -17,48 +17,38 @@
package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.app.map.addCopyright
import org.meshtastic.app.map.addPolyline
import org.meshtastic.app.map.addPositionMarkers
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
private const val DEG_D = 1e-7
@Composable
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val density = LocalDensity.current
val positionLogs by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
val geoPoints = positionLogs.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
val mapView =
rememberMapViewWithLifecycle(
applicationId = nodeMapViewModel.applicationId,
box = cameraView,
tileSource = CustomTileSource.getTileSource(nodeMapViewModel.mapStyleId),
)
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { mapView },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(positionLogs) {}
Scaffold(
topBar = {
MainAppBar(
title = node?.user?.long_name ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {},
onClickChip = {},
)
},
)
) { paddingValues ->
NodeTrackOsmMap(
positions = positions,
applicationId = nodeMapViewModel.applicationId,
mapStyleId = nodeMapViewModel.mapStyleId,
modifier = Modifier.fillMaxSize().padding(paddingValues),
)
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.node
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain
* [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation
* ([NodeTrackOsmMap]).
*/
@Composable
fun NodeTrackMap(destNum: Int, positions: List<Position>, modifier: Modifier = Modifier) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
NodeTrackOsmMap(
positions = positions,
applicationId = vm.applicationId,
mapStyleId = vm.mapStyleId,
modifier = modifier,
)
}

View file

@ -0,0 +1,150 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.app.map.addCopyright
import org.meshtastic.app.map.addPolyline
import org.meshtastic.app.map.addPositionMarkers
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.component.MapControlsOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.last_heard_filter_label
import org.meshtastic.feature.map.LastHeardFilter
import org.meshtastic.proto.Position
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import kotlin.math.roundToInt
/**
* A focused OSMDroid map composable that renders **only** a node's position track a dashed polyline with directional
* markers for each historical position.
*
* Applies the [lastHeardTrackFilter][org.meshtastic.feature.map.BaseMapViewModel.MapFilterState.lastHeardTrackFilter]
* from [MapViewModel] to filter positions by time, matching the behavior of the Google Maps implementation. Includes a
* minimal [MapControlsOverlay][org.meshtastic.app.map.component.MapControlsOverlay] with a track time filter slider so
* users can adjust the time range directly from the map.
*
* Unlike the main [org.meshtastic.app.map.MapView], this composable does **not** include node clusters, waypoints, or
* location tracking. It is designed to be embedded inside the position-log adaptive layout.
*/
@Composable
fun NodeTrackOsmMap(
positions: List<Position>,
applicationId: String,
mapStyleId: Int,
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
) {
val density = LocalDensity.current
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
val filteredPositions =
remember(positions, lastHeardTrackFilter) {
positions.filter {
lastHeardTrackFilter == LastHeardFilter.Any || it.time > nowSeconds - lastHeardTrackFilter.seconds
}
}
val geoPoints =
remember(filteredPositions) {
filteredPositions.map { GeoPoint((it.latitude_i ?: 0) * DEG_D, (it.longitude_i ?: 0) * DEG_D) }
}
val cameraView = remember(geoPoints) { BoundingBox.fromGeoPoints(geoPoints) }
val mapView =
rememberMapViewWithLifecycle(
applicationId = applicationId,
box = cameraView,
tileSource = CustomTileSource.getTileSource(mapStyleId),
)
var filterMenuExpanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
AndroidView(
modifier = Modifier.matchParentSize(),
factory = { mapView },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
map.addPositionMarkers(filteredPositions) {}
},
)
// Track filter controls overlay
MapControlsOverlay(
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
onToggleFilterMenu = { filterMenuExpanded = true },
filterDropdownContent = {
DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(lastHeardTrackFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
Res.string.last_heard_filter_label,
stringResource(lastHeardTrackFilter.label),
),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, filterOptions.size - 1)
mapViewModel.setLastHeardTrackFilter(filterOptions[newIndex])
},
valueRange = 0f..(filterOptions.size - 1).toFloat(),
steps = filterOptions.size - 2,
)
}
}
},
)
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.traceroute
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable traceroute map. Delegates to the OSMDroid implementation
* ([TracerouteOsmMap]).
*/
@Composable
fun TracerouteMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
TracerouteOsmMap(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
modifier = modifier,
)
}

View file

@ -0,0 +1,288 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.app.map.traceroute
import android.graphics.Paint
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.R
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.app.map.addCopyright
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.app.map.zoomIn
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.util.GeoConstants.EARTH_RADIUS_METERS
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.feature.map.tracerouteNodeSelection
import org.meshtastic.proto.Position
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
private const val TRACEROUTE_OFFSET_METERS = 100.0
private const val TRACEROUTE_SINGLE_POINT_ZOOM = 12.0
private const val TRACEROUTE_ZOOM_OUT_LEVELS = 0.5
/**
* A focused OSMDroid map composable that renders **only** traceroute visualization node markers for each hop and
* forward/return offset polylines with auto-centering camera.
*
* Unlike the main `MapView`, this composable does **not** include node clusters, waypoints, location tracking, or any
* map controls. It is designed to be embedded inside `TracerouteMapScreen`'s scaffold.
*/
@Composable
fun TracerouteOsmMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
mapViewModel: MapViewModel = koinViewModel(),
) {
val context = LocalContext.current
val density = LocalDensity.current
val nodes by mapViewModel.nodes.collectAsStateWithLifecycle()
val markerIcon = remember { AppCompatResources.getDrawable(context, R.drawable.ic_location_on) }
// Resolve which nodes to display for the traceroute
val tracerouteSelection =
remember(tracerouteOverlay, tracerouteNodePositions, nodes) {
mapViewModel.tracerouteNodeSelection(
tracerouteOverlay = tracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions,
nodes = nodes,
)
}
val displayNodes = tracerouteSelection.nodesForMarkers
val nodeLookup = tracerouteSelection.nodeLookup
// Report mappable count
LaunchedEffect(tracerouteOverlay, displayNodes) {
if (tracerouteOverlay != null) {
onMappableCountChanged(displayNodes.size, tracerouteOverlay.relatedNodeNums.size)
}
}
// Compute polyline GeoPoints from node positions
val forwardPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.forwardRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
val returnPoints =
remember(tracerouteOverlay, nodeLookup) {
tracerouteOverlay?.returnRoute?.mapNotNull {
nodeLookup[it]?.let { node -> GeoPoint(node.latitude, node.longitude) }
} ?: emptyList()
}
// Compute offset polylines for visual separation
val headingReferencePoints =
remember(forwardPoints, returnPoints) {
when {
forwardPoints.size >= 2 -> forwardPoints
returnPoints.size >= 2 -> returnPoints
else -> emptyList()
}
}
val forwardOffsetPoints =
remember(forwardPoints, headingReferencePoints) {
offsetPolyline(
points = forwardPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = headingReferencePoints,
sideMultiplier = 1.0,
)
}
val returnOffsetPoints =
remember(returnPoints, headingReferencePoints) {
offsetPolyline(
points = returnPoints,
offsetMeters = TRACEROUTE_OFFSET_METERS,
headingReferencePoints = headingReferencePoints,
sideMultiplier = -1.0,
)
}
// Camera auto-center
var hasCentered by remember(tracerouteOverlay) { mutableStateOf(false) }
// Build initial camera from all traceroute points
val allPoints = remember(forwardPoints, returnPoints) { (forwardPoints + returnPoints).distinct() }
val initialCameraView =
remember(allPoints) { if (allPoints.isEmpty()) null else BoundingBox.fromGeoPoints(allPoints) }
val mapView =
rememberMapViewWithLifecycle(
applicationId = mapViewModel.applicationId,
box = initialCameraView ?: BoundingBox(),
tileSource = CustomTileSource.getTileSource(mapViewModel.mapStyleId),
)
// Center camera on traceroute bounds
LaunchedEffect(tracerouteOverlay, forwardPoints, returnPoints) {
if (tracerouteOverlay == null || hasCentered) return@LaunchedEffect
if (allPoints.isNotEmpty()) {
if (allPoints.size == 1) {
mapView.controller.setCenter(allPoints.first())
mapView.controller.setZoom(TRACEROUTE_SINGLE_POINT_ZOOM)
} else {
mapView.zoomToBoundingBox(
BoundingBox.fromGeoPoints(allPoints).zoomIn(-TRACEROUTE_ZOOM_OUT_LEVELS),
true,
)
}
hasCentered = true
}
}
AndroidView(
modifier = modifier,
factory = { mapView.apply { setDestroyMode(false) } },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
// Render traceroute polylines
buildTraceroutePolylines(forwardOffsetPoints, returnOffsetPoints, density).forEach { map.overlays.add(it) }
// Render simple node markers
displayNodes.forEach { node ->
val position = GeoPoint(node.latitude, node.longitude)
val marker =
MarkerWithLabel(mapView = map, label = "${node.user.short_name} ${formatAgo(node.position.time)}")
.apply {
id = node.user.id
title = node.user.long_name
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
this.position = position
icon = markerIcon
setNodeColors(node.colors)
}
map.overlays.add(marker)
}
map.invalidate()
},
)
}
private fun buildTraceroutePolylines(
forwardPoints: List<GeoPoint>,
returnPoints: List<GeoPoint>,
density: androidx.compose.ui.unit.Density,
): List<Polyline> {
val polylines = mutableListOf<Polyline>()
fun buildPolyline(points: List<GeoPoint>, color: Int, strokeWidth: Float): Polyline = Polyline().apply {
setPoints(points)
outlinePaint.apply {
this.color = color
this.strokeWidth = strokeWidth
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
style = Paint.Style.STROKE
}
}
forwardPoints
.takeIf { it.size >= 2 }
?.let { points ->
polylines.add(buildPolyline(points, TracerouteColors.OutgoingRoute.toArgb(), with(density) { 6.dp.toPx() }))
}
returnPoints
.takeIf { it.size >= 2 }
?.let { points ->
polylines.add(buildPolyline(points, TracerouteColors.ReturnRoute.toArgb(), with(density) { 5.dp.toPx() }))
}
return polylines
}
// --- Haversine offset math for OSMDroid (no SphericalUtil available) ---
private fun Double.toRad(): Double = this * PI / 180.0
private fun bearingRad(from: GeoPoint, to: GeoPoint): Double {
val lat1 = from.latitude.toRad()
val lat2 = to.latitude.toRad()
val dLon = (to.longitude - from.longitude).toRad()
return atan2(sin(dLon) * cos(lat2), cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon))
}
private fun GeoPoint.offsetPoint(headingRad: Double, offsetMeters: Double): GeoPoint {
val distanceByRadius = offsetMeters / EARTH_RADIUS_METERS
val lat1 = latitude.toRad()
val lon1 = longitude.toRad()
val lat2 = asin(sin(lat1) * cos(distanceByRadius) + cos(lat1) * sin(distanceByRadius) * cos(headingRad))
val lon2 =
lon1 + atan2(sin(headingRad) * sin(distanceByRadius) * cos(lat1), cos(distanceByRadius) - sin(lat1) * sin(lat2))
return GeoPoint(lat2 * 180.0 / PI, lon2 * 180.0 / PI)
}
private fun offsetPolyline(
points: List<GeoPoint>,
offsetMeters: Double,
headingReferencePoints: List<GeoPoint> = points,
sideMultiplier: Double = 1.0,
): List<GeoPoint> {
val headingPoints = headingReferencePoints.takeIf { it.size >= 2 } ?: points
if (points.size < 2 || headingPoints.size < 2 || offsetMeters == 0.0) return points
val headings =
headingPoints.mapIndexed { index, _ ->
when (index) {
0 -> bearingRad(headingPoints[0], headingPoints[1])
headingPoints.lastIndex ->
bearingRad(headingPoints[headingPoints.lastIndex - 1], headingPoints[headingPoints.lastIndex])
else -> bearingRad(headingPoints[index - 1], headingPoints[index + 1])
}
}
return points.mapIndexed { index, point ->
val heading = headings[index.coerceIn(0, headings.lastIndex)]
val perpendicularHeading = heading + (PI / 2 * sideMultiplier)
point.offsetPoint(perpendicularHeading, abs(offsetMeters))
}
}

View file

@ -23,31 +23,17 @@ import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.annotation.Single
import org.meshtastic.core.ui.util.MapViewProvider
/** Google Maps implementation of [MapViewProvider]. */
@Single
class GoogleMapViewProvider : MapViewProvider {
@Composable
override fun MapView(
modifier: Modifier,
viewModel: Any,
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int?,
nodeTracks: List<Any>?,
tracerouteOverlay: Any?,
tracerouteNodePositions: Map<Int, Any>,
onTracerouteMappableCountChanged: (Int, Int) -> Unit,
waypointId: Int?,
) {
override fun MapView(modifier: Modifier, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val mapViewModel: MapViewModel = koinViewModel()
LaunchedEffect(waypointId) { mapViewModel.setWaypointId(waypointId) }
org.meshtastic.app.map.MapView(
modifier = modifier,
mapViewModel = mapViewModel,
navigateToNodeDetails = navigateToNodeDetails,
focusedNodeNum = focusedNodeNum,
nodeTracks = nodeTracks as? List<org.meshtastic.proto.Position>,
tracerouteOverlay = tracerouteOverlay as? org.meshtastic.feature.map.model.TracerouteOverlay,
tracerouteNodePositions = tracerouteNodePositions as? Map<Int, org.meshtastic.proto.Position> ?: emptyMap(),
onTracerouteMappableCountChanged = onTracerouteMappableCountChanged,
)
}
}

File diff suppressed because it is too large Load diff

View file

@ -42,9 +42,9 @@ import org.meshtastic.core.resources.only_favorites
import org.meshtastic.core.resources.show_precision_circle
import org.meshtastic.core.resources.show_waypoints
import org.meshtastic.core.ui.icon.Favorite
import org.meshtastic.core.ui.icon.Lens
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Place
import org.meshtastic.core.ui.icon.RadioButtonUnchecked
import org.meshtastic.core.ui.icon.PinDrop
import org.meshtastic.feature.map.LastHeardFilter
import kotlin.math.roundToInt
@ -73,7 +73,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.Place,
imageVector = MeshtasticIcons.PinDrop,
contentDescription = stringResource(Res.string.show_waypoints),
)
},
@ -89,7 +89,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.RadioButtonUnchecked, // Placeholder icon
imageVector = MeshtasticIcons.Lens,
contentDescription = stringResource(Res.string.show_precision_circle),
)
},

View file

@ -25,14 +25,13 @@ import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.locked
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint
private const val DEG_D = 1e-7
@Composable
fun WaypointMarkers(
displayableWaypoints: List<Waypoint>,

View file

@ -16,13 +16,14 @@
*/
package org.meshtastic.app.map.node
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.feature.map.node.NodeMapViewModel
@ -31,7 +32,6 @@ import org.meshtastic.feature.map.node.NodeMapViewModel
fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit) {
val node by nodeMapViewModel.node.collectAsStateWithLifecycle()
val positions by nodeMapViewModel.positionLogs.collectAsStateWithLifecycle()
val destNum = node?.num
Scaffold(
topBar = {
@ -46,8 +46,9 @@ fun NodeMapScreen(nodeMapViewModel: NodeMapViewModel, onNavigateUp: () -> Unit)
)
},
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {})
}
MapView(
modifier = Modifier.fillMaxSize().padding(paddingValues),
mode = GoogleMapMode.NodeTrack(focusedNode = node, positions = positions),
)
}
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.node
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to a
* [org.meshtastic.core.model.Node] via [NodeMapViewModel] and delegates to [MapView] in [GoogleMapMode.NodeTrack] mode,
* which provides the full shared map infrastructure (location tracking, tile providers, controls overlay with track
* filter).
*/
@Composable
fun NodeTrackMap(destNum: Int, positions: List<Position>, modifier: Modifier = Modifier) {
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
val focusedNode by vm.node.collectAsStateWithLifecycle()
MapView(modifier = modifier, mode = GoogleMapMode.NodeTrack(focusedNode = focusedNode, positions = positions))
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.traceroute
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.app.map.GoogleMapMode
import org.meshtastic.app.map.MapView
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.proto.Position
/**
* Flavor-unified entry point for the embeddable traceroute map. Delegates to [MapView] in [GoogleMapMode.Traceroute]
* mode, which provides the full shared map infrastructure (location tracking, tile providers, controls overlay).
*/
@Composable
fun TracerouteMap(
tracerouteOverlay: TracerouteOverlay?,
tracerouteNodePositions: Map<Int, Position>,
onMappableCountChanged: (shown: Int, total: Int) -> Unit,
modifier: Modifier = Modifier,
) {
MapView(
modifier = modifier,
mode =
GoogleMapMode.Traceroute(
overlay = tracerouteOverlay,
nodePositions = tracerouteNodePositions,
onMappableCountChanged = onMappableCountChanged,
),
)
}

View file

@ -69,14 +69,24 @@ import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.core.ui.util.LocalNodeMapScreenProvider
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.intro.AppIntroductionScreen
import org.meshtastic.feature.intro.IntroViewModel
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
import org.meshtastic.feature.map.node.NodeMapViewModel
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
class MainActivity : ComponentActivity() {
private val model: UIViewModel by viewModel()
@ -164,32 +174,42 @@ class MainActivity : ComponentActivity() {
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
{ destNum, positions, modifier ->
org.meshtastic.app.map.node.NodeTrackMap(destNum, positions, modifier)
},
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
org.meshtastic.core.ui.util.LocalNodeMapScreenProvider provides
LocalTracerouteMapProvider provides
{ overlay, nodePositions, onMappableCountChanged, modifier ->
org.meshtastic.app.map.traceroute.TracerouteMap(
tracerouteOverlay = overlay,
tracerouteNodePositions = nodePositions,
onMappableCountChanged = onMappableCountChanged,
modifier = modifier,
)
},
LocalNodeMapScreenProvider provides
{ destNum, onNavigateUp ->
val vm = koinViewModel<org.meshtastic.feature.map.node.NodeMapViewModel>()
val vm = koinViewModel<NodeMapViewModel>()
vm.setDestNum(destNum)
org.meshtastic.app.map.node.NodeMapScreen(vm, onNavigateUp = onNavigateUp)
},
org.meshtastic.core.ui.util.LocalTracerouteMapScreenProvider provides
LocalTracerouteMapScreenProvider provides
{ destNum, requestId, logUuid, onNavigateUp ->
val metricsViewModel =
koinViewModel<org.meshtastic.feature.node.metrics.MetricsViewModel> {
org.koin.core.parameter.parametersOf(destNum)
}
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(destNum) }
metricsViewModel.setNodeId(destNum)
org.meshtastic.feature.node.metrics.TracerouteMapScreen(
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = requestId,
logUuid = logUuid,
onNavigateUp = onNavigateUp,
)
},
org.meshtastic.core.ui.util.LocalMapMainScreenProvider provides
LocalMapMainScreenProvider provides
{ onClickNodeChip, navigateToNodeDetails, waypointId ->
val viewModel = koinViewModel<org.meshtastic.feature.map.SharedMapViewModel>()
org.meshtastic.feature.map.MapScreen(
val viewModel = koinViewModel<SharedMapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = onClickNodeChip,
navigateToNodeDetails = navigateToNodeDetails,

View file

@ -24,13 +24,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
/**
* A compact icon button used in map control overlays. Uses [FilledIconButton] for a consistent, compact appearance
* across both Google and F-Droid flavors.
*/
@Composable
fun MapButton(
modifier: Modifier = Modifier,
icon: ImageVector,
iconTint: Color? = null,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
iconTint: Color? = null,
) {
FilledIconButton(onClick = onClick, modifier = modifier) {
Icon(

View file

@ -27,17 +27,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.app.map.MapViewModel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.manage_map_layers
import org.meshtastic.core.resources.map_filter
import org.meshtastic.core.resources.map_tile_source
import org.meshtastic.core.resources.orient_north
import org.meshtastic.core.resources.refresh
import org.meshtastic.core.resources.toggle_my_position
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.LocationDisabled
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MapCompass
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.MyLocation
@ -45,77 +40,58 @@ import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Tune
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
/**
* Shared map controls overlay used by both Google and F-Droid map views. Provides compass, filter button, location
* tracking button, and optional slots for flavor-specific content (map type selector, layers, refresh).
*
* @param onToggleFilterMenu Callback to open/close the filter dropdown.
* @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button typically a
* `DropdownMenu` with filter options.
* @param mapTypeContent Optional composable for a map type selector button + dropdown. Google flavor provides map type
* and custom tile options; F-Droid provides a tile source selector.
* @param layersContent Optional composable for a layers management button.
* @param showRefresh Whether to show a refresh button (e.g., for network map layers).
* @param isRefreshing Whether a refresh is currently in progress.
* @param onRefresh Callback when the refresh button is clicked.
*/
@Suppress("LongParameterList")
@Composable
fun MapControlsOverlay(
onToggleFilterMenu: () -> Unit,
modifier: Modifier = Modifier,
mapFilterMenuExpanded: Boolean,
onMapFilterMenuDismissRequest: () -> Unit,
onToggleMapFilterMenu: () -> Unit,
mapViewModel: MapViewModel, // For MapFilterDropdown and MapTypeDropdown
mapTypeMenuExpanded: Boolean,
onMapTypeMenuDismissRequest: () -> Unit,
onToggleMapTypeMenu: () -> Unit,
onManageLayersClicked: () -> Unit,
onManageCustomTileProvidersClicked: () -> Unit, // New parameter
isNodeMap: Boolean,
// Location tracking parameters
isLocationTrackingEnabled: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
bearing: Float = 0f,
onCompassClick: () -> Unit = {},
followPhoneBearing: Boolean,
followPhoneBearing: Boolean = false,
filterDropdownContent: @Composable () -> Unit = {},
mapTypeContent: @Composable () -> Unit = {},
layersContent: @Composable () -> Unit = {},
isLocationTrackingEnabled: Boolean = false,
onToggleLocationTracking: () -> Unit = {},
showRefresh: Boolean = false,
isRefreshing: Boolean = false,
onRefresh: () -> Unit = {},
) {
Row(modifier = modifier) {
// Compass
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
if (isNodeMap) {
// Filter button + dropdown
Box {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onToggleMapFilterMenu,
onClick = onToggleFilterMenu,
)
NodeMapFilterDropdown(
expanded = mapFilterMenuExpanded,
onDismissRequest = onMapFilterMenuDismissRequest,
mapViewModel = mapViewModel,
)
} else {
Box {
MapButton(
icon = MeshtasticIcons.Tune,
contentDescription = stringResource(Res.string.map_filter),
onClick = onToggleMapFilterMenu,
)
MapFilterDropdown(
expanded = mapFilterMenuExpanded,
onDismissRequest = onMapFilterMenuDismissRequest,
mapViewModel = mapViewModel,
)
}
filterDropdownContent()
}
Box {
MapButton(
icon = MeshtasticIcons.Map,
contentDescription = stringResource(Res.string.map_tile_source),
onClick = onToggleMapTypeMenu,
)
MapTypeDropdown(
expanded = mapTypeMenuExpanded,
onDismissRequest = onMapTypeMenuDismissRequest,
mapViewModel = mapViewModel, // Pass mapViewModel
onManageCustomTileProvidersClicked = onManageCustomTileProvidersClicked, // Pass new callback
)
}
// Map type selector (flavor-specific)
mapTypeContent()
MapButton(
icon = MeshtasticIcons.Layers,
contentDescription = stringResource(Res.string.manage_map_layers),
onClick = onManageLayersClicked,
)
// Layers button (flavor-specific)
layersContent()
// Refresh button (optional)
if (showRefresh) {
if (isRefreshing) {
Box(modifier = Modifier.padding(8.dp)) {
@ -132,12 +108,7 @@ fun MapControlsOverlay(
// Location tracking button
MapButton(
icon =
if (isLocationTrackingEnabled) {
MeshtasticIcons.LocationDisabled
} else {
MeshtasticIcons.MyLocation
},
icon = if (isLocationTrackingEnabled) MeshtasticIcons.LocationDisabled else MeshtasticIcons.MyLocation,
contentDescription = stringResource(Res.string.toggle_my_position),
onClick = onToggleLocationTracking,
)
@ -146,12 +117,16 @@ fun MapControlsOverlay(
@Composable
private fun CompassButton(onClick: () -> Unit, bearing: Float, isFollowing: Boolean) {
val icon = if (isFollowing) MeshtasticIcons.MapCompass else MeshtasticIcons.MapCompass
val iconTint =
when {
isFollowing -> MaterialTheme.colorScheme.primary
bearing == 0f -> MaterialTheme.colorScheme.StatusRed
else -> null
}
MapButton(
modifier = Modifier.rotate(-bearing),
icon = icon,
iconTint = MaterialTheme.colorScheme.StatusRed.takeIf { bearing == 0f },
icon = MeshtasticIcons.MapCompass,
iconTint = iconTint,
contentDescription = stringResource(Res.string.orient_north),
onClick = onClick,
)