mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-13 03:17:48 -04:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ fun NodeMapScreen(
|
||||
},
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier.padding(paddingValues)) {
|
||||
MapView(focusedNodeNum = destNum, nodeTrack = positions, navigateToNodeDetails = {})
|
||||
MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user