From 61c6d6c76e6baaf71cb712d6fc5b950d8e0112f7 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 27 Sep 2025 13:40:41 -0500 Subject: [PATCH] feat(map): add last heard filter for map nodes (#3219) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../com/geeksville/mesh/ui/map/MapView.kt | 12 +++-- .../ui/map/components/MapFilterDropdown.kt | 44 +++++++++++++++-- .../mesh/ui/map/BaseMapViewModel.kt | 48 +++++++++++++++++-- .../org/meshtastic/core/prefs/map/MapPrefs.kt | 2 + core/strings/src/main/res/values/strings.xml | 6 +++ 5 files changed, 98 insertions(+), 14 deletions(-) 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 c784e5d37..92923437e 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 @@ -301,11 +301,13 @@ fun MapView( val displayableWaypoints = waypoints.values.mapNotNull { it.data.waypoint } val filteredNodes = - if (mapFilterState.onlyFavorites) { - allNodes.filter { it.isFavorite || it.num == ourNodeInfo?.num } - } else { - allNodes - } + allNodes + .filter { node -> !mapFilterState.onlyFavorites || node.isFavorite || node.num == ourNodeInfo?.num } + .filter { node -> + mapFilterState.lastHeardFilter.seconds == 0L || + (System.currentTimeMillis() / 1000 - node.lastHeard) <= mapFilterState.lastHeardFilter.seconds || + node.num == ourNodeInfo?.num + } val nodeClusterItems = filteredNodes.map { node -> 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 46b862a6b..74889c135 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 @@ -17,21 +17,33 @@ package com.geeksville.mesh.ui.map.components +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.RadioButtonUnchecked import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +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.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.ui.map.LastHeardFilter import com.geeksville.mesh.ui.map.MapViewModel import org.meshtastic.core.strings.R +import kotlin.math.roundToInt @Composable internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) { @@ -41,10 +53,7 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, text = { Text(stringResource(id = R.string.only_favorites)) }, onClick = { mapViewModel.toggleOnlyFavorites() }, leadingIcon = { - Icon( - imageVector = Icons.Filled.Favorite, - contentDescription = stringResource(id = R.string.only_favorites), - ) + Icon(imageVector = Icons.Filled.Star, contentDescription = stringResource(id = R.string.only_favorites)) }, trailingIcon = { Checkbox( @@ -85,5 +94,30 @@ internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, ) }, ) + 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( + R.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, + ) + } } } 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 af473d967..c62a05617 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 @@ -18,6 +18,7 @@ package com.geeksville.mesh.ui.map import android.os.RemoteException +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.geeksville.mesh.MeshProtos @@ -37,7 +38,28 @@ import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DataPacket import org.meshtastic.core.prefs.map.MapPrefs +import org.meshtastic.core.strings.R import timber.log.Timber +import java.util.concurrent.TimeUnit + +@Suppress("MagicNumber") +sealed class LastHeardFilter(val seconds: Long, @StringRes val label: Int) { + data object Any : LastHeardFilter(0L, R.string.any) + + data object OneHour : LastHeardFilter(TimeUnit.HOURS.toSeconds(1), R.string.one_hour) + + data object EightHours : LastHeardFilter(TimeUnit.HOURS.toSeconds(8), R.string.eight_hours) + + data object OneDay : LastHeardFilter(TimeUnit.DAYS.toSeconds(1), R.string.one_day) + + data object TwoDays : LastHeardFilter(TimeUnit.DAYS.toSeconds(2), R.string.two_days) + + companion object { + fun fromSeconds(seconds: Long): LastHeardFilter = entries.find { it.seconds == seconds } ?: Any + + val entries = listOf(Any, OneHour, EightHours, OneDay, TwoDays) + } +} @Suppress("TooManyFunctions") abstract class BaseMapViewModel( @@ -80,6 +102,13 @@ abstract class BaseMapViewModel( private val showPrecisionCircleOnMap = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap) + private val lastHeardFilter = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter)) + + fun setLastHeardFilter(filter: LastHeardFilter) { + mapPrefs.lastHeardFilter = filter.seconds + lastHeardFilter.value = filter + } + val ourNodeInfo: StateFlow = nodeRepository.ourNodeInfo val isConnected = @@ -133,20 +162,31 @@ abstract class BaseMapViewModel( } } - data class MapFilterState(val onlyFavorites: Boolean, val showWaypoints: Boolean, val showPrecisionCircle: Boolean) + data class MapFilterState( + val onlyFavorites: Boolean, + val showWaypoints: Boolean, + val showPrecisionCircle: Boolean, + val lastHeardFilter: LastHeardFilter, + ) val mapFilterStateFlow: StateFlow = - combine(showOnlyFavorites, showWaypointsOnMap, showPrecisionCircleOnMap) { + combine(showOnlyFavorites, showWaypointsOnMap, showPrecisionCircleOnMap, lastHeardFilter) { favoritesOnly, showWaypoints, showPrecisionCircle, + lastHeard, -> - MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle) + MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle, lastHeard) } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), initialValue = - MapFilterState(showOnlyFavorites.value, showWaypointsOnMap.value, showPrecisionCircleOnMap.value), + MapFilterState( + showOnlyFavorites.value, + showWaypointsOnMap.value, + showPrecisionCircleOnMap.value, + lastHeardFilter.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 ac2f1d7ce..fb9c396c2 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 @@ -29,6 +29,7 @@ interface MapPrefs { var showOnlyFavorites: Boolean var showWaypointsOnMap: Boolean var showPrecisionCircleOnMap: Boolean + var lastHeardFilter: Long } @Singleton @@ -37,4 +38,5 @@ class MapPrefsImpl @Inject constructor(@MapSharedPreferences prefs: SharedPrefer override var showOnlyFavorites: Boolean by PrefDelegate(prefs, "show_only_favorites", false) 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) } diff --git a/core/strings/src/main/res/values/strings.xml b/core/strings/src/main/res/values/strings.xml index 20babda35..d018563a0 100644 --- a/core/strings/src/main/res/values/strings.xml +++ b/core/strings/src/main/res/values/strings.xml @@ -901,4 +901,10 @@ "[Remote] %1$s" Send Device Telemetry Enable/Disable the device telemetry module to send metrics to the mesh + Any + 1 Hour + 8 Hours + 24 Hours + 48 Hours + Filter by Last Heard time: %s