feat(map): add last heard filter for map nodes (#3219)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-09-27 13:40:41 -05:00 committed by GitHub
parent ab18e424b1
commit 61c6d6c76e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 98 additions and 14 deletions

View file

@ -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 ->

View file

@ -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,
)
}
}
}

View file

@ -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<Node?> = 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<MapFilterState> =
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,
),
)
}

View file

@ -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)
}

View file

@ -901,4 +901,10 @@
<string name="remotely_administrating">"[Remote] %1$s"</string>
<string name="device_telemetry_enabled">Send Device Telemetry</string>
<string name="device_telemetry_enabled_summary">Enable/Disable the device telemetry module to send metrics to the mesh</string>
<string name="any">Any</string>
<string name="one_hour">1 Hour</string>
<string name="eight_hours">8 Hours</string>
<string name="one_day">24 Hours</string>
<string name="two_days">48 Hours</string>
<string name="last_heard_filter_label">Filter by Last Heard time: %s</string>
</resources>