diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt b/app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt index 92923437e..dd1aacd27 100644 --- a/app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt +++ b/app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt @@ -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?): List { - if (nodeTrack.isNullOrEmpty()) return emptyList() - - val sortedTrack = nodeTrack.sortedBy { it.time } - if (sortedTrack.size <= 2) return sortedTrack.map { it } - - val filteredPoints = mutableListOf() - 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? = null, + nodeTracks: List? = null, ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -407,7 +356,7 @@ fun MapView( onMapLoaded = { val pointsToBound: List = 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 = { diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/components/MapControlsOverlay.kt b/app/src/google/java/com/geeksville/mesh/ui/map/components/MapControlsOverlay.kt index 79ccbb062..b1db202c4 100644 --- a/app/src/google/java/com/geeksville/mesh/ui/map/components/MapControlsOverlay.kt +++ b/app/src/google/java/com/geeksville/mesh/ui/map/components/MapControlsOverlay.kt @@ -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, diff --git a/app/src/google/java/com/geeksville/mesh/ui/map/components/MapFilterDropdown.kt b/app/src/google/java/com/geeksville/mesh/ui/map/components/MapFilterDropdown.kt index 74889c135..71c6813c5 100644 --- a/app/src/google/java/com/geeksville/mesh/ui/map/components/MapFilterDropdown.kt +++ b/app/src/google/java/com/geeksville/mesh/ui/map/components/MapFilterDropdown.kt @@ -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, + ) + } + } +} diff --git a/app/src/google/java/com/geeksville/mesh/ui/node/NodeMap.kt b/app/src/google/java/com/geeksville/mesh/ui/node/NodeMap.kt index 5abad9568..61fd78e48 100644 --- a/app/src/google/java/com/geeksville/mesh/ui/node/NodeMap.kt +++ b/app/src/google/java/com/geeksville/mesh/ui/node/NodeMap.kt @@ -61,7 +61,7 @@ fun NodeMapScreen( }, ) { paddingValues -> Box(modifier = Modifier.padding(paddingValues)) { - MapView(focusedNodeNum = destNum, nodeTrack = positions, navigateToNodeDetails = {}) + MapView(focusedNodeNum = destNum, nodeTracks = positions, navigateToNodeDetails = {}) } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/BaseMapViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/map/BaseMapViewModel.kt index c62a05617..79884ecc2 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/BaseMapViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/BaseMapViewModel.kt @@ -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 = 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 = - 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, ), ) } diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapPrefs.kt index fb9c396c2..6edabbc0c 100644 --- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapPrefs.kt +++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/map/MapPrefs.kt @@ -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) }