feat(navigation): Implement adaptive list-detail for contacts and nodes (#3850)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-11-28 20:05:07 -06:00 committed by GitHub
parent d60e84fa4d
commit 78274c7923
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 630 additions and 222 deletions

View file

@ -61,7 +61,7 @@ fun MetricsSection(
TitledCard(title = stringResource(Res.string.logs), modifier = modifier) {
nonPositionLogs.forEach { type ->
ListItem(text = stringResource(type.titleRes), leadingIcon = type.icon) {
onAction(NodeDetailAction.Navigate(type.route))
onAction(NodeDetailAction.Navigate(type.routeFactory(node.num)))
}
}
}

View file

@ -61,6 +61,9 @@ import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig
private const val ACTIVE_ALPHA = 0.5f
private const val INACTIVE_ALPHA = 0.2f
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@ -74,6 +77,7 @@ fun NodeItem(
onLongClick: (() -> Unit)? = null,
currentTimeMillis: Long,
connectionState: ConnectionState,
isActive: Boolean = false,
) {
val isFavorite = remember(thatNode) { thatNode.isFavorite }
val isIgnored = thatNode.isIgnored
@ -91,7 +95,8 @@ fun NodeItem(
thatNode.colors.second
}
?.let {
val containerColor = Color(it).copy(alpha = 0.2f)
val alpha = if (isActive) ACTIVE_ALPHA else INACTIVE_ALPHA
val containerColor = Color(it).copy(alpha = alpha)
contentColor = contentColorFor(containerColor)
CardDefaults.cardColors().copy(containerColor = containerColor, contentColor = contentColor)
} ?: (CardDefaults.cardColors())

View file

@ -90,7 +90,7 @@ fun PositionSection(
InsetDivider()
ListItem(text = stringResource(LogsType.NODE_MAP.titleRes), leadingIcon = LogsType.NODE_MAP.icon) {
onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.route))
onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.routeFactory(node.num)))
}
}
@ -99,7 +99,7 @@ fun PositionSection(
InsetDivider()
ListItem(text = stringResource(LogsType.POSITIONS.titleRes), leadingIcon = LogsType.POSITIONS.icon) {
onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.route))
onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.routeFactory(node.num)))
}
}
}

View file

@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@ -48,7 +49,14 @@ fun NodeDetailScreen(
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
overrideNodeId: Int? = null,
) {
LaunchedEffect(overrideNodeId) {
if (overrideNodeId != null) {
viewModel.setNodeId(overrideNodeId)
}
}
val state by viewModel.state.collectAsStateWithLifecycle()
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle()

View file

@ -17,6 +17,8 @@
package org.meshtastic.feature.node.list
import kotlinx.coroutines.flow.map
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.UiPreferencesDataSource
import javax.inject.Inject
@ -27,6 +29,13 @@ class NodeFilterPreferences @Inject constructor(private val uiPreferencesDataSou
val onlyDirect = uiPreferencesDataSource.onlyDirect
val showIgnored = uiPreferencesDataSource.showIgnored
val nodeSortOption =
uiPreferencesDataSource.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
fun setNodeSort(option: NodeSortOption) {
uiPreferencesDataSource.setNodeSort(option.ordinal)
}
fun toggleIncludeUnknown() {
uiPreferencesDataSource.setIncludeUnknown(!includeUnknown.value)
}

View file

@ -91,6 +91,7 @@ fun NodeListScreen(
navigateToNodeDetails: (Int) -> Unit,
viewModel: NodeListViewModel = hiltViewModel(),
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeNodeId: Int? = null,
) {
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
@ -208,6 +209,8 @@ fun NodeListScreen(
null
}
val isActive = remember(activeNodeId, node.num) { activeNodeId == node.num }
NodeItem(
modifier = Modifier.animateItem(),
thisNode = ourNode,
@ -218,6 +221,7 @@ fun NodeListScreen(
onLongClick = longClick,
currentTimeMillis = currentTimeMillis,
connectionState = connectionState,
isActive = isActive,
)
val isThisNode = remember(node) { ourNode?.num == node.num }
if (!isThisNode) {

View file

@ -17,6 +17,7 @@
package org.meshtastic.feature.node.list
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@ -32,7 +33,6 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@ -44,10 +44,10 @@ import javax.inject.Inject
class NodeListViewModel
@Inject
constructor(
private val savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val uiPreferencesDataSource: UiPreferencesDataSource,
val nodeActions: NodeActions,
val nodeFilterPreferences: NodeFilterPreferences,
) : ViewModel() {
@ -63,10 +63,9 @@ constructor(
private val _sharedContactRequested: MutableStateFlow<AdminProtos.SharedContact?> = MutableStateFlow(null)
val sharedContactRequested = _sharedContactRequested.asStateFlow()
private val nodeSortOption =
uiPreferencesDataSource.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
private val _nodeFilterText = MutableStateFlow("")
private val _nodeFilterText = savedStateHandle.getStateFlow(KEY_FILTER_TEXT, "")
private val includeUnknown = nodeFilterPreferences.includeUnknown
private val excludeInfrastructure = nodeFilterPreferences.excludeInfrastructure
private val onlyOnline = nodeFilterPreferences.onlyOnline
@ -134,11 +133,11 @@ constructor(
var nodeFilterText: String
get() = _nodeFilterText.value
set(value) {
_nodeFilterText.value = value
savedStateHandle[KEY_FILTER_TEXT] = value
}
fun setSortOption(sort: NodeSortOption) {
uiPreferencesDataSource.setNodeSort(sort.ordinal)
nodeFilterPreferences.setNodeSort(sort)
}
fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) {
@ -150,6 +149,10 @@ constructor(
fun ignoreNode(node: Node) = viewModelScope.launch { nodeActions.ignoreNode(node) }
fun removeNode(nodeNum: Int) = viewModelScope.launch { nodeActions.removeNode(nodeNum) }
companion object {
private const val KEY_FILTER_TEXT = "filter_text"
}
}
data class NodesUiState(

View file

@ -24,16 +24,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -73,7 +72,7 @@ private const val DEFAULT_ID_SUFFIX_LENGTH = 4
private fun MeshPacket.hasValidSignal(): Boolean =
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)
@Suppress("LongParameterList")
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MetricsViewModel
@Inject
@ -82,13 +81,16 @@ constructor(
private val app: Application,
private val dispatchers: CoroutineDispatchers,
private val meshLogRepository: MeshLogRepository,
radioConfigRepository: RadioConfigRepository,
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
) : ViewModel() {
private val destNum = savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum
private var destNum: Int? =
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
private var jobs: Job? = null
private fun MeshLog.hasValidTraceroute(): Boolean =
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
@ -132,126 +134,157 @@ constructor(
val timeFrame: StateFlow<TimeFrame> = _timeFrame
init {
if (destNum != null) {
nodeRepository.nodeDBbyNum
.mapLatest { nodes -> nodes[destNum] to nodes.keys.firstOrNull() }
.distinctUntilChanged()
.onEach { (node, ourNode) ->
// Create a fallback node if not found in database (for hidden clients, etc.)
val actualNode = node ?: createFallbackNode(destNum)
val deviceHardware =
actualNode.user.hwModel.safeNumber().let {
deviceHardwareRepository.getDeviceHardwareByModel(it)
initializeFlows()
}
fun setNodeId(id: Int) {
if (destNum != id) {
destNum = id
initializeFlows()
}
}
@Suppress("LongMethod")
private fun initializeFlows() {
jobs?.cancel()
val currentDestNum = destNum
jobs =
viewModelScope.launch {
if (currentDestNum != null) {
launch {
nodeRepository.nodeDBbyNum
.mapLatest { nodes -> nodes[currentDestNum] to nodes.keys.firstOrNull() }
.distinctUntilChanged()
.collect { (node, ourNode) ->
// Create a fallback node if not found in database (for hidden clients, etc.)
val actualNode = node ?: createFallbackNode(currentDestNum)
val deviceHardware =
actualNode.user.hwModel.safeNumber().let {
deviceHardwareRepository.getDeviceHardwareByModel(it)
}
_state.update { state ->
state.copy(
node = actualNode,
isLocal = currentDestNum == ourNode,
deviceHardware = deviceHardware.getOrNull(),
)
}
}
}
launch {
radioConfigRepository.deviceProfileFlow.collect { profile ->
val moduleConfig = profile.moduleConfig
_state.update { state ->
state.copy(
isManaged = profile.config.security.isManaged,
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
displayUnits = profile.config.display.units,
)
}
}
_state.update { state ->
state.copy(
node = actualNode,
isLocal = destNum == ourNode,
deviceHardware = deviceHardware.getOrNull(),
)
}
}
.launchIn(viewModelScope)
radioConfigRepository.deviceProfileFlow
.onEach { profile ->
val moduleConfig = profile.moduleConfig
_state.update { state ->
state.copy(
isManaged = profile.config.security.isManaged,
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
displayUnits = profile.config.display.units,
)
launch {
meshLogRepository.getTelemetryFrom(currentDestNum).collect { telemetry ->
_state.update { state ->
state.copy(
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
powerMetrics = telemetry.filter { it.hasPowerMetrics() },
hostMetrics = telemetry.filter { it.hasHostMetrics() },
)
}
_environmentState.update { state ->
state.copy(
environmentMetrics =
telemetry.filter {
it.hasEnvironmentMetrics() &&
it.environmentMetrics.hasRelativeHumidity() &&
it.environmentMetrics.hasTemperature() &&
!it.environmentMetrics.temperature.isNaN()
},
)
}
}
}
}
.launchIn(viewModelScope)
meshLogRepository
.getTelemetryFrom(destNum)
.onEach { telemetry ->
_state.update { state ->
state.copy(
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
powerMetrics = telemetry.filter { it.hasPowerMetrics() },
hostMetrics = telemetry.filter { it.hasHostMetrics() },
)
launch {
meshLogRepository.getMeshPacketsFrom(currentDestNum).collect { meshPackets ->
_state.update { state ->
state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() })
}
}
}
_environmentState.update { state ->
state.copy(
environmentMetrics =
telemetry.filter {
it.hasEnvironmentMetrics() &&
it.environmentMetrics.hasRelativeHumidity() &&
it.environmentMetrics.hasTemperature() &&
!it.environmentMetrics.temperature.isNaN()
},
)
launch {
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
meshLogRepository.getLogsFrom(currentDestNum, PortNum.TRACEROUTE_APP_VALUE),
) { request, response ->
_state.update { state ->
state.copy(
tracerouteRequests = request.filter { it.hasValidTraceroute() },
tracerouteResults = response,
)
}
}
.collect {}
}
}
.launchIn(viewModelScope)
meshLogRepository
.getMeshPacketsFrom(destNum)
.onEach { meshPackets ->
_state.update { state -> state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) }
}
.launchIn(viewModelScope)
launch {
meshLogRepository.getMeshPacketsFrom(
currentDestNum,
PortNum.POSITION_APP_VALUE,
).collect { packets ->
val distinctPositions =
packets
.mapNotNull { it.toPosition() }
.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time ||
(old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
}
.toList()
_state.update { state -> state.copy(positionLogs = distinctPositions) }
}
}
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
meshLogRepository.getLogsFrom(destNum ?: 0, PortNum.TRACEROUTE_APP_VALUE),
) { request, response ->
_state.update { state ->
state.copy(
tracerouteRequests = request.filter { it.hasValidTraceroute() },
tracerouteResults = response,
)
launch {
meshLogRepository.getLogsFrom(
currentDestNum,
Portnums.PortNum.PAXCOUNTER_APP_VALUE,
).collect { logs ->
_state.update { state -> state.copy(paxMetrics = logs) }
}
}
launch {
firmwareReleaseRepository.stableRelease.filterNotNull().collect { latestStable ->
_state.update { state -> state.copy(latestStableFirmware = latestStable) }
}
}
launch {
firmwareReleaseRepository.alphaRelease.filterNotNull().collect { latestAlpha ->
_state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) }
}
}
launch {
meshLogRepository
.getMyNodeInfo()
.map { it?.firmwareEdition }
.distinctUntilChanged()
.collect { firmwareEdition ->
_state.update { state -> state.copy(firmwareEdition = firmwareEdition) }
}
}
Timber.d("MetricsViewModel created")
} else {
Timber.d("MetricsViewModel: destNum is null, skipping metrics flows initialization.")
}
}
.launchIn(viewModelScope)
meshLogRepository
.getMeshPacketsFrom(destNum, PortNum.POSITION_APP_VALUE)
.onEach { packets ->
val distinctPositions =
packets
.mapNotNull { it.toPosition() }
.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time ||
(old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
}
.toList()
_state.update { state -> state.copy(positionLogs = distinctPositions) }
}
.launchIn(viewModelScope)
meshLogRepository
.getLogsFrom(destNum, Portnums.PortNum.PAXCOUNTER_APP_VALUE)
.onEach { logs -> _state.update { state -> state.copy(paxMetrics = logs) } }
.launchIn(viewModelScope)
firmwareReleaseRepository.stableRelease
.filterNotNull()
.onEach { latestStable -> _state.update { state -> state.copy(latestStableFirmware = latestStable) } }
.launchIn(viewModelScope)
firmwareReleaseRepository.alphaRelease
.filterNotNull()
.onEach { latestAlpha -> _state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) } }
.launchIn(viewModelScope)
meshLogRepository
.getMyNodeInfo()
.map { it?.firmwareEdition }
.distinctUntilChanged()
.onEach { firmwareEdition -> _state.update { state -> state.copy(firmwareEdition = firmwareEdition) } }
.launchIn(viewModelScope)
Timber.d("MetricsViewModel created")
} else {
Timber.d("MetricsViewModel: destNum is null, skipping metrics flows initialization.")
}
}
override fun onCleared() {

View file

@ -42,14 +42,14 @@ import org.meshtastic.core.strings.power_metrics_log
import org.meshtastic.core.strings.sig_metrics_log
import org.meshtastic.core.strings.traceroute_log
enum class LogsType(val titleRes: StringResource, val icon: ImageVector, val route: Route) {
DEVICE(Res.string.device_metrics_log, Icons.Default.ChargingStation, NodeDetailRoutes.DeviceMetrics),
NODE_MAP(Res.string.node_map, Icons.Default.Map, NodeDetailRoutes.NodeMap),
POSITIONS(Res.string.position_log, Icons.Default.LocationOn, NodeDetailRoutes.PositionLog),
ENVIRONMENT(Res.string.env_metrics_log, Icons.Default.Thermostat, NodeDetailRoutes.EnvironmentMetrics),
SIGNAL(Res.string.sig_metrics_log, Icons.Default.SignalCellularAlt, NodeDetailRoutes.SignalMetrics),
POWER(Res.string.power_metrics_log, Icons.Default.Power, NodeDetailRoutes.PowerMetrics),
TRACEROUTE(Res.string.traceroute_log, Icons.Default.Route, NodeDetailRoutes.TracerouteLog),
HOST(Res.string.host_metrics_log, Icons.Default.Memory, NodeDetailRoutes.HostMetricsLog),
PAX(Res.string.pax_metrics_log, Icons.Default.People, NodeDetailRoutes.PaxMetrics),
enum class LogsType(val titleRes: StringResource, val icon: ImageVector, val routeFactory: (Int) -> Route) {
DEVICE(Res.string.device_metrics_log, Icons.Default.ChargingStation, { NodeDetailRoutes.DeviceMetrics(it) }),
NODE_MAP(Res.string.node_map, Icons.Default.Map, { NodeDetailRoutes.NodeMap(it) }),
POSITIONS(Res.string.position_log, Icons.Default.LocationOn, { NodeDetailRoutes.PositionLog(it) }),
ENVIRONMENT(Res.string.env_metrics_log, Icons.Default.Thermostat, { NodeDetailRoutes.EnvironmentMetrics(it) }),
SIGNAL(Res.string.sig_metrics_log, Icons.Default.SignalCellularAlt, { NodeDetailRoutes.SignalMetrics(it) }),
POWER(Res.string.power_metrics_log, Icons.Default.Power, { NodeDetailRoutes.PowerMetrics(it) }),
TRACEROUTE(Res.string.traceroute_log, Icons.Default.Route, { NodeDetailRoutes.TracerouteLog(it) }),
HOST(Res.string.host_metrics_log, Icons.Default.Memory, { NodeDetailRoutes.HostMetricsLog(it) }),
PAX(Res.string.pax_metrics_log, Icons.Default.People, { NodeDetailRoutes.PaxMetrics(it) }),
}