fix(node): list and detail usability fixes (#4336)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-01-27 12:33:46 -06:00
committed by GitHub
parent 3f45687351
commit dff3e60b8c
12 changed files with 284 additions and 306 deletions

View File

@@ -94,8 +94,8 @@ import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.icon.Close
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.HardwareModel
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.QrCode2
import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
import org.meshtastic.core.ui.icon.VolumeUpTwoTone
@@ -231,7 +231,7 @@ fun ContactsScreen(
),
onClick = onNavigateToShare,
) {
Icon(MeshtasticIcons.HardwareModel, contentDescription = stringResource(Res.string.share_contact))
Icon(MeshtasticIcons.QrCode2, contentDescription = stringResource(Res.string.share_contact))
}
},
) { paddingValues ->

View File

@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@@ -14,10 +14,10 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import android.content.ClipData
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
@@ -137,8 +137,10 @@ fun BasicListItem(
) {
ListItem(
modifier =
if (onLongClick != null || onClick != null) {
modifier.combinedClickable(onLongClick = onLongClick, onClick = onClick ?: {})
if (onLongClick != null) {
modifier.combinedClickable(enabled = enabled, onLongClick = onLongClick, onClick = onClick ?: {})
} else if (onClick != null) {
modifier.clickable(enabled = enabled, onClick = onClick)
} else {
modifier
},

View File

@@ -30,6 +30,7 @@ import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Folder
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.QrCode2
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Save
import androidx.compose.material.icons.rounded.Search
@@ -79,3 +80,6 @@ val MeshtasticIcons.SelectAll: ImageVector
get() = Icons.Rounded.SelectAll
val MeshtasticIcons.ThumbUp: ImageVector
get() = Icons.Rounded.ThumbUp
val MeshtasticIcons.QrCode2: ImageVector
get() = Icons.Rounded.QrCode2

View File

@@ -48,7 +48,6 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.navigation.common)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.molecule.runtime)
implementation(libs.kermit)
implementation(libs.coil)
implementation(libs.markdown.renderer.android)

View File

@@ -107,13 +107,16 @@ fun DeviceActions(
onAction = onAction,
onFavoriteClick = { displayedDialog = DialogType.FAVORITE },
)
SectionDivider(Modifier.padding(vertical = 8.dp))
ManagementActions(
node = node,
onIgnoreClick = { displayedDialog = DialogType.IGNORE },
onMuteClick = { displayedDialog = DialogType.MUTE },
onRemoveClick = { displayedDialog = DialogType.REMOVE },
)
if (!isLocal) {
SectionDivider(Modifier.padding(vertical = 8.dp))
ManagementActions(
node = node,
onIgnoreClick = { displayedDialog = DialogType.IGNORE },
onMuteClick = { displayedDialog = DialogType.MUTE },
onRemoveClick = { displayedDialog = DialogType.REMOVE },
)
}
}
TelemetricActionsSection(
@@ -123,6 +126,7 @@ fun DeviceActions(
lastRequestNeighborsTime = lastRequestNeighborsTime,
metricsState = metricsState,
onAction = onAction,
isLocal = isLocal,
)
}
}
@@ -168,12 +172,14 @@ private fun PrimaryActionsRow(
}
}
IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) {
Icon(
imageVector = if (node.isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = stringResource(Res.string.favorite),
tint = if (node.isFavorite) Color.Yellow else LocalContentColor.current,
)
if (!isLocal) {
IconToggleButton(checked = node.isFavorite, onCheckedChange = { onFavoriteClick() }) {
Icon(
imageVector = if (node.isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
contentDescription = stringResource(Res.string.favorite),
tint = if (node.isFavorite) Color.Yellow else LocalContentColor.current,
)
}
}
}
}

View File

@@ -140,8 +140,10 @@ private fun MainNodeDetails(node: Node) {
HearsAndHopsRow(node)
SectionDivider()
UserAndUptimeRow(node)
SectionDivider()
SignalRow(node)
if (node.hopsAway == 0) {
SectionDivider()
SignalRow(node)
}
if (node.viaMqtt || node.manuallyVerified) {
SectionDivider()
MqttAndVerificationRow(node)

View File

@@ -75,6 +75,7 @@ fun PositionSection(
) {
val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(metricsState.displayUnits)
val hasValidPosition = node.latitude != 0.0 || node.longitude != 0.0
val isLocal = metricsState.isLocal
SectionCard(title = Res.string.position, modifier = modifier) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
@@ -84,7 +85,9 @@ fun PositionSection(
Spacer(Modifier.height(8.dp))
}
PositionActionButtons(node, hasValidPosition, metricsState.displayUnits, onAction)
if (!isLocal) {
PositionActionButtons(node, hasValidPosition, metricsState.displayUnits, onAction)
}
if (availableLogs.contains(LogsType.NODE_MAP) || availableLogs.contains(LogsType.POSITIONS)) {
Spacer(Modifier.height(12.dp))

View File

@@ -89,12 +89,15 @@ internal fun TelemetricActionsSection(
lastRequestNeighborsTime: Long?,
metricsState: MetricsState,
onAction: (NodeDetailAction) -> Unit,
isLocal: Boolean = false,
) {
val features = rememberTelemetricFeatures(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState)
val features = rememberTelemetricFeatures(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState, isLocal)
SectionCard(title = Res.string.telemetry) {
features
.filter { it.isVisible(node) }
.filter { feature ->
feature.isVisible(node) || (feature.logsType != null && availableLogs.contains(feature.logsType))
}
.forEachIndexed { index, feature ->
if (index > 0) {
SectionDivider()
@@ -116,12 +119,14 @@ private fun rememberTelemetricFeatures(
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
metricsState: MetricsState,
): List<TelemetricFeature> = remember(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState) {
isLocal: Boolean,
): List<TelemetricFeature> = remember(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState, isLocal) {
listOf(
TelemetricFeature(
titleRes = Res.string.userinfo,
icon = MeshtasticIcons.Person,
requestAction = { NodeMenuAction.RequestUserInfo(it) },
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.TRACEROUTE.titleRes,
@@ -129,6 +134,7 @@ private fun rememberTelemetricFeatures(
requestAction = { NodeMenuAction.TraceRoute(it) },
logsType = LogsType.TRACEROUTE,
cooldownTimestamp = lastTracerouteTime,
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = Res.string.neighbor_info,
@@ -138,6 +144,13 @@ private fun rememberTelemetricFeatures(
cooldownTimestamp = lastRequestNeighborsTime,
cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS,
),
TelemetricFeature(
titleRes = LogsType.SIGNAL.titleRes,
icon = LogsType.SIGNAL.icon,
requestAction = null,
logsType = LogsType.SIGNAL,
isVisible = { it.hopsAway == 0 && !isLocal },
),
TelemetricFeature(
titleRes = LogsType.DEVICE.titleRes,
icon = LogsType.DEVICE.icon,

View File

@@ -1,233 +0,0 @@
/*
* Copyright (c) 2025-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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import com.meshtastic.core.strings.getString
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.fallback_node_name
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.metrics.safeNumber
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.ClientOnlyProtos.DeviceProfile
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Portnums.PortNum
private const val DEFAULT_ID_SUFFIX_LENGTH = 4
@Composable
@Suppress("LongMethod", "FunctionName")
fun NodeDetailPresenter(
nodeId: Int?,
nodeRepository: NodeRepository,
meshLogRepository: MeshLogRepository,
radioConfigRepository: RadioConfigRepository,
deviceHardwareRepository: DeviceHardwareRepository,
firmwareReleaseRepository: FirmwareReleaseRepository,
nodeRequestActions: NodeRequestActions,
): NodeDetailUiState {
if (nodeId == null) return NodeDetailUiState()
val ourNode by nodeRepository.ourNodeInfo.collectAsState(null)
val ourNodeNum by remember { nodeRepository.nodeDBbyNum.map { it.keys.firstOrNull() } }.collectAsState(null)
val specificNode by remember(nodeId) { nodeRepository.nodeDBbyNum.map { it[nodeId] } }.collectAsState(null)
val myInfo by nodeRepository.myNodeInfo.collectAsState(null)
val profile by radioConfigRepository.deviceProfileFlow.collectAsState(DeviceProfile.getDefaultInstance())
val telemetry by remember(nodeId) { meshLogRepository.getTelemetryFrom(nodeId) }.collectAsState(emptyList())
val packets by remember(nodeId) { meshLogRepository.getMeshPacketsFrom(nodeId) }.collectAsState(emptyList())
val positionPackets by
remember(nodeId) { meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP_VALUE) }
.collectAsState(emptyList())
val paxLogs by
remember(nodeId) { meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP_VALUE) }
.collectAsState(emptyList())
val tracerouteRequests by
remember(nodeId) {
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE).map { logs ->
logs.filter { log ->
with(log.fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == nodeId }
}
}
}
.collectAsState(emptyList())
val tracerouteResults by
remember(nodeId) { meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP_VALUE) }
.collectAsState(emptyList())
val firmwareEdition by
remember { meshLogRepository.getMyNodeInfo().map { it?.firmwareEdition }.distinctUntilChanged() }
.collectAsState(null)
val stable by firmwareReleaseRepository.stableRelease.collectAsState(null)
val alpha by firmwareReleaseRepository.alphaRelease.collectAsState(null)
val lastTracerouteTime by nodeRequestActions.lastTracerouteTimes.collectAsState(emptyMap())
val lastRequestNeighborsTime by nodeRequestActions.lastRequestNeighborTimes.collectAsState(emptyMap())
val fallbackNameString = remember { getString(Res.string.fallback_node_name) }
val metricsState =
remember(
specificNode,
ourNodeNum,
myInfo,
profile,
telemetry,
packets,
positionPackets,
paxLogs,
tracerouteRequests,
tracerouteResults,
firmwareEdition,
stable,
alpha,
nodeId,
fallbackNameString, // Dependency for fallback creation
) {
val actualNode = specificNode ?: createFallbackNode(nodeId, fallbackNameString)
val pioEnv = if (nodeId == ourNodeNum) myInfo?.pioEnv else null
val moduleConfig = profile.moduleConfig
val displayUnits = profile.config.display.units
Triple(actualNode, pioEnv, moduleConfig to displayUnits)
}
val (actualNode, pioEnv, configPair) = metricsState
val (moduleConfig, displayUnits) = configPair
val deviceHardware by
produceState<DeviceHardware?>(initialValue = null, key1 = actualNode.user.hwModel, key2 = pioEnv) {
val hwModel = actualNode.user.hwModel.safeNumber()
value = deviceHardwareRepository.getDeviceHardwareByModel(hwModel, target = pioEnv).getOrNull()
}
val finalMetricsState =
remember(
metricsState, // triggers when actualNode or pioEnv or configs change
deviceHardware,
telemetry,
packets,
positionPackets,
paxLogs,
tracerouteRequests,
tracerouteResults,
firmwareEdition,
stable,
alpha,
) {
MetricsState(
node = actualNode,
isLocal = nodeId == ourNodeNum,
deviceHardware = deviceHardware,
reportedTarget = pioEnv,
isManaged = profile.config.security.isManaged,
isFahrenheit =
moduleConfig.telemetry.environmentDisplayFahrenheit ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
powerMetrics = telemetry.filter { it.hasPowerMetrics() },
hostMetrics = telemetry.filter { it.hasHostMetrics() },
signalMetrics = packets.filter { it.rxTime > 0 },
positionLogs = positionPackets.mapNotNull { it.toPosition() },
paxMetrics = paxLogs,
tracerouteRequests = tracerouteRequests,
tracerouteResults = tracerouteResults,
firmwareEdition = firmwareEdition,
latestStableFirmware = stable ?: FirmwareRelease(),
latestAlphaFirmware = alpha ?: FirmwareRelease(),
)
}
val environmentState =
remember(telemetry) {
EnvironmentMetricsState(
environmentMetrics =
telemetry.filter {
it.hasEnvironmentMetrics() &&
it.environmentMetrics.hasRelativeHumidity() &&
it.environmentMetrics.hasTemperature() &&
!it.environmentMetrics.temperature.isNaN()
},
)
}
val availableLogs =
remember(finalMetricsState, environmentState) { getAvailableLogs(finalMetricsState, environmentState) }
return NodeDetailUiState(
node = finalMetricsState.node,
ourNode = ourNode,
metricsState = finalMetricsState,
environmentState = environmentState,
availableLogs = availableLogs,
lastTracerouteTime = lastTracerouteTime[nodeId],
lastRequestNeighborsTime = lastRequestNeighborsTime[nodeId],
)
}
private fun createFallbackNode(nodeNum: Int, fallbackName: String): Node {
val userId = DataPacket.nodeNumToDefaultId(nodeNum)
val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH)
val longName = "$fallbackName $safeUserId"
val defaultUser =
MeshProtos.User.newBuilder()
.setId(userId)
.setLongName(longName)
.setShortName(safeUserId)
.setHwModel(MeshProtos.HardwareModel.UNSET)
.build()
return Node(num = nodeNum, user = defaultUser)
}
private fun getAvailableLogs(metricsState: MetricsState, envState: EnvironmentMetricsState): Set<LogsType> = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (envState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}

View File

@@ -25,15 +25,14 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
@@ -138,7 +137,7 @@ private fun NodeDetailScaffold(
compassViewModel?.uiState?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(CompassUiState()) }
val node = uiState.node
val scrollState = rememberScrollState()
val listState = rememberLazyListState()
Scaffold(
modifier = modifier,
@@ -158,7 +157,7 @@ private fun NodeDetailScaffold(
NodeDetailContent(
uiState = uiState,
viewModel = viewModel,
scrollState = scrollState,
listState = listState,
onAction = { action ->
when (action) {
is NodeDetailAction.ShareContact -> activeOverlay = NodeDetailOverlay.SharedContact
@@ -191,7 +190,7 @@ private fun NodeDetailScaffold(
private fun NodeDetailContent(
uiState: NodeDetailUiState,
viewModel: NodeDetailViewModel,
scrollState: ScrollState,
listState: LazyListState,
onAction: (NodeDetailAction) -> Unit,
onFirmwareSelect: (FirmwareRelease) -> Unit,
modifier: Modifier = Modifier,
@@ -207,7 +206,7 @@ private fun NodeDetailContent(
node = uiState.node,
ourNode = uiState.ourNode,
uiState = uiState,
scrollState = scrollState,
listState = listState,
onAction = onAction,
onFirmwareSelect = onFirmwareSelect,
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
@@ -282,31 +281,37 @@ private fun NodeDetailList(
node: Node,
ourNode: Node?,
uiState: NodeDetailUiState,
scrollState: ScrollState,
listState: LazyListState,
onAction: (NodeDetailAction) -> Unit,
onFirmwareSelect: (FirmwareRelease) -> Unit,
onSaveNotes: (Int, String) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxSize().verticalScroll(scrollState).padding(16.dp).focusable(),
LazyColumn(
modifier = modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
NodeDetailsSection(node)
DeviceActions(
node = node,
lastTracerouteTime = uiState.lastTracerouteTime,
lastRequestNeighborsTime = uiState.lastRequestNeighborsTime,
availableLogs = uiState.availableLogs,
onAction = onAction,
metricsState = uiState.metricsState,
isLocal = uiState.metricsState.isLocal,
)
PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction)
if (uiState.metricsState.deviceHardware != null) DeviceDetailsSection(uiState.metricsState)
NotesSection(node = node, onSaveNotes = onSaveNotes)
item { NodeDetailsSection(node) }
item {
DeviceActions(
node = node,
lastTracerouteTime = uiState.lastTracerouteTime,
lastRequestNeighborsTime = uiState.lastRequestNeighborsTime,
availableLogs = uiState.availableLogs,
onAction = onAction,
metricsState = uiState.metricsState,
isLocal = uiState.metricsState.isLocal,
)
}
item { PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction) }
if (uiState.metricsState.deviceHardware != null) {
item { DeviceDetailsSection(uiState.metricsState) }
}
item { NotesSection(node = node, onSaveNotes = onSaveNotes) }
if (!uiState.metricsState.isManaged) {
AdministrationSection(node, uiState.metricsState, onAction, onFirmwareSelect)
item { AdministrationSection(node, uiState.metricsState, onAction, onFirmwareSelect) }
}
}
}

View File

@@ -16,35 +16,51 @@
*/
package org.meshtastic.feature.node.detail
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import app.cash.molecule.RecompositionMode
import app.cash.molecule.launchMolecule
import com.meshtastic.core.strings.getString
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.fallback_node_name
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.metrics.safeNumber
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Portnums.PortNum
import org.meshtastic.proto.TelemetryProtos.Telemetry
import javax.inject.Inject
data class NodeDetailUiState(
@@ -57,6 +73,7 @@ data class NodeDetailUiState(
val lastRequestNeighborsTime: Long? = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class NodeDetailViewModel
@Inject
@@ -74,27 +91,154 @@ constructor(
) : ViewModel() {
private val nodeIdFromRoute: Int? =
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetail>().destNum }
.getOrElse { runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull() }
private val manualNodeId = MutableStateFlow<Int?>(null)
private val activeNodeId =
combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> fromRoute ?: manual }
combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> manual ?: fromRoute }
.distinctUntilChanged()
val uiState: StateFlow<NodeDetailUiState> =
viewModelScope.launchMolecule(mode = RecompositionMode.Immediate) {
val nodeId by activeNodeId.collectAsState(null)
private val ourNodeNumFlow = nodeRepository.nodeDBbyNum.map { it.keys.firstOrNull() }.distinctUntilChanged()
NodeDetailPresenter(
nodeId = nodeId,
nodeRepository = nodeRepository,
meshLogRepository = meshLogRepository,
radioConfigRepository = radioConfigRepository,
deviceHardwareRepository = deviceHardwareRepository,
firmwareReleaseRepository = firmwareReleaseRepository,
nodeRequestActions = nodeRequestActions,
)
}
val uiState: StateFlow<NodeDetailUiState> =
activeNodeId
.flatMapLatest { nodeId ->
if (nodeId == null) return@flatMapLatest flowOf(NodeDetailUiState())
val nodeFlow = nodeRepository.nodeDBbyNum.map { it[nodeId] }.distinctUntilChanged()
val telemetryFlow = meshLogRepository.getTelemetryFrom(nodeId).distinctUntilChanged()
val packetsFlow = meshLogRepository.getMeshPacketsFrom(nodeId).distinctUntilChanged()
val posPacketsFlow =
meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP_VALUE).distinctUntilChanged()
val paxLogsFlow =
meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP_VALUE).distinctUntilChanged()
val trReqsFlow =
meshLogRepository
.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE)
.map { logs ->
logs.filter { log ->
with(log.fromRadio.packet) {
hasDecoded() && decoded.wantResponse && from == 0 && to == nodeId
}
}
}
.distinctUntilChanged()
val trResFlow =
meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP_VALUE).distinctUntilChanged()
combine(
nodeRepository.ourNodeInfo,
ourNodeNumFlow,
nodeFlow,
nodeRepository.myNodeInfo,
radioConfigRepository.deviceProfileFlow,
telemetryFlow,
packetsFlow,
posPacketsFlow,
paxLogsFlow,
trReqsFlow,
trResFlow,
meshLogRepository.getMyNodeInfo().map { it?.firmwareEdition }.distinctUntilChanged(),
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
nodeRequestActions.lastTracerouteTimes,
nodeRequestActions.lastRequestNeighborTimes,
) { args ->
@Suppress("UNCHECKED_CAST")
NodeDetailUiStateData(
nodeId = nodeId,
actualNode = (args[2] as Node?) ?: createFallbackNode(nodeId),
ourNode = args[0] as Node?,
ourNodeNum = args[1] as Int?,
myInfo = (args[3] as MyNodeEntity?)?.toMyNodeInfo(),
profile = args[4] as org.meshtastic.proto.ClientOnlyProtos.DeviceProfile,
telemetry = args[5] as List<Telemetry>,
packets = args[6] as List<MeshProtos.MeshPacket>,
positionPackets = args[7] as List<MeshProtos.MeshPacket>,
paxLogs = args[8] as List<MeshLog>,
tracerouteRequests = args[9] as List<MeshLog>,
tracerouteResults = args[10] as List<MeshLog>,
firmwareEdition = args[11] as MeshProtos.FirmwareEdition?,
stable = args[12] as FirmwareRelease?,
alpha = args[13] as FirmwareRelease?,
lastTracerouteTime = (args[14] as Map<Int, Long>)[nodeId],
lastRequestNeighborsTime = (args[15] as Map<Int, Long>)[nodeId],
)
}
.flatMapLatest { data ->
val pioEnv = if (data.nodeId == data.ourNodeNum) data.myInfo?.pioEnv else null
val hwModel = data.actualNode.user.hwModel.safeNumber()
flow {
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel, pioEnv).getOrNull()
val moduleConfig = data.profile.moduleConfig
val displayUnits = data.profile.config.display.units
val metricsState =
MetricsState(
node = data.actualNode,
isLocal = data.nodeId == data.ourNodeNum,
deviceHardware = hw,
reportedTarget = pioEnv,
isManaged = data.profile.config.security.isManaged,
isFahrenheit =
moduleConfig.telemetry.environmentDisplayFahrenheit ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = data.telemetry.filter { it.hasDeviceMetrics() },
powerMetrics = data.telemetry.filter { it.hasPowerMetrics() },
hostMetrics = data.telemetry.filter { it.hasHostMetrics() },
signalMetrics = data.packets.filter { it.rxTime > 0 },
positionLogs = data.positionPackets.mapNotNull { it.toPosition() },
paxMetrics = data.paxLogs,
tracerouteRequests = data.tracerouteRequests,
tracerouteResults = data.tracerouteResults,
firmwareEdition = data.firmwareEdition,
latestStableFirmware = data.stable ?: FirmwareRelease(),
latestAlphaFirmware = data.alpha ?: FirmwareRelease(),
)
val environmentState =
EnvironmentMetricsState(
environmentMetrics =
data.telemetry.filter {
it.hasEnvironmentMetrics() &&
it.environmentMetrics.hasRelativeHumidity() &&
it.environmentMetrics.hasTemperature() &&
!it.environmentMetrics.temperature.isNaN()
},
)
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
emit(
NodeDetailUiState(
node = metricsState.node,
ourNode = data.ourNode,
metricsState = metricsState,
environmentState = environmentState,
availableLogs = availableLogs,
lastTracerouteTime = data.lastTracerouteTime,
lastRequestNeighborsTime = data.lastRequestNeighborsTime,
),
)
}
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NodeDetailUiState())
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
@@ -140,4 +284,39 @@ constructor(
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
return "${channel}${node.user.id}"
}
@Suppress("MagicNumber")
private fun createFallbackNode(nodeNum: Int): Node {
val userId = DataPacket.nodeNumToDefaultId(nodeNum)
val safeUserId = userId.padStart(4, '0').takeLast(4)
val longName = "${getString(Res.string.fallback_node_name)}_$safeUserId"
val defaultUser =
MeshProtos.User.newBuilder()
.setId(userId)
.setLongName(longName)
.setShortName(safeUserId)
.setHwModel(MeshProtos.HardwareModel.UNSET)
.build()
return Node(num = nodeNum, user = defaultUser)
}
}
private data class NodeDetailUiStateData(
val nodeId: Int,
val actualNode: Node,
val ourNode: Node?,
val ourNodeNum: Int?,
val myInfo: MyNodeInfo?,
val profile: org.meshtastic.proto.ClientOnlyProtos.DeviceProfile,
val telemetry: List<Telemetry>,
val packets: List<MeshProtos.MeshPacket>,
val positionPackets: List<MeshProtos.MeshPacket>,
val paxLogs: List<MeshLog>,
val tracerouteRequests: List<MeshLog>,
val tracerouteResults: List<MeshLog>,
val firmwareEdition: MeshProtos.FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val lastTracerouteTime: Long?,
val lastRequestNeighborsTime: Long?,
)

View File

@@ -41,7 +41,6 @@ dd-sdk-android = "3.5.0"
detekt = "1.23.8"
devtools-ksp = "2.3.4"
markdownRenderer = "0.39.1"
molecule = "2.2.0"
osmdroid-android = "6.1.20"
protobuf = "4.33.4"
@@ -114,7 +113,6 @@ location-services = { module = "com.google.android.gms:play-services-location",
maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "maps-compose" }
maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" }
maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" }
molecule-runtime = { module = "app.cash.molecule:molecule-runtime", version.ref = "molecule" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" }
protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" }
protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" }