mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(metrics/map): DRY up charts, decompose MapView monoliths, add test coverage (#5049)
This commit is contained in:
parent
56332f4d77
commit
520fa717a9
71 changed files with 3464 additions and 2169 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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") },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue