refactor(metrics/map): DRY up charts, decompose MapView monoliths, add test coverage (#5049)

This commit is contained in:
James Rich 2026-04-10 15:54:09 -05:00 committed by GitHub
parent 56332f4d77
commit 520fa717a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 3464 additions and 2169 deletions

View file

@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.flowOf
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.traceroute
@ -51,9 +52,8 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Route
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalTracerouteMapOverlayInsetsProvider
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.core.ui.util.LocalTracerouteMapProvider
import org.meshtastic.proto.Position
@Composable
@ -117,16 +117,14 @@ private fun TracerouteMapScaffold(
},
) { paddingValues ->
Box(modifier = modifier.fillMaxSize().padding(paddingValues)) {
LocalMapViewProvider.current?.MapView(
modifier = Modifier,
viewModel = Unit,
navigateToNodeDetails = {},
tracerouteOverlay = overlay,
tracerouteNodePositions = snapshotPositions,
onTracerouteMappableCountChanged = { shown: Int, total: Int ->
LocalTracerouteMapProvider.current(
overlay,
snapshotPositions,
{ shown: Int, total: Int ->
tracerouteNodesShown = shown
tracerouteNodesTotal = total
},
Modifier.fillMaxSize(),
)
Column(
modifier = Modifier.align(insets.overlayAlignment).padding(insets.overlayPadding),

View file

@ -58,18 +58,20 @@ import org.meshtastic.core.ui.icon.VolumeMute
import org.meshtastic.core.ui.icon.VolumeOff
import org.meshtastic.core.ui.icon.VolumeUp
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.proto.Config
@Composable
fun DeviceActions(
node: Node,
ourNode: Node?,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
availableLogs: Set<LogsType>,
onAction: (NodeDetailAction) -> Unit,
metricsState: MetricsState,
displayUnits: Config.DisplayConfig.DisplayUnits,
isFahrenheit: Boolean,
modifier: Modifier = Modifier,
isLocal: Boolean = false,
) {
@ -85,10 +87,12 @@ fun DeviceActions(
TelemetricActionsSection(
node = node,
ourNode = ourNode,
availableLogs = availableLogs,
lastTracerouteTime = lastTracerouteTime,
lastRequestNeighborsTime = lastRequestNeighborsTime,
metricsState = metricsState,
displayUnits = displayUnits,
isFahrenheit = isFahrenheit,
onAction = onAction,
isLocal = isLocal,
)

View file

@ -0,0 +1,168 @@
/*
* Copyright (c) 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/>.
*/
@file:Suppress("TooManyFunctions", "MagicNumber")
package org.meshtastic.feature.node.component
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.proto.Config
// ---------------------------------------------------------------------------
// Sample data for previews
// ---------------------------------------------------------------------------
private val previewData = NodePreviewParameterProvider()
// ---------------------------------------------------------------------------
// DeviceActions previews
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun DeviceActionsRemotePreview() {
val node = previewData.mickeyMouse
AppTheme {
Surface {
DeviceActions(
node = node,
ourNode = previewData.mickeyMouse.copy(num = 9999),
lastTracerouteTime = null,
lastRequestNeighborsTime = null,
availableLogs =
setOf(
LogsType.DEVICE,
LogsType.POSITIONS,
LogsType.ENVIRONMENT,
LogsType.SIGNAL,
LogsType.TRACEROUTE,
),
onAction = {},
displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
isFahrenheit = false,
)
}
}
}
@PreviewLightDark
@Composable
private fun DeviceActionsLocalPreview() {
val node = previewData.mickeyMouse
AppTheme {
Surface {
DeviceActions(
node = node,
ourNode = node,
lastTracerouteTime = null,
lastRequestNeighborsTime = null,
availableLogs = setOf(LogsType.DEVICE, LogsType.POSITIONS),
onAction = {},
displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
isFahrenheit = false,
isLocal = true,
)
}
}
}
// ---------------------------------------------------------------------------
// TelemetricActionsSection previews
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun TelemetricActionsSectionPreview() {
val node = previewData.mickeyMouse
AppTheme {
Surface {
TelemetricActionsSection(
node = node,
ourNode = previewData.mickeyMouse.copy(num = 9999),
availableLogs =
setOf(
LogsType.DEVICE,
LogsType.POSITIONS,
LogsType.ENVIRONMENT,
LogsType.SIGNAL,
LogsType.TRACEROUTE,
LogsType.NEIGHBOR_INFO,
),
lastTracerouteTime = null,
lastRequestNeighborsTime = null,
displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
isFahrenheit = false,
onAction = {},
)
}
}
}
@PreviewLightDark
@Composable
private fun TelemetricActionsSectionEmptyPreview() {
val node = previewData.minnieMouse
AppTheme {
Surface {
TelemetricActionsSection(
node = node,
ourNode = previewData.mickeyMouse,
availableLogs = emptySet(),
lastTracerouteTime = null,
lastRequestNeighborsTime = null,
displayUnits = Config.DisplayConfig.DisplayUnits.IMPERIAL,
isFahrenheit = true,
onAction = {},
)
}
}
}
// ---------------------------------------------------------------------------
// PositionInlineContent preview
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun PositionInlineContentPreview() {
val node = previewData.mickeyMouse
AppTheme {
Surface {
PositionInlineContent(
node = node,
ourNode = previewData.mickeyMouse.copy(num = 9999),
displayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
onAction = {},
)
}
}
}
// ---------------------------------------------------------------------------
// NodeDetailsSection preview
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun NodeDetailsSectionPreview() {
val node = previewData.mickeyMouse
AppTheme { Surface { NodeDetailsSection(node = node) } }
}

View file

@ -16,10 +16,7 @@
*/
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@ -28,9 +25,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -42,87 +36,48 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.resources.position
import org.meshtastic.core.ui.icon.Compass
import org.meshtastic.core.ui.icon.Distance
import org.meshtastic.core.ui.icon.LocationOn
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.Config
private const val EXCHANGE_BUTTON_WEIGHT = 1.1f
private const val COMPASS_BUTTON_WEIGHT = 0.9f
private const val MAP_HEIGHT_DP = 200
/**
* Displays node position details, last update time, distance, and related actions like requesting position and
* accessing map/position logs.
* Inline position content shown beneath the Position row in the Telemetry section. Displays the inline map with
* distance badge, linked coordinates, and compass button.
*/
@Composable
fun PositionSection(
internal fun PositionInlineContent(
node: Node,
ourNode: Node?,
metricsState: MetricsState,
availableLogs: Set<LogsType>,
displayUnits: Config.DisplayConfig.DisplayUnits,
onAction: (NodeDetailAction) -> Unit,
modifier: Modifier = Modifier,
) {
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
val distance = ourNode?.distance(node)?.takeIf { it > 0 }?.toDistanceString(displayUnits)
SectionCard(title = Res.string.position, modifier = modifier) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
if (hasValidPosition) {
PositionMap(node, distance)
LinkedCoordinatesItem(node, metricsState.displayUnits)
Spacer(Modifier.height(8.dp))
}
PositionActionButtons(
node = node,
isLocal = isLocal,
hasValidPosition = hasValidPosition,
displayUnits = metricsState.displayUnits,
onAction = onAction,
)
if (availableLogs.contains(LogsType.NODE_MAP) || availableLogs.contains(LogsType.POSITIONS)) {
Spacer(Modifier.height(12.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (availableLogs.contains(LogsType.NODE_MAP)) {
AssistChip(
onClick = { onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.routeFactory(node.num))) },
label = { Text(stringResource(LogsType.NODE_MAP.titleRes)) },
leadingIcon = { Icon(vectorResource(LogsType.NODE_MAP.icon), null, Modifier.size(18.dp)) },
)
}
if (availableLogs.contains(LogsType.POSITIONS)) {
AssistChip(
onClick = {
onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.routeFactory(node.num)))
},
label = { Text(stringResource(LogsType.POSITIONS.titleRes)) },
leadingIcon = { Icon(vectorResource(LogsType.POSITIONS.icon), null, Modifier.size(18.dp)) },
)
}
}
}
}
PositionMap(node, distance)
LinkedCoordinatesItem(node, displayUnits)
Spacer(Modifier.height(8.dp))
FilledTonalButton(
onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
) {
Icon(MeshtasticIcons.Compass, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(Res.string.open_compass),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@ -150,59 +105,3 @@ private fun PositionMap(node: Node, distance: String?) {
}
}
}
@Composable
private fun PositionActionButtons(
node: Node,
isLocal: Boolean,
hasValidPosition: Boolean,
displayUnits: Config.DisplayConfig.DisplayUnits,
onAction: (NodeDetailAction) -> Unit,
) {
if (isLocal && !hasValidPosition) return
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (!isLocal) {
Button(
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
modifier = Modifier.weight(EXCHANGE_BUTTON_WEIGHT),
shape = MaterialTheme.shapes.large,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Icon(MeshtasticIcons.LocationOn, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(Res.string.exchange_position),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
}
if (hasValidPosition) {
FilledTonalButton(
onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
modifier = if (isLocal) Modifier.fillMaxWidth() else Modifier.weight(COMPASS_BUTTON_WEIGHT),
shape = MaterialTheme.shapes.large,
) {
Icon(MeshtasticIcons.Compass, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(Res.string.open_compass),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}

View file

@ -61,8 +61,8 @@ import org.meshtastic.core.resources.userinfo
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.Config
private data class TelemetricFeature(
val titleRes: StringResource,
@ -72,21 +72,32 @@ private data class TelemetricFeature(
val isVisible: (Node) -> Boolean = { true },
val cooldownTimestamp: Long? = null,
val cooldownDuration: Long = COOL_DOWN_TIME_MS,
val content: @Composable ((Node) -> Unit)? = null,
val content: @Composable ((Node, (NodeDetailAction) -> Unit) -> Unit)? = null,
val hasContent: (Node) -> Boolean = { false },
)
@Composable
internal fun TelemetricActionsSection(
node: Node,
ourNode: Node?,
availableLogs: Set<LogsType>,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
metricsState: MetricsState,
displayUnits: Config.DisplayConfig.DisplayUnits,
isFahrenheit: Boolean,
onAction: (NodeDetailAction) -> Unit,
isLocal: Boolean = false,
) {
val features = rememberTelemetricFeatures(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState, isLocal)
val features =
rememberTelemetricFeatures(
node,
ourNode,
lastTracerouteTime,
lastRequestNeighborsTime,
displayUnits,
isFahrenheit,
isLocal,
)
SectionCard(title = Res.string.telemetry) {
features
@ -111,83 +122,94 @@ internal fun TelemetricActionsSection(
@Composable
private fun rememberTelemetricFeatures(
node: Node,
ourNode: Node?,
lastTracerouteTime: Long?,
lastRequestNeighborsTime: Long?,
metricsState: MetricsState,
displayUnits: Config.DisplayConfig.DisplayUnits,
isFahrenheit: Boolean,
isLocal: Boolean,
): List<TelemetricFeature> = remember(node, lastTracerouteTime, lastRequestNeighborsTime, metricsState, isLocal) {
listOf(
TelemetricFeature(
titleRes = Res.string.userinfo,
icon = Res.drawable.ic_person,
requestAction = { NodeMenuAction.RequestUserInfo(it) },
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.TRACEROUTE.titleRes,
icon = LogsType.TRACEROUTE.icon,
requestAction = { NodeMenuAction.TraceRoute(it) },
logsType = LogsType.TRACEROUTE,
cooldownTimestamp = lastTracerouteTime,
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.NEIGHBOR_INFO.titleRes,
icon = LogsType.NEIGHBOR_INFO.icon,
requestAction = { NodeMenuAction.RequestNeighborInfo(it) },
logsType = LogsType.NEIGHBOR_INFO,
isVisible = { it.capabilities.canRequestNeighborInfo },
cooldownTimestamp = lastRequestNeighborsTime,
cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS,
),
TelemetricFeature(
titleRes = LogsType.SIGNAL.titleRes,
icon = LogsType.SIGNAL.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
logsType = LogsType.SIGNAL,
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.DEVICE.titleRes,
icon = LogsType.DEVICE.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) },
logsType = LogsType.DEVICE,
),
TelemetricFeature(
titleRes = LogsType.ENVIRONMENT.titleRes,
icon = Res.drawable.ic_thermostat,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) },
logsType = LogsType.ENVIRONMENT,
content = { EnvironmentMetrics(it, metricsState.displayUnits, metricsState.isFahrenheit) },
hasContent = { it.hasEnvironmentMetrics },
),
TelemetricFeature(
titleRes = Res.string.request_air_quality_metrics,
icon = Res.drawable.ic_air,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) },
),
TelemetricFeature(
titleRes = LogsType.POWER.titleRes,
icon = LogsType.POWER.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) },
logsType = LogsType.POWER,
content = { PowerMetrics(it) },
hasContent = { it.hasPowerMetrics },
),
TelemetricFeature(
titleRes = LogsType.HOST.titleRes,
icon = LogsType.HOST.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) },
logsType = LogsType.HOST,
),
TelemetricFeature(
titleRes = LogsType.PAX.titleRes,
icon = LogsType.PAX.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) },
logsType = LogsType.PAX,
),
)
}
): List<TelemetricFeature> =
remember(node, ourNode, lastTracerouteTime, lastRequestNeighborsTime, displayUnits, isFahrenheit, isLocal) {
listOf(
TelemetricFeature(
titleRes = Res.string.userinfo,
icon = Res.drawable.ic_person,
requestAction = { NodeMenuAction.RequestUserInfo(it) },
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.POSITIONS.titleRes,
icon = LogsType.POSITIONS.icon,
requestAction = if (isLocal) null else { n -> NodeMenuAction.RequestPosition(n) },
logsType = LogsType.POSITIONS,
content = { node, action -> PositionInlineContent(node, ourNode, displayUnits, action) },
hasContent = { it.latitude != 0.0 || it.longitude != 0.0 },
),
TelemetricFeature(
titleRes = LogsType.TRACEROUTE.titleRes,
icon = LogsType.TRACEROUTE.icon,
requestAction = { NodeMenuAction.TraceRoute(it) },
logsType = LogsType.TRACEROUTE,
cooldownTimestamp = lastTracerouteTime,
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.NEIGHBOR_INFO.titleRes,
icon = LogsType.NEIGHBOR_INFO.icon,
requestAction = { NodeMenuAction.RequestNeighborInfo(it) },
logsType = LogsType.NEIGHBOR_INFO,
isVisible = { it.capabilities.canRequestNeighborInfo },
cooldownTimestamp = lastRequestNeighborsTime,
cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS,
),
TelemetricFeature(
titleRes = LogsType.SIGNAL.titleRes,
icon = LogsType.SIGNAL.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
logsType = LogsType.SIGNAL,
isVisible = { !isLocal },
),
TelemetricFeature(
titleRes = LogsType.DEVICE.titleRes,
icon = LogsType.DEVICE.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) },
logsType = LogsType.DEVICE,
),
TelemetricFeature(
titleRes = LogsType.ENVIRONMENT.titleRes,
icon = Res.drawable.ic_thermostat,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) },
logsType = LogsType.ENVIRONMENT,
content = { node, _ -> EnvironmentMetrics(node, displayUnits, isFahrenheit) },
hasContent = { it.hasEnvironmentMetrics },
),
TelemetricFeature(
titleRes = Res.string.request_air_quality_metrics,
icon = Res.drawable.ic_air,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) },
),
TelemetricFeature(
titleRes = LogsType.POWER.titleRes,
icon = LogsType.POWER.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) },
logsType = LogsType.POWER,
content = { node, _ -> PowerMetrics(node) },
hasContent = { it.hasPowerMetrics },
),
TelemetricFeature(
titleRes = LogsType.HOST.titleRes,
icon = LogsType.HOST.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) },
logsType = LogsType.HOST,
),
TelemetricFeature(
titleRes = LogsType.PAX.titleRes,
icon = LogsType.PAX.icon,
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) },
logsType = LogsType.PAX,
),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@ -273,7 +295,7 @@ private fun FeatureRow(node: Node, feature: TelemetricFeature, hasLogs: Boolean,
if (showContent) {
Column(modifier = Modifier.padding(start = 56.dp, end = 20.dp, bottom = 12.dp)) {
feature.content.invoke(node)
feature.content.invoke(node, onAction)
}
}
}

View file

@ -41,7 +41,6 @@ import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceDetailsSection
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.component.PositionSection
import org.meshtastic.feature.node.model.NodeDetailAction
/**
@ -81,8 +80,8 @@ fun NodeDetailContent(
}
/**
* Scrollable list of node detail sections: identity, device actions, position, hardware details, notes, and
* administration.
* Scrollable list of node detail sections: identity, device actions (including telemetry and position), hardware
* details, notes, and administration.
*/
@Composable
fun NodeDetailList(
@ -105,15 +104,16 @@ fun NodeDetailList(
item {
DeviceActions(
node = node,
ourNode = ourNode,
lastTracerouteTime = uiState.lastTracerouteTime,
lastRequestNeighborsTime = uiState.lastRequestNeighborsTime,
availableLogs = uiState.availableLogs,
onAction = onAction,
metricsState = uiState.metricsState,
displayUnits = uiState.metricsState.displayUnits,
isFahrenheit = uiState.metricsState.isFahrenheit,
isLocal = uiState.metricsState.isLocal,
)
}
item { PositionSection(node, ourNode, uiState.metricsState, uiState.availableLogs, onAction) }
if (uiState.metricsState.deviceHardware != null) {
item { DeviceDetailsSection(uiState.metricsState) }
}

View file

@ -0,0 +1,125 @@
/*
* Copyright (c) 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/>.
*/
@file:Suppress("TooManyFunctions", "MagicNumber")
package org.meshtastic.feature.node.detail
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
// ---------------------------------------------------------------------------
// Sample data for previews
// ---------------------------------------------------------------------------
private val previewData = NodePreviewParameterProvider()
// ---------------------------------------------------------------------------
// NodeDetailContent previews
// ---------------------------------------------------------------------------
@PreviewLightDark
@Composable
private fun NodeDetailContentRemotePreview() {
val node = previewData.mickeyMouse
AppTheme {
Surface {
NodeDetailContent(
uiState =
NodeDetailUiState(
node = node,
ourNode = previewData.mickeyMouse.copy(num = 9999),
metricsState = MetricsState(isLocal = false, isManaged = false),
availableLogs =
setOf(
LogsType.DEVICE,
LogsType.POSITIONS,
LogsType.ENVIRONMENT,
LogsType.SIGNAL,
LogsType.TRACEROUTE,
),
),
onAction = {},
onFirmwareSelect = {},
onSaveNotes = { _, _ -> },
)
}
}
}
@PreviewLightDark
@Composable
private fun NodeDetailContentLocalPreview() {
val node = previewData.mickeyMouse
AppTheme {
Surface {
NodeDetailContent(
uiState =
NodeDetailUiState(
node = node,
ourNode = node,
metricsState = MetricsState(isLocal = true, isManaged = false),
availableLogs = setOf(LogsType.DEVICE, LogsType.POSITIONS),
),
onAction = {},
onFirmwareSelect = {},
onSaveNotes = { _, _ -> },
)
}
}
}
@PreviewLightDark
@Composable
private fun NodeDetailContentLoadingPreview() {
AppTheme {
Surface {
NodeDetailContent(
uiState = NodeDetailUiState(),
onAction = {},
onFirmwareSelect = {},
onSaveNotes = { _, _ -> },
)
}
}
}
@PreviewLightDark
@Composable
private fun NodeDetailContentMinimalPreview() {
val node = previewData.minnieMouse
AppTheme {
Surface {
NodeDetailContent(
uiState =
NodeDetailUiState(
node = node,
ourNode = previewData.mickeyMouse,
metricsState = MetricsState(isLocal = false, isManaged = true),
availableLogs = emptySet(),
),
onAction = {},
onFirmwareSelect = {},
onSaveNotes = { _, _ -> },
)
}
}
}

View file

@ -186,7 +186,6 @@ constructor(
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)

View file

@ -19,9 +19,9 @@ package org.meshtastic.feature.node.metrics
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
@ -31,16 +31,13 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
@ -65,16 +62,12 @@ import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.avg
import org.meshtastic.core.resources.collapse_chart
import org.meshtastic.core.resources.expand_chart
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.logs
import org.meshtastic.core.resources.max
import org.meshtastic.core.resources.min
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.BarChart
import org.meshtastic.core.ui.icon.Info
@ -137,6 +130,46 @@ fun GenericMetricChart(
)
}
/**
* Common scaffold for all metric chart composables. Provides:
* - A [Column] container with the supplied [modifier]
* - An empty-data guard (returns early when [isEmpty] is true)
* - A remembered [CartesianChartModelProducer] passed to [content]
* - A trailing [Legend] strip
*
* @param isEmpty Whether the chart data is empty when true, nothing is rendered.
* @param legendData Legend items shown below the chart.
* @param key Optional key for the [CartesianChartModelProducer] (e.g. a selected channel). Pass a different value to
* recreate the producer.
* @param hiddenSet Indices of hidden legend items (toggleable legend).
* @param onToggle Callback when a legend item is toggled; when null, a read-only legend is rendered.
* @param content Builder lambda receiving the [CartesianChartModelProducer] and a standard `Modifier.weight(1f)`
* suitable for the chart area.
*/
@Composable
fun MetricChartScaffold(
isEmpty: Boolean,
legendData: List<LegendData>,
modifier: Modifier = Modifier,
key: Any? = Unit,
hiddenSet: Set<Int> = emptySet(),
onToggle: ((Int) -> Unit)? = null,
content: @Composable ColumnScope.(CartesianChartModelProducer, Modifier) -> Unit,
) {
Column(modifier = modifier) {
if (isEmpty) return@Column
val modelProducer = remember(key) { CartesianChartModelProducer() }
val chartModifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp)
content(modelProducer, chartModifier)
Legend(
legendData = legendData,
modifier = Modifier.padding(top = 0.dp),
hiddenSet = hiddenSet,
onToggle = onToggle,
)
}
}
/**
* An adaptive layout for metric screens. Uses a split Row for wide screens (tablets/landscape) and a stacked Column for
* narrow screens (phones). When [isChartExpanded] is true, the card list is hidden and the chart fills the available
@ -164,7 +197,7 @@ fun AdaptiveMetricLayout(
if (isChartExpanded) {
Modifier.fillMaxWidth().weight(1f)
} else {
Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f)
Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.45f)
},
)
AnimatedVisibility(visible = !isChartExpanded, enter = expandVertically(), exit = shrinkVertically()) {
@ -175,40 +208,6 @@ fun AdaptiveMetricLayout(
}
}
/**
* Displays a compact row of min/max/avg statistics for a metric. Intended to be placed between the chart controls and
* the chart itself.
*/
@Composable
fun MetricSummaryRow(values: List<Float>, label: String = "", modifier: Modifier = Modifier) {
if (values.isEmpty()) return
val minVal = values.min()
val maxVal = values.max()
val avgVal = values.average().toFloat()
Row(
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
SummaryChip(label = stringResource(Res.string.min), value = formatString("%.1f %s", minVal, label))
SummaryChip(label = stringResource(Res.string.avg), value = formatString("%.1f %s", avgVal, label))
SummaryChip(label = stringResource(Res.string.max), value = formatString("%.1f %s", maxVal, label))
}
}
@Composable
private fun SummaryChip(label: String, value: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(text = value, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface)
}
}
/**
* A high-level template for metric screens that handles the Scaffold, AppBar, adaptive layout, and chart-to-list
* synchronisation.

View file

@ -29,10 +29,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.decoration.Decoration
import com.patrykandpatrick.vico.compose.cartesian.decoration.HorizontalLine
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker
import com.patrykandpatrick.vico.compose.cartesian.marker.DefaultCartesianMarker
import com.patrykandpatrick.vico.compose.cartesian.marker.LineCartesianLayerMarkerTarget
@ -249,10 +252,13 @@ object ChartStyling {
if (target is LineCartesianLayerMarkerTarget) {
target.points.forEachIndexed { pointIndex, point ->
if (pointIndex > 0) append(", ")
// Force alpha to 1f so text is readable even if the line is transparent/subtle
val color = point.color.copy(alpha = .8f)
val text = format(point.entry.y, color)
withStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) { append(text) }
// Pass the opaque color to the format lambda so callers can match without alpha gymnastics.
// Apply 0.8 alpha only on the rendered text for readability.
val opaqueColor = point.color.copy(alpha = 1f)
val text = format(point.entry.y, opaqueColor)
withStyle(SpanStyle(color = opaqueColor.copy(alpha = .8f), fontWeight = FontWeight.Bold)) {
append(text)
}
}
}
}
@ -267,3 +273,25 @@ object ChartStyling {
fun rememberAxisLabel(color: Color = MaterialTheme.colorScheme.onSurfaceVariant): TextComponent =
rememberTextComponent(style = TextStyle(color = color, fontSize = 10.sp, fontWeight = FontWeight.Medium))
}
/**
* Creates a [LineCartesianLayer] only when [hasData] is true, returning null otherwise.
*
* Extracts the repeated `if (data.isNotEmpty()) rememberLineCartesianLayer(...) else null` pattern used in every metric
* chart composable.
*/
@Composable
fun rememberConditionalLayer(
hasData: Boolean,
lineProvider: LineCartesianLayer.LineProvider,
verticalAxisPosition: Axis.Position.Vertical,
rangeProvider: CartesianLayerRangeProvider? = null,
): LineCartesianLayer? = if (hasData) {
rememberLineCartesianLayer(
lineProvider = lineProvider,
verticalAxisPosition = verticalAxisPosition,
rangeProvider = rangeProvider ?: CartesianLayerRangeProvider.auto(),
)
} else {
null
}

View file

@ -48,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
@ -56,6 +57,7 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.info
@ -63,29 +65,13 @@ import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.snr
import org.meshtastic.core.ui.icon.Info
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
import kotlin.time.Duration.Companion.days
object CommonCharts {
const val MS_PER_SEC = 1000L
const val MAX_PERCENT_VALUE = 100f
const val SCROLL_BIAS = 0.5f
/** Gets the Material 3 primary color with optional opacity adjustment. */
@Composable
fun getMaterial3PrimaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.primary.copy(alpha = alpha)
/** Gets the Material 3 secondary color with optional opacity adjustment. */
@Composable
fun getMaterial3SecondaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha)
/** Gets the Material 3 tertiary color with optional opacity adjustment. */
@Composable
fun getMaterial3TertiaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.tertiary.copy(alpha = alpha)
/** Gets the Material 3 error color with optional opacity adjustment. */
@Composable
fun getMaterial3ErrorColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.error.copy(alpha = alpha)
/**
* A dynamic [CartesianValueFormatter] that adjusts the time format based on the total data span
* ([CartesianRanges.xLength]).
@ -118,8 +104,6 @@ object CommonCharts {
}
}
fun formatDateTime(timestampMillis: Long): String = DateFormatter.formatDateTime(timestampMillis)
/**
* Shared bottom time axis used by all metric chart screens.
*
@ -142,7 +126,7 @@ data class LegendData(
val nameRes: StringResource,
val color: Color,
val isLine: Boolean = false,
val environmentMetric: Environment? = null,
val metricKey: Any? = null,
)
data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color)
@ -163,9 +147,9 @@ fun Legend(
onToggle: ((Int) -> Unit)? = null,
) {
FlowRow(
modifier = modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
legendData.forEachIndexed { index, data ->
val isVisible = index !in hiddenSet
@ -173,7 +157,7 @@ fun Legend(
FilterChip(
selected = isVisible,
onClick = { onToggle(index) },
label = { Text(stringResource(data.nameRes)) },
label = { Text(text = stringResource(data.nameRes), style = MaterialTheme.typography.labelSmall) },
leadingIcon = { LegendIndicator(color = data.color, isLine = data.isLine) },
modifier = Modifier.padding(horizontal = 2.dp),
)
@ -262,7 +246,8 @@ fun MetricIndicator(color: Color, modifier: Modifier = Modifier) {
Box(modifier = modifier.size(8.dp).clip(CircleShape).background(color))
}
@Suppress("UnusedPrivateMember") // Compose preview
@PreviewLightDark
@Suppress("unused") // Compose preview
@Composable
private fun LegendPreview() {
val data =
@ -270,10 +255,12 @@ private fun LegendPreview() {
LegendData(nameRes = Res.string.rssi, color = Color.Red, isLine = true),
LegendData(nameRes = Res.string.snr, color = Color.Green, isLine = true),
)
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
// Read-only legend
Legend(legendData = data)
// Toggleable legend
Legend(legendData = data, hiddenSet = setOf(1), onToggle = {})
AppTheme {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
// Read-only legend
Legend(legendData = data)
// Toggleable legend
Legend(legendData = data, hiddenSet = setOf(1), onToggle = {})
}
}
}

View file

@ -15,11 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -32,9 +31,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -49,21 +45,22 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_util_definition
@ -84,7 +81,6 @@ import org.meshtastic.core.ui.theme.GraphColors.Cyan
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Telemetry
private enum class Device(val color: Color) {
@ -106,20 +102,10 @@ private enum class Device(val color: Color) {
private val LEGEND_DATA =
listOf(
LegendData(nameRes = Res.string.battery, color = Device.BATTERY.color, isLine = true, environmentMetric = null),
LegendData(nameRes = Res.string.voltage, color = Device.VOLTAGE.color, isLine = true, environmentMetric = null),
LegendData(
nameRes = Res.string.channel_utilization,
color = Device.CH_UTIL.color,
isLine = true,
environmentMetric = null,
),
LegendData(
nameRes = Res.string.air_utilization,
color = Device.AIR_UTIL.color,
isLine = true,
environmentMetric = null,
),
LegendData(nameRes = Res.string.battery, color = Device.BATTERY.color, isLine = true),
LegendData(nameRes = Res.string.voltage, color = Device.VOLTAGE.color, isLine = true),
LegendData(nameRes = Res.string.channel_utilization, color = Device.CH_UTIL.color, isLine = true),
LegendData(nameRes = Res.string.air_utilization, color = Device.AIR_UTIL.color, isLine = true),
)
@Suppress("LongMethod")
@ -188,10 +174,6 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
onTimeFrameSelected = viewModel::setTimeFrame,
modifier = Modifier.padding(horizontal = 16.dp),
)
if (hasBattery) {
val batteryValues = remember(data) { data.mapNotNull { it.device_metrics?.battery_level?.toFloat() } }
MetricSummaryRow(values = batteryValues, label = "%")
}
},
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
DeviceMetricsChart(
@ -219,7 +201,6 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
@ -228,10 +209,10 @@ private fun DeviceMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
Column(modifier = modifier) {
if (telemetries.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
MetricChartScaffold(isEmpty = telemetries.isEmpty(), legendData = legendData, modifier = modifier) {
modelProducer,
chartModifier,
->
val batteryColor = Device.BATTERY.color
val voltageColor = Device.VOLTAGE.color
val chUtilColor = Device.CH_UTIL.color
@ -247,7 +228,7 @@ private fun DeviceMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
when (color) {
batteryColor -> formatString(percentValueTemplate, batteryLabel, value)
voltageColor -> formatString(voltageValueTemplate, voltageLabel, value)
chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value)
@ -322,28 +303,20 @@ private fun DeviceMetricsChart(
}
val leftLayer =
if (leftLayerSeriesStyles.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0),
)
} else {
null
}
rememberConditionalLayer(
hasData = leftLayerSeriesStyles.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(leftLayerSeriesStyles),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0, maxY = 100.0),
)
val rightLayer =
if (voltageData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
ChartStyling.createGradientLine(lineColor = voltageColor),
),
verticalAxisPosition = Axis.Position.Vertical.End,
)
} else {
null
}
rememberConditionalLayer(
hasData = voltageData.isNotEmpty(),
lineProvider =
LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(lineColor = voltageColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
val layers = remember(leftLayer, rightLayer) { listOfNotNull(leftLayer, rightLayer) }
@ -356,7 +329,7 @@ private fun DeviceMetricsChart(
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
modifier = chartModifier,
layers = layers,
startAxis =
if (leftLayer != null) {
@ -384,14 +357,12 @@ private fun DeviceMetricsChart(
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = legendData, modifier = Modifier.padding(top = 0.dp))
}
}
@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
@PreviewLightDark
@Suppress("detekt:MagicNumber") // Compose preview with fake data
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsChartPreview() {
val now = nowSeconds.toInt()
val telemetries =
@ -422,7 +393,6 @@ private fun DeviceMetricsChartPreview() {
@Composable
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val deviceMetrics = telemetry.device_metrics
val time = telemetry.time.toLong() * MS_PER_SEC
@ -431,101 +401,75 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
val uptimeLabel = stringResource(Res.string.uptime)
val percentValueTemplate = stringResource(Res.string.device_metrics_percent_value)
val labelValueTemplate = stringResource(Res.string.device_metrics_label_value)
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Surface(color = Color.Transparent) {
SelectionContainer {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
/* Time, Battery, and Voltage */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = CommonCharts.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
/* Time, Battery, and Voltage */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DateFormatter.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceMetrics?.battery_level != null) {
MetricIndicator(Device.BATTERY.color)
Spacer(Modifier.width(4.dp))
}
if (deviceMetrics?.voltage != null) {
MetricIndicator(Device.VOLTAGE.color)
Spacer(Modifier.width(8.dp))
}
MaterialBatteryInfo(
level = deviceMetrics?.battery_level ?: 0,
voltage = deviceMetrics?.voltage ?: 0f,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceMetrics?.battery_level != null) {
MetricIndicator(Device.BATTERY.color)
Spacer(Modifier.width(4.dp))
}
if (deviceMetrics?.voltage != null) {
MetricIndicator(Device.VOLTAGE.color)
Spacer(Modifier.width(8.dp))
}
MaterialBatteryInfo(
level = deviceMetrics?.battery_level ?: 0,
voltage = deviceMetrics?.voltage ?: 0f,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
/* Channel Utilization and Air Utilization Tx */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceMetrics?.channel_utilization != null) {
MetricIndicator(Device.CH_UTIL.color)
Spacer(Modifier.width(4.dp))
Text(
text =
formatString(
percentValueTemplate,
channelUtilizationLabel,
deviceMetrics.channel_utilization ?: 0f,
),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Spacer(Modifier.width(12.dp))
}
if (deviceMetrics?.air_util_tx != null) {
MetricIndicator(Device.AIR_UTIL.color)
Spacer(Modifier.width(4.dp))
Text(
text =
formatString(
percentValueTemplate,
airUtilizationLabel,
deviceMetrics.air_util_tx ?: 0f,
),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
Text(
/* Channel Utilization and Air Utilization Tx */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceMetrics?.channel_utilization != null) {
MetricValueRow(
color = Device.CH_UTIL.color,
text =
formatString(
labelValueTemplate,
uptimeLabel,
formatUptime(deviceMetrics?.uptime_seconds ?: 0),
percentValueTemplate,
channelUtilizationLabel,
deviceMetrics.channel_utilization ?: 0f,
),
)
Spacer(Modifier.width(12.dp))
}
if (deviceMetrics?.air_util_tx != null) {
MetricValueRow(
color = Device.AIR_UTIL.color,
text =
formatString(
percentValueTemplate,
airUtilizationLabel,
deviceMetrics.air_util_tx ?: 0f,
),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
Text(
text =
formatString(labelValueTemplate, uptimeLabel, formatUptime(deviceMetrics?.uptime_seconds ?: 0)),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
}
@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
@PreviewLightDark
@Suppress("detekt:MagicNumber") // Compose preview with fake data
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsCardPreview() {
val now = nowSeconds.toInt()
val telemetry =
@ -543,9 +487,9 @@ private fun DeviceMetricsCardPreview() {
AppTheme { DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) }
}
@Suppress("detekt:MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
@PreviewLightDark
@Suppress("detekt:MagicNumber") // Compose preview with fake data
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun DeviceMetricsScreenPreview() {
val now = nowSeconds.toInt()
val telemetries =

View file

@ -57,52 +57,42 @@ private val LEGEND_DATA_1 =
nameRes = Res.string.temperature,
color = Environment.TEMPERATURE.color,
isLine = true,
environmentMetric = Environment.TEMPERATURE,
metricKey = Environment.TEMPERATURE,
),
LegendData(
nameRes = Res.string.humidity,
color = Environment.HUMIDITY.color,
isLine = true,
environmentMetric = Environment.HUMIDITY,
metricKey = Environment.HUMIDITY,
),
)
private val LEGEND_DATA_2 =
listOf(
LegendData(
nameRes = Res.string.iaq,
color = Environment.IAQ.color,
isLine = true,
environmentMetric = Environment.IAQ,
),
LegendData(nameRes = Res.string.iaq, color = Environment.IAQ.color, isLine = true, metricKey = Environment.IAQ),
LegendData(
nameRes = Res.string.baro_pressure,
color = Environment.BAROMETRIC_PRESSURE.color,
isLine = true,
environmentMetric = Environment.BAROMETRIC_PRESSURE,
),
LegendData(
nameRes = Res.string.lux,
color = Environment.LUX.color,
isLine = true,
environmentMetric = Environment.LUX,
metricKey = Environment.BAROMETRIC_PRESSURE,
),
LegendData(nameRes = Res.string.lux, color = Environment.LUX.color, isLine = true, metricKey = Environment.LUX),
LegendData(
nameRes = Res.string.uv_lux,
color = Environment.UV_LUX.color,
isLine = true,
environmentMetric = Environment.UV_LUX,
metricKey = Environment.UV_LUX,
),
LegendData(
nameRes = Res.string.wind_speed,
color = Environment.WIND_SPEED.color,
isLine = true,
environmentMetric = Environment.WIND_SPEED,
metricKey = Environment.WIND_SPEED,
),
LegendData(
nameRes = Res.string.radiation,
color = Environment.RADIATION.color,
isLine = true,
environmentMetric = Environment.RADIATION,
metricKey = Environment.RADIATION,
),
)
@ -112,13 +102,13 @@ private val LEGEND_DATA_3 =
nameRes = Res.string.soil_temperature,
color = Environment.SOIL_TEMPERATURE.color,
isLine = true,
environmentMetric = Environment.SOIL_TEMPERATURE,
metricKey = Environment.SOIL_TEMPERATURE,
),
LegendData(
nameRes = Res.string.soil_moisture,
color = Environment.SOIL_MOISTURE.color,
isLine = true,
environmentMetric = Environment.SOIL_MOISTURE,
metricKey = Environment.SOIL_MOISTURE,
),
)
@ -143,14 +133,14 @@ fun EnvironmentMetricsChart(
val allLegendData =
(LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3).filter {
graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0]
graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0]
}
// Legend toggle state: tracks indices into allLegendData that are hidden
var hiddenIndices by remember { mutableStateOf(emptySet<Int>()) }
val hiddenMetrics =
remember(hiddenIndices, allLegendData) {
hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.environmentMetric }.toSet()
hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.metricKey as? Environment }.toSet()
}
val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) }
@ -216,7 +206,7 @@ fun EnvironmentMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
val label = colorToLabel[color.copy(alpha = 1f)] ?: ""
val label = colorToLabel[color] ?: ""
formatString("%s: %.1f", label, value)
},
)

View file

@ -15,11 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("TooManyFunctions")
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -31,26 +30,24 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.env_metrics_log
@ -73,7 +70,7 @@ import org.meshtastic.core.resources.wind_lull
import org.meshtastic.core.resources.wind_speed
import org.meshtastic.core.ui.component.IaqDisplayMode
import org.meshtastic.core.ui.component.IndoorAirQuality
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Telemetry
@Composable
@ -100,14 +97,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
onTimeFrameSelected = viewModel::setTimeFrame,
modifier = Modifier.padding(horizontal = 16.dp),
)
val tempValues =
remember(filteredTelemetries) {
filteredTelemetries.mapNotNull { it.environment_metrics?.temperature?.takeIf { t -> !t.isNaN() } }
}
if (tempValues.isNotEmpty()) {
val unit = if (state.isFahrenheit) "°F" else "°C"
MetricSummaryRow(values = tempValues, label = unit)
}
},
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
EnvironmentMetricsChart(
@ -135,7 +124,6 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Un
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun TemperatureDisplay(
envMetrics: org.meshtastic.proto.EnvironmentMetrics,
environmentDisplayFahrenheit: Boolean,
@ -157,7 +145,6 @@ private fun TemperatureDisplay(
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasHumidity = envMetrics.relative_humidity?.let { !it.isNaN() } == true
val hasPressure = envMetrics.barometric_pressure?.let { !it.isNaN() && it > 0 } == true
@ -198,7 +185,6 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun SoilMetricsDisplay(
envMetrics: org.meshtastic.proto.EnvironmentMetrics,
environmentDisplayFahrenheit: Boolean,
@ -251,7 +237,6 @@ private fun SoilMetricsDisplay(
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasLux = envMetrics.lux != null && !envMetrics.lux!!.isNaN()
val hasUvLux = envMetrics.uv_lux != null && !envMetrics.uv_lux!!.isNaN()
@ -287,7 +272,6 @@ private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasVoltage = envMetrics.voltage != null && !envMetrics.voltage!!.isNaN()
val hasCurrent = envMetrics.current != null && !envMetrics.current!!.isNaN()
@ -315,7 +299,6 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val iaqValue = envMetrics.iaq
val gasResistance = envMetrics.gas_resistance
@ -351,7 +334,6 @@ private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
envMetrics.radiation?.let { radiation ->
if (!radiation.isNaN() && radiation > 0f) {
@ -371,7 +353,6 @@ private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasSpeed = envMetrics.wind_speed != null && !envMetrics.wind_speed!!.isNaN()
val hasGust = envMetrics.wind_gust != null && !envMetrics.wind_gust!!.isNaN()
@ -386,7 +367,6 @@ private fun WindDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically) {
@ -414,7 +394,6 @@ private fun WindSpeedRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics, hasGust: Boolean, hasLull: Boolean) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (hasGust) {
@ -435,7 +414,6 @@ private fun WindGustLullRow(envMetrics: org.meshtastic.proto.EnvironmentMetrics,
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val has1h = envMetrics.rainfall_1h != null && !envMetrics.rainfall_1h!!.isNaN()
val has24h = envMetrics.rainfall_24h != null && !envMetrics.rainfall_24h!!.isNaN()
@ -462,34 +440,18 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun EnvironmentMetricsCard(
telemetry: Telemetry,
environmentDisplayFahrenheit: Boolean,
isSelected: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Surface(color = Color.Transparent) {
SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) }
}
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit)
}
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics()
val time = telemetry.time.toLong() * MS_PER_SEC
@ -497,7 +459,7 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa
/* Time and Temperature */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = CommonCharts.formatDateTime(time),
text = DateFormatter.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
@ -521,9 +483,9 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa
}
}
@Suppress("MagicNumber", "UnusedPrivateMember") // Compose preview with fake data
@PreviewLightDark
@Suppress("MagicNumber") // Compose preview with fake data
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PreviewEnvironmentMetricsContent() {
val fakeEnvMetrics =
org.meshtastic.proto.EnvironmentMetrics(
@ -547,7 +509,5 @@ private fun PreviewEnvironmentMetricsContent() {
rainfall_24h = 12.3f,
)
val fakeTelemetry = Telemetry(time = nowSeconds.toInt(), environment_metrics = fakeEnvMetrics)
MaterialTheme {
Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) }
}
AppTheme { Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) } }
}

View file

@ -18,22 +18,17 @@
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.free_memory
@ -104,11 +99,10 @@ internal fun HostMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
Column(modifier = modifier) {
if (data.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
MetricChartScaffold(isEmpty = data.isEmpty(), legendData = HOST_METRICS_LEGEND_DATA, modifier = modifier) {
modelProducer,
chartModifier,
->
val load1Data = remember(data) { data.filter { it.host_metrics?.load1 != null && it.host_metrics!!.load1 > 0 } }
val load5Data = remember(data) { data.filter { it.host_metrics?.load5 != null && it.host_metrics!!.load5 > 0 } }
val load15Data =
@ -157,7 +151,7 @@ internal fun HostMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
when (color) {
load1Color -> formatString("L1: %.2f", value)
load5Color -> formatString("L5: %.2f", value)
load15Color -> formatString("L15: %.2f", value)
@ -167,39 +161,33 @@ internal fun HostMetricsChart(
)
val hasLoad = load1Data.isNotEmpty() || load5Data.isNotEmpty() || load15Data.isNotEmpty()
val load1Style = if (load1Data.isNotEmpty()) ChartStyling.createStyledLine(load1Color) else null
val load5Style = if (load5Data.isNotEmpty()) ChartStyling.createDashedLine(load5Color) else null
val load15Style = if (load15Data.isNotEmpty()) ChartStyling.createSubtleLine(load15Color) else null
val loadStyles = listOfNotNull(load1Style, load5Style, load15Style)
val loadLayer =
if (hasLoad) {
val load1Style = if (load1Data.isNotEmpty()) ChartStyling.createStyledLine(load1Color) else null
val load5Style = if (load5Data.isNotEmpty()) ChartStyling.createDashedLine(load5Color) else null
val load15Style = if (load15Data.isNotEmpty()) ChartStyling.createSubtleLine(load15Color) else null
val styles = listOfNotNull(load1Style, load5Style, load15Style)
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(styles),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
} else {
null
}
rememberConditionalLayer(
hasData = hasLoad,
lineProvider = LineCartesianLayer.LineProvider.series(loadStyles),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
val memLayer =
if (memData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(memColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
} else {
null
}
rememberConditionalLayer(
hasData = memData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(memColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
val layers = remember(loadLayer, memLayer) { listOfNotNull(loadLayer, memLayer) }
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
modifier = chartModifier,
layers = layers,
startAxis =
if (hasLoad) {
@ -226,7 +214,5 @@ internal fun HostMetricsChart(
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = HOST_METRICS_LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}
}

View file

@ -14,9 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
@ -27,6 +31,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
@ -38,6 +43,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@ -49,7 +55,6 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
/** Shared metric log/list UI components used by TracerouteLog, NeighborInfoLog, HostMetricsLog, and PositionLog. */
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun MetricLogItem(icon: ImageVector, text: String, contentDescription: String, modifier: Modifier = Modifier) {
Card(
modifier = modifier.fillMaxWidth().heightIn(min = 64.dp).padding(vertical = 4.dp, horizontal = 8.dp),
@ -99,3 +104,45 @@ fun DeleteItem(onClick: () -> Unit) {
},
)
}
/**
* A selectable [Card] for metric log items. Provides consistent selection styling (primary border + primaryContainer
* background) and text selection support across all metric screens.
*/
@Composable
fun SelectableMetricCard(
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Card(
modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
SelectionContainer { content() }
}
}
/** A compact row displaying a colored [MetricIndicator] dot/line followed by a text value. */
@Composable
fun MetricValueRow(color: Color, text: String, modifier: Modifier = Modifier) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
MetricIndicator(color)
Spacer(Modifier.width(4.dp))
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}

View file

@ -47,7 +47,9 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.GeoConstants
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
@ -61,7 +63,6 @@ import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.MetricsState
@ -333,12 +334,12 @@ open class MetricsViewModel(
.toLocalDateTime(TimeZone.currentSystemDefault())
val rxDateTime = "\"${localDateTime.date}\",\"${localDateTime.time}\""
val latitude = (position.latitude_i ?: 0) * 1e-7
val longitude = (position.longitude_i ?: 0) * 1e-7
val latitude = (position.latitude_i ?: 0) * GeoConstants.DEG_D
val longitude = (position.longitude_i ?: 0) * GeoConstants.DEG_D
val altitude = position.altitude
val satsInView = position.sats_in_view
val speed = position.ground_speed
val heading = formatString("%.2f", (position.ground_track ?: 0) * 1e-5)
val heading = formatString("%.2f", (position.ground_track ?: 0) * GeoConstants.HEADING_DEG)
sink.writeUtf8(
"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n",

View file

@ -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/>.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -28,8 +28,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@ -46,7 +44,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
@ -57,12 +54,19 @@ import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.ble_devices
import org.meshtastic.core.resources.no_pax_metrics_logs
import org.meshtastic.core.resources.pax
import org.meshtastic.core.resources.pax_ble_format
import org.meshtastic.core.resources.pax_ble_marker
import org.meshtastic.core.resources.pax_metrics_log
import org.meshtastic.core.resources.pax_total_format
import org.meshtastic.core.resources.pax_total_marker
import org.meshtastic.core.resources.pax_wifi_format
import org.meshtastic.core.resources.pax_wifi_marker
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.resources.wifi_devices
import org.meshtastic.core.ui.component.IconInfo
@ -80,14 +84,13 @@ private enum class PaxSeries(val color: Color, val legendRes: StringResource) {
private val LEGEND_DATA =
listOf(
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null),
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color, environmentMetric = null),
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color, environmentMetric = null),
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color),
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color),
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color),
)
@Suppress("LongMethod")
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PaxMetricsChart(
modifier: Modifier = Modifier,
totalSeries: List<Pair<Int, Int>>,
@ -97,10 +100,10 @@ private fun PaxMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
Column(modifier = modifier) {
if (totalSeries.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
MetricChartScaffold(isEmpty = totalSeries.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) {
modelProducer,
chartModifier,
->
val paxColor = PaxSeries.PAX.color
val bleColor = PaxSeries.BLE.color
val wifiColor = PaxSeries.WIFI.color
@ -116,22 +119,26 @@ private fun PaxMetricsChart(
}
val axisLabel = ChartStyling.rememberAxisLabel()
val bleMarkerTemplate = stringResource(Res.string.pax_ble_marker)
val wifiMarkerTemplate = stringResource(Res.string.pax_wifi_marker)
val paxMarkerTemplate = stringResource(Res.string.pax_total_marker)
val marker =
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
bleColor -> formatString("BLE: %.0f", value)
wifiColor -> formatString("WiFi: %.0f", value)
paxColor -> formatString("PAX: %.0f", value)
else -> formatString("%.0f", value)
val formatted = formatString("%.0f", value)
when (color) {
bleColor -> bleMarkerTemplate.replace("%1\$s", formatted)
wifiColor -> wifiMarkerTemplate.replace("%1\$s", formatted)
paxColor -> paxMarkerTemplate.replace("%1\$s", formatted)
else -> formatted
}
},
)
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
modifier = chartModifier,
layers =
listOf(
rememberLineCartesianLayer(
@ -151,8 +158,6 @@ private fun PaxMetricsChart(
onPointSelected = onPointSelected,
vicoScrollState = vicoScrollState,
)
Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 4.dp))
}
}
@ -169,7 +174,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
remember(paxMetrics) {
paxMetrics
.map {
val t = (it.first.received_date / CommonCharts.MS_PER_SEC).toInt()
val t = (it.first.received_date / MS_PER_SEC).toInt()
Triple(t, it.second.ble, it.second.wifi)
}
.sortedBy { it.first }
@ -184,7 +189,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
titleRes = Res.string.pax_metrics_log,
nodeName = state.node?.user?.long_name ?: "",
data = paxMetrics,
timeProvider = { (it.first.received_date / CommonCharts.MS_PER_SEC).toDouble() },
timeProvider = { (it.first.received_date / MS_PER_SEC).toDouble() },
onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.PAX) },
controlPart = {
TimeFrameSelector(
@ -224,8 +229,8 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
PaxMetricsItem(
log = log,
pax = pax,
isSelected = (log.received_date / CommonCharts.MS_PER_SEC).toDouble() == selectedX,
onClick = { onCardClick((log.received_date / CommonCharts.MS_PER_SEC).toDouble()) },
isSelected = (log.received_date / MS_PER_SEC).toDouble() == selectedX,
onClick = { onCardClick((log.received_date / MS_PER_SEC).toDouble()) },
)
}
}
@ -250,21 +255,8 @@ fun PaxcountInfo(
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClick: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(
text = DateFormatter.formatDateTime(log.received_date),
@ -278,17 +270,20 @@ fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, isSelected: Boolean, onClic
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
MetricIndicator(PaxSeries.PAX.color)
Spacer(Modifier.width(4.dp))
Text(text = "PAX: ${pax.ble + pax.wifi}", style = MaterialTheme.typography.bodyLarge)
MetricValueRow(
color = PaxSeries.PAX.color,
text = stringResource(Res.string.pax_total_format, pax.ble + pax.wifi),
)
Spacer(Modifier.width(8.dp))
MetricIndicator(PaxSeries.BLE.color)
Spacer(Modifier.width(4.dp))
Text(text = "B:${pax.ble}", style = MaterialTheme.typography.bodyLarge)
MetricValueRow(
color = PaxSeries.BLE.color,
text = stringResource(Res.string.pax_ble_format, pax.ble),
)
Spacer(Modifier.width(8.dp))
MetricIndicator(PaxSeries.WIFI.color)
Spacer(Modifier.width(4.dp))
Text(text = "W:${pax.wifi}", style = MaterialTheme.typography.bodyLarge)
MetricValueRow(
color = PaxSeries.WIFI.color,
text = stringResource(Res.string.pax_wifi_format, pax.wifi),
)
}
Text(

View file

@ -33,6 +33,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.model.util.GeoConstants.HEADING_DEG
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
@ -79,9 +81,6 @@ fun PositionLogHeader(compactWidth: Boolean) {
}
}
const val DEG_D = 1e-7
const val HEADING_DEG = 1e-5
@Composable
fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) {
Row(

View file

@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@ -44,12 +45,19 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.collapse_chart
import org.meshtastic.core.resources.expand_chart
import org.meshtastic.core.resources.logs
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.resources.save
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.BarChart
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.List
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Save
import org.meshtastic.core.ui.util.LocalNodeTrackMapProvider
@Composable
private fun ActionButtons(
@ -92,16 +100,32 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
org.meshtastic.core.ui.util.rememberSaveFileLauncher { uri -> viewModel.savePositionCSV(uri) }
var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) }
var isMapExpanded by remember { mutableStateOf(false) }
val trackMap = LocalNodeTrackMapProvider.current
val destNum = state.node?.num ?: 0
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.long_name ?: "",
subtitle =
stringResource(Res.string.position_log) +
" (${state.positionLogs.size} ${stringResource(Res.string.logs)})",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {
IconButton(onClick = { isMapExpanded = !isMapExpanded }) {
Icon(
imageVector = if (isMapExpanded) MeshtasticIcons.List else MeshtasticIcons.BarChart,
contentDescription =
stringResource(
if (isMapExpanded) Res.string.collapse_chart else Res.string.expand_chart,
),
)
}
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestPosition() }) {
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
@ -112,30 +136,38 @@ fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
)
},
) { innerPadding ->
BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
val compactWidth = maxWidth < 600.dp
Column {
val textStyle =
if (compactWidth) {
MaterialTheme.typography.bodySmall
} else {
LocalTextStyle.current
}
CompositionLocalProvider(LocalTextStyle provides textStyle) {
PositionLogHeader(compactWidth)
PositionList(compactWidth, state.positionLogs, state.displayUnits)
}
Column(modifier = Modifier.padding(innerPadding)) {
AdaptiveMetricLayout(
isChartExpanded = isMapExpanded,
chartPart = { modifier -> trackMap(destNum, state.positionLogs, modifier) },
listPart = { modifier ->
BoxWithConstraints(modifier = modifier) {
val compactWidth = maxWidth < 600.dp
Column {
val textStyle =
if (compactWidth) {
MaterialTheme.typography.bodySmall
} else {
LocalTextStyle.current
}
CompositionLocalProvider(LocalTextStyle provides textStyle) {
PositionLogHeader(compactWidth)
PositionList(compactWidth, state.positionLogs, state.displayUnits)
}
ActionButtons(
clearButtonEnabled = clearButtonEnabled,
onClear = {
clearButtonEnabled = false
viewModel.clearPosition()
},
saveButtonEnabled = state.hasPositionLogs(),
onSave = { exportPositionLauncher("position.csv", "text/csv") },
)
}
ActionButtons(
clearButtonEnabled = clearButtonEnabled,
onClear = {
clearButtonEnabled = false
viewModel.clearPosition()
},
saveButtonEnabled = state.hasPositionLogs(),
onSave = { exportPositionLauncher("position.csv", "text/csv") },
)
}
}
},
)
}
}
}

View file

@ -15,11 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@ -29,17 +28,12 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -47,7 +41,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
@ -57,14 +50,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_1
import org.meshtastic.core.resources.channel_2
@ -79,7 +72,6 @@ import org.meshtastic.core.resources.power_metrics_log
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.Telemetry
private enum class PowerMetric(val color: Color) {
@ -100,18 +92,8 @@ private enum class PowerChannel(val strRes: StringResource) {
private val LEGEND_DATA =
listOf(
LegendData(
nameRes = Res.string.current,
color = PowerMetric.CURRENT.color,
isLine = true,
environmentMetric = null,
),
LegendData(
nameRes = Res.string.voltage,
color = PowerMetric.VOLTAGE.color,
isLine = true,
environmentMetric = null,
),
LegendData(nameRes = Res.string.current, color = PowerMetric.CURRENT.color, isLine = true),
LegendData(nameRes = Res.string.voltage, color = PowerMetric.VOLTAGE.color, isLine = true),
)
@Suppress("LongMethod")
@ -187,7 +169,6 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
@Suppress("LongMethod")
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
@ -196,17 +177,19 @@ private fun PowerMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
Column(modifier = modifier) {
if (telemetries.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
MetricChartScaffold(
isEmpty = telemetries.isEmpty(),
legendData = LEGEND_DATA,
modifier = modifier,
key = selectedChannel,
) { modelProducer, chartModifier ->
val currentColor = PowerMetric.CURRENT.color
val voltageColor = PowerMetric.VOLTAGE.color
val marker =
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
when (color) {
currentColor -> formatString("Current: %.0f mA", value)
voltageColor -> formatString("Voltage: %.1f V", value)
else -> formatString("%.1f", value)
@ -223,7 +206,7 @@ private fun PowerMetricsChart(
telemetries.filter { !retrieveVoltage(selectedChannel, it).isNaN() }
}
LaunchedEffect(currentData, voltageData) {
LaunchedEffect(selectedChannel, currentData, voltageData) {
modelProducer.runTransaction {
if (currentData.isNotEmpty()) {
lineSeries {
@ -245,32 +228,25 @@ private fun PowerMetricsChart(
}
val currentLayer =
if (currentData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createBoldLine(currentColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
)
} else {
null
}
rememberConditionalLayer(
hasData = currentData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createBoldLine(currentColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
)
val voltageLayer =
if (voltageData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(voltageColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
} else {
null
}
rememberConditionalLayer(
hasData = voltageData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(voltageColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
val layers = remember(currentLayer, voltageLayer) { listOfNotNull(currentLayer, voltageLayer) }
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
modifier = chartModifier,
layers = layers,
startAxis =
if (currentData.isNotEmpty()) {
@ -297,50 +273,31 @@ private fun PowerMetricsChart(
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}
}
@Composable
@Suppress("CyclomaticComplexMethod", "LongMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val time = telemetry.time.toLong() * MS_PER_SEC
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Surface {
SelectionContainer {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
/* Time */
Row {
Text(
text = CommonCharts.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
}
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
Row(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
/* Time */
Row {
Text(
text = DateFormatter.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(8.dp))
val pm = telemetry.power_metrics
if (pm != null) {
PowerChannelsRow1(pm)
PowerChannelsExtraRows(pm)
}
}
val pm = telemetry.power_metrics
if (pm != null) {
PowerChannelsRow1(pm)
PowerChannelsExtraRows(pm)
}
}
}
@ -348,7 +305,6 @@ private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick:
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerChannelsRow1(pm: org.meshtastic.proto.PowerMetrics) {
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
if (pm.ch1_current != null || pm.ch1_voltage != null) {
@ -365,7 +321,6 @@ private fun PowerChannelsRow1(pm: org.meshtastic.proto.PowerMetrics) {
@Composable
@Suppress("CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerChannelsExtraRows(pm: org.meshtastic.proto.PowerMetrics) {
val hasCh456 =
hasChannelData(pm.ch4_voltage, pm.ch4_current) ||
@ -403,7 +358,6 @@ private fun PowerChannelsExtraRows(pm: org.meshtastic.proto.PowerMetrics) {
private fun hasChannelData(voltage: Float?, current: Float?): Boolean = voltage != null || current != null
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current: Float) {
Column {
Text(
@ -411,30 +365,13 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Row(verticalAlignment = Alignment.CenterVertically) {
MetricIndicator(PowerMetric.VOLTAGE.color)
Spacer(Modifier.width(4.dp))
Text(
text = formatString("%.2fV", voltage),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
MetricIndicator(PowerMetric.CURRENT.color)
Spacer(Modifier.width(4.dp))
Text(
text = formatString("%.1fmA", current),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
MetricValueRow(color = PowerMetric.VOLTAGE.color, text = formatString("%.2fV", voltage))
MetricValueRow(color = PowerMetric.CURRENT.color, text = formatString("%.1fmA", current))
}
}
/** Retrieves the appropriate voltage depending on `channelSelected`. */
@Suppress("CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
PowerChannel.ONE -> telemetry.power_metrics?.ch1_voltage ?: Float.NaN
PowerChannel.TWO -> telemetry.power_metrics?.ch2_voltage ?: Float.NaN
@ -448,7 +385,6 @@ private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry)
/** Retrieves the appropriate current depending on `channelSelected`. */
@Suppress("CyclomaticComplexMethod")
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun retrieveCurrent(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
PowerChannel.ONE -> telemetry.power_metrics?.ch1_current ?: Float.NaN
PowerChannel.TWO -> telemetry.power_metrics?.ch2_current ?: Float.NaN

View file

@ -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/>.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -31,12 +31,8 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -51,12 +47,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.rssi_definition
@ -66,7 +62,6 @@ import org.meshtastic.core.resources.snr_definition
import org.meshtastic.core.ui.component.LoraSignalIndicator
import org.meshtastic.core.ui.theme.GraphColors.Blue
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.MeshPacket
private enum class SignalMetric(val color: Color) {
@ -76,8 +71,8 @@ private enum class SignalMetric(val color: Color) {
private val LEGEND_DATA =
listOf(
LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color, environmentMetric = null),
LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color, environmentMetric = null),
LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color),
LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color),
)
@Suppress("LongMethod")
@ -134,7 +129,6 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun SignalMetricsChart(
modifier: Modifier = Modifier,
meshPackets: List<MeshPacket>,
@ -142,10 +136,10 @@ private fun SignalMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
Column(modifier = modifier) {
if (meshPackets.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
MetricChartScaffold(isEmpty = meshPackets.isEmpty(), legendData = LEGEND_DATA, modifier = modifier) {
modelProducer,
chartModifier,
->
val rssiColor = SignalMetric.RSSI.color
val snrColor = SignalMetric.SNR.color
@ -168,7 +162,7 @@ private fun SignalMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
if (color.copy(alpha = 1f) == rssiColor) {
if (color == rssiColor) {
formatString("RSSI: %.0f dBm", value)
} else {
formatString("SNR: %.1f dB", value)
@ -177,31 +171,25 @@ private fun SignalMetricsChart(
)
val rssiLayer =
if (rssiData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(rssiColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
)
} else {
null
}
rememberConditionalLayer(
hasData = rssiData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(rssiColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
)
val snrLayer =
if (snrData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(snrColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
} else {
null
}
rememberConditionalLayer(
hasData = snrData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(snrColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
val layers = remember(rssiLayer, snrLayer) { listOfNotNull(rssiLayer, snrLayer) }
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
modifier = chartModifier,
layers = layers,
startAxis =
if (rssiData.isNotEmpty()) {
@ -228,70 +216,47 @@ private fun SignalMetricsChart(
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}
}
@Composable
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) {
val time = meshPacket.rx_time.toLong() * MS_PER_SEC
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
colors =
CardDefaults.cardColors(
containerColor =
if (isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Surface(color = Color.Transparent) {
SelectionContainer {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
/* Data */
Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
Column(modifier = Modifier.padding(12.dp)) {
/* Time */
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = CommonCharts.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
}
Spacer(modifier = Modifier.height(8.dp))
/* SNR and RSSI */
Row(verticalAlignment = Alignment.CenterVertically) {
MetricIndicator(SignalMetric.RSSI.color)
Spacer(Modifier.width(4.dp))
Text(
text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()),
style = MaterialTheme.typography.labelLarge,
)
Spacer(Modifier.width(12.dp))
MetricIndicator(SignalMetric.SNR.color)
Spacer(Modifier.width(4.dp))
Text(
text = formatString("%.1f dB", meshPacket.rx_snr),
style = MaterialTheme.typography.labelLarge,
)
}
}
SelectableMetricCard(isSelected = isSelected, onClick = onClick) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
/* Data */
Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
Column(modifier = Modifier.padding(12.dp)) {
/* Time */
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DateFormatter.formatDateTime(time),
style = MaterialTheme.typography.titleMediumEmphasized,
fontWeight = FontWeight.Bold,
)
}
/* Signal Indicator */
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi)
Spacer(modifier = Modifier.height(8.dp))
/* SNR and RSSI */
Row(verticalAlignment = Alignment.CenterVertically) {
MetricValueRow(
color = SignalMetric.RSSI.color,
text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()),
)
Spacer(Modifier.width(12.dp))
MetricValueRow(
color = SignalMetric.SNR.color,
text = formatString("%.1f dB", meshPacket.rx_snr),
)
}
}
}
/* Signal Indicator */
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi)
}
}
}
}

View file

@ -18,22 +18,17 @@
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianLayerRangeProvider
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.fullRouteDiscovery
@ -151,11 +146,10 @@ internal fun TracerouteMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
Column(modifier = modifier) {
if (points.isEmpty()) return@Column
val modelProducer = remember { CartesianChartModelProducer() }
MetricChartScaffold(isEmpty = points.isEmpty(), legendData = TRACEROUTE_LEGEND_DATA, modifier = modifier) {
modelProducer,
chartModifier,
->
val forwardData = remember(points) { points.filter { it.forwardHops != null } }
val returnData = remember(points) { points.filter { it.returnHops != null } }
val rttData = remember(points) { points.filter { it.roundTripSeconds != null } }
@ -184,7 +178,7 @@ internal fun TracerouteMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
when (color) {
forwardColor -> formatString("Fwd: %.0f hops", value)
returnColor -> formatString("Ret: %.0f hops", value)
else -> formatString("RTT: %.1f s", value)
@ -193,36 +187,27 @@ internal fun TracerouteMetricsChart(
)
val forwardLayer =
if (forwardData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(forwardColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
} else {
null
}
rememberConditionalLayer(
hasData = forwardData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createStyledLine(forwardColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
val returnLayer =
if (returnData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(returnColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
} else {
null
}
rememberConditionalLayer(
hasData = returnData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createDashedLine(returnColor)),
verticalAxisPosition = Axis.Position.Vertical.Start,
rangeProvider = CartesianLayerRangeProvider.fixed(minY = 0.0),
)
val rttLayer =
if (rttData.isNotEmpty()) {
rememberLineCartesianLayer(
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(rttColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
} else {
null
}
rememberConditionalLayer(
hasData = rttData.isNotEmpty(),
lineProvider = LineCartesianLayer.LineProvider.series(ChartStyling.createGradientLine(rttColor)),
verticalAxisPosition = Axis.Position.Vertical.End,
)
val layers =
remember(forwardLayer, returnLayer, rttLayer) { listOfNotNull(forwardLayer, returnLayer, rttLayer) }
@ -230,7 +215,7 @@ internal fun TracerouteMetricsChart(
if (layers.isNotEmpty()) {
GenericMetricChart(
modelProducer = modelProducer,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.dp),
modifier = chartModifier,
layers = layers,
startAxis =
if (forwardData.isNotEmpty() || returnData.isNotEmpty()) {
@ -257,7 +242,5 @@ internal fun TracerouteMetricsChart(
vicoScrollState = vicoScrollState,
)
}
Legend(legendData = TRACEROUTE_LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
}
}

View file

@ -58,6 +58,7 @@ import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TracerouteOverlay
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
@ -83,7 +84,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.annotateTraceroute
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.proto.RouteDiscovery

View file

@ -27,7 +27,6 @@ import org.meshtastic.core.resources.host_metrics_log
import org.meshtastic.core.resources.ic_charging_station
import org.meshtastic.core.resources.ic_groups
import org.meshtastic.core.resources.ic_location_on
import org.meshtastic.core.resources.ic_map
import org.meshtastic.core.resources.ic_memory
import org.meshtastic.core.resources.ic_people
import org.meshtastic.core.resources.ic_power
@ -35,7 +34,6 @@ import org.meshtastic.core.resources.ic_route
import org.meshtastic.core.resources.ic_signal_cellular_alt
import org.meshtastic.core.resources.ic_thermostat
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.node_map
import org.meshtastic.core.resources.pax_metrics_log
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.resources.power_metrics_log
@ -44,7 +42,6 @@ import org.meshtastic.core.resources.traceroute_log
enum class LogsType(val titleRes: StringResource, val icon: DrawableResource, val routeFactory: (Int) -> Route) {
DEVICE(Res.string.device_metrics_log, Res.drawable.ic_charging_station, { NodeDetailRoute.DeviceMetrics(it) }),
NODE_MAP(Res.string.node_map, Res.drawable.ic_map, { NodeDetailRoute.NodeMap(it) }),
POSITIONS(Res.string.position_log, Res.drawable.ic_location_on, { NodeDetailRoute.PositionLog(it) }),
ENVIRONMENT(Res.string.env_metrics_log, Res.drawable.ic_thermostat, { NodeDetailRoute.EnvironmentMetrics(it) }),
SIGNAL(Res.string.signal_quality, Res.drawable.ic_signal_cellular_alt, { NodeDetailRoute.SignalMetrics(it) }),

View file

@ -122,11 +122,6 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
)
}
entry<NodeDetailRoute.NodeMap>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val mapScreen = org.meshtastic.core.ui.util.LocalNodeMapScreenProvider.current
mapScreen(args.destNum) { backStack.removeLastOrNull() }
}
entry<NodeDetailRoute.TracerouteLog>(metadata = { ListDetailSceneStrategy.extraPane() }) { args ->
val metricsViewModel = koinViewModel<MetricsViewModel> { parametersOf(args.destNum) }
metricsViewModel.setNodeId(args.destNum)

View file

@ -0,0 +1,185 @@
/*
* Copyright (c) 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.metrics
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.MeshLog
import org.meshtastic.proto.Data
import org.meshtastic.proto.FromRadio
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import org.meshtastic.proto.Paxcount as ProtoPaxcount
/**
* Tests for `MetricsViewModel.decodePaxFromLog()`.
*
* Uses a minimal testable subclass to access the protected function without wiring the full ViewModel dependency graph.
*/
class DecodePaxFromLogTest {
/**
* Minimal subclass that exposes `decodePaxFromLog` without requiring all ViewModel dependencies. `MetricsViewModel`
* is open, so we override with no-op constructor arguments are not needed we only call the self-contained
* `decodePaxFromLog` method.
*/
private val decoder =
object {
/** Delegates to MetricsViewModel logic extracted into a standalone helper for testing. */
fun decode(log: MeshLog): ProtoPaxcount? = decodePaxFromLogStandalone(log)
}
// ---- Binary proto path ----
@Test
fun binaryProto_validPaxcount_decoded() {
val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 3600)
val payload = ProtoPaxcount.ADAPTER.encode(pax)
val log = meshLogWithPacket(payload, wantResponse = false)
val result = decoder.decode(log)
assertNotNull(result)
assertEquals(10, result.wifi)
assertEquals(5, result.ble)
assertEquals(3600, result.uptime)
}
@Test
fun binaryProto_wantResponse_returnsNull() {
val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 100)
val payload = ProtoPaxcount.ADAPTER.encode(pax)
val log = meshLogWithPacket(payload, wantResponse = true)
assertNull(decoder.decode(log))
}
@Test
fun binaryProto_allZeroValues_returnsNull() {
val pax = ProtoPaxcount(wifi = 0, ble = 0, uptime = 0)
val payload = ProtoPaxcount.ADAPTER.encode(pax)
val log = meshLogWithPacket(payload, wantResponse = false)
assertNull(decoder.decode(log))
}
@Test
fun binaryProto_wrongPortNum_returnsNull() {
val pax = ProtoPaxcount(wifi = 10, ble = 5, uptime = 100)
val payload = ProtoPaxcount.ADAPTER.encode(pax)
val log = meshLogWithPacket(payload, wantResponse = false, portNum = PortNum.POSITION_APP)
assertNull(decoder.decode(log))
}
// ---- Base64 fallback path ----
@Test
fun base64Fallback_validPayload_decoded() {
val pax = ProtoPaxcount(wifi = 7, ble = 3, uptime = 500)
val bytes = ProtoPaxcount.ADAPTER.encode(pax)
val base64 = okio.ByteString.of(*bytes).base64()
val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = base64)
val result = decoder.decode(log)
assertNotNull(result)
assertEquals(7, result.wifi)
assertEquals(3, result.ble)
}
// ---- Hex fallback path ----
// Note: The hex path (`else if`) in the original code is unreachable for pure hex strings
// because hex chars [0-9a-fA-F] are a strict subset of base64 chars [A-Za-z0-9+/=].
// The base64 `if` branch always matches first. The hex fallback would only trigger for
// strings that fail the base64 regex but pass the hex regex — which is impossible given
// the charsets. This is documented here as a known design characteristic of decodePaxFromLog().
// ---- Error handling ----
@Test
fun invalidRawMessage_returnsNull() {
val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = "not-valid-anything!@#")
assertNull(decoder.decode(log))
}
@Test
fun emptyLog_returnsNull() {
val log = MeshLog(uuid = "test", message_type = "pax", received_date = 0, raw_message = "")
assertNull(decoder.decode(log))
}
// ---- Helpers ----
private fun meshLogWithPacket(
payload: ByteArray,
wantResponse: Boolean,
portNum: PortNum = PortNum.PAXCOUNTER_APP,
): MeshLog {
val data = Data(portnum = portNum, payload = payload.toByteString(), want_response = wantResponse)
val packet = MeshPacket(decoded = data)
val fromRadio = FromRadio(packet = packet)
return MeshLog(
uuid = "test",
message_type = "packet",
received_date = nowSeconds * 1000,
raw_message = "",
fromRadio = fromRadio,
)
}
}
/**
* Standalone reimplementation of `MetricsViewModel.decodePaxFromLog()` for testing.
*
* This avoids needing to instantiate the full ViewModel with all its dependencies. The logic is identical to the
* ViewModel method.
*/
@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount")
private fun decodePaxFromLogStandalone(log: MeshLog): ProtoPaxcount? {
try {
val packet = log.fromRadio.packet
val decoded = packet?.decoded
if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) {
if (decoded.want_response == true) return null
val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload)
if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) return pax
}
} catch (e: Exception) {
// Swallow, fall through to alternative parsing
}
try {
val base64 = log.raw_message.trim()
if (base64.matches(Regex("^[A-Za-z0-9+/=\\r\\n]+$"))) {
val bytes = base64.okioDecodeBase64()
return ProtoPaxcount.ADAPTER.decode(bytes)
} else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) {
val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
return ProtoPaxcount.ADAPTER.decode(bytes)
}
} catch (e: Exception) {
// Swallow
}
return null
}
private fun String.okioDecodeBase64(): ByteArray = this.decodeBase64()?.toByteArray() ?: ByteArray(0)

View file

@ -0,0 +1,275 @@
/*
* Copyright (c) 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.metrics
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Telemetry
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@Suppress("MagicNumber")
class EnvironmentMetricsForGraphingTest {
private val now = nowSeconds.toInt()
private fun telemetry(time: Int = now, env: EnvironmentMetrics) = Telemetry(time = time, environment_metrics = env)
// ---- Empty input ----
@Test
fun emptyMetrics_returnsDefaultGraphingData() {
val state = EnvironmentMetricsState(emptyList())
val result = state.environmentMetricsForGraphing()
assertTrue(result.metrics.isEmpty())
assertTrue(result.shouldPlot.none { it })
}
// ---- Fahrenheit conversion ----
@Test
fun useFahrenheit_convertsTemperatureMinMax() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(temperature = 0f)),
telemetry(env = EnvironmentMetrics(temperature = 100f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true)
assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal])
// 0C = 32F, 100C = 212F
assertEquals(32f, result.rightMinMax.first, 0.01f)
assertEquals(212f, result.rightMinMax.second, 0.01f)
}
@Test
fun useFahrenheit_convertsSoilTemperature() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(soil_temperature = 20f)),
telemetry(env = EnvironmentMetrics(soil_temperature = 30f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true)
assertTrue(result.shouldPlot[Environment.SOIL_TEMPERATURE.ordinal])
// 20C = 68F, 30C = 86F
assertEquals(68f, result.rightMinMax.first, 0.01f)
assertEquals(86f, result.rightMinMax.second, 0.01f)
}
// ---- Humidity filtering ----
@Test
fun humidity_zeroFilteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(relative_humidity = 0.0f)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.HUMIDITY.ordinal])
}
@Test
fun humidity_nonZeroIncluded() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(relative_humidity = 45f)),
telemetry(env = EnvironmentMetrics(relative_humidity = 65f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.HUMIDITY.ordinal])
assertEquals(45f, result.rightMinMax.first, 0.01f)
assertEquals(65f, result.rightMinMax.second, 0.01f)
}
// ---- IAQ sentinel filtering ----
@Test
fun iaq_intMinValueFilteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(iaq = Int.MIN_VALUE)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.IAQ.ordinal])
}
@Test
fun iaq_validValueIncluded() {
val metrics =
listOf(telemetry(env = EnvironmentMetrics(iaq = 50)), telemetry(env = EnvironmentMetrics(iaq = 150)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.IAQ.ordinal])
assertEquals(50f, result.rightMinMax.first, 0.01f)
assertEquals(150f, result.rightMinMax.second, 0.01f)
}
// ---- Soil moisture sentinel filtering ----
@Test
fun soilMoisture_intMinValueFilteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(soil_moisture = Int.MIN_VALUE)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.SOIL_MOISTURE.ordinal])
}
@Test
fun soilMoisture_validValueIncluded() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(soil_moisture = 30)),
telemetry(env = EnvironmentMetrics(soil_moisture = 70)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.SOIL_MOISTURE.ordinal])
}
// ---- Barometric pressure (left axis) ----
@Test
fun barometricPressure_onLeftAxis() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(barometric_pressure = 1013.25f)),
telemetry(env = EnvironmentMetrics(barometric_pressure = 1020.50f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal])
assertEquals(1013.25f, result.leftMinMax.first, 0.01f)
assertEquals(1020.50f, result.leftMinMax.second, 0.01f)
}
@Test
fun barometricPressure_doesNotAffectRightAxis() {
// Only pressure, no other metrics
val metrics = listOf(telemetry(env = EnvironmentMetrics(barometric_pressure = 1013.25f)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
// rightMinMax should be 0/1 defaults since no right-axis metrics
assertEquals(0f, result.rightMinMax.first, 0.01f)
assertEquals(1f, result.rightMinMax.second, 0.01f)
}
// ---- Lux, UV lux, wind speed, radiation ----
@Test
fun lux_plotted() {
val metrics =
listOf(telemetry(env = EnvironmentMetrics(lux = 500f)), telemetry(env = EnvironmentMetrics(lux = 1200f)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.LUX.ordinal])
assertEquals(500f, result.rightMinMax.first, 0.01f)
assertEquals(1200f, result.rightMinMax.second, 0.01f)
}
@Test
fun uvLux_plotted() {
val metrics =
listOf(telemetry(env = EnvironmentMetrics(uv_lux = 2f)), telemetry(env = EnvironmentMetrics(uv_lux = 8f)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.UV_LUX.ordinal])
}
@Test
fun windSpeed_plotted() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(wind_speed = 5f)),
telemetry(env = EnvironmentMetrics(wind_speed = 25f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.WIND_SPEED.ordinal])
}
@Test
fun radiation_positiveValuesOnly() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(radiation = 0f)),
telemetry(env = EnvironmentMetrics(radiation = 0.15f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.RADIATION.ordinal])
// 0f is filtered out (radiation > 0f only), so min should be 0.15
assertEquals(0.15f, result.rightMinMax.first, 0.01f)
assertEquals(0.15f, result.rightMinMax.second, 0.01f)
}
// ---- NaN filtering ----
@Test
fun nanTemperature_filteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(temperature = Float.NaN)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.TEMPERATURE.ordinal])
}
@Test
fun nanPressure_filteredOut() {
val metrics = listOf(telemetry(env = EnvironmentMetrics(barometric_pressure = Float.NaN)))
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal])
assertEquals(0f, result.leftMinMax.first, 0.01f)
assertEquals(0f, result.leftMinMax.second, 0.01f)
}
// ---- Multiple metrics combined ----
@Test
fun multipleMetrics_rightAxisMinMaxSpansAll() {
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(temperature = 10f, relative_humidity = 80f)),
telemetry(env = EnvironmentMetrics(temperature = 30f, relative_humidity = 40f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal])
assertTrue(result.shouldPlot[Environment.HUMIDITY.ordinal])
// right min/max should span both: min(10, 40) = 10, max(30, 80) = 80
assertEquals(10f, result.rightMinMax.first, 0.01f)
assertEquals(80f, result.rightMinMax.second, 0.01f)
}
// ---- Gas resistance ----
// ---- Gas resistance (not currently graphed by environmentMetricsForGraphing) ----
@Test
fun gasResistance_notPlottedByGraphingFunction() {
// Note: GAS_RESISTANCE is defined in the Environment enum but environmentMetricsForGraphing()
// does not have explicit handling for it. This test documents that current behavior.
val metrics =
listOf(
telemetry(env = EnvironmentMetrics(gas_resistance = 100f)),
telemetry(env = EnvironmentMetrics(gas_resistance = 500f)),
)
val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing()
assertFalse(result.shouldPlot[Environment.GAS_RESISTANCE.ordinal])
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright (c) 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.metrics
import org.meshtastic.proto.HardwareModel
import kotlin.test.Test
import kotlin.test.assertEquals
class HardwareModelSafeNumberTest {
@Test
fun knownModel_returnsValue() {
assertEquals(HardwareModel.TBEAM.value, HardwareModel.TBEAM.safeNumber())
}
@Test
fun unset_returnsZero() {
assertEquals(0, HardwareModel.UNSET.safeNumber())
}
@Test
fun customFallback_used() {
// Known model with custom fallback — should still return real value
assertEquals(HardwareModel.HELTEC_V3.value, HardwareModel.HELTEC_V3.safeNumber(fallbackValue = 999))
}
@Test
fun defaultFallback_isNegativeOne() {
// For known models the fallback is never used, but verify the API default
val result = HardwareModel.UNSET.safeNumber()
assertEquals(0, result) // UNSET.value is 0, not the fallback
}
}

View file

@ -0,0 +1,120 @@
/*
* Copyright (c) 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.model
import org.meshtastic.core.common.util.nowSeconds
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@Suppress("MagicNumber")
class TimeFrameTest {
// ---- timeThreshold ----
@Test
fun allTime_thresholdIsZero() {
assertEquals(0L, TimeFrame.ALL_TIME.timeThreshold(now = 1000000L))
}
@Test
fun oneHour_thresholdIsNowMinus3600() {
val now = 1000000L
assertEquals(now - 3600, TimeFrame.ONE_HOUR.timeThreshold(now = now))
}
@Test
fun twentyFourHours_thresholdIsNowMinus86400() {
val now = 1000000L
assertEquals(now - 86400, TimeFrame.TWENTY_FOUR_HOURS.timeThreshold(now = now))
}
@Test
fun sevenDays_thresholdIsNowMinus604800() {
val now = 1000000L
assertEquals(now - 604800, TimeFrame.SEVEN_DAYS.timeThreshold(now = now))
}
@Test
fun twoWeeks_thresholdIsCorrect() {
val now = 2000000L
assertEquals(now - 1209600, TimeFrame.TWO_WEEKS.timeThreshold(now = now))
}
@Test
fun oneMonth_thresholdIsCorrect() {
val now = 3000000L
assertEquals(now - 2592000, TimeFrame.ONE_MONTH.timeThreshold(now = now))
}
// ---- isAvailable ----
@Test
fun allTime_alwaysAvailable() {
assertTrue(TimeFrame.ALL_TIME.isAvailable(oldestTimestampSeconds = nowSeconds, now = nowSeconds))
}
@Test
fun oneHour_alwaysAvailable() {
assertTrue(TimeFrame.ONE_HOUR.isAvailable(oldestTimestampSeconds = nowSeconds, now = nowSeconds))
}
@Test
fun twentyFourHours_availableWhenDataOlderThan24h() {
val now = 1000000L
val oldest = now - 90000 // 25 hours ago
assertTrue(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now))
}
@Test
fun twentyFourHours_notAvailableWhenDataYoungerThan24h() {
val now = 1000000L
val oldest = now - 3600 // 1 hour ago
assertFalse(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now))
}
@Test
fun sevenDays_notAvailableForTwoDayOldData() {
val now = 1000000L
val oldest = now - (2 * 86400) // 2 days ago
assertFalse(TimeFrame.SEVEN_DAYS.isAvailable(oldestTimestampSeconds = oldest, now = now))
}
@Test
fun sevenDays_availableForEightDayOldData() {
val now = 1000000L
val oldest = now - (8 * 86400) // 8 days ago
assertTrue(TimeFrame.SEVEN_DAYS.isAvailable(oldestTimestampSeconds = oldest, now = now))
}
@Test
fun isAvailable_exactBoundary_returnsTrue() {
val now = 1000000L
// Exactly 24 hours of data range
val oldest = now - 86400
assertTrue(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now))
}
@Test
fun isAvailable_justUnderBoundary_returnsFalse() {
val now = 1000000L
// One second less than 24 hours
val oldest = now - 86399
assertFalse(TimeFrame.TWENTY_FOUR_HOURS.isAvailable(oldestTimestampSeconds = oldest, now = now))
}
}