Add distance-based node filter

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-06 14:25:03 -05:00
parent 839d43ddf1
commit 6450c69820
13 changed files with 193 additions and 12 deletions
@@ -38,6 +38,10 @@ object GPSFormat {
fun latLongToMeter(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double =
PositionUtils.distance(latitudeA, longitudeA, latitudeB, longitudeB)
/** @return distance in kilometers along the surface of the earth (ish) */
fun distanceKm(latitudeA: Double, longitudeA: Double, latitudeB: Double, longitudeB: Double): Double =
latLongToMeter(latitudeA, longitudeA, latitudeB, longitudeB) / 1000.0
/**
* Computes the bearing in degrees between two points on Earth.
*
@@ -39,6 +39,12 @@ class LocationUtilsTest {
assertTrue(distance2 > 78000 && distance2 < 79000, "Distance was $distance2")
}
@Test
fun testDistanceKm() {
val distance = distanceKm(0.0, 0.0, 0.0, 1.0)
assertTrue(distance > 111 && distance < 112, "Distance was $distance")
}
@Test
fun testBearing() {
// North
@@ -20,6 +20,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.atomicfu.atomic
@@ -118,6 +119,21 @@ class UiPrefsImpl(
scope.launch { dataStore.edit { it[KEY_EXCLUDE_MQTT] = value } }
}
override val maxDistanceKm: StateFlow<Float?> =
dataStore.data.map { it[KEY_MAX_DISTANCE_KM] }.stateIn(scope, SharingStarted.Lazily, null)
override fun setMaxDistanceKm(value: Float?) {
scope.launch {
dataStore.edit {
if (value == null) {
it.remove(KEY_MAX_DISTANCE_KM)
} else {
it[KEY_MAX_DISTANCE_KM] = value
}
}
}
}
override val hasShownNotPairedWarning: StateFlow<Boolean> =
dataStore.data
.map { it[KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF] ?: false }
@@ -195,6 +211,7 @@ class UiPrefsImpl(
val KEY_ONLY_DIRECT = booleanPreferencesKey("only-direct")
val KEY_SHOW_IGNORED = booleanPreferencesKey("show-ignored")
val KEY_EXCLUDE_MQTT = booleanPreferencesKey("exclude-mqtt")
val KEY_MAX_DISTANCE_KM = floatPreferencesKey("max-distance-km")
val KEY_BLE_AUTO_SCAN = booleanPreferencesKey("ble-auto-scan")
val KEY_NETWORK_AUTO_SCAN = booleanPreferencesKey("network-auto-scan")
val KEY_SHOW_BLE_TRANSPORT = booleanPreferencesKey("show-ble-transport")
@@ -112,6 +112,10 @@ interface UiPrefs {
fun setExcludeMqtt(value: Boolean)
val maxDistanceKm: StateFlow<Float?>
fun setMaxDistanceKm(value: Float?)
val hasShownNotPairedWarning: StateFlow<Boolean>
fun setHasShownNotPairedWarning(shown: Boolean)
@@ -779,6 +779,10 @@
<string name="node_filter_exclude_mqtt">Exclude MQTT</string>
<string name="node_filter_ignored">You are viewing ignored nodes,\nPress to return to the node list.</string>
<string name="node_filter_include_unknown">Include unknown</string>
<string name="node_filter_max_distance">Max distance</string>
<string name="node_filter_max_distance_value">Max distance: %1$s</string>
<string name="node_filter_distance_km">%1$s km</string>
<string name="node_filter_distance_unlimited">Unlimited</string>
<string name="node_filter_only_direct">Only show direct nodes</string>
<string name="node_filter_only_online">Hide offline nodes</string>
<string name="node_filter_placeholder">Filter</string>
@@ -132,6 +132,12 @@ class FakeUiPrefs : UiPrefs {
excludeMqtt.value = value
}
override val maxDistanceKm = MutableStateFlow<Float?>(null)
override fun setMaxDistanceKm(value: Float?) {
maxDistanceKm.value = value
}
override val hasShownNotPairedWarning = MutableStateFlow(false)
override fun setHasShownNotPairedWarning(shown: Boolean) {
@@ -18,6 +18,7 @@ package org.meshtastic.feature.node.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -26,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -39,9 +41,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -61,8 +65,12 @@ import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.desc_node_filter_clear
import org.meshtastic.core.resources.node_filter_exclude_infrastructure
import org.meshtastic.core.resources.node_filter_exclude_mqtt
import org.meshtastic.core.resources.node_filter_distance_km
import org.meshtastic.core.resources.node_filter_distance_unlimited
import org.meshtastic.core.resources.node_filter_ignored
import org.meshtastic.core.resources.node_filter_include_unknown
import org.meshtastic.core.resources.node_filter_max_distance
import org.meshtastic.core.resources.node_filter_max_distance_value
import org.meshtastic.core.resources.node_filter_only_direct
import org.meshtastic.core.resources.node_filter_only_online
import org.meshtastic.core.resources.node_filter_placeholder
@@ -74,6 +82,8 @@ import org.meshtastic.core.ui.icon.Close
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Search
import org.meshtastic.core.ui.icon.Sort
import kotlin.math.abs
import kotlin.math.roundToInt
@Suppress("LongParameterList")
@Composable
@@ -96,6 +106,8 @@ fun NodeFilterTextField(
ignoredNodeCount: Int,
excludeMqtt: Boolean,
onToggleExcludeMqtt: () -> Unit,
maxDistanceKm: Float?,
onMaxDistanceKmChange: (Float?) -> Unit,
) {
Column(modifier = modifier.background(MaterialTheme.colorScheme.background)) {
Row {
@@ -120,6 +132,8 @@ fun NodeFilterTextField(
ignoredNodeCount = ignoredNodeCount,
excludeMqtt = excludeMqtt,
onToggleExcludeMqtt = onToggleExcludeMqtt,
maxDistanceKm = maxDistanceKm,
onMaxDistanceKmChange = onMaxDistanceKmChange,
),
)
}
@@ -157,6 +171,8 @@ data class NodeFilterToggles(
val ignoredNodeCount: Int,
val excludeMqtt: Boolean,
val onToggleExcludeMqtt: () -> Unit,
val maxDistanceKm: Float?,
val onMaxDistanceKmChange: (Float?) -> Unit,
)
@Composable
@@ -288,9 +304,70 @@ private fun NodeSortButton(
checked = toggles.excludeMqtt,
onClick = toggles.onToggleExcludeMqtt,
)
HorizontalDivider(modifier = Modifier.padding(MenuDefaults.DropdownMenuItemContentPadding))
DistanceFilterDropdownSection(
maxDistanceKm = toggles.maxDistanceKm,
onMaxDistanceKmChange = toggles.onMaxDistanceKmChange,
)
}
}
@Composable
private fun DistanceFilterDropdownSection(maxDistanceKm: Float?, onMaxDistanceKmChange: (Float?) -> Unit) {
val distanceOptions = remember { listOf<Float?>(null, 1f, 5f, 10f, 50f) }
val selectedIndex =
remember(maxDistanceKm) {
distanceOptions.indexOf(maxDistanceKm).takeIf { it >= 0 }
?: distanceOptions.indices
.filter { distanceOptions[it] != null }
.minByOrNull { index -> abs(distanceOptions[index]!! - (maxDistanceKm ?: 0f)) }
?: 0
}
var sliderPosition by remember(selectedIndex) { mutableFloatStateOf(selectedIndex.toFloat()) }
val distanceLabel =
maxDistanceKm?.let { stringResource(Res.string.node_filter_distance_km, formatDistanceKm(it)) }
?: stringResource(Res.string.node_filter_distance_unlimited)
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).widthIn(min = 240.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = stringResource(Res.string.node_filter_max_distance), style = MaterialTheme.typography.titleSmall)
Text(
text = stringResource(Res.string.node_filter_max_distance_value, distanceLabel),
style = MaterialTheme.typography.labelLarge,
)
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
onValueChangeFinished = {
val newIndex = sliderPosition.roundToInt().coerceIn(0, distanceOptions.lastIndex)
onMaxDistanceKmChange(distanceOptions[newIndex])
},
valueRange = 0f..distanceOptions.lastIndex.toFloat(),
steps = distanceOptions.size - 2,
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
distanceOptions.forEach { option ->
Text(
text =
option?.let { stringResource(Res.string.node_filter_distance_km, formatDistanceKm(it)) }
?: stringResource(Res.string.node_filter_distance_unlimited),
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
}
private fun formatDistanceKm(value: Float): String =
value.toInt().takeIf { it.toFloat() == value }?.toString() ?: value.toString()
@Composable
private fun DropdownMenuTitle(text: String) {
Text(
@@ -17,8 +17,10 @@
package org.meshtastic.feature.node.domain.usecase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.distanceKm
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.repository.NodeRepository
@@ -29,15 +31,19 @@ import org.meshtastic.proto.Config
@Single
open class GetFilteredNodesUseCase constructor(private val nodeRepository: NodeRepository) {
@Suppress("CyclomaticComplexMethod", "LongMethod")
open operator fun invoke(filter: NodeFilterState, sort: NodeSortOption): Flow<List<Node>> = nodeRepository
.getNodes(
sort = sort,
filter = filter.filterText,
includeUnknown = filter.includeUnknown,
onlyOnline = filter.onlyOnline,
onlyDirect = filter.onlyDirect,
)
.map { list ->
open operator fun invoke(filter: NodeFilterState, sort: NodeSortOption): Flow<List<Node>> =
combine(
nodeRepository.getNodes(
sort = sort,
filter = filter.filterText,
includeUnknown = filter.includeUnknown,
onlyOnline = filter.onlyOnline,
onlyDirect = filter.onlyDirect,
),
nodeRepository.ourNodeInfo,
) { list, ourNode ->
list to ourNode
}.map { (list, ourNode) ->
list
.filter { node -> node.isIgnored == filter.showIgnored }
.filter { node ->
@@ -58,5 +64,21 @@ open class GetFilteredNodesUseCase constructor(private val nodeRepository: NodeR
}
}
.filter { node -> if (filter.excludeMqtt) !node.viaMqtt else true }
.filter { node ->
val maxDistanceKm = filter.maxDistanceKm
val ourDistanceAnchor = ourNode?.takeIf { it.validPosition != null }
val nodePosition = node.validPosition
if (maxDistanceKm == null || ourDistanceAnchor == null || nodePosition == null) {
true
} else {
distanceKm(
latitudeA = ourDistanceAnchor.latitude,
longitudeA = ourDistanceAnchor.longitude,
latitudeB = node.latitude,
longitudeB = node.longitude,
) <= maxDistanceKm
}
}
}
}
@@ -29,6 +29,7 @@ open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) {
open val onlyDirect = uiPrefs.onlyDirect
open val showIgnored = uiPrefs.showIgnored
open val excludeMqtt = uiPrefs.excludeMqtt
open val maxDistanceKm = uiPrefs.maxDistanceKm
open val nodeSortOption =
uiPrefs.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
@@ -60,4 +61,8 @@ open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) {
open fun toggleExcludeMqtt() {
uiPrefs.setExcludeMqtt(!excludeMqtt.value)
}
open fun setMaxDistanceKm(value: Float?) {
uiPrefs.setMaxDistanceKm(value)
}
}
@@ -183,6 +183,8 @@ fun NodeListScreen(
ignoredNodeCount = ignoredNodeCount,
excludeMqtt = state.filter.excludeMqtt,
onToggleExcludeMqtt = { viewModel.nodeFilterPreferences.toggleExcludeMqtt() },
maxDistanceKm = state.filter.maxDistanceKm,
onMaxDistanceKmChange = viewModel.nodeFilterPreferences::setMaxDistanceKm,
)
}
@@ -94,10 +94,11 @@ class NodeListViewModel(
}
private val nodeFilter: Flow<NodeFilterState> =
combine(_nodeFilterText, filterToggles, nodeFilterPreferences.excludeMqtt) {
combine(_nodeFilterText, filterToggles, nodeFilterPreferences.excludeMqtt, nodeFilterPreferences.maxDistanceKm) {
filterText,
filterToggles,
excludeMqtt,
maxDistanceKm,
->
NodeFilterState(
filterText = filterText,
@@ -107,6 +108,7 @@ class NodeListViewModel(
onlyDirect = filterToggles.onlyDirect,
showIgnored = filterToggles.showIgnored,
excludeMqtt = excludeMqtt,
maxDistanceKm = maxDistanceKm,
)
}
val nodesUiState: StateFlow<NodesUiState> =
@@ -176,10 +178,11 @@ data class NodeFilterState(
val onlyDirect: Boolean = false,
val showIgnored: Boolean = false,
val excludeMqtt: Boolean = false,
val maxDistanceKm: Float? = null,
) {
/** True if any user-applied filter is narrowing the visible node set. */
val isActive: Boolean
get() = filterText.isNotEmpty() || excludeInfrastructure || onlyOnline || onlyDirect || excludeMqtt
get() = filterText.isNotEmpty() || excludeInfrastructure || onlyOnline || onlyDirect || excludeMqtt || maxDistanceKm != null
}
data class NodeFilterToggles(
@@ -20,6 +20,7 @@ import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
@@ -28,6 +29,7 @@ import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.feature.node.list.NodeFilterState
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
import kotlin.test.Test
@@ -36,11 +38,14 @@ import kotlin.test.assertEquals
class GetFilteredNodesUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var ourNodeInfo: MutableStateFlow<Node?>
private lateinit var useCase: GetFilteredNodesUseCase
@BeforeTest
fun setUp() {
nodeRepository = mock()
ourNodeInfo = MutableStateFlow(null)
every { nodeRepository.ourNodeInfo } returns ourNodeInfo
useCase = GetFilteredNodesUseCase(nodeRepository)
}
@@ -50,9 +55,17 @@ class GetFilteredNodesUseCaseTest {
ignored: Boolean = false,
name: String = "Node$num",
viaMqtt: Boolean = false,
latitudeI: Int = 0,
longitudeI: Int = 0,
): Node {
val user = User(id = "!$num", long_name = name, short_name = "N$num", role = role)
return Node(num = num, user = user, isIgnored = ignored, viaMqtt = viaMqtt)
return Node(
num = num,
user = user,
position = Position(latitude_i = latitudeI, longitude_i = longitudeI),
isIgnored = ignored,
viaMqtt = viaMqtt,
)
}
@Test
@@ -152,4 +165,21 @@ class GetFilteredNodesUseCaseTest {
// Assert
assertEquals(2, result.size)
}
@Test
fun `invoke filters out nodes beyond max distance and keeps nodes without position`() = runTest {
val ourNode = createNode(0, latitudeI = 10000000, longitudeI = 10000000)
val nearbyNode = createNode(1, latitudeI = 10000000, longitudeI = 10100000)
val farNode = createNode(2, latitudeI = 10000000, longitudeI = 12000000)
val noPositionNode = createNode(3)
val filter = NodeFilterState(maxDistanceKm = 5f)
ourNodeInfo.value = ourNode
every { nodeRepository.getNodes(any(), any(), any(), any(), any()) } returns
flowOf(listOf(nearbyNode, farNode, noPositionNode))
val result = useCase(filter, NodeSortOption.LAST_HEARD).first()
assertEquals(listOf(1, 3), result.map(Node::num))
}
}
@@ -70,6 +70,7 @@ class NodeListViewModelTest {
every { nodeFilterPreferences.onlyDirect } returns MutableStateFlow(false)
every { nodeFilterPreferences.showIgnored } returns MutableStateFlow(false)
every { nodeFilterPreferences.excludeMqtt } returns MutableStateFlow(false)
every { nodeFilterPreferences.maxDistanceKm } returns MutableStateFlow(null)
every { getFilteredNodesUseCase(any(), any()) } returns MutableStateFlow(emptyList())