From 2ac28e28c36ffe5cb78347cd5de377d180dedec8 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 30 Aug 2026 05:45:05 +0000 Subject: [PATCH] feat(map): filter the map by node role and by how a node was heard (#6959) --- .skills/compose-ui/strings-index.txt | 6 + .../kotlin/org/meshtastic/app/map/MapView.kt | 18 +- .../meshtastic/core/model/util/TimeUtils.kt | 8 + .../meshtastic/core/prefs/map/MapPrefsImpl.kt | 50 ++++ .../core/repository/AppPreferences.kt | 35 +++ .../composeResources/values/strings.xml | 6 + .../core/testing/FakeAppPreferences.kt | 36 +++ .../map/maplibre/MapLibreMapViewProvider.kt | 29 +-- .../feature/map/BaseMapViewModel.kt | 185 +++++++++++---- .../meshtastic/feature/map/MapNodePolicy.kt | 35 ++- .../map/component/MapControlsOverlay.kt | 21 +- .../feature/map/component/MapFilterActions.kt | 33 ++- .../feature/map/component/MapFilterSheet.kt | 223 ++++++++++++++++++ ...apFilterMenu.kt => NodeTrackFilterMenu.kt} | 68 +----- .../feature/map/BaseMapViewModelTest.kt | 40 ++++ .../feature/map/MapNodePolicyTest.kt | 147 ++++++++++-- .../map/component/MapFilterSheetTest.kt | 93 ++++++++ 17 files changed, 866 insertions(+), 167 deletions(-) create mode 100644 feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterSheet.kt rename feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/{MapFilterMenu.kt => NodeTrackFilterMenu.kt} (54%) create mode 100644 feature/map/src/jvmTest/kotlin/org/meshtastic/feature/map/component/MapFilterSheetTest.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index da017d3ae1..6e6d5fdab5 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index c92695022f..3d84ebb8b6 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -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(), ) } }, diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeUtils.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeUtils.kt index a17bd5751e..15d7ab5efe 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeUtils.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TimeUtils.kt @@ -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() diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt index bf0aae54a2..a500b6f70c 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt @@ -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 = + 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 = + 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 = + 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 = + 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 = + 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> = + dataStore.data + .map { it[KEY_EXCLUDED_ROLES_PREF] ?: emptySet() } + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + override fun setExcludedMapRoles(roles: Set) { + scope.launch { dataStore.edit { it[KEY_EXCLUDED_ROLES_PREF] = roles } } + } + override val hiddenLayerUrls: StateFlow> = 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") diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index f7447beccb..7918c27704 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -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 + + fun setOnlyOnlineOnMap(only: Boolean) + + val onlyDirectOnMap: StateFlow + + fun setOnlyDirectOnMap(only: Boolean) + + val excludeMqttOnMap: StateFlow + + fun setExcludeMqttOnMap(exclude: Boolean) + + val showIgnoredOnMap: StateFlow + + fun setShowIgnoredOnMap(show: Boolean) + + val includeUnknownOnMap: StateFlow + + 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> + + fun setExcludedMapRoles(roles: Set) + /** URIs of imported map layers the user has toggled off; a layer is visible unless its URI is in this set. */ val hiddenLayerUrls: StateFlow> diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index b4442d789c..2c09186e8b 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1084,6 +1084,12 @@ Downloading Paused Map Filter\n + All + Display + Nodes + Node roles + Show ignored nodes + Filter map Map layers support .kml, .kmz, or GeoJSON formats. Opacity: %1$d% %1$s<br>Last heard: %2$s<br>Last position: %3$s<br>Battery: %4$s diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index 894dfd3bdb..0dc7188e14 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -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>(emptySet()) + + override fun setExcludedMapRoles(roles: Set) { + excludedMapRoles.value = roles + } + override val hiddenLayerUrls = MutableStateFlow>(emptySet()) override fun updateHiddenLayerUrls(transform: (Set) -> Set) { diff --git a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt index 9e5fa48b57..a3417de10a 100644 --- a/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt +++ b/feature/map-maplibre/src/commonMain/kotlin/org/meshtastic/feature/map/maplibre/MapLibreMapViewProvider.kt @@ -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 = { diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index ec9631cef6..fbdd9e4c74 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -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> = - nodes - .map { nodes -> nodes.filter { node -> node.validPosition != null } } - .stateInWhileSubscribed(initialValue = emptyList()) - val waypoints: StateFlow> = 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 = 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 = mapPrefs.showOnlyFavorites - fun toggleOnlyFavorites() { - val newValue = !showOnlyFavorites.value - showOnlyFavorites.value = newValue - mapPrefs.setShowOnlyFavorites(newValue) + fun toggleOnlyFavorites() = mapPrefs.setShowOnlyFavorites(!showOnlyFavoritesOnMap.value) + + val showWaypointsOnMap: StateFlow = mapPrefs.showWaypointsOnMap + + fun toggleShowWaypointsOnMap() = mapPrefs.setShowWaypointsOnMap(!showWaypointsOnMap.value) + + val showPrecisionCircleOnMap: StateFlow = mapPrefs.showPrecisionCircleOnMap + + fun toggleShowPrecisionCircleOnMap() = mapPrefs.setShowPrecisionCircleOnMap(!showPrecisionCircleOnMap.value) + + val onlyOnlineOnMap: StateFlow = mapPrefs.onlyOnlineOnMap + + fun toggleOnlyOnline() = mapPrefs.setOnlyOnlineOnMap(!onlyOnlineOnMap.value) + + val onlyDirectOnMap: StateFlow = mapPrefs.onlyDirectOnMap + + fun toggleOnlyDirect() = mapPrefs.setOnlyDirectOnMap(!onlyDirectOnMap.value) + + val excludeMqttOnMap: StateFlow = mapPrefs.excludeMqttOnMap + + fun toggleExcludeMqtt() = mapPrefs.setExcludeMqttOnMap(!excludeMqttOnMap.value) + + val showIgnoredOnMap: StateFlow = mapPrefs.showIgnoredOnMap + + fun toggleShowIgnored() = mapPrefs.setShowIgnoredOnMap(!showIgnoredOnMap.value) + + val includeUnknownOnMap: StateFlow = 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> = + combine(nodeRepository.getNodes(), showIgnoredOnMap) { all, showIgnored -> + all.filter { node -> node.validPosition != null && (showIgnored || !node.isIgnored) } + } + .stateInWhileSubscribed(initialValue = emptyList()) + + val excludedMapRoles: StateFlow> = + 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 = showWaypoints.asStateFlow() + fun clearExcludedRoles() = mapPrefs.setExcludedMapRoles(emptySet()) - fun toggleShowWaypointsOnMap() { - val newValue = !showWaypoints.value - showWaypoints.value = newValue - mapPrefs.setShowWaypointsOnMap(newValue) - } + val lastHeardFilter: StateFlow = + mapPrefs.lastHeardFilter + .map(LastHeardFilter::fromSeconds) + .stateInWhileSubscribed(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter.value)) - private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap.value) - val showPrecisionCircleOnMap: StateFlow = 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 = + mapPrefs.lastHeardTrackFilter + .map(LastHeardFilter::fromSeconds) + .stateInWhileSubscribed(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter.value)) - private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter.value)) - val lastHeardFilter: StateFlow = 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 = 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 = 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 = + // Two intermediate combines rather than one: `combine` tops out at five flows, and there are eleven. + private val displayFilters: Flow = 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 = + combine(excludedMapRoles, onlyOnlineOnMap, onlyDirectOnMap, excludeMqttOnMap, showIgnoredOnMap, ::NodeFilters) + + val mapFilterStateFlow: StateFlow = + 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, + 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, + ) } /** diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapNodePolicy.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapNodePolicy.kt index 649c46b8ad..4f96fdc354 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapNodePolicy.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/MapNodePolicy.kt @@ -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): Set = + names.mapNotNullTo(mutableSetOf()) { name -> Config.DeviceConfig.Role.entries.firstOrNull { it.name == name } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt index 56262449d6..3e3207701d 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt @@ -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() } } diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterActions.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterActions.kt index e2918d69fc..49de12b093 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterActions.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterActions.kt @@ -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, ) diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterSheet.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterSheet.kt new file mode 100644 index 0000000000..82fd8195bb --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterSheet.kt @@ -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 . + */ +@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, + 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() }) }, + ) +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterMenu.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackFilterMenu.kt similarity index 54% rename from feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterMenu.kt rename to feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackFilterMenu.kt index d8eddd4608..0dae691996 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapFilterMenu.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/NodeTrackFilterMenu.kt @@ -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()) } diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index 0f7b490a81..19bb5cf232 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt @@ -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> 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 { diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapNodePolicyTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapNodePolicyTest.kt index f08ba9c0ea..9ac476ed7f 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapNodePolicyTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/MapNodePolicyTest.kt @@ -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 = 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, 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)) + } } diff --git a/feature/map/src/jvmTest/kotlin/org/meshtastic/feature/map/component/MapFilterSheetTest.kt b/feature/map/src/jvmTest/kotlin/org/meshtastic/feature/map/component/MapFilterSheetTest.kt new file mode 100644 index 0000000000..242ba621fb --- /dev/null +++ b/feature/map/src/jvmTest/kotlin/org/meshtastic/feature/map/component/MapFilterSheetTest.kt @@ -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 . + */ +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 = 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() + 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() } + } +}