diff --git a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt
index 6c3f6295e..05dd025a5 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/contact/Contacts.kt
@@ -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 ->
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ListItem.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ListItem.kt
index b2d1d2f80..15fb16b54 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ListItem.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/component/ListItem.kt
@@ -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 .
*/
-
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
},
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Actions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Actions.kt
index db959e523..c58056d76 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Actions.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/icon/Actions.kt
@@ -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
diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts
index 5deaa65da..055df4f4a 100644
--- a/feature/node/build.gradle.kts
+++ b/feature/node/build.gradle.kts
@@ -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)
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt
index e98b41176..784e75d09 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt
@@ -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,
+ )
+ }
}
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
index 47c0ad333..5e5f072c3 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt
@@ -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)
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
index 0925b8eb9..1a6f26c6a 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/PositionSection.kt
@@ -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))
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
index 9640a9a43..6a2ce3c80 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt
@@ -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 = remember(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState) {
+ isLocal: Boolean,
+): List = 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,
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailPresenter.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailPresenter.kt
deleted file mode 100644
index 8914745cf..000000000
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailPresenter.kt
+++ /dev/null
@@ -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 .
- */
-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(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 = 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)
-}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
index 4eb65761d..54c500aa1 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreen.kt
@@ -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) }
}
}
}
diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
index dfa25928f..c392c0587 100644
--- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
+++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt
@@ -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().destNum }.getOrNull()
+ runCatching { savedStateHandle.toRoute().destNum }
+ .getOrElse { runCatching { savedStateHandle.toRoute().destNum }.getOrNull() }
private val manualNodeId = MutableStateFlow(null)
private val activeNodeId =
- combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> fromRoute ?: manual }
+ combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> manual ?: fromRoute }
.distinctUntilChanged()
- val uiState: StateFlow =
- 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 =
+ 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,
+ packets = args[6] as List,
+ positionPackets = args[7] as List,
+ paxLogs = args[8] as List,
+ tracerouteRequests = args[9] as List,
+ tracerouteResults = args[10] as List,
+ firmwareEdition = args[11] as MeshProtos.FirmwareEdition?,
+ stable = args[12] as FirmwareRelease?,
+ alpha = args[13] as FirmwareRelease?,
+ lastTracerouteTime = (args[14] as Map)[nodeId],
+ lastRequestNeighborsTime = (args[15] as Map)[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 = 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,
+ val packets: List,
+ val positionPackets: List,
+ val paxLogs: List,
+ val tracerouteRequests: List,
+ val tracerouteResults: List,
+ val firmwareEdition: MeshProtos.FirmwareEdition?,
+ val stable: FirmwareRelease?,
+ val alpha: FirmwareRelease?,
+ val lastTracerouteTime: Long?,
+ val lastRequestNeighborsTime: Long?,
+)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 323273885..51b241fcb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -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" }