mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-02 06:24:16 +02:00
Add distance-based node filter
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
+77
@@ -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(
|
||||
|
||||
+31
-9
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -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(
|
||||
|
||||
+31
-1
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user