feat(map): Add last heard filter for node tracks (#3222)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2025-09-27 16:56:19 -05:00
committed by GitHub
parent 61c6d6c76e
commit f3d34ed8a9
6 changed files with 127 additions and 125 deletions

View File

@@ -23,7 +23,6 @@ import android.app.Activity
import android.content.Intent
import android.graphics.Canvas
import android.graphics.Paint
import android.location.Location
import android.net.Uri
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -65,7 +64,6 @@ import androidx.core.graphics.createBitmap
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.MeshProtos.Position
import com.geeksville.mesh.MeshProtos.Waypoint
import com.geeksville.mesh.android.BuildUtils.debug
@@ -95,7 +93,6 @@ import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.JointType
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.android.gms.maps.model.RoundCap
import com.google.maps.android.clustering.ClusterItem
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
@@ -125,54 +122,6 @@ import java.text.DateFormat
private const val MIN_TRACK_POINT_DISTANCE_METERS = 20f
@Suppress("ReturnCount")
private fun filterNodeTrack(nodeTrack: List<Position>?): List<Position> {
if (nodeTrack.isNullOrEmpty()) return emptyList()
val sortedTrack = nodeTrack.sortedBy { it.time }
if (sortedTrack.size <= 2) return sortedTrack.map { it }
val filteredPoints = mutableListOf<MeshProtos.Position>()
var lastAddedPointProto = sortedTrack.first()
filteredPoints.add(lastAddedPointProto)
for (i in 1 until sortedTrack.size - 1) {
val currentPointProto = sortedTrack[i]
val currentPoint = currentPointProto.toLatLng()
val lastAddedPoint = lastAddedPointProto.toLatLng()
val distanceResults = FloatArray(1)
Location.distanceBetween(
lastAddedPoint.latitude,
lastAddedPoint.longitude,
currentPoint.latitude,
currentPoint.longitude,
distanceResults,
)
if (distanceResults[0] > MIN_TRACK_POINT_DISTANCE_METERS) {
filteredPoints.add(currentPointProto)
lastAddedPointProto = currentPointProto
}
}
val lastOriginalPointProto = sortedTrack.last()
if (filteredPoints.last() != lastOriginalPointProto) {
val distanceResults = FloatArray(1)
val lastAddedPoint = lastAddedPointProto.toLatLng()
val lastOriginalPoint = lastOriginalPointProto.toLatLng()
Location.distanceBetween(
lastAddedPoint.latitude,
lastAddedPoint.longitude,
lastOriginalPoint.latitude,
lastOriginalPoint.longitude,
distanceResults,
)
if (distanceResults[0] > MIN_TRACK_POINT_DISTANCE_METERS || filteredPoints.size == 1) {
filteredPoints.add(lastAddedPointProto)
}
}
return filteredPoints
}
@Suppress("CyclomaticComplexMethod", "LongMethod")
@OptIn(MapsComposeExperimentalApi::class, ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -180,7 +129,7 @@ fun MapView(
mapViewModel: MapViewModel = hiltViewModel(),
navigateToNodeDetails: (Int) -> Unit,
focusedNodeNum: Int? = null,
nodeTrack: List<Position>? = null,
nodeTracks: List<Position>? = null,
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
@@ -407,7 +356,7 @@ fun MapView(
onMapLoaded = {
val pointsToBound: List<LatLng> =
when {
!nodeTrack.isNullOrEmpty() -> nodeTrack.map { it.toLatLng() }
!nodeTracks.isNullOrEmpty() -> nodeTracks.map { it.toLatLng() }
allNodes.isNotEmpty() || displayableWaypoints.isNotEmpty() ->
allNodes.mapNotNull { it.toLatLng() } + displayableWaypoints.map { it.toLatLng() }
@@ -438,70 +387,69 @@ fun MapView(
}
}
if (nodeTrack != null && focusedNodeNum != null) {
val originalLatLngs =
nodeTrack.sortedBy { it.time }.map { LatLng(it.latitudeI * DEG_D, it.longitudeI * DEG_D) }
val filteredLatLngs = filterNodeTrack(nodeTrack)
if (nodeTracks != null && focusedNodeNum != null) {
val lastHeardTrackFilter = mapFilterState.lastHeardTrackFilter
val timeFilteredPositions =
nodeTracks.filter {
lastHeardTrackFilter == LastHeardFilter.Any ||
it.time > System.currentTimeMillis() / 1000 - lastHeardTrackFilter.seconds
}
val sortedPositions = timeFilteredPositions.sortedBy { it.time }
allNodes
.find { it.num == focusedNodeNum }
?.let { focusedNode ->
sortedPositions.forEachIndexed { index, position ->
val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
val dateFormat = remember {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
}
val alpha = (index.toFloat() / (sortedPositions.size.toFloat() - 1))
val color = Color(focusedNode!!.colors.second).copy(alpha = alpha)
if (index == sortedPositions.lastIndex) {
MarkerComposable(state = markerState, zIndex = 1f) {
NodeChip(
node = focusedNode,
isThisNode = false,
isConnected = false,
onAction = {},
)
}
} else {
MarkerInfoWindowComposable(
state = markerState,
title = stringResource(R.string.position),
snippet = formatAgo(position.time),
zIndex = alpha,
infoContent = {
PositionInfoWindowContent(
position = position,
dateFormat = dateFormat,
displayUnits = displayUnits,
)
},
) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Default.TripOrigin,
contentDescription = stringResource(R.string.track_point),
tint = color,
)
}
}
}
val focusedNode = allNodes.find { it.num == focusedNodeNum }
val polylineColor = focusedNode?.colors?.let { Color(it.first) } ?: Color.Blue
if (originalLatLngs.isNotEmpty()) {
focusedNode?.let {
MarkerComposable(
state = rememberUpdatedMarkerState(position = originalLatLngs.first()),
zIndex = 1f,
) {
NodeChip(node = it, isThisNode = false, isConnected = false, onAction = {})
if (sortedPositions.size > 1 && focusedNode != null) {
val segments = sortedPositions.windowed(size = 2, step = 1, partialWindows = false)
segments.forEachIndexed { index, segmentPoints ->
val alpha = (index.toFloat() / (segments.size.toFloat() - 1))
Polyline(
points = segmentPoints.map { it.toLatLng() },
jointType = JointType.ROUND,
color = Color(focusedNode.colors.second).copy(alpha = alpha),
width = 8f,
)
}
}
}
}
val pointsForMarkers =
if (originalLatLngs.isNotEmpty() && focusedNode != null) {
filteredLatLngs.drop(1)
} else {
filteredLatLngs
}
pointsForMarkers.forEachIndexed { index, position ->
val markerState = rememberUpdatedMarkerState(position = position.toLatLng())
val dateFormat = remember {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
}
val alpha = 1 - (index.toFloat() / pointsForMarkers.size.toFloat())
MarkerInfoWindowComposable(
state = markerState,
title = stringResource(R.string.position),
snippet = formatAgo(position.time),
zIndex = alpha,
infoContent = {
PositionInfoWindowContent(
position = position,
dateFormat = dateFormat,
displayUnits = displayUnits,
)
},
) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Default.TripOrigin,
contentDescription = stringResource(R.string.track_point),
modifier = Modifier.padding(8.dp),
tint = polylineColor.copy(alpha = alpha),
)
}
}
if (filteredLatLngs.size > 1) {
Polyline(
points = filteredLatLngs.map { it.toLatLng() },
jointType = JointType.ROUND,
endCap = RoundCap(),
startCap = RoundCap(),
geodesic = true,
color = polylineColor,
width = 8f,
zIndex = 0f,
)
}
} else {
NodeClusterMarkers(
nodeClusterItems = nodeClusterItems,
@@ -612,7 +560,7 @@ fun MapView(
mapTypeMenuExpanded = false
showCustomTileManagerSheet = true
},
showFilterButton = focusedNodeNum == null,
isNodeMap = focusedNodeNum != null,
hasLocationPermission = hasLocationPermission,
isLocationTrackingEnabled = isLocationTrackingEnabled,
onToggleLocationTracking = {

View File

@@ -50,7 +50,7 @@ fun MapControlsOverlay(
onToggleMapTypeMenu: () -> Unit,
onManageLayersClicked: () -> Unit,
onManageCustomTileProvidersClicked: () -> Unit, // New parameter
showFilterButton: Boolean,
isNodeMap: Boolean,
// Location tracking parameters
hasLocationPermission: Boolean = false,
isLocationTrackingEnabled: Boolean = false,
@@ -66,7 +66,18 @@ fun MapControlsOverlay(
trailingContent = {},
content = {
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
if (showFilterButton) {
if (isNodeMap) {
MapButton(
icon = Icons.Outlined.Tune,
contentDescription = stringResource(id = R.string.map_filter),
onClick = onToggleMapFilterMenu,
)
NodeMapFilterDropdown(
expanded = mapFilterMenuExpanded,
onDismissRequest = onMapFilterMenuDismissRequest,
mapViewModel = mapViewModel,
)
} else {
Box {
MapButton(
icon = Icons.Outlined.Tune,

View File

@@ -121,3 +121,34 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit,
}
}
}
@Composable
internal fun NodeMapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardTrackFilter)
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
Text(
text =
stringResource(
R.string.last_heard_filter_label,
stringResource(mapFilterState.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

@@ -61,7 +61,7 @@ fun NodeMapScreen(
},
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
MapView(focusedNodeNum = destNum, nodeTrack = positions, navigateToNodeDetails = {})
MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {})
}
}
}

View File

@@ -104,11 +104,18 @@ abstract class BaseMapViewModel(
private val lastHeardFilter = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter))
private val lastHeardTrackFilter = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter))
fun setLastHeardFilter(filter: LastHeardFilter) {
mapPrefs.lastHeardFilter = filter.seconds
lastHeardFilter.value = filter
}
fun setLastHeardTrackFilter(filter: LastHeardFilter) {
mapPrefs.lastHeardTrackFilter = filter.seconds
lastHeardTrackFilter.value = filter
}
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
val isConnected =
@@ -167,16 +174,18 @@ abstract class BaseMapViewModel(
val showWaypoints: Boolean,
val showPrecisionCircle: Boolean,
val lastHeardFilter: LastHeardFilter,
val lastHeardTrackFilter: LastHeardFilter,
)
val mapFilterStateFlow: StateFlow<MapFilterState> =
combine(showOnlyFavorites, showWaypointsOnMap, showPrecisionCircleOnMap, lastHeardFilter) {
favoritesOnly,
showWaypoints,
showPrecisionCircle,
lastHeard,
->
MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle, lastHeard)
combine(
showOnlyFavorites,
showWaypointsOnMap,
showPrecisionCircleOnMap,
lastHeardFilter,
lastHeardTrackFilter,
) { favoritesOnly, showWaypoints, showPrecisionCircle, lastHeardFilter, lastHeardTrackFilter ->
MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle, lastHeardFilter, lastHeardTrackFilter)
}
.stateIn(
scope = viewModelScope,
@@ -187,6 +196,7 @@ abstract class BaseMapViewModel(
showWaypointsOnMap.value,
showPrecisionCircleOnMap.value,
lastHeardFilter.value,
lastHeardTrackFilter.value,
),
)
}

View File

@@ -30,6 +30,7 @@ interface MapPrefs {
var showWaypointsOnMap: Boolean
var showPrecisionCircleOnMap: Boolean
var lastHeardFilter: Long
var lastHeardTrackFilter: Long
}
@Singleton
@@ -39,4 +40,5 @@ class MapPrefsImpl @Inject constructor(@MapSharedPreferences prefs: SharedPrefer
override var showWaypointsOnMap: Boolean by PrefDelegate(prefs, "show_waypoints", true)
override var showPrecisionCircleOnMap: Boolean by PrefDelegate(prefs, "show_precision_circle", true)
override var lastHeardFilter: Long by PrefDelegate(prefs, "last_heard_filter", 0L)
override var lastHeardTrackFilter: Long by PrefDelegate(prefs, "last_heard_track_filter", 0L)
}