mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-04 22:23:47 -04:00
fix(node): list and detail usability fixes (#4336)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user