feat: allow hiding offline and/or non-direct nodes from list and map (#2052)

Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
Łukasz Kosson 2025-06-09 19:44:53 +02:00 committed by GitHub
parent 6becdf137b
commit e781d6774b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 211 additions and 16 deletions

View file

@ -23,6 +23,7 @@ import com.geeksville.mesh.util.GPSFormat
import com.geeksville.mesh.util.bearing
import com.geeksville.mesh.util.latLongToMeter
import com.geeksville.mesh.util.anonymize
import com.geeksville.mesh.util.onlineTimeThreshold
import kotlinx.parcelize.Parcelize
//
@ -202,9 +203,7 @@ data class NodeInfo(
*/
val isOnline: Boolean
get() {
val now = System.currentTimeMillis() / 1000
val timeout = 15 * 60
return (now - lastHeard <= timeout)
return lastHeard > onlineTimeThreshold()
}
/// return the position if it is valid, else null

View file

@ -28,6 +28,7 @@ import com.geeksville.mesh.database.entity.MyNodeEntity
import com.geeksville.mesh.database.entity.NodeEntity
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.NodeSortOption
import com.geeksville.mesh.util.onlineTimeThreshold
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -96,10 +97,14 @@ class NodeRepository @Inject constructor(
sort: NodeSortOption = NodeSortOption.LAST_HEARD,
filter: String = "",
includeUnknown: Boolean = true,
onlyOnline: Boolean = false,
onlyDirect: Boolean = false,
) = nodeInfoDao.getNodes(
sort = sort.sqlValue,
filter = filter,
includeUnknown = includeUnknown,
hopsAwayMax = if (onlyDirect) 0 else -1,
lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1,
).mapLatest { list ->
list.map {
it.toModel()

View file

@ -70,6 +70,8 @@ interface NodeInfoDao {
AND (:filter = ''
OR (long_name LIKE '%' || :filter || '%'
OR short_name LIKE '%' || :filter || '%'))
AND (:lastHeardMin = -1 OR last_heard >= :lastHeardMin)
AND (:hopsAwayMax = -1 OR (hops_away <= :hopsAwayMax AND hops_away >= 0) OR num = (SELECT myNodeNum FROM my_node LIMIT 1))
ORDER BY CASE
WHEN num = (SELECT myNodeNum FROM my_node LIMIT 1) THEN 0
ELSE 1
@ -105,6 +107,8 @@ interface NodeInfoDao {
sort: String,
filter: String,
includeUnknown: Boolean,
hopsAwayMax: Int,
lastHeardMin: Int,
): Flow<List<NodeWithRelations>>
@Upsert

View file

@ -33,6 +33,7 @@ import com.geeksville.mesh.Position
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.util.onlineTimeThreshold
import com.google.protobuf.ByteString
data class NodeWithRelations(
@ -164,9 +165,7 @@ data class NodeEntity(
*/
val isOnline: Boolean
get() {
val now = System.currentTimeMillis() / 1000
val timeout = 2 * 60 * 60
return (now - lastHeard <= timeout)
return lastHeard > onlineTimeThreshold()
}
companion object {

View file

@ -68,6 +68,7 @@ import com.geeksville.mesh.util.getShortDate
import com.geeksville.mesh.util.positionToMeter
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -152,6 +153,8 @@ data class NodesUiState(
val sort: NodeSortOption = NodeSortOption.LAST_HEARD,
val filter: String = "",
val includeUnknown: Boolean = false,
val onlyOnline: Boolean = false,
val onlyDirect: Boolean = false,
val gpsFormat: Int = 0,
val distanceUnits: Int = 0,
val tempInFahrenheit: Boolean = false,
@ -270,6 +273,8 @@ class UIViewModel @Inject constructor(
private val nodeSortOption = MutableStateFlow(NodeSortOption.VIA_FAVORITE)
private val includeUnknown = MutableStateFlow(preferences.getBoolean("include-unknown", false))
private val showDetails = MutableStateFlow(preferences.getBoolean("show-details", false))
private val onlyOnline = MutableStateFlow(preferences.getBoolean("only-online", false))
private val onlyDirect = MutableStateFlow(preferences.getBoolean("only-direct", false))
fun setSortOption(sort: NodeSortOption) {
nodeSortOption.value = sort
@ -285,17 +290,44 @@ class UIViewModel @Inject constructor(
preferences.edit { putBoolean("include-unknown", includeUnknown.value) }
}
val nodesUiState: StateFlow<NodesUiState> = combine(
fun toggleOnlyOnline() {
onlyOnline.value = !onlyOnline.value
preferences.edit { putBoolean("only-online", onlyOnline.value) }
}
fun toggleOnlyDirect() {
onlyDirect.value = !onlyDirect.value
preferences.edit { putBoolean("only-direct", onlyDirect.value) }
}
data class NodeFilterState(
val filterText: String,
val includeUnknown: Boolean,
val onlyOnline: Boolean,
val onlyDirect: Boolean,
)
val nodeFilterStateFlow: Flow<NodeFilterState> = combine(
nodeFilterText,
nodeSortOption,
includeUnknown,
onlyOnline,
onlyDirect,
) { filterText, includeUnknown, onlyOnline, onlyDirect ->
NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect)
}
val nodesUiState: StateFlow<NodesUiState> = combine(
nodeFilterStateFlow,
nodeSortOption,
showDetails,
radioConfigRepository.deviceProfileFlow,
) { filter, sort, includeUnknown, showDetails, profile ->
) { filterFlow, sort, showDetails, profile ->
NodesUiState(
sort = sort,
filter = filter,
includeUnknown = includeUnknown,
filter = filterFlow.filterText,
includeUnknown = filterFlow.includeUnknown,
onlyOnline = filterFlow.onlyOnline,
onlyDirect = filterFlow.onlyDirect,
gpsFormat = profile.config.display.gpsFormat.number,
distanceUnits = profile.config.display.units.number,
tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
@ -314,7 +346,7 @@ class UIViewModel @Inject constructor(
)
val nodeList: StateFlow<List<Node>> = nodesUiState.flatMapLatest { state ->
nodeDB.getNodes(state.sort, state.filter, state.includeUnknown)
nodeDB.getNodes(state.sort, state.filter, state.includeUnknown, state.onlyOnline, state.onlyDirect)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),

View file

@ -104,6 +104,10 @@ fun NodeScreen(
onSortSelect = model::setSortOption,
includeUnknown = state.includeUnknown,
onToggleIncludeUnknown = model::toggleIncludeUnknown,
onlyOnline = state.onlyOnline,
onToggleOnlyOnline = model::toggleOnlyOnline,
onlyDirect = state.onlyDirect,
onToggleOnlyDirect = model::toggleOnlyDirect,
showDetails = state.showDetails,
onToggleShowDetails = model::toggleShowDetails,
)

View file

@ -59,6 +59,7 @@ import com.geeksville.mesh.model.NodeSortOption
import com.geeksville.mesh.ui.common.preview.LargeFontPreview
import com.geeksville.mesh.ui.common.theme.AppTheme
@Suppress("LongParameterList")
@Composable
fun NodeFilterTextField(
modifier: Modifier = Modifier,
@ -68,6 +69,10 @@ fun NodeFilterTextField(
onSortSelect: (NodeSortOption) -> Unit,
includeUnknown: Boolean,
onToggleIncludeUnknown: () -> Unit,
onlyOnline: Boolean,
onToggleOnlyOnline: () -> Unit,
onlyDirect: Boolean,
onToggleOnlyDirect: () -> Unit,
showDetails: Boolean,
onToggleShowDetails: () -> Unit,
) {
@ -86,6 +91,10 @@ fun NodeFilterTextField(
onSortSelect = onSortSelect,
includeUnknown = includeUnknown,
onToggleIncludeUnknown = onToggleIncludeUnknown,
onlyOnline = onlyOnline,
onToggleOnlyOnline = onToggleOnlyOnline,
onlyDirect = onlyDirect,
onToggleOnlyDirect = onToggleOnlyDirect,
showDetails = showDetails,
onToggleShowDetails = onToggleShowDetails,
)
@ -152,6 +161,10 @@ private fun NodeSortButton(
onSortSelect: (NodeSortOption) -> Unit,
includeUnknown: Boolean,
onToggleIncludeUnknown: () -> Unit,
onlyOnline: Boolean,
onToggleOnlyOnline: () -> Unit,
onlyDirect: Boolean,
onToggleOnlyDirect: () -> Unit,
showDetails: Boolean,
onToggleShowDetails: () -> Unit,
modifier: Modifier = Modifier,
@ -207,6 +220,46 @@ private fun NodeSortButton(
}
}
)
DropdownMenuItem(
onClick = {
onToggleOnlyOnline()
expanded = false
},
text = {
Row {
AnimatedVisibility(visible = onlyOnline) {
Icon(
imageVector = Icons.Default.Done,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp),
)
}
Text(
text = stringResource(id = R.string.node_filter_only_online),
)
}
}
)
DropdownMenuItem(
onClick = {
onToggleOnlyDirect()
expanded = false
},
text = {
Row {
AnimatedVisibility(visible = onlyDirect) {
Icon(
imageVector = Icons.Default.Done,
contentDescription = null,
modifier = Modifier.padding(end = 4.dp),
)
}
Text(
text = stringResource(id = R.string.node_filter_only_direct),
)
}
}
)
HorizontalDivider()
DropdownMenuItem(
onClick = {
@ -243,6 +296,10 @@ private fun NodeFilterTextFieldPreview() {
onSortSelect = {},
includeUnknown = false,
onToggleIncludeUnknown = {},
onlyOnline = false,
onToggleOnlyOnline = {},
onlyDirect = false,
onToggleOnlyDirect = {},
showDetails = false,
onToggleShowDetails = {},
)

View file

@ -60,3 +60,6 @@ private fun formatUptime(seconds: Long): String {
"${secs}s".takeIf { secs > 0 },
).joinToString(" ")
}
@Suppress("MagicNumber")
fun onlineTimeThreshold() = (System.currentTimeMillis() / 1000 - 2 * 60 * 60).toInt()