mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(metrics/map): DRY up charts, decompose MapView monoliths, add test coverage (#5049)
This commit is contained in:
parent
56332f4d77
commit
520fa717a9
71 changed files with 3464 additions and 2169 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue