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" }