mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-12 21:30:02 -04:00
feat(map): filter the map by node role and by how a node was heard (#6959)
This commit is contained in:
1 parent
17587eb242
commit
2ac28e28c3
17 files changed
+866
-167
No files matched your search
Generated
+6
@@ -1048,6 +1048,12 @@ map_download_status_complete
|
||||
map_download_status_downloading
|
||||
map_download_status_paused
|
||||
map_filter
|
||||
map_filter_all_roles
|
||||
map_filter_display_title
|
||||
map_filter_nodes_title
|
||||
map_filter_roles_title
|
||||
map_filter_show_ignored
|
||||
map_filter_title
|
||||
map_layer_formats
|
||||
map_layer_opacity
|
||||
map_node_popup_details
|
||||
|
||||
@@ -181,12 +181,12 @@ import org.meshtastic.feature.map.component.DeleteWaypointDialog
|
||||
import org.meshtastic.feature.map.component.EditWaypointDialog
|
||||
import org.meshtastic.feature.map.component.MapButton
|
||||
import org.meshtastic.feature.map.component.MapControlsOverlay
|
||||
import org.meshtastic.feature.map.component.MapFilterActions
|
||||
import org.meshtastic.feature.map.component.MapFilterMenu
|
||||
import org.meshtastic.feature.map.component.MapFilterSheet
|
||||
import org.meshtastic.feature.map.component.NodeTrackFilterMenu
|
||||
import org.meshtastic.feature.map.component.RasterOverlayToggles
|
||||
import org.meshtastic.feature.map.component.SitePlannerLaunch
|
||||
import org.meshtastic.feature.map.component.WaypointInfoDialog
|
||||
import org.meshtastic.feature.map.component.mapFilterActions
|
||||
import org.meshtastic.feature.map.component.toSitePlannerParams
|
||||
import org.meshtastic.feature.map.includes
|
||||
import org.meshtastic.feature.map.kml.ICON_URL_PROPERTY
|
||||
@@ -909,6 +909,7 @@ fun MapView(
|
||||
MapControlsOverlay(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 8.dp),
|
||||
onToggleFilterMenu = { mapFilterMenuExpanded = true },
|
||||
filtersActive = mode !is GoogleMapMode.NodeTrack && mapFilterState.isNarrowing,
|
||||
filterDropdownContent = {
|
||||
if (mode is GoogleMapMode.NodeTrack) {
|
||||
NodeTrackFilterMenu(
|
||||
@@ -917,18 +918,11 @@ fun MapView(
|
||||
selected = mapFilterState.lastHeardTrackFilter,
|
||||
onSelect = mapViewModel::setLastHeardTrackFilter,
|
||||
)
|
||||
} else {
|
||||
MapFilterMenu(
|
||||
expanded = mapFilterMenuExpanded,
|
||||
} else if (mapFilterMenuExpanded) {
|
||||
MapFilterSheet(
|
||||
onDismissRequest = { mapFilterMenuExpanded = false },
|
||||
filterState = mapFilterState,
|
||||
actions =
|
||||
MapFilterActions(
|
||||
onToggleOnlyFavorites = mapViewModel::toggleOnlyFavorites,
|
||||
onToggleShowWaypoints = mapViewModel::toggleShowWaypointsOnMap,
|
||||
onToggleShowPrecisionCircle = mapViewModel::toggleShowPrecisionCircleOnMap,
|
||||
onSelectLastHeard = mapViewModel::setLastHeardFilter,
|
||||
),
|
||||
actions = mapViewModel.mapFilterActions(),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -21,4 +21,12 @@ import kotlin.time.Duration.Companion.hours
|
||||
|
||||
private val ONLINE_WINDOW_HOURS = 2.hours
|
||||
|
||||
/**
|
||||
* How recently a node must have been heard to count as online, in seconds.
|
||||
*
|
||||
* Exposed as well as applied by [onlineTimeThreshold] because not every caller has the wall clock to hand: the map's
|
||||
* filter rules are pure functions given a `now`, so they compare against this window rather than re-reading the clock.
|
||||
*/
|
||||
val ONLINE_WINDOW_SECONDS: Long = ONLINE_WINDOW_HOURS.inWholeSeconds
|
||||
|
||||
fun onlineTimeThreshold(): Int = (nowInstant - ONLINE_WINDOW_HOURS).epochSeconds.toInt()
|
||||
@@ -83,6 +83,50 @@ class MapPrefsImpl(private val dataStore: MapDataStore, dispatchers: CoroutineDi
|
||||
scope.launch { dataStore.edit { it[KEY_LAST_HEARD_TRACK_FILTER_PREF] = seconds } }
|
||||
}
|
||||
|
||||
override val onlyOnlineOnMap: StateFlow<Boolean> =
|
||||
dataStore.data.map { it[KEY_ONLY_ONLINE_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
|
||||
|
||||
override fun setOnlyOnlineOnMap(only: Boolean) {
|
||||
scope.launch { dataStore.edit { it[KEY_ONLY_ONLINE_PREF] = only } }
|
||||
}
|
||||
|
||||
override val onlyDirectOnMap: StateFlow<Boolean> =
|
||||
dataStore.data.map { it[KEY_ONLY_DIRECT_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
|
||||
|
||||
override fun setOnlyDirectOnMap(only: Boolean) {
|
||||
scope.launch { dataStore.edit { it[KEY_ONLY_DIRECT_PREF] = only } }
|
||||
}
|
||||
|
||||
override val excludeMqttOnMap: StateFlow<Boolean> =
|
||||
dataStore.data.map { it[KEY_EXCLUDE_MQTT_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
|
||||
|
||||
override fun setExcludeMqttOnMap(exclude: Boolean) {
|
||||
scope.launch { dataStore.edit { it[KEY_EXCLUDE_MQTT_PREF] = exclude } }
|
||||
}
|
||||
|
||||
override val showIgnoredOnMap: StateFlow<Boolean> =
|
||||
dataStore.data.map { it[KEY_SHOW_IGNORED_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
|
||||
|
||||
override fun setShowIgnoredOnMap(show: Boolean) {
|
||||
scope.launch { dataStore.edit { it[KEY_SHOW_IGNORED_PREF] = show } }
|
||||
}
|
||||
|
||||
override val includeUnknownOnMap: StateFlow<Boolean> =
|
||||
dataStore.data.map { it[KEY_INCLUDE_UNKNOWN_PREF] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
|
||||
|
||||
override fun setIncludeUnknownOnMap(include: Boolean) {
|
||||
scope.launch { dataStore.edit { it[KEY_INCLUDE_UNKNOWN_PREF] = include } }
|
||||
}
|
||||
|
||||
override val excludedMapRoles: StateFlow<Set<String>> =
|
||||
dataStore.data
|
||||
.map { it[KEY_EXCLUDED_ROLES_PREF] ?: emptySet() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
override fun setExcludedMapRoles(roles: Set<String>) {
|
||||
scope.launch { dataStore.edit { it[KEY_EXCLUDED_ROLES_PREF] = roles } }
|
||||
}
|
||||
|
||||
override val hiddenLayerUrls: StateFlow<Set<String>> =
|
||||
dataStore.data
|
||||
.map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
|
||||
@@ -155,6 +199,12 @@ class MapPrefsImpl(private val dataStore: MapDataStore, dispatchers: CoroutineDi
|
||||
val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls")
|
||||
val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers")
|
||||
val KEY_LAYER_OPACITY_PREF = stringSetPreferencesKey("layer_opacity")
|
||||
val KEY_ONLY_ONLINE_PREF = booleanPreferencesKey("map_only_online")
|
||||
val KEY_ONLY_DIRECT_PREF = booleanPreferencesKey("map_only_direct")
|
||||
val KEY_EXCLUDE_MQTT_PREF = booleanPreferencesKey("map_exclude_mqtt")
|
||||
val KEY_SHOW_IGNORED_PREF = booleanPreferencesKey("map_show_ignored")
|
||||
val KEY_INCLUDE_UNKNOWN_PREF = booleanPreferencesKey("map_include_unknown")
|
||||
val KEY_EXCLUDED_ROLES_PREF = stringSetPreferencesKey("map_excluded_roles")
|
||||
val KEY_CAMERA_LATITUDE = doublePreferencesKey("camera_latitude")
|
||||
val KEY_CAMERA_LONGITUDE = doublePreferencesKey("camera_longitude")
|
||||
val KEY_CAMERA_ZOOM = doublePreferencesKey("camera_zoom")
|
||||
|
||||
+35
@@ -285,6 +285,41 @@ interface MapPrefs {
|
||||
|
||||
fun setLastHeardTrackFilter(seconds: Long)
|
||||
|
||||
/**
|
||||
* Node filters shared with the node list's vocabulary, persisted separately: a user filtering the map to routers
|
||||
* has not asked for the same of their contact list.
|
||||
*/
|
||||
val onlyOnlineOnMap: StateFlow<Boolean>
|
||||
|
||||
fun setOnlyOnlineOnMap(only: Boolean)
|
||||
|
||||
val onlyDirectOnMap: StateFlow<Boolean>
|
||||
|
||||
fun setOnlyDirectOnMap(only: Boolean)
|
||||
|
||||
val excludeMqttOnMap: StateFlow<Boolean>
|
||||
|
||||
fun setExcludeMqttOnMap(exclude: Boolean)
|
||||
|
||||
val showIgnoredOnMap: StateFlow<Boolean>
|
||||
|
||||
fun setShowIgnoredOnMap(show: Boolean)
|
||||
|
||||
val includeUnknownOnMap: StateFlow<Boolean>
|
||||
|
||||
fun setIncludeUnknownOnMap(include: Boolean)
|
||||
|
||||
/**
|
||||
* Names of the device roles the user has switched off on the map.
|
||||
*
|
||||
* Excluded rather than included, and by name rather than ordinal: an included set would make nodes reporting a role
|
||||
* added by future firmware invisible with no way to discover why, and `ROUTER_CLIENT = 3` is already a deprecated
|
||||
* slot.
|
||||
*/
|
||||
val excludedMapRoles: StateFlow<Set<String>>
|
||||
|
||||
fun setExcludedMapRoles(roles: Set<String>)
|
||||
|
||||
/** URIs of imported map layers the user has toggled off; a layer is visible unless its URI is in this set. */
|
||||
val hiddenLayerUrls: StateFlow<Set<String>>
|
||||
|
||||
|
||||
@@ -1084,6 +1084,12 @@
|
||||
<string name="map_download_status_downloading">Downloading</string>
|
||||
<string name="map_download_status_paused">Paused</string>
|
||||
<string name="map_filter">Map Filter\n</string>
|
||||
<string name="map_filter_all_roles">All</string>
|
||||
<string name="map_filter_display_title">Display</string>
|
||||
<string name="map_filter_nodes_title">Nodes</string>
|
||||
<string name="map_filter_roles_title">Node roles</string>
|
||||
<string name="map_filter_show_ignored">Show ignored nodes</string>
|
||||
<string name="map_filter_title">Filter map</string>
|
||||
<string name="map_layer_formats">Map layers support .kml, .kmz, or GeoJSON formats.</string>
|
||||
<string name="map_layer_opacity">Opacity: %1$d%</string>
|
||||
<string name="map_node_popup_details">%1$s<br>Last heard: %2$s<br>Last position: %3$s<br>Battery: %4$s</string>
|
||||
|
||||
@@ -304,6 +304,42 @@ class FakeMapPrefs : MapPrefs {
|
||||
lastHeardTrackFilter.value = seconds
|
||||
}
|
||||
|
||||
override val onlyOnlineOnMap = MutableStateFlow(false)
|
||||
|
||||
override fun setOnlyOnlineOnMap(only: Boolean) {
|
||||
onlyOnlineOnMap.value = only
|
||||
}
|
||||
|
||||
override val onlyDirectOnMap = MutableStateFlow(false)
|
||||
|
||||
override fun setOnlyDirectOnMap(only: Boolean) {
|
||||
onlyDirectOnMap.value = only
|
||||
}
|
||||
|
||||
override val excludeMqttOnMap = MutableStateFlow(false)
|
||||
|
||||
override fun setExcludeMqttOnMap(exclude: Boolean) {
|
||||
excludeMqttOnMap.value = exclude
|
||||
}
|
||||
|
||||
override val showIgnoredOnMap = MutableStateFlow(false)
|
||||
|
||||
override fun setShowIgnoredOnMap(show: Boolean) {
|
||||
showIgnoredOnMap.value = show
|
||||
}
|
||||
|
||||
override val includeUnknownOnMap = MutableStateFlow(true)
|
||||
|
||||
override fun setIncludeUnknownOnMap(include: Boolean) {
|
||||
includeUnknownOnMap.value = include
|
||||
}
|
||||
|
||||
override val excludedMapRoles = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
override fun setExcludedMapRoles(roles: Set<String>) {
|
||||
excludedMapRoles.value = roles
|
||||
}
|
||||
|
||||
override val hiddenLayerUrls = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
override fun updateHiddenLayerUrls(transform: (Set<String>) -> Set<String>) {
|
||||
|
||||
+13
-16
@@ -52,8 +52,8 @@ import org.meshtastic.feature.map.component.ClusterMemberEntry
|
||||
import org.meshtastic.feature.map.component.ClusterMembersDialog
|
||||
import org.meshtastic.feature.map.component.EditWaypointDialog
|
||||
import org.meshtastic.feature.map.component.MapControlsOverlay
|
||||
import org.meshtastic.feature.map.component.MapFilterActions
|
||||
import org.meshtastic.feature.map.component.MapFilterMenu
|
||||
import org.meshtastic.feature.map.component.MapFilterSheet
|
||||
import org.meshtastic.feature.map.component.mapFilterActions
|
||||
import org.meshtastic.feature.map.layers.LayerOpacityStore
|
||||
import org.meshtastic.feature.map.maplibre.component.BasemapButton
|
||||
import org.meshtastic.feature.map.maplibre.component.BasemapSelection
|
||||
@@ -320,28 +320,25 @@ private fun BoxScope.MapToolbar(
|
||||
) {
|
||||
var filterMenuExpanded by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
// Hoisted out of the dropdown slot: the button's badge needs the same state the sheet does.
|
||||
val filterViewModel: SharedMapViewModel = koinViewModel()
|
||||
val filterState by filterViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
|
||||
|
||||
MapControlsOverlay(
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = TOOLBAR_INSET.dp),
|
||||
onToggleFilterMenu = { filterMenuExpanded = !filterMenuExpanded },
|
||||
filtersActive = filterState.isNarrowing,
|
||||
bearing = cameraState.position.bearing.toFloat(),
|
||||
followPhoneBearing = location.followingBearing,
|
||||
onCompassClick = location.onCompassClick,
|
||||
filterDropdownContent = {
|
||||
val filterViewModel: SharedMapViewModel = koinViewModel()
|
||||
val filterState by filterViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
|
||||
MapFilterMenu(
|
||||
expanded = filterMenuExpanded,
|
||||
onDismissRequest = { filterMenuExpanded = false },
|
||||
filterState = filterState,
|
||||
actions =
|
||||
MapFilterActions(
|
||||
onToggleOnlyFavorites = filterViewModel::toggleOnlyFavorites,
|
||||
onToggleShowWaypoints = filterViewModel::toggleShowWaypointsOnMap,
|
||||
onToggleShowPrecisionCircle = filterViewModel::toggleShowPrecisionCircleOnMap,
|
||||
onSelectLastHeard = filterViewModel::setLastHeardFilter,
|
||||
),
|
||||
)
|
||||
if (filterMenuExpanded) {
|
||||
MapFilterSheet(
|
||||
onDismissRequest = { filterMenuExpanded = false },
|
||||
filterState = filterState,
|
||||
actions = filterViewModel.mapFilterActions(),
|
||||
)
|
||||
}
|
||||
},
|
||||
mapTypeContent = { BasemapButton(selection = basemaps, extra = basemapMenuExtra) },
|
||||
layersContent = {
|
||||
|
||||
@@ -17,9 +17,8 @@
|
||||
package org.meshtastic.feature.map
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
@@ -50,6 +49,7 @@ import org.meshtastic.core.resources.two_days
|
||||
import org.meshtastic.core.ui.viewmodel.safeLaunch
|
||||
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
|
||||
import org.meshtastic.proto.ChannelSet
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Waypoint
|
||||
|
||||
@@ -102,11 +102,6 @@ open class BaseMapViewModel(
|
||||
.map { nodes -> nodes.filterNot { node -> node.isIgnored } }
|
||||
.stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
val nodesWithPosition: StateFlow<List<Node>> =
|
||||
nodes
|
||||
.map { nodes -> nodes.filter { node -> node.validPosition != null } }
|
||||
.stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
val waypoints: StateFlow<Map<Int, DataPacket>> =
|
||||
packetRepository
|
||||
.getWaypoints()
|
||||
@@ -124,49 +119,82 @@ open class BaseMapViewModel(
|
||||
/** True if the waypoint with [id] was created by this device (vs. received from another node over the mesh). */
|
||||
fun isMyWaypoint(id: Int): Boolean = waypoints.value[id]?.isFromLocal(myNodeNum) == true
|
||||
|
||||
private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites.value)
|
||||
val showOnlyFavoritesOnMap: StateFlow<Boolean> = showOnlyFavorites.asStateFlow()
|
||||
// Every filter reads its persisted flow directly rather than snapshotting `.value` into a mirror at
|
||||
// construction. MapPrefsImpl's flows start eagerly but load from DataStore asynchronously, so a view model built
|
||||
// before that first read kept the defaults forever — nothing wrote the persisted values back into a mirror.
|
||||
val showOnlyFavoritesOnMap: StateFlow<Boolean> = mapPrefs.showOnlyFavorites
|
||||
|
||||
fun toggleOnlyFavorites() {
|
||||
val newValue = !showOnlyFavorites.value
|
||||
showOnlyFavorites.value = newValue
|
||||
mapPrefs.setShowOnlyFavorites(newValue)
|
||||
fun toggleOnlyFavorites() = mapPrefs.setShowOnlyFavorites(!showOnlyFavoritesOnMap.value)
|
||||
|
||||
val showWaypointsOnMap: StateFlow<Boolean> = mapPrefs.showWaypointsOnMap
|
||||
|
||||
fun toggleShowWaypointsOnMap() = mapPrefs.setShowWaypointsOnMap(!showWaypointsOnMap.value)
|
||||
|
||||
val showPrecisionCircleOnMap: StateFlow<Boolean> = mapPrefs.showPrecisionCircleOnMap
|
||||
|
||||
fun toggleShowPrecisionCircleOnMap() = mapPrefs.setShowPrecisionCircleOnMap(!showPrecisionCircleOnMap.value)
|
||||
|
||||
val onlyOnlineOnMap: StateFlow<Boolean> = mapPrefs.onlyOnlineOnMap
|
||||
|
||||
fun toggleOnlyOnline() = mapPrefs.setOnlyOnlineOnMap(!onlyOnlineOnMap.value)
|
||||
|
||||
val onlyDirectOnMap: StateFlow<Boolean> = mapPrefs.onlyDirectOnMap
|
||||
|
||||
fun toggleOnlyDirect() = mapPrefs.setOnlyDirectOnMap(!onlyDirectOnMap.value)
|
||||
|
||||
val excludeMqttOnMap: StateFlow<Boolean> = mapPrefs.excludeMqttOnMap
|
||||
|
||||
fun toggleExcludeMqtt() = mapPrefs.setExcludeMqttOnMap(!excludeMqttOnMap.value)
|
||||
|
||||
val showIgnoredOnMap: StateFlow<Boolean> = mapPrefs.showIgnoredOnMap
|
||||
|
||||
fun toggleShowIgnored() = mapPrefs.setShowIgnoredOnMap(!showIgnoredOnMap.value)
|
||||
|
||||
val includeUnknownOnMap: StateFlow<Boolean> = mapPrefs.includeUnknownOnMap
|
||||
|
||||
fun toggleIncludeUnknown() = mapPrefs.setIncludeUnknownOnMap(!includeUnknownOnMap.value)
|
||||
|
||||
/**
|
||||
* The nodes the map draws from.
|
||||
*
|
||||
* Built from the repository rather than from [nodes], which drops every ignored node unconditionally — that is the
|
||||
* right default for the pickers that read it, but it left the map's own show-ignored filter with nothing to add
|
||||
* back. [MapNodePolicy] still decides; this only stops the discard happening before it is asked.
|
||||
*
|
||||
* Declared here rather than beside [nodes] because it reads [showIgnoredOnMap], and a property initialiser cannot
|
||||
* see one declared below it.
|
||||
*/
|
||||
val nodesWithPosition: StateFlow<List<Node>> =
|
||||
combine(nodeRepository.getNodes(), showIgnoredOnMap) { all, showIgnored ->
|
||||
all.filter { node -> node.validPosition != null && (showIgnored || !node.isIgnored) }
|
||||
}
|
||||
.stateInWhileSubscribed(initialValue = emptyList())
|
||||
|
||||
val excludedMapRoles: StateFlow<Set<Config.DeviceConfig.Role>> =
|
||||
mapPrefs.excludedMapRoles
|
||||
.map(::decodeExcludedRoles)
|
||||
.stateInWhileSubscribed(decodeExcludedRoles(mapPrefs.excludedMapRoles.value))
|
||||
|
||||
fun toggleRoleExcluded(role: Config.DeviceConfig.Role) {
|
||||
val newValue = excludedMapRoles.value.let { if (role in it) it - role else it + role }
|
||||
mapPrefs.setExcludedMapRoles(newValue.mapTo(mutableSetOf()) { it.name })
|
||||
}
|
||||
|
||||
private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap.value)
|
||||
val showWaypointsOnMap: StateFlow<Boolean> = showWaypoints.asStateFlow()
|
||||
fun clearExcludedRoles() = mapPrefs.setExcludedMapRoles(emptySet())
|
||||
|
||||
fun toggleShowWaypointsOnMap() {
|
||||
val newValue = !showWaypoints.value
|
||||
showWaypoints.value = newValue
|
||||
mapPrefs.setShowWaypointsOnMap(newValue)
|
||||
}
|
||||
val lastHeardFilter: StateFlow<LastHeardFilter> =
|
||||
mapPrefs.lastHeardFilter
|
||||
.map(LastHeardFilter::fromSeconds)
|
||||
.stateInWhileSubscribed(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter.value))
|
||||
|
||||
private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap.value)
|
||||
val showPrecisionCircleOnMap: StateFlow<Boolean> = showPrecisionCircle.asStateFlow()
|
||||
fun setLastHeardFilter(filter: LastHeardFilter) = mapPrefs.setLastHeardFilter(filter.seconds)
|
||||
|
||||
fun toggleShowPrecisionCircleOnMap() {
|
||||
val newValue = !showPrecisionCircle.value
|
||||
showPrecisionCircle.value = newValue
|
||||
mapPrefs.setShowPrecisionCircleOnMap(newValue)
|
||||
}
|
||||
val lastHeardTrackFilter: StateFlow<LastHeardFilter> =
|
||||
mapPrefs.lastHeardTrackFilter
|
||||
.map(LastHeardFilter::fromSeconds)
|
||||
.stateInWhileSubscribed(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter.value))
|
||||
|
||||
private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter.value))
|
||||
val lastHeardFilter: StateFlow<LastHeardFilter> = lastHeardFilterValue.asStateFlow()
|
||||
|
||||
fun setLastHeardFilter(filter: LastHeardFilter) {
|
||||
lastHeardFilterValue.value = filter
|
||||
mapPrefs.setLastHeardFilter(filter.seconds)
|
||||
}
|
||||
|
||||
private val lastHeardTrackFilterValue =
|
||||
MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter.value))
|
||||
val lastHeardTrackFilter: StateFlow<LastHeardFilter> = lastHeardTrackFilterValue.asStateFlow()
|
||||
|
||||
fun setLastHeardTrackFilter(filter: LastHeardFilter) {
|
||||
lastHeardTrackFilterValue.value = filter
|
||||
mapPrefs.setLastHeardTrackFilter(filter.seconds)
|
||||
}
|
||||
fun setLastHeardTrackFilter(filter: LastHeardFilter) = mapPrefs.setLastHeardTrackFilter(filter.seconds)
|
||||
|
||||
open fun getUser(userId: String?) =
|
||||
nodeRepository.getUser(userId ?: org.meshtastic.core.model.NodeAddress.ID_BROADCAST)
|
||||
@@ -189,17 +217,42 @@ open class BaseMapViewModel(
|
||||
|
||||
fun generatePacketId(): Int = radioController.generatePacketId()
|
||||
|
||||
/**
|
||||
* Everything the map's filter sheet controls.
|
||||
*
|
||||
* The node-level filters mirror the node list's, down to reusing its string resources, so the same words mean the
|
||||
* same thing on both screens. [showIgnored] is the one deliberate divergence — see [MapNodePolicy].
|
||||
*/
|
||||
data class MapFilterState(
|
||||
val onlyFavorites: Boolean,
|
||||
val showWaypoints: Boolean,
|
||||
val showPrecisionCircle: Boolean,
|
||||
val lastHeardFilter: LastHeardFilter,
|
||||
val lastHeardTrackFilter: LastHeardFilter,
|
||||
)
|
||||
/** Roles the user has switched off. Excluded rather than included so a role added by future firmware shows. */
|
||||
val excludedRoles: Set<Config.DeviceConfig.Role> = emptySet(),
|
||||
val onlyOnline: Boolean = false,
|
||||
val onlyDirect: Boolean = false,
|
||||
val excludeMqtt: Boolean = false,
|
||||
val showIgnored: Boolean = false,
|
||||
val includeUnknown: Boolean = true,
|
||||
) {
|
||||
/** True when anything here is narrowing the node set, so the filter button can show it. */
|
||||
val isNarrowing: Boolean
|
||||
get() =
|
||||
onlyFavorites ||
|
||||
excludedRoles.isNotEmpty() ||
|
||||
onlyOnline ||
|
||||
onlyDirect ||
|
||||
excludeMqtt ||
|
||||
!includeUnknown ||
|
||||
lastHeardFilter != LastHeardFilter.Any
|
||||
}
|
||||
|
||||
val mapFilterStateFlow: StateFlow<MapFilterState> =
|
||||
// Two intermediate combines rather than one: `combine` tops out at five flows, and there are eleven.
|
||||
private val displayFilters: Flow<MapFilterState> =
|
||||
combine(
|
||||
showOnlyFavorites,
|
||||
showOnlyFavoritesOnMap,
|
||||
showWaypointsOnMap,
|
||||
showPrecisionCircleOnMap,
|
||||
lastHeardFilter,
|
||||
@@ -207,16 +260,52 @@ open class BaseMapViewModel(
|
||||
) { favoritesOnly, showWaypoints, showPrecisionCircle, lastHeardFilter, lastHeardTrackFilter ->
|
||||
MapFilterState(favoritesOnly, showWaypoints, showPrecisionCircle, lastHeardFilter, lastHeardTrackFilter)
|
||||
}
|
||||
|
||||
private val nodeFilters: Flow<NodeFilters> =
|
||||
combine(excludedMapRoles, onlyOnlineOnMap, onlyDirectOnMap, excludeMqttOnMap, showIgnoredOnMap, ::NodeFilters)
|
||||
|
||||
val mapFilterStateFlow: StateFlow<MapFilterState> =
|
||||
combine(displayFilters, nodeFilters, includeUnknownOnMap) { display, nodes, includeUnknown ->
|
||||
display.with(nodes, includeUnknown)
|
||||
}
|
||||
.stateInWhileSubscribed(
|
||||
initialValue =
|
||||
MapFilterState(
|
||||
showOnlyFavorites.value,
|
||||
showOnlyFavoritesOnMap.value,
|
||||
showWaypointsOnMap.value,
|
||||
showPrecisionCircleOnMap.value,
|
||||
lastHeardFilter.value,
|
||||
lastHeardTrackFilter.value,
|
||||
),
|
||||
)
|
||||
.with(
|
||||
NodeFilters(
|
||||
excludedMapRoles.value,
|
||||
onlyOnlineOnMap.value,
|
||||
onlyDirectOnMap.value,
|
||||
excludeMqttOnMap.value,
|
||||
showIgnoredOnMap.value,
|
||||
),
|
||||
includeUnknownOnMap.value,
|
||||
),
|
||||
)
|
||||
|
||||
/** The five node-level toggles, boxed so they fit one `combine`. */
|
||||
private data class NodeFilters(
|
||||
val excludedRoles: Set<Config.DeviceConfig.Role>,
|
||||
val onlyOnline: Boolean,
|
||||
val onlyDirect: Boolean,
|
||||
val excludeMqtt: Boolean,
|
||||
val showIgnored: Boolean,
|
||||
)
|
||||
|
||||
private fun MapFilterState.with(nodes: NodeFilters, includeUnknown: Boolean) = copy(
|
||||
excludedRoles = nodes.excludedRoles,
|
||||
onlyOnline = nodes.onlyOnline,
|
||||
onlyDirect = nodes.onlyDirect,
|
||||
excludeMqtt = nodes.excludeMqtt,
|
||||
showIgnored = nodes.showIgnored,
|
||||
includeUnknown = includeUnknown,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
package org.meshtastic.feature.map
|
||||
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.ONLINE_WINDOW_SECONDS
|
||||
import org.meshtastic.proto.Config
|
||||
|
||||
/**
|
||||
* Which nodes a map shows, and which of them draws on top.
|
||||
@@ -64,9 +66,36 @@ object MapNodePolicy {
|
||||
node.validPosition != null && (node.num == myNodeNum || node.passesFilters(filterState, nowSeconds))
|
||||
}
|
||||
|
||||
/**
|
||||
* One node against every filter.
|
||||
*
|
||||
* The node-level rules match the node list's query exactly, because they carry its labels: direct means `hopsAway
|
||||
* == 0` — a node whose distance was never measured (-1) is not direct — and unknown means a node that has not sent
|
||||
* a short name.
|
||||
*
|
||||
* [BaseMapViewModel.MapFilterState.showIgnored] is the one place the map deliberately differs. The node list
|
||||
* segregates (`isIgnored == showIgnored`, so switching it on shows *only* ignored nodes); here it reads literally
|
||||
* and adds them, because a map of nothing but ignored nodes is not a view anyone asked for.
|
||||
*/
|
||||
private fun Node.passesFilters(filterState: BaseMapViewModel.MapFilterState, nowSeconds: Long): Boolean {
|
||||
if (filterState.onlyFavorites && !isFavorite) return false
|
||||
val window = filterState.lastHeardFilter.seconds
|
||||
return window == LastHeardFilter.Any.seconds || (nowSeconds - lastHeard) <= window
|
||||
val secondsSinceHeard = nowSeconds - lastHeard
|
||||
val cutoff = filterState.lastHeardFilter.seconds
|
||||
return (!filterState.onlyFavorites || isFavorite) &&
|
||||
(!isIgnored || filterState.showIgnored) &&
|
||||
user.role !in filterState.excludedRoles &&
|
||||
(!filterState.onlyOnline || secondsSinceHeard <= ONLINE_WINDOW_SECONDS) &&
|
||||
(!filterState.onlyDirect || hopsAway == 0) &&
|
||||
(!filterState.excludeMqtt || !viaMqtt) &&
|
||||
(filterState.includeUnknown || user.short_name.isNotEmpty()) &&
|
||||
(cutoff == LastHeardFilter.Any.seconds || secondsSinceHeard <= cutoff)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves persisted role names back to roles, dropping any the current protobufs no longer define.
|
||||
*
|
||||
* Names rather than ordinals, and forgiving of names it does not know: a set written by a newer build (or a role
|
||||
* removed from the protos) must not throw on the way back in.
|
||||
*/
|
||||
fun decodeExcludedRoles(names: Set<String>): Set<Config.DeviceConfig.Role> =
|
||||
names.mapNotNullTo(mutableSetOf()) { name -> Config.DeviceConfig.Role.entries.firstOrNull { it.name == name } }
|
||||
+14
-7
@@ -21,6 +21,8 @@ package org.meshtastic.feature.map.component
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.FloatingToolbarDefaults
|
||||
@@ -55,8 +57,10 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
* Zoom is deliberately not here — it lives in [MapZoomControls], in the lower corner where Google Maps draws its own.
|
||||
*
|
||||
* @param onToggleFilterMenu Callback to open/close the filter dropdown.
|
||||
* @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button — typically a
|
||||
* `DropdownMenu` with filter options.
|
||||
* @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button — [MapFilterSheet] on the
|
||||
* main map, a dropdown on the node-track map.
|
||||
* @param filtersActive Whether any filter is narrowing what the map shows. Badges the button, because a filter that
|
||||
* hides nodes is otherwise indistinguishable from a quiet mesh.
|
||||
* @param mapTypeContent Optional composable for a map type selector button + dropdown. Google flavor provides map type
|
||||
* and custom tile options; F-Droid provides a tile source selector.
|
||||
* @param layersContent Optional composable for a layers management button.
|
||||
@@ -75,6 +79,7 @@ fun MapControlsOverlay(
|
||||
onCompassClick: () -> Unit = {},
|
||||
followPhoneBearing: Boolean = false,
|
||||
filterDropdownContent: @Composable () -> Unit = {},
|
||||
filtersActive: Boolean = false,
|
||||
mapTypeContent: @Composable () -> Unit = {},
|
||||
layersContent: @Composable () -> Unit = {},
|
||||
onSitePlannerClick: (() -> Unit)? = null,
|
||||
@@ -96,11 +101,13 @@ fun MapControlsOverlay(
|
||||
// Filter button + dropdown (optional)
|
||||
onToggleFilterMenu?.let { onClick ->
|
||||
Box {
|
||||
MapButton(
|
||||
icon = MeshtasticIcons.Tune,
|
||||
contentDescription = stringResource(Res.string.map_filter),
|
||||
onClick = onClick,
|
||||
)
|
||||
BadgedBox(badge = { if (filtersActive) Badge() }) {
|
||||
MapButton(
|
||||
icon = MeshtasticIcons.Tune,
|
||||
contentDescription = stringResource(Res.string.map_filter),
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
filterDropdownContent()
|
||||
}
|
||||
}
|
||||
|
||||
+32
-1
@@ -17,18 +17,49 @@
|
||||
package org.meshtastic.feature.map.component
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
import org.meshtastic.feature.map.LastHeardFilter
|
||||
import org.meshtastic.proto.Config
|
||||
|
||||
/**
|
||||
* What the map's filter menu can do.
|
||||
*
|
||||
* Hoisted into its own type so [MapFilterMenu] never holds a view model: each flavour builds this from whichever
|
||||
* Hoisted into its own type so [MapFilterSheet] never holds a view model: each flavour builds this from whichever
|
||||
* [org.meshtastic.feature.map.BaseMapViewModel] subclass it has, and the menu stays a pure function of state.
|
||||
*/
|
||||
@Stable
|
||||
@Suppress("LongParameterList") // One callback per control; a bag of lambdas is the point of this type.
|
||||
class MapFilterActions(
|
||||
val onToggleOnlyFavorites: () -> Unit,
|
||||
val onToggleShowWaypoints: () -> Unit,
|
||||
val onToggleShowPrecisionCircle: () -> Unit,
|
||||
val onSelectLastHeard: (LastHeardFilter) -> Unit,
|
||||
val onToggleRoleExcluded: (Config.DeviceConfig.Role) -> Unit,
|
||||
val onClearExcludedRoles: () -> Unit,
|
||||
val onToggleOnlyOnline: () -> Unit,
|
||||
val onToggleOnlyDirect: () -> Unit,
|
||||
val onToggleExcludeMqtt: () -> Unit,
|
||||
val onToggleShowIgnored: () -> Unit,
|
||||
val onToggleIncludeUnknown: () -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* The standard wiring from a view model to [MapFilterSheet].
|
||||
*
|
||||
* Both flavours build the identical bag of method references, and eleven of them written out twice is eleven chances
|
||||
* for the two maps to drift apart again — which is exactly what [org.meshtastic.feature.map.MapNodePolicy] exists to
|
||||
* prevent on the rules side.
|
||||
*/
|
||||
fun BaseMapViewModel.mapFilterActions(): MapFilterActions = MapFilterActions(
|
||||
onToggleOnlyFavorites = ::toggleOnlyFavorites,
|
||||
onToggleShowWaypoints = ::toggleShowWaypointsOnMap,
|
||||
onToggleShowPrecisionCircle = ::toggleShowPrecisionCircleOnMap,
|
||||
onSelectLastHeard = ::setLastHeardFilter,
|
||||
onToggleRoleExcluded = ::toggleRoleExcluded,
|
||||
onClearExcludedRoles = ::clearExcludedRoles,
|
||||
onToggleOnlyOnline = ::toggleOnlyOnline,
|
||||
onToggleOnlyDirect = ::toggleOnlyDirect,
|
||||
onToggleExcludeMqtt = ::toggleExcludeMqtt,
|
||||
onToggleShowIgnored = ::toggleShowIgnored,
|
||||
onToggleIncludeUnknown = ::toggleIncludeUnknown,
|
||||
)
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||
|
||||
package org.meshtastic.feature.map.component
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.map_filter_all_roles
|
||||
import org.meshtastic.core.resources.map_filter_display_title
|
||||
import org.meshtastic.core.resources.map_filter_nodes_title
|
||||
import org.meshtastic.core.resources.map_filter_roles_title
|
||||
import org.meshtastic.core.resources.map_filter_show_ignored
|
||||
import org.meshtastic.core.resources.map_filter_title
|
||||
import org.meshtastic.core.resources.node_filter_exclude_mqtt
|
||||
import org.meshtastic.core.resources.node_filter_include_unknown
|
||||
import org.meshtastic.core.resources.node_filter_only_direct
|
||||
import org.meshtastic.core.resources.node_filter_only_online
|
||||
import org.meshtastic.core.resources.only_favorites
|
||||
import org.meshtastic.core.resources.show_precision_circle
|
||||
import org.meshtastic.core.resources.show_waypoints
|
||||
import org.meshtastic.core.ui.icon.Favorite
|
||||
import org.meshtastic.core.ui.icon.Lens
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.PinDrop
|
||||
import org.meshtastic.core.ui.icon.role
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
import org.meshtastic.proto.Config
|
||||
|
||||
/** The tag the chip for one role carries, so a test can pick out a single role. */
|
||||
fun roleFilterChipTestTag(role: Config.DeviceConfig.Role): String = "role-filter-${role.name}"
|
||||
|
||||
/**
|
||||
* The main map's filters.
|
||||
*
|
||||
* A sheet rather than the dropdown this replaces: eleven controls and a role chip for every device role is more than a
|
||||
* menu should hold, and the layers button on the same screen already opens a sheet.
|
||||
*
|
||||
* State in, actions out, exactly as the dropdown was — neither engine keeps its own copy, and the Google flavour mounts
|
||||
* this same composable.
|
||||
*/
|
||||
@Composable
|
||||
fun MapFilterSheet(
|
||||
onDismissRequest: () -> Unit,
|
||||
filterState: BaseMapViewModel.MapFilterState,
|
||||
actions: MapFilterActions,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismissRequest) {
|
||||
MapFilterSheetContent(filterState = filterState, actions = actions)
|
||||
}
|
||||
}
|
||||
|
||||
/** The sheet's body, separate from the sheet itself so it can be tested without a window manager to host a modal. */
|
||||
@Composable
|
||||
internal fun MapFilterSheetContent(filterState: BaseMapViewModel.MapFilterState, actions: MapFilterActions) {
|
||||
Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
text = stringResource(Res.string.map_filter_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
|
||||
SectionTitle(stringResource(Res.string.map_filter_display_title))
|
||||
FilterToggle(
|
||||
label = stringResource(Res.string.only_favorites),
|
||||
icon = MeshtasticIcons.Favorite,
|
||||
checked = filterState.onlyFavorites,
|
||||
onToggle = actions.onToggleOnlyFavorites,
|
||||
)
|
||||
FilterToggle(
|
||||
label = stringResource(Res.string.show_waypoints),
|
||||
icon = MeshtasticIcons.PinDrop,
|
||||
checked = filterState.showWaypoints,
|
||||
onToggle = actions.onToggleShowWaypoints,
|
||||
)
|
||||
FilterToggle(
|
||||
label = stringResource(Res.string.show_precision_circle),
|
||||
icon = MeshtasticIcons.Lens,
|
||||
checked = filterState.showPrecisionCircle,
|
||||
onToggle = actions.onToggleShowPrecisionCircle,
|
||||
)
|
||||
LastHeardSlider(selected = filterState.lastHeardFilter, onSelect = actions.onSelectLastHeard)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SectionTitle(stringResource(Res.string.map_filter_roles_title))
|
||||
RoleFilterChips(
|
||||
excluded = filterState.excludedRoles,
|
||||
onToggle = actions.onToggleRoleExcluded,
|
||||
onClear = actions.onClearExcludedRoles,
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SectionTitle(stringResource(Res.string.map_filter_nodes_title))
|
||||
NodeFilterToggles(filterState = filterState, actions = actions)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The node-level filters, in the node list's own words — four of these five labels are its string resources, so a user
|
||||
* who has met them there does not have to learn them twice.
|
||||
*/
|
||||
@Composable
|
||||
private fun NodeFilterToggles(filterState: BaseMapViewModel.MapFilterState, actions: MapFilterActions) = Column {
|
||||
FilterToggle(
|
||||
label = stringResource(Res.string.node_filter_only_online),
|
||||
checked = filterState.onlyOnline,
|
||||
onToggle = actions.onToggleOnlyOnline,
|
||||
)
|
||||
FilterToggle(
|
||||
label = stringResource(Res.string.node_filter_only_direct),
|
||||
checked = filterState.onlyDirect,
|
||||
onToggle = actions.onToggleOnlyDirect,
|
||||
)
|
||||
FilterToggle(
|
||||
label = stringResource(Res.string.node_filter_exclude_mqtt),
|
||||
checked = filterState.excludeMqtt,
|
||||
onToggle = actions.onToggleExcludeMqtt,
|
||||
)
|
||||
// The fifth is ours: the list's `node_filter_show_ignored` reads "Only show ignored Nodes", which is what the
|
||||
// list does and the opposite of what this does.
|
||||
FilterToggle(
|
||||
label = stringResource(Res.string.map_filter_show_ignored),
|
||||
checked = filterState.showIgnored,
|
||||
onToggle = actions.onToggleShowIgnored,
|
||||
)
|
||||
FilterToggle(
|
||||
label = stringResource(Res.string.node_filter_include_unknown),
|
||||
checked = filterState.includeUnknown,
|
||||
onToggle = actions.onToggleIncludeUnknown,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One chip per device role, plus an "All" chip that clears the lot.
|
||||
*
|
||||
* Selected means shown, which is the way round a user reads a chip row — the state behind it is the complement, a set
|
||||
* of *excluded* roles, so that a role introduced by later firmware appears instead of silently vanishing.
|
||||
*/
|
||||
@Composable
|
||||
private fun RoleFilterChips(
|
||||
excluded: Set<Config.DeviceConfig.Role>,
|
||||
onToggle: (Config.DeviceConfig.Role) -> Unit,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = excluded.isEmpty(),
|
||||
onClick = onClear,
|
||||
label = { Text(stringResource(Res.string.map_filter_all_roles)) },
|
||||
)
|
||||
// Deprecated slots are still values on the wire, so a node can report one and has to be filterable.
|
||||
@Suppress("DEPRECATION")
|
||||
Config.DeviceConfig.Role.entries.forEach { role ->
|
||||
FilterChip(
|
||||
selected = role !in excluded,
|
||||
onClick = { onToggle(role) },
|
||||
label = { Text(role.name) },
|
||||
leadingIcon = { Icon(imageVector = MeshtasticIcons.role(role), contentDescription = null) },
|
||||
modifier = Modifier.testTag(roleFilterChipTestTag(role)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionTitle(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterToggle(label: String, checked: Boolean, onToggle: () -> Unit, icon: ImageVector? = null) {
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
leadingContent = icon?.let { { Icon(imageVector = it, contentDescription = null) } },
|
||||
trailingContent = { Checkbox(checked = checked, onCheckedChange = { onToggle() }) },
|
||||
)
|
||||
}
|
||||
+3
-65
@@ -20,14 +20,9 @@ package org.meshtastic.feature.map.component
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuGroup
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -36,61 +31,13 @@ import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.last_heard_filter_label
|
||||
import org.meshtastic.core.resources.only_favorites
|
||||
import org.meshtastic.core.resources.show_precision_circle
|
||||
import org.meshtastic.core.resources.show_waypoints
|
||||
import org.meshtastic.core.ui.icon.Favorite
|
||||
import org.meshtastic.core.ui.icon.Lens
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.PinDrop
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
import org.meshtastic.feature.map.LastHeardFilter
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The main map's filter menu.
|
||||
*
|
||||
* State in, actions out: every control here reads [BaseMapViewModel.MapFilterState] and calls a setter the base view
|
||||
* model already owns, so neither engine needs its own copy. Both had one — the same four controls over the same state,
|
||||
* differing only in whether the rows carried icons.
|
||||
*/
|
||||
@Composable
|
||||
fun MapFilterMenu(
|
||||
expanded: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
filterState: BaseMapViewModel.MapFilterState,
|
||||
actions: MapFilterActions,
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
|
||||
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
|
||||
FilterToggle(
|
||||
label = stringResource(Res.string.only_favorites),
|
||||
icon = MeshtasticIcons.Favorite,
|
||||
checked = filterState.onlyFavorites,
|
||||
onToggle = actions.onToggleOnlyFavorites,
|
||||
)
|
||||
FilterToggle(
|
||||
label = stringResource(Res.string.show_waypoints),
|
||||
icon = MeshtasticIcons.PinDrop,
|
||||
checked = filterState.showWaypoints,
|
||||
onToggle = actions.onToggleShowWaypoints,
|
||||
)
|
||||
FilterToggle(
|
||||
label = stringResource(Res.string.show_precision_circle),
|
||||
icon = MeshtasticIcons.Lens,
|
||||
checked = filterState.showPrecisionCircle,
|
||||
onToggle = actions.onToggleShowPrecisionCircle,
|
||||
)
|
||||
}
|
||||
LastHeardSlider(selected = filterState.lastHeardFilter, onSelect = actions.onSelectLastHeard)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter menu for one node's position track.
|
||||
*
|
||||
@@ -110,23 +57,14 @@ fun NodeTrackFilterMenu(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterToggle(label: String, icon: ImageVector, checked: Boolean, onToggle: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = onToggle,
|
||||
leadingIcon = { Icon(imageVector = icon, contentDescription = label) },
|
||||
trailingIcon = { Checkbox(checked = checked, onCheckedChange = { onToggle() }) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The age cutoff, as a slider over [LastHeardFilter]'s own entries.
|
||||
*
|
||||
* Written out four times before this: twice in each engine, once for the map and once for a track.
|
||||
* Written out four times before this: twice in each engine, once for the map and once for a track. Shared with
|
||||
* [MapFilterSheet], which shows the same control for the map's own cutoff.
|
||||
*/
|
||||
@Composable
|
||||
private fun LastHeardSlider(selected: LastHeardFilter, onSelect: (LastHeardFilter) -> Unit) {
|
||||
internal fun LastHeardSlider(selected: LastHeardFilter, onSelect: (LastHeardFilter) -> Unit) {
|
||||
val options = LastHeardFilter.entries
|
||||
val selectedIndex = options.indexOf(selected)
|
||||
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
|
||||
@@ -31,6 +31,7 @@ import org.meshtastic.core.common.util.MeasurementSystem
|
||||
import org.meshtastic.core.common.util.nowSeconds
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.NodeAddress
|
||||
import org.meshtastic.core.repository.MapPrefs
|
||||
import org.meshtastic.core.repository.PacketRepository
|
||||
@@ -40,6 +41,7 @@ import org.meshtastic.core.testing.FakeNotificationPrefs
|
||||
import org.meshtastic.core.testing.FakeRadioConfigRepository
|
||||
import org.meshtastic.core.testing.FakeRadioController
|
||||
import org.meshtastic.core.testing.TestDataFactory
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.Waypoint
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
@@ -57,6 +59,7 @@ class BaseMapViewModelTest {
|
||||
private lateinit var radioConfigRepository: FakeRadioConfigRepository
|
||||
private lateinit var waypointPacketsFlow: MutableStateFlow<List<DataPacket>>
|
||||
private val mapPrefs: MapPrefs = mock()
|
||||
private val showIgnored = MutableStateFlow(false)
|
||||
private val packetRepository: PacketRepository = mock()
|
||||
private val localeUnitsProvider = FakeLocaleUnitsProvider()
|
||||
|
||||
@@ -73,6 +76,12 @@ class BaseMapViewModelTest {
|
||||
every { mapPrefs.showPrecisionCircleOnMap } returns MutableStateFlow(false)
|
||||
every { mapPrefs.lastHeardFilter } returns MutableStateFlow(0L)
|
||||
every { mapPrefs.lastHeardTrackFilter } returns MutableStateFlow(0L)
|
||||
every { mapPrefs.onlyOnlineOnMap } returns MutableStateFlow(false)
|
||||
every { mapPrefs.onlyDirectOnMap } returns MutableStateFlow(false)
|
||||
every { mapPrefs.excludeMqttOnMap } returns MutableStateFlow(false)
|
||||
every { mapPrefs.showIgnoredOnMap } returns showIgnored
|
||||
every { mapPrefs.includeUnknownOnMap } returns MutableStateFlow(true)
|
||||
every { mapPrefs.excludedMapRoles } returns MutableStateFlow(emptySet())
|
||||
|
||||
waypointPacketsFlow = MutableStateFlow(emptyList())
|
||||
every { packetRepository.getWaypoints() } returns waypointPacketsFlow
|
||||
@@ -128,6 +137,37 @@ class BaseMapViewModelTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the map's node set hides ignored nodes by default`() = runTest(testDispatcher) {
|
||||
nodeRepository.setNodes(listOf(positioned(1), positioned(2, isIgnored = true)))
|
||||
|
||||
viewModel.nodesWithPosition.test {
|
||||
assertEquals(listOf(1), awaitItem().map { it.num })
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the map's node set carries ignored nodes once the filter asks for them`() = runTest(testDispatcher) {
|
||||
// The filter rule lives in MapNodePolicy, but the node list reaching it is built here — and it used to
|
||||
// discard every ignored node unconditionally, so the toggle had nothing to add back.
|
||||
// Set on the prefs flow, after the view model was built: that is the DataStore-arrives-late case, which
|
||||
// used to be lost because the view model snapshotted `.value` into a mirror nothing updated.
|
||||
nodeRepository.setNodes(listOf(positioned(1), positioned(2, isIgnored = true)))
|
||||
showIgnored.value = true
|
||||
|
||||
viewModel.nodesWithPosition.test {
|
||||
assertEquals(listOf(1, 2), awaitItem().map { it.num }.sorted())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun positioned(num: Int, isIgnored: Boolean = false) = Node(
|
||||
num = num,
|
||||
position = Position(latitude_i = 450_000_000, longitude_i = -1_220_000_000),
|
||||
isIgnored = isIgnored,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun testConnectionStateFlow() = runTest(testDispatcher) {
|
||||
viewModel.isConnected.test {
|
||||
|
||||
+132
-15
@@ -17,7 +17,9 @@
|
||||
package org.meshtastic.feature.map
|
||||
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.proto.Config
|
||||
import org.meshtastic.proto.Position
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
@@ -25,22 +27,52 @@ import kotlin.test.assertTrue
|
||||
/** The rules both map engines answer: who appears, and who draws on top. */
|
||||
class MapNodePolicyTest {
|
||||
|
||||
private fun node(num: Int, latitude: Double, longitude: Double, lastHeard: Int = 0, isFavorite: Boolean = false) =
|
||||
Node(
|
||||
num = num,
|
||||
position = Position(latitude_i = (latitude * 1e7).toInt(), longitude_i = (longitude * 1e7).toInt()),
|
||||
lastHeard = lastHeard,
|
||||
isFavorite = isFavorite,
|
||||
)
|
||||
@Suppress("LongParameterList")
|
||||
private fun node(
|
||||
num: Int,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
lastHeard: Int = 0,
|
||||
isFavorite: Boolean = false,
|
||||
role: Config.DeviceConfig.Role = Config.DeviceConfig.Role.CLIENT,
|
||||
hopsAway: Int = 0,
|
||||
viaMqtt: Boolean = false,
|
||||
isIgnored: Boolean = false,
|
||||
shortName: String = "ABCD",
|
||||
) = Node(
|
||||
num = num,
|
||||
position = Position(latitude_i = (latitude * 1e7).toInt(), longitude_i = (longitude * 1e7).toInt()),
|
||||
lastHeard = lastHeard,
|
||||
isFavorite = isFavorite,
|
||||
user = User(short_name = shortName, role = role),
|
||||
hopsAway = hopsAway,
|
||||
viaMqtt = viaMqtt,
|
||||
isIgnored = isIgnored,
|
||||
)
|
||||
|
||||
private fun filters(onlyFavorites: Boolean = false, lastHeard: LastHeardFilter = LastHeardFilter.Any) =
|
||||
BaseMapViewModel.MapFilterState(
|
||||
onlyFavorites = onlyFavorites,
|
||||
showWaypoints = true,
|
||||
showPrecisionCircle = true,
|
||||
lastHeardFilter = lastHeard,
|
||||
lastHeardTrackFilter = LastHeardFilter.Any,
|
||||
)
|
||||
@Suppress("LongParameterList")
|
||||
private fun filters(
|
||||
onlyFavorites: Boolean = false,
|
||||
lastHeard: LastHeardFilter = LastHeardFilter.Any,
|
||||
excludedRoles: Set<Config.DeviceConfig.Role> = emptySet(),
|
||||
onlyOnline: Boolean = false,
|
||||
onlyDirect: Boolean = false,
|
||||
excludeMqtt: Boolean = false,
|
||||
showIgnored: Boolean = false,
|
||||
includeUnknown: Boolean = true,
|
||||
) = BaseMapViewModel.MapFilterState(
|
||||
onlyFavorites = onlyFavorites,
|
||||
showWaypoints = true,
|
||||
showPrecisionCircle = true,
|
||||
lastHeardFilter = lastHeard,
|
||||
lastHeardTrackFilter = LastHeardFilter.Any,
|
||||
excludedRoles = excludedRoles,
|
||||
onlyOnline = onlyOnline,
|
||||
onlyDirect = onlyDirect,
|
||||
excludeMqtt = excludeMqtt,
|
||||
showIgnored = showIgnored,
|
||||
includeUnknown = includeUnknown,
|
||||
)
|
||||
|
||||
private fun visible(nodes: List<Node>, state: BaseMapViewModel.MapFilterState, now: Long = 0, mine: Int? = null) =
|
||||
MapNodePolicy.visibleNodes(nodes, state, now, mine).map { it.num }
|
||||
@@ -92,4 +124,89 @@ class MapNodePolicyTest {
|
||||
assertEquals(MapNodePolicy.PRIORITY_ORDINARY, MapNodePolicy.priorityOf(ordinary, myNodeNum = 1))
|
||||
assertTrue(MapNodePolicy.PRIORITY_PROMINENT > MapNodePolicy.PRIORITY_ORDINARY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a node whose role is excluded is hidden`() {
|
||||
val nodes =
|
||||
listOf(
|
||||
node(1, 45.0, -122.0, role = Config.DeviceConfig.Role.ROUTER),
|
||||
node(2, 45.1, -122.1, role = Config.DeviceConfig.Role.CLIENT),
|
||||
)
|
||||
assertEquals(listOf(2), visible(nodes, filters(excludedRoles = setOf(Config.DeviceConfig.Role.ROUTER))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `excluding CLIENT also hides every node that never reported a role`() {
|
||||
// CLIENT is 0, the proto default, so "never said" and "said CLIENT" are the same value on the wire. Worth
|
||||
// pinning: it is the one surprising consequence of a per-role filter.
|
||||
val nodes = listOf(node(1, 45.0, -122.0), node(2, 45.1, -122.1, role = Config.DeviceConfig.Role.ROUTER))
|
||||
assertEquals(listOf(2), visible(nodes, filters(excludedRoles = setOf(Config.DeviceConfig.Role.CLIENT))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the online filter drops nodes unheard for over two hours`() {
|
||||
// 97_000 is 3_000s back, inside the two-hour window; 90_000 is 10_000s back, outside it.
|
||||
val nodes = listOf(node(1, 45.0, -122.0, lastHeard = 90_000), node(2, 45.1, -122.1, lastHeard = 97_000))
|
||||
assertEquals(listOf(2), visible(nodes, filters(onlyOnline = true), now = 100_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the direct filter drops both relayed nodes and nodes of unknown distance`() {
|
||||
// The node list's query is `hops_away <= 0 AND hops_away >= 0`, so -1 — never measured — is not direct.
|
||||
val nodes =
|
||||
listOf(node(1, 45.0, -122.0, hopsAway = 2), node(2, 45.1, -122.1), node(3, 45.2, -122.2, hopsAway = -1))
|
||||
assertEquals(listOf(2), visible(nodes, filters(onlyDirect = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the mqtt filter drops nodes heard over mqtt`() {
|
||||
val nodes = listOf(node(1, 45.0, -122.0, viaMqtt = true), node(2, 45.1, -122.1))
|
||||
assertEquals(listOf(2), visible(nodes, filters(excludeMqtt = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignored nodes are hidden by default`() {
|
||||
val nodes = listOf(node(1, 45.0, -122.0, isIgnored = true), node(2, 45.1, -122.1))
|
||||
assertEquals(listOf(2), visible(nodes, filters()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showing ignored nodes adds them rather than showing only them`() {
|
||||
// The node list segregates — its filter is `isIgnored == showIgnored` — but a map of nothing but ignored
|
||||
// nodes is not a view anyone wants. Here the toggle reads literally: include them too.
|
||||
val nodes = listOf(node(1, 45.0, -122.0, isIgnored = true), node(2, 45.1, -122.1))
|
||||
assertEquals(listOf(1, 2), visible(nodes, filters(showIgnored = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `excluding unknown nodes drops the ones with no name yet`() {
|
||||
// "Unknown" is a node that has not sent a short name, matching the node list's `short_name IS NOT NULL`.
|
||||
val nodes = listOf(node(1, 45.0, -122.0, shortName = ""), node(2, 45.1, -122.1))
|
||||
assertEquals(listOf(2), visible(nodes, filters(includeUnknown = false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `my own node survives every new filter`() {
|
||||
val mine =
|
||||
node(
|
||||
1,
|
||||
45.0,
|
||||
-122.0,
|
||||
lastHeard = 0,
|
||||
role = Config.DeviceConfig.Role.ROUTER,
|
||||
hopsAway = 3,
|
||||
viaMqtt = true,
|
||||
isIgnored = true,
|
||||
shortName = "",
|
||||
)
|
||||
val state =
|
||||
filters(
|
||||
excludedRoles = setOf(Config.DeviceConfig.Role.ROUTER),
|
||||
onlyOnline = true,
|
||||
onlyDirect = true,
|
||||
excludeMqtt = true,
|
||||
includeUnknown = false,
|
||||
)
|
||||
assertEquals(listOf(1), visible(listOf(mine), state, now = 100_000, mine = 1))
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.feature.map.component
|
||||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsNotSelected
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.v2.runComposeUiTest
|
||||
import org.meshtastic.feature.map.BaseMapViewModel
|
||||
import org.meshtastic.feature.map.LastHeardFilter
|
||||
import org.meshtastic.proto.Config
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class MapFilterSheetTest {
|
||||
|
||||
private val router = Config.DeviceConfig.Role.ROUTER
|
||||
|
||||
private fun state(excludedRoles: Set<Config.DeviceConfig.Role> = emptySet()) = BaseMapViewModel.MapFilterState(
|
||||
onlyFavorites = false,
|
||||
showWaypoints = true,
|
||||
showPrecisionCircle = true,
|
||||
lastHeardFilter = LastHeardFilter.Any,
|
||||
lastHeardTrackFilter = LastHeardFilter.Any,
|
||||
excludedRoles = excludedRoles,
|
||||
)
|
||||
|
||||
private fun actions(onToggleRole: (Config.DeviceConfig.Role) -> Unit = {}) = MapFilterActions(
|
||||
onToggleOnlyFavorites = {},
|
||||
onToggleShowWaypoints = {},
|
||||
onToggleShowPrecisionCircle = {},
|
||||
onSelectLastHeard = {},
|
||||
onToggleRoleExcluded = onToggleRole,
|
||||
onClearExcludedRoles = {},
|
||||
onToggleOnlyOnline = {},
|
||||
onToggleOnlyDirect = {},
|
||||
onToggleExcludeMqtt = {},
|
||||
onToggleShowIgnored = {},
|
||||
onToggleIncludeUnknown = {},
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a role chip reads as selected when that role is shown`() = runComposeUiTest {
|
||||
// The state behind the row is the complement — a set of excluded roles — so the chip's selected flag is the
|
||||
// one place that inversion can go wrong, and it would look like the filter working backwards.
|
||||
setContent { MapFilterSheetContent(filterState = state(), actions = actions()) }
|
||||
|
||||
onNodeWithTag(roleFilterChipTestTag(router)).assertIsSelected()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a role chip reads as unselected once that role is excluded`() = runComposeUiTest {
|
||||
setContent { MapFilterSheetContent(filterState = state(excludedRoles = setOf(router)), actions = actions()) }
|
||||
|
||||
onNodeWithTag(roleFilterChipTestTag(router)).assertIsNotSelected()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping a role chip reports that role`() = runComposeUiTest {
|
||||
val toggled = mutableListOf<Config.DeviceConfig.Role>()
|
||||
setContent { MapFilterSheetContent(filterState = state(), actions = actions { toggled += it }) }
|
||||
|
||||
onNodeWithTag(roleFilterChipTestTag(router)).performClick()
|
||||
|
||||
runOnIdle { assertEquals(listOf(router), toggled) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every role has a chip`() = runComposeUiTest {
|
||||
// A role with no chip is a role the user can never hide, and one that silently stays on the map.
|
||||
setContent { MapFilterSheetContent(filterState = state(), actions = actions()) }
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
Config.DeviceConfig.Role.entries.forEach { role -> onNodeWithTag(roleFilterChipTestTag(role)).assertExists() }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user