From 70a1b3c4796a9dcced019df81fd31580e7db2e59 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Wed, 28 Jan 2026 18:52:59 -0600 Subject: [PATCH] Refactor: Replace custom charts with Vico library (#4348) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../mesh/navigation/NodesNavigation.kt | 17 + .../org/meshtastic/core/model/NeighborInfo.kt | 51 +++ .../org/meshtastic/core/navigation/Routes.kt | 2 + .../composeResources/values/strings.xml | 1 - feature/node/build.gradle.kts | 3 + .../component/TelemetricActionsSection.kt | 18 +- .../node/detail/NodeDetailViewModel.kt | 32 +- .../feature/node/detail/NodeRequestActions.kt | 4 +- .../feature/node/metrics/BaseMetricChart.kt | 94 +++++ .../feature/node/metrics/ChartStyling.kt | 269 ++++++++++++++ .../feature/node/metrics/CommonCharts.kt | 275 +++++++------- .../feature/node/metrics/DeviceMetrics.kt | 304 ++++++++-------- .../feature/node/metrics/EnvironmentCharts.kt | 338 ++++++----------- .../node/metrics/EnvironmentMetrics.kt | 102 ++++-- .../node/metrics/EnvironmentMetricsState.kt | 35 +- .../feature/node/metrics/GraphUtil.kt | 122 ------- .../feature/node/metrics/HostMetricsLog.kt | 6 +- .../feature/node/metrics/MetricsViewModel.kt | 32 +- .../feature/node/metrics/NeighborInfoLog.kt | 236 ++++++++++++ .../feature/node/metrics/PaxMetrics.kt | 283 +++++++++------ .../feature/node/metrics/PowerMetrics.kt | 343 ++++++++---------- .../feature/node/metrics/SignalMetrics.kt | 275 +++++++------- .../feature/node/metrics/TracerouteLog.kt | 55 +-- .../meshtastic/feature/node/model/LogsType.kt | 7 +- .../feature/node/model/MetricsState.kt | 87 +---- .../metrics/EnvironmentMetricsStateTest.kt | 32 +- gradle/libs.versions.toml | 4 + 27 files changed, 1689 insertions(+), 1338 deletions(-) create mode 100644 core/model/src/main/kotlin/org/meshtastic/core/model/NeighborInfo.kt create mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt create mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt delete mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/GraphUtil.kt create mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt index f5cd4341b..0a8e50f34 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodesNavigation.kt @@ -18,6 +18,7 @@ package com.geeksville.mesh.navigation import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.CellTower +import androidx.compose.material.icons.rounded.Groups import androidx.compose.material.icons.rounded.LightMode import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.Memory @@ -49,6 +50,7 @@ import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.device import org.meshtastic.core.strings.environment import org.meshtastic.core.strings.host +import org.meshtastic.core.strings.neighbor_info import org.meshtastic.core.strings.pax import org.meshtastic.core.strings.position_log import org.meshtastic.core.strings.power @@ -61,6 +63,7 @@ import org.meshtastic.feature.node.metrics.DeviceMetricsScreen import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen import org.meshtastic.feature.node.metrics.HostMetricsLogScreen import org.meshtastic.feature.node.metrics.MetricsViewModel +import org.meshtastic.feature.node.metrics.NeighborInfoLogScreen import org.meshtastic.feature.node.metrics.PaxMetricsScreen import org.meshtastic.feature.node.metrics.PositionLogScreen import org.meshtastic.feature.node.metrics.PowerMetricsScreen @@ -234,6 +237,14 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo ) { it.destNum } + NodeDetailRoutes.NeighborInfoLog::class -> + addNodeDetailScreenComposable( + navController, + entry, + entry.screenComposable, + ) { + it.destNum + } else -> Unit } } @@ -312,6 +323,12 @@ enum class NodeDetailRoute( Icons.Rounded.PermScanWifi, { metricsVM, onNavigateUp -> TracerouteLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) }, ), + NEIGHBOR_INFO( + Res.string.neighbor_info, + NodeDetailRoutes.NeighborInfoLog::class, + Icons.Rounded.Groups, + { metricsVM, onNavigateUp -> NeighborInfoLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) }, + ), POWER( Res.string.power, NodeDetailRoutes.PowerMetrics::class, diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/NeighborInfo.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/NeighborInfo.kt new file mode 100644 index 000000000..f8ff59fc0 --- /dev/null +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/NeighborInfo.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import org.meshtastic.proto.MeshProtos + +val MeshProtos.MeshPacket.neighborInfo: MeshProtos.NeighborInfo? + get() = + if (hasDecoded() && decoded.portnumValue == 71) { // NEIGHBORINFO_APP_VALUE = 71 + runCatching { MeshProtos.NeighborInfo.parseFrom(decoded.payload) }.getOrNull() + } else { + null + } + +fun MeshProtos.NeighborInfo.getNeighborInfoResponse( + getUser: (nodeNum: Int) -> String, + header: String = "Neighbors:", +): String = buildString { + append(header) + append("\n\n") + if (neighborsList.isEmpty()) { + append("No neighbors reported.") + } else { + neighborsList.forEach { n -> + append("• ") + append(getUser(n.nodeId)) + append(" (SNR: ") + append(n.snr) + append(")\n") + } + } +} + +fun MeshProtos.MeshPacket.getNeighborInfoResponse( + getUser: (nodeNum: Int) -> String, + header: String = "Neighbors:", +): String? = neighborInfo?.getNeighborInfoResponse(getUser, header) diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt index e5d7462c1..d3a43e392 100644 --- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -82,6 +82,8 @@ object NodeDetailRoutes { @Serializable data class HostMetricsLog(val destNum: Int) : Route @Serializable data class PaxMetrics(val destNum: Int) : Route + + @Serializable data class NeighborInfoLog(val destNum: Int) : Route } object SettingsRoutes { diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index ffaf934e7..89fec2c5a 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -425,7 +425,6 @@ Position Last position update Environment Metrics - Signal Metrics Administration Remote Administration Bad diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index 055df4f4a..fae12946a 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -53,6 +53,9 @@ dependencies { implementation(libs.markdown.renderer.android) implementation(libs.markdown.renderer.m3) implementation(libs.markdown.renderer) + implementation(libs.vico.compose) + implementation(libs.vico.compose.m2) + implementation(libs.vico.compose.m3) googleImplementation(libs.location.services) googleImplementation(libs.maps.compose) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt index 6a2ce3c80..38bd41eed 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt @@ -51,19 +51,15 @@ import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.logs -import org.meshtastic.core.strings.neighbor_info import org.meshtastic.core.strings.request_air_quality_metrics -import org.meshtastic.core.strings.request_local_stats import org.meshtastic.core.strings.request_telemetry import org.meshtastic.core.strings.telemetry import org.meshtastic.core.strings.userinfo import org.meshtastic.core.ui.icon.AirQuality import org.meshtastic.core.ui.icon.Chart -import org.meshtastic.core.ui.icon.Groups import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Person import org.meshtastic.core.ui.icon.Refresh -import org.meshtastic.core.ui.icon.Speed import org.meshtastic.core.ui.icon.Temperature import org.meshtastic.feature.node.model.LogsType import org.meshtastic.feature.node.model.MetricsState @@ -137,9 +133,10 @@ private fun rememberTelemetricFeatures( isVisible = { !isLocal }, ), TelemetricFeature( - titleRes = Res.string.neighbor_info, - icon = MeshtasticIcons.Groups, + 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, @@ -147,9 +144,9 @@ private fun rememberTelemetricFeatures( TelemetricFeature( titleRes = LogsType.SIGNAL.titleRes, icon = LogsType.SIGNAL.icon, - requestAction = null, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) }, logsType = LogsType.SIGNAL, - isVisible = { it.hopsAway == 0 && !isLocal }, + isVisible = { !isLocal }, ), TelemetricFeature( titleRes = LogsType.DEVICE.titleRes, @@ -178,11 +175,6 @@ private fun rememberTelemetricFeatures( content = { PowerMetrics(it) }, hasContent = { it.hasPowerMetrics }, ), - TelemetricFeature( - titleRes = Res.string.request_local_stats, - icon = MeshtasticIcons.Speed, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) }, - ), TelemetricFeature( titleRes = LogsType.HOST.titleRes, icon = LogsType.HOST.icon, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index c392c0587..ff21843f3 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -126,6 +126,19 @@ constructor( .distinctUntilChanged() val trResFlow = meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP_VALUE).distinctUntilChanged() + val niReqsFlow = + meshLogRepository + .getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP_VALUE) + .map { logs -> + logs.filter { log -> + with(log.fromRadio.packet) { + hasDecoded() && decoded.wantResponse && from == 0 && to == nodeId + } + } + } + .distinctUntilChanged() + val niResFlow = + meshLogRepository.getLogsFrom(nodeId, PortNum.NEIGHBORINFO_APP_VALUE).distinctUntilChanged() combine( nodeRepository.ourNodeInfo, @@ -139,6 +152,8 @@ constructor( paxLogsFlow, trReqsFlow, trResFlow, + niReqsFlow, + niResFlow, meshLogRepository.getMyNodeInfo().map { it?.firmwareEdition }.distinctUntilChanged(), firmwareReleaseRepository.stableRelease, firmwareReleaseRepository.alphaRelease, @@ -159,11 +174,13 @@ constructor( paxLogs = args[8] as List, tracerouteRequests = args[9] as List, tracerouteResults = args[10] as List, - firmwareEdition = args[11] as MeshProtos.FirmwareEdition?, - stable = args[12] as FirmwareRelease?, - alpha = args[13] as FirmwareRelease?, - lastTracerouteTime = (args[14] as Map)[nodeId], - lastRequestNeighborsTime = (args[15] as Map)[nodeId], + neighborInfoRequests = args[11] as List, + neighborInfoResults = args[12] as List, + firmwareEdition = args[13] as MeshProtos.FirmwareEdition?, + stable = args[14] as FirmwareRelease?, + alpha = args[15] as FirmwareRelease?, + lastTracerouteTime = (args[16] as Map)[nodeId], + lastRequestNeighborsTime = (args[17] as Map)[nodeId], ) } .flatMapLatest { data -> @@ -194,6 +211,8 @@ constructor( paxMetrics = data.paxLogs, tracerouteRequests = data.tracerouteRequests, tracerouteResults = data.tracerouteResults, + neighborInfoRequests = data.neighborInfoRequests, + neighborInfoResults = data.neighborInfoResults, firmwareEdition = data.firmwareEdition, latestStableFirmware = data.stable ?: FirmwareRelease(), latestAlphaFirmware = data.alpha ?: FirmwareRelease(), @@ -220,6 +239,7 @@ constructor( if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL) if (metricsState.hasPowerMetrics()) add(LogsType.POWER) if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE) + if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO) if (metricsState.hasHostMetrics()) add(LogsType.HOST) if (metricsState.hasPaxMetrics()) add(LogsType.PAX) } @@ -314,6 +334,8 @@ private data class NodeDetailUiStateData( val paxLogs: List, val tracerouteRequests: List, val tracerouteResults: List, + val neighborInfoRequests: List, + val neighborInfoResults: List, val firmwareEdition: MeshProtos.FirmwareEdition?, val stable: FirmwareRelease?, val alpha: FirmwareRelease?, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt index f5874e4f5..2c246c501 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeRequestActions.kt @@ -38,10 +38,10 @@ import org.meshtastic.core.strings.request_air_quality_metrics import org.meshtastic.core.strings.request_device_metrics import org.meshtastic.core.strings.request_environment_metrics import org.meshtastic.core.strings.request_host_metrics -import org.meshtastic.core.strings.request_local_stats import org.meshtastic.core.strings.request_pax_metrics import org.meshtastic.core.strings.request_power_metrics import org.meshtastic.core.strings.requesting_from +import org.meshtastic.core.strings.signal_quality import org.meshtastic.core.strings.traceroute import org.meshtastic.core.strings.user_info import javax.inject.Inject @@ -128,7 +128,7 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics TelemetryType.POWER -> Res.string.request_power_metrics - TelemetryType.LOCAL_STATS -> Res.string.request_local_stats + TelemetryType.LOCAL_STATS -> Res.string.signal_quality TelemetryType.HOST -> Res.string.request_host_metrics TelemetryType.PAX -> Res.string.request_pax_metrics } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt new file mode 100644 index 000000000..8cd100143 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.Zoom +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis +import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker +import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarkerVisibilityListener +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState + +/** + * A generic chart host for Meshtastic metric charts. Handles common boilerplate for markers, scrolling, and point + * selection synchronization. + * + * @param modelProducer The [CartesianChartModelProducer] for the chart. + * @param layers The chart layers (e.g., LineCartesianLayer). + * @param modifier The modifier for the chart host. + * @param startAxis The start vertical axis. + * @param endAxis The end vertical axis. + * @param bottomAxis The bottom horizontal axis. + * @param marker The marker to show on interaction. + * @param selectedX The currently selected X value (used for persistent markers). + * @param onPointSelected Callback when a point is selected via interaction. + * @param vicoScrollState The scroll state for the chart. + */ +@Composable +fun GenericMetricChart( + modelProducer: CartesianChartModelProducer, + layers: List, + modifier: Modifier = Modifier, + startAxis: VerticalAxis? = null, + endAxis: VerticalAxis? = null, + bottomAxis: HorizontalAxis? = null, + marker: CartesianMarker? = null, + selectedX: Double? = null, + onPointSelected: ((Double) -> Unit)? = null, + vicoScrollState: VicoScrollState = rememberVicoScrollState(), +) { + val markerVisibilityListener = + remember(onPointSelected) { + object : CartesianMarkerVisibilityListener { + override fun onShown(marker: CartesianMarker, targets: List) { + targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } + } + + override fun onUpdated(marker: CartesianMarker, targets: List) { + targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) } + } + } + } + + CartesianChartHost( + chart = + @Suppress("SpreadOperator") + rememberCartesianChart( + *layers.toTypedArray(), + startAxis = startAxis, + endAxis = endAxis, + bottomAxis = bottomAxis, + marker = marker, + markerVisibilityListener = markerVisibilityListener, + persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null }, + ), + modelProducer = modelProducer, + modifier = modifier, + scrollState = vicoScrollState, + zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content), + ) +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt new file mode 100644 index 000000000..11d758038 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/ChartStyling.kt @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +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.HorizontalAxis +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine +import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker +import com.patrykandpatrick.vico.compose.cartesian.marker.DefaultCartesianMarker +import com.patrykandpatrick.vico.compose.cartesian.marker.LineCartesianLayerMarkerTarget +import com.patrykandpatrick.vico.compose.cartesian.marker.rememberDefaultCartesianMarker +import com.patrykandpatrick.vico.compose.common.Fill +import com.patrykandpatrick.vico.compose.common.Insets +import com.patrykandpatrick.vico.compose.common.MarkerCornerBasedShape +import com.patrykandpatrick.vico.compose.common.component.ShapeComponent +import com.patrykandpatrick.vico.compose.common.component.TextComponent +import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent +import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent +import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent + +/** + * Utility object for chart styling and component creation. Provides reusable styled lines, points, and axes for Vico + * charts. + */ +object ChartStyling { + // Point sizes + const val SMALL_POINT_SIZE_DP = 6f + const val MEDIUM_POINT_SIZE_DP = 8f + const val LARGE_POINT_SIZE_DP = 10f + + // Line stroke widths + const val THIN_LINE_WIDTH_DP = 1.5f + const val MEDIUM_LINE_WIDTH_DP = 2f + const val THICK_LINE_WIDTH_DP = 2.5f + + /** + * Creates a solid line with optional point markers. + * + * @param lineColor The color of the line + * @param pointSize Size of point markers (in dp). If null, no point markers are shown. + * @param lineWidth Width of the line in dp + * @return Configured [LineCartesianLayer.Line] + */ + @Composable + fun createStyledLine( + lineColor: Color, + pointSize: Float? = MEDIUM_POINT_SIZE_DP, + lineWidth: Float = MEDIUM_LINE_WIDTH_DP, + ): LineCartesianLayer.Line = LineCartesianLayer.rememberLine( + fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), + pointProvider = + pointSize?.let { + LineCartesianLayer.PointProvider.single( + LineCartesianLayer.Point( + rememberShapeComponent(fill = Fill(lineColor), shape = CircleShape), + size = it.dp, + ), + ) + }, + stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), + ) + + /** + * Creates a transparent line (no line, only points). Useful for distinguishing multiple metrics on the same chart. + * + * @param pointColor The color of the point markers + * @param pointSize Size of point markers in dp + * @return Configured [LineCartesianLayer.Line] + */ + @Composable + fun createPointOnlyLine(pointColor: Color, pointSize: Float = MEDIUM_POINT_SIZE_DP): LineCartesianLayer.Line = + LineCartesianLayer.rememberLine( + // we still need to give the line a color, the Marker derives the label color from the line + fill = LineCartesianLayer.LineFill.single(Fill(pointColor)), + // magic sauce to make the line disappear + stroke = LineCartesianLayer.LineStroke.Dashed(thickness = 0.dp, dashLength = 0.dp), + pointProvider = + LineCartesianLayer.PointProvider.single( + LineCartesianLayer.Point( + rememberShapeComponent(fill = Fill(pointColor), shape = CircleShape), + size = pointSize.dp, + ), + ), + ) + + /** + * Creates a line with a gradient fill effect. The gradient goes from the line color to transparent. + * + * @param lineColor The primary color of the line + * @param pointSize Size of point markers (in dp). If null, no point markers are shown. + * @param lineWidth Width of the line in dp + * @return Configured [LineCartesianLayer.Line] + */ + @Composable + fun createGradientLine( + lineColor: Color, + pointSize: Float? = MEDIUM_POINT_SIZE_DP, + lineWidth: Float = MEDIUM_LINE_WIDTH_DP, + ): LineCartesianLayer.Line { + val gradientBrush = + Brush.verticalGradient(colors = listOf(lineColor.copy(alpha = 0.3f), lineColor.copy(alpha = 0.1f))) + return LineCartesianLayer.rememberLine( + fill = LineCartesianLayer.LineFill.single(Fill(lineColor)), + areaFill = LineCartesianLayer.AreaFill.single(Fill(gradientBrush)), + pointProvider = + pointSize?.let { + LineCartesianLayer.PointProvider.single( + LineCartesianLayer.Point( + rememberShapeComponent(fill = Fill(lineColor), shape = CircleShape), + size = it.dp, + ), + ) + }, + stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp), + ) + } + + /** + * Creates a bold line suitable for highlighting primary metrics. + * + * @param lineColor The color of the line + * @param pointSize Size of point markers (in dp). If null, no point markers are shown. + * @return Configured [LineCartesianLayer.Line] + */ + @Composable + fun createBoldLine(lineColor: Color, pointSize: Float? = LARGE_POINT_SIZE_DP): LineCartesianLayer.Line = + createStyledLine(lineColor = lineColor, pointSize = pointSize, lineWidth = THICK_LINE_WIDTH_DP) + + /** + * Creates a subtle line suitable for secondary metrics. + * + * @param lineColor The color of the line + * @param pointSize Size of point markers (in dp). If null, no point markers are shown. + * @return Configured [LineCartesianLayer.Line] + */ + @Composable + fun createSubtleLine(lineColor: Color, pointSize: Float? = SMALL_POINT_SIZE_DP): LineCartesianLayer.Line = + createStyledLine(lineColor = lineColor, pointSize = pointSize, lineWidth = THIN_LINE_WIDTH_DP) + + /** + * Gets Material 3 theme-aware colors with opacity. Useful for creating color variants while respecting the current + * theme. + * + * @param baseColor The base color + * @param alpha The alpha/opacity value (0f-1f) + * @return Color with adjusted alpha + */ + fun createThemedColor(baseColor: Color, alpha: Float = 1f): Color = baseColor.copy(alpha = alpha) + + /** + * Creates and remembers a default [CartesianMarker] styled for the Meshtastic theme. + * + * @param valueFormatter The formatter for the marker label content. + * @param showIndicator Whether to show the point indicator on the line/column. + * @return A configured [CartesianMarker] + */ + @Composable + fun rememberMarker( + valueFormatter: DefaultCartesianMarker.ValueFormatter = + DefaultCartesianMarker.ValueFormatter.default(colorCode = true), + showIndicator: Boolean = true, + ): CartesianMarker { + val labelBackground = + rememberShapeComponent( + fill = Fill(MaterialTheme.colorScheme.surfaceContainerHigh), + shape = MarkerCornerBasedShape(MaterialTheme.shapes.extraSmall), + strokeFill = Fill(MaterialTheme.colorScheme.outlineVariant), + strokeThickness = 1.dp, + ) + val label = + rememberTextComponent( + style = + TextStyle( + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + ), + background = labelBackground, + padding = Insets(horizontal = 8.dp, vertical = 4.dp), + margins = Insets(bottom = 4.dp), + ) + val guideline = + rememberLineComponent( + fill = Fill(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)), + thickness = 1.dp, + ) + + val indicator = + if (showIndicator) { + // Force alpha to 1f so the indicator is visible even for "invisible" lines + { color: Color -> ShapeComponent(fill = Fill(color.copy(alpha = 1f)), shape = CircleShape) } + } else { + null + } + + return rememberDefaultCartesianMarker( + label = label, + valueFormatter = valueFormatter, + guideline = guideline, + indicator = indicator, + ) + } + + /** + * Creates a [DefaultCartesianMarker.ValueFormatter] that colors the text to match the series color. + * + * @param format A lambda that provides the string content for a given Y value and its series color. + */ + fun createColoredMarkerValueFormatter( + format: (value: Double, color: Color) -> String, + ): DefaultCartesianMarker.ValueFormatter = DefaultCartesianMarker.ValueFormatter { _, targets -> + buildAnnotatedString { + targets.forEachIndexed { index, target -> + if (index > 0) append(", ") + 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) } + } + } + } + } + } + + /** + * Creates a standard [HorizontalAxis.ItemPlacer] with optimized spacing. + * + * @param spacing The number of data points to skip between labels. + */ + fun rememberItemPlacer(spacing: Int = 50): HorizontalAxis.ItemPlacer = + HorizontalAxis.ItemPlacer.aligned(spacing = { spacing }, addExtremeLabelPadding = true) + + /** + * Creates and remembers a [com.patrykandpatrick.vico.compose.common.component.TextComponent] styled for axis + * labels. + */ + @Composable + fun rememberAxisLabel(color: Color = MaterialTheme.colorScheme.onSurfaceVariant): TextComponent = + rememberTextComponent(style = TextStyle(color = color, fontSize = 10.sp, fontWeight = FontWeight.Medium)) +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt index cc1ac4850..90846e9a9 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt @@ -14,25 +14,34 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("MagicNumber") + package org.meshtastic.feature.node.metrics -import android.graphics.Paint -import android.graphics.Typeface import androidx.compose.foundation.Canvas +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.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Info import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -40,48 +49,96 @@ import androidx.compose.material3.TextButton 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.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import com.patrykandpatrick.vico.compose.cartesian.CartesianDrawingContext +import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.close +import org.meshtastic.core.strings.delete import org.meshtastic.core.strings.info import org.meshtastic.core.strings.logs import org.meshtastic.core.strings.rssi import org.meshtastic.core.strings.snr -import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_MINUTE_FORMAT -import org.meshtastic.feature.node.metrics.CommonCharts.MAX_PERCENT_VALUE -import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC +import org.meshtastic.core.ui.icon.Delete +import org.meshtastic.core.ui.icon.MeshtasticIcons import java.text.DateFormat +import java.util.Date object CommonCharts { val DATE_TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) val TIME_MINUTE_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.SHORT) + val TIME_SECONDS_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM) val DATE_TIME_MINUTE_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) + val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT) const val MS_PER_SEC = 1000L const val MAX_PERCENT_VALUE = 100f -} + const val SCROLL_BIAS = 0.5f -private const val LINE_ON = 10f -private const val LINE_OFF = 20f -private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM) -private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT) -private const val DATE_Y = 32f -private const val LINE_LIMIT = 4 -private const val TEXT_PAINT_ALPHA = 192 + /** + * Gets the Material 3 primary color with optional opacity adjustment. + * + * @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque). + * @return Color based on current theme's primary color. + */ + @Composable + fun getMaterial3PrimaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.primary.copy(alpha = alpha) + + /** + * Gets the Material 3 secondary color with optional opacity adjustment. + * + * @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque). + * @return Color based on current theme's secondary color. + */ + @Composable + fun getMaterial3SecondaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha) + + /** + * Gets the Material 3 tertiary color with optional opacity adjustment. + * + * @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque). + * @return Color based on current theme's tertiary color. + */ + @Composable + fun getMaterial3TertiaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.tertiary.copy(alpha = alpha) + + /** + * Gets the Material 3 error color with optional opacity adjustment. + * + * @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque). + * @return Color based on current theme's error color. + */ + @Composable + fun getMaterial3ErrorColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.error.copy(alpha = alpha) + + /** A dynamic [CartesianValueFormatter] that adjusts the time format based on the visible X range. */ + val dynamicTimeFormatter = CartesianValueFormatter { context, value, _ -> + val date = Date((value * MS_PER_SEC.toDouble()).toLong()) + val xLength = context.ranges.xLength + val zoom = if (context is CartesianDrawingContext) context.zoom else 1f + val visibleSpan = xLength / zoom + + val formatter = + when { + visibleSpan <= 3600 -> TIME_SECONDS_FORMAT // < 1 hour visible + visibleSpan <= 86400 * 2 -> TIME_MINUTE_FORMAT // < 2 days visible + visibleSpan <= 86400 * 14 -> DATE_TIME_MINUTE_FORMAT // < 2 weeks visible + else -> DATE_FORMAT + } + formatter.format(date) + } +} data class LegendData( val nameRes: StringResource, @@ -106,137 +163,6 @@ fun ChartHeader(amount: Int) { } } -/** - * Draws chart lines with respect to the Y-axis. - * - * @param lineColors A list of 5 [Color]s for the chart lines, 0 being the lowest line on the chart. - */ -@Composable -fun HorizontalLinesOverlay(modifier: Modifier, lineColors: List) { - /* 100 is a good number to divide into quarters */ - val verticalSpacing = MAX_PERCENT_VALUE / LINE_LIMIT - Canvas(modifier = modifier) { - val lineStart = 0f - val height = size.height - val width = size.width - /* Horizontal Lines */ - var lineY = 0f - for (i in 0..LINE_LIMIT) { - val ratio = lineY / MAX_PERCENT_VALUE - val y = height - (ratio * height) - drawLine( - start = Offset(lineStart, y), - end = Offset(width, y), - color = lineColors[i], - strokeWidth = 1.dp.toPx(), - cap = StrokeCap.Round, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f), - ) - lineY += verticalSpacing - } - } -} - -/** Draws labels on the Y-axis with respect to the range. Defined by (`maxValue` - `minValue`). */ -@Composable -fun YAxisLabels(modifier: Modifier, labelColor: Color, minValue: Float, maxValue: Float) { - val range = maxValue - minValue - val verticalSpacing = range / LINE_LIMIT - val density = LocalDensity.current - Canvas(modifier = modifier) { - val height = size.height - - /* Y Labels */ - val textPaint = - Paint().apply { - color = labelColor.toArgb() - textAlign = Paint.Align.LEFT - textSize = density.run { 12.dp.toPx() } - typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)) - alpha = TEXT_PAINT_ALPHA - } - - drawContext.canvas.nativeCanvas.apply { - var label = minValue - repeat(LINE_LIMIT + 1) { - val ratio = (label - minValue) / range - val y = height - (ratio * height) - drawText("${label.toInt()}", 0f, y + 4.dp.toPx(), textPaint) - label += verticalSpacing - } - } - } -} - -/** Draws the vertical lines to help the user relate the plotted data within a time frame. */ -@Composable -fun TimeAxisOverlay(modifier: Modifier, oldest: Int, newest: Int, timeInterval: Long) { - val range = newest - oldest - val density = LocalDensity.current - val lineColor = MaterialTheme.colorScheme.onSurface - Canvas(modifier = modifier) { - val height = size.height - val width = size.width - - /* Cut out the time remaining in order to place the lines on the dot. */ - val timeRemaining = oldest % timeInterval - var current = oldest.toLong() - current -= timeRemaining - current += timeInterval - - val textPaint = - Paint().apply { - color = lineColor.toArgb() - textAlign = Paint.Align.LEFT - textSize = density.run { 12.dp.toPx() } - typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)) - alpha = TEXT_PAINT_ALPHA - } - - /* Vertical Lines with labels */ - drawContext.canvas.nativeCanvas.apply { - while (current <= newest) { - val ratio = (current - oldest).toFloat() / range - val x = (ratio * width) - drawLine( - start = Offset(x, 0f), - end = Offset(x, height), - color = lineColor, - strokeWidth = 1.dp.toPx(), - cap = StrokeCap.Round, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f), - ) - - /* Time */ - drawText(TIME_FORMAT.format(current * MS_PER_SEC), x, 0f, textPaint) - /* Date */ - drawText(DATE_FORMAT.format(current * MS_PER_SEC), x, DATE_Y, textPaint) - current += timeInterval - } - } - } -} - -/** Draws the `oldest` and `newest` times for the respective telemetry data. Expects time in seconds. */ -@Composable -fun TimeLabels(oldest: Int, newest: Int) { - Row { - Text( - text = DATE_TIME_MINUTE_FORMAT.format(oldest * MS_PER_SEC), - modifier = Modifier.wrapContentWidth(), - style = TextStyle(fontWeight = FontWeight.Bold), - fontSize = 12.sp, - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - text = DATE_TIME_MINUTE_FORMAT.format(newest * MS_PER_SEC), - modifier = Modifier.wrapContentWidth(), - style = TextStyle(fontWeight = FontWeight.Bold), - fontSize = 12.sp, - ) - } -} - /** * Creates the legend that identifies the colors used for the graph. * @@ -326,6 +252,57 @@ private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) { ) } +@Composable +fun DeleteItem(onClick: () -> Unit) { + DropdownMenuItem( + onClick = onClick, + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = MeshtasticIcons.Delete, + contentDescription = stringResource(Res.string.delete), + tint = MaterialTheme.colorScheme.error, + ) + Spacer(modifier = Modifier.width(12.dp)) + Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error) + } + }, + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +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), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Box( + modifier = + Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) + } + Text( + text = text, + style = MaterialTheme.typography.titleMediumEmphasized, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @Preview @Composable private fun LegendPreview() { diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index 5d85d2d73..f5b86c3e5 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -14,12 +14,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("MagicNumber") + package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.horizontalScroll +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 import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -28,12 +29,13 @@ 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.items -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState 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.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -44,26 +46,28 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState 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.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.meshtastic.core.strings.getString +import com.patrykandpatrick.vico.compose.cartesian.Scroll +import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis +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 com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.strings.Res @@ -73,10 +77,9 @@ import org.meshtastic.core.strings.battery import org.meshtastic.core.strings.ch_util_definition import org.meshtastic.core.strings.channel_air_util import org.meshtastic.core.strings.channel_utilization +import org.meshtastic.core.strings.device_metrics_log import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MaterialBatteryInfo -import org.meshtastic.core.ui.component.OptionLabel -import org.meshtastic.core.ui.component.SlidingSelector import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.core.ui.theme.AppTheme @@ -85,18 +88,10 @@ import org.meshtastic.core.ui.theme.GraphColors.Green import org.meshtastic.core.ui.theme.GraphColors.Magenta import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT -import org.meshtastic.feature.node.metrics.CommonCharts.MAX_PERCENT_VALUE import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC -import org.meshtastic.feature.node.metrics.GraphUtil.createPath -import org.meshtastic.feature.node.metrics.GraphUtil.plotPoint -import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.proto.TelemetryProtos import org.meshtastic.proto.TelemetryProtos.Telemetry -private const val CHART_WEIGHT = 1f -private const val Y_AXIS_WEIGHT = 0.1f -private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT) - private enum class Device(val color: Color) { BATTERY(Green) { override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.batteryLevel.toFloat() @@ -134,8 +129,12 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } var displayInfoDialog by remember { mutableStateOf(false) } - val selectedTimeFrame by viewModel.timeFrame.collectAsState() - val data = state.deviceMetricsFiltered(selectedTimeFrame) + val data = state.deviceMetrics + + val lazyListState = rememberLazyListState() + val vicoScrollState = rememberVicoScrollState() + val coroutineScope = rememberCoroutineScope() + var selectedX by remember { mutableStateOf(null) } LaunchedEffect(Unit) { viewModel.effects.collect { effect -> @@ -152,6 +151,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat topBar = { MainAppBar( title = state.node?.user?.longName ?: "", + subtitle = stringResource(Res.string.device_metrics_log), ourNode = null, showNodeChip = false, canNavigateUp = true, @@ -183,20 +183,33 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat DeviceMetricsChart( modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f), telemetries = data.reversed(), - selectedTimeFrame, promptInfoDialog = { displayInfoDialog = true }, + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onPointSelected = { x -> + selectedX = x + val index = data.indexOfFirst { it.time.toDouble() == x } + if (index != -1) { + coroutineScope.launch { lazyListState.animateScrollToItem(index) } + } + }, ) - SlidingSelector( - TimeFrame.entries.toList(), - selectedTimeFrame, - onOptionSelected = { viewModel.setTimeFrame(it) }, - ) { - OptionLabel(stringResource(it.strRes)) - } - /* Device Metric Cards */ - LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> DeviceMetricsCard(telemetry) } } + LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(data) { _, telemetry -> + DeviceMetricsCard( + telemetry = telemetry, + isSelected = telemetry.time.toDouble() == selectedX, + onClick = { + selectedX = telemetry.time.toDouble() + coroutineScope.launch { + vicoScrollState.animateScroll(Scroll.Absolute.x(telemetry.time.toDouble(), 0.5f)) + } + }, + ) + } + } } } } @@ -206,121 +219,82 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat private fun DeviceMetricsChart( modifier: Modifier = Modifier, telemetries: List, - selectedTime: TimeFrame, promptInfoDialog: () -> Unit, + vicoScrollState: VicoScrollState, + selectedX: Double?, + onPointSelected: (Double) -> Unit, ) { - val graphColor = MaterialTheme.colorScheme.onSurface - ChartHeader(amount = telemetries.size) if (telemetries.isEmpty()) return - val (oldest, newest) = - remember(key1 = telemetries) { Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time }) } - val timeDiff = newest.time - oldest.time - - val scrollState = rememberScrollState() - val screenWidth = LocalWindowInfo.current.containerSize.width - val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) } - - // Calculate visible time range based on scroll position and chart width - val visibleTimeRange = run { - val totalWidthPx = with(LocalDensity.current) { dp.toPx() } - val scrollPx = scrollState.value.toFloat() - // Calculate visible width based on actual weight distribution - val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO - val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f) - val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f) - // With reverseScrolling = true, scrolling right shows older data (left side of chart) - val visibleOldest = oldest.time + (timeDiff * (1f - rightRatio)).toInt() - val visibleNewest = oldest.time + (timeDiff * (1f - leftRatio)).toInt() - visibleOldest to visibleNewest - } - - TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second) - - Spacer(modifier = Modifier.height(16.dp)) - - Row { - Box( - contentAlignment = Alignment.TopStart, - modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(weight = 1f), - ) { - /* - * The order of the colors are with respect to the ChUtil. - * 25 - 49 Orange - * 50 - 100 Red - */ - HorizontalLinesOverlay( - modifier.width(dp), - lineColors = listOf(graphColor, Color.Yellow, Color.Red, graphColor, graphColor), - ) - - TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval()) - - /* Plot Battery Line, ChUtil, and AirUtilTx */ - Canvas(modifier = modifier.width(dp)) { - val height = size.height - val width = size.width - for (i in telemetries.indices) { - val telemetry = telemetries[i] - - /* x-value time */ - val xRatio = (telemetry.time - oldest.time).toFloat() / timeDiff - val x = xRatio * width - - /* Channel Utilization */ - plotPoint( - drawContext = drawContext, - color = Device.CH_UTIL.color, - x = x, - value = telemetry.deviceMetrics.channelUtilization, - divisor = MAX_PERCENT_VALUE, - ) - - /* Air Utilization Transmit */ - plotPoint( - drawContext = drawContext, - color = Device.AIR_UTIL.color, - x = x, - value = telemetry.deviceMetrics.airUtilTx, - divisor = MAX_PERCENT_VALUE, - ) + val modelProducer = remember { CartesianChartModelProducer() } + val batteryColor = Device.BATTERY.color + val chUtilColor = Device.CH_UTIL.color + val airUtilColor = Device.AIR_UTIL.color + val marker = + ChartStyling.rememberMarker( + valueFormatter = + ChartStyling.createColoredMarkerValueFormatter { value, color -> + when (color.copy(alpha = 1f)) { + batteryColor -> "Battery: %.1f%%".format(value) + chUtilColor -> "ChUtil: %.1f%%".format(value) + airUtilColor -> "AirUtil: %.1f%%".format(value) + else -> "%.1f%%".format(value) } + }, + ) - /* Battery Line */ - var index = 0 - while (index < telemetries.size) { - val path = Path() - index = - createPath( - telemetries = telemetries, - index = index, - path = path, - oldestTime = oldest.time, - timeRange = timeDiff, - width = width, - timeThreshold = selectedTime.timeThreshold(), - ) { i -> - val telemetry = telemetries.getOrNull(i) ?: telemetries.last() - val ratio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE - val y = height - (ratio * height) - return@createPath y - } - drawPath( - path = path, - color = Device.BATTERY.color, - style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round), - ) - } + LaunchedEffect(telemetries) { + modelProducer.runTransaction { + lineSeries { + series(x = telemetries.map { it.time }, y = telemetries.map { it.deviceMetrics.batteryLevel }) + series(x = telemetries.map { it.time }, y = telemetries.map { it.deviceMetrics.channelUtilization }) + series(x = telemetries.map { it.time }, y = telemetries.map { it.deviceMetrics.airUtilTx }) } } - YAxisLabels(modifier = modifier.weight(weight = Y_AXIS_WEIGHT), graphColor, minValue = 0f, maxValue = 100f) } - Spacer(modifier = Modifier.height(16.dp)) + + val axisLabel = ChartStyling.rememberAxisLabel() + + GenericMetricChart( + modelProducer = modelProducer, + modifier = modifier.padding(8.dp), + layers = + listOf( + rememberLineCartesianLayer( + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createBoldLine( + lineColor = batteryColor, + pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP, + ), + ChartStyling.createPointOnlyLine( + pointColor = chUtilColor, + pointSize = ChartStyling.LARGE_POINT_SIZE_DP, + ), + ChartStyling.createPointOnlyLine( + pointColor = airUtilColor, + pointSize = ChartStyling.LARGE_POINT_SIZE_DP, + ), + ), + ), + ), + startAxis = + VerticalAxis.rememberStart(label = axisLabel, valueFormatter = { _, value, _ -> "%.0f%%".format(value) }), + bottomAxis = + HorizontalAxis.rememberBottom( + label = axisLabel, + valueFormatter = CommonCharts.dynamicTimeFormatter, + itemPlacer = ChartStyling.rememberItemPlacer(spacing = 20), + labelRotationDegrees = 45f, + ), + marker = marker, + selectedX = selectedX, + onPointSelected = onPointSelected, + vicoScrollState = vicoScrollState, + ) Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog) - - Spacer(modifier = Modifier.height(16.dp)) } @Suppress("detekt:MagicNumber") // fake data @@ -346,32 +320,46 @@ private fun DeviceMetricsChartPreview() { DeviceMetricsChart( modifier = Modifier.height(400.dp), telemetries = telemetries, - selectedTime = TimeFrame.TWENTY_FOUR_HOURS, promptInfoDialog = {}, + vicoScrollState = rememberVicoScrollState(), + selectedX = null, + onPointSelected = {}, ) } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun DeviceMetricsCard(telemetry: Telemetry) { +private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) { val deviceMetrics = telemetry.deviceMetrics val time = telemetry.time * MS_PER_SEC - Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) { - Surface { + 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(8.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { /* Time, Battery, and Voltage */ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( text = DATE_TIME_FORMAT.format(time), - style = TextStyle(fontWeight = FontWeight.Bold), - fontSize = MaterialTheme.typography.labelLarge.fontSize, + style = MaterialTheme.typography.titleMediumEmphasized, ) MaterialBatteryInfo(level = deviceMetrics.batteryLevel, voltage = deviceMetrics.voltage) } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(8.dp)) /* Channel Utilization and Air Utilization Tx */ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { @@ -407,7 +395,7 @@ private fun DeviceMetricsCardPreview() { .setUptimeSeconds(7200), ) .build() - AppTheme { DeviceMetricsCard(telemetry = telemetry) } + AppTheme { DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) } } @Suppress("detekt:MagicNumber") // fake data @@ -448,22 +436,18 @@ private fun DeviceMetricsScreenPreview() { DeviceMetricsChart( modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f), - telemetries.reversed(), - TimeFrame.TWENTY_FOUR_HOURS, + telemetries = telemetries.reversed(), promptInfoDialog = { displayInfoDialog = true }, + vicoScrollState = rememberVicoScrollState(), + selectedX = null, + onPointSelected = {}, ) - SlidingSelector( - TimeFrame.entries.toList(), - TimeFrame.TWENTY_FOUR_HOURS, - onOptionSelected = { /* Preview only */ }, - ) { - OptionLabel(stringResource(it.strRes)) - } - /* Device Metric Cards */ LazyColumn(modifier = Modifier.fillMaxSize()) { - items(telemetries) { telemetry -> DeviceMetricsCard(telemetry) } + itemsIndexed(telemetries) { _, telemetry -> + DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) + } } } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt index aa048aede..5e5c39996 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -14,32 +14,26 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.unit.Dp 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.HorizontalAxis +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.meshtastic.core.strings.Res import org.meshtastic.core.strings.baro_pressure import org.meshtastic.core.strings.humidity @@ -49,14 +43,8 @@ import org.meshtastic.core.strings.soil_moisture import org.meshtastic.core.strings.soil_temperature import org.meshtastic.core.strings.temperature import org.meshtastic.core.strings.uv_lux -import org.meshtastic.feature.node.metrics.GraphUtil.createPath -import org.meshtastic.feature.node.metrics.GraphUtil.drawPathWithGradient -import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.proto.TelemetryProtos.Telemetry -private const val CHART_WEIGHT = 1f -private const val Y_AXIS_WEIGHT = 0.1f - @Suppress("MagicNumber") private val LEGEND_DATA_1 = listOf( @@ -117,71 +105,126 @@ private val LEGEND_DATA_3 = ), ) +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun EnvironmentMetricsChart( modifier: Modifier = Modifier, telemetries: List, graphData: EnvironmentGraphingData, - selectedTime: TimeFrame, promptInfoDialog: () -> Unit, + vicoScrollState: VicoScrollState, + selectedX: Double?, + onPointSelected: (Double) -> Unit, ) { ChartHeader(amount = telemetries.size) if (telemetries.isEmpty()) { return } - val (oldest, newest) = graphData.times - val timeDiff = newest - oldest - - val scrollState = rememberScrollState() - val screenWidth = LocalWindowInfo.current.containerSize.width - val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) } - + val modelProducer = remember { CartesianChartModelProducer() } val shouldPlot = graphData.shouldPlot + val onSurfaceColor = MaterialTheme.colorScheme.onSurface - // Calculate visible time range based on scroll position and chart width - val visibleTimeRange = run { - val totalWidthPx = with(LocalDensity.current) { dp.toPx() } - val scrollPx = scrollState.value.toFloat() - // Calculate chart width ratio dynamically based on whether barometric pressure is plotted - val yAxisCount = if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) 2 else 1 - val chartWidthRatio = CHART_WEIGHT / (CHART_WEIGHT + (Y_AXIS_WEIGHT * yAxisCount)) - val visibleWidthPx = screenWidth * chartWidthRatio - val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f) - val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f) - // With reverseScrolling = true, scrolling right shows older data (left side of chart) - val visibleOldest = oldest + (timeDiff * (1f - rightRatio)).toInt() - val visibleNewest = oldest + (timeDiff * (1f - leftRatio)).toInt() - visibleOldest to visibleNewest + val allLegendData = LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3 + val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) } + + LaunchedEffect(telemetries, graphData) { + modelProducer.runTransaction { + /* Pressure on its own layer/axis */ + if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) { + lineSeries { + series( + x = telemetries.mapNotNull { t -> Environment.BAROMETRIC_PRESSURE.getValue(t)?.let { t.time } }, + y = telemetries.mapNotNull { t -> Environment.BAROMETRIC_PRESSURE.getValue(t) }, + ) + } + } + /* Everything else on the default axis */ + Environment.entries.forEach { metric -> + if (metric != Environment.BAROMETRIC_PRESSURE && shouldPlot[metric.ordinal]) { + lineSeries { + series( + x = telemetries.mapNotNull { t -> metric.getValue(t)?.let { t.time } }, + y = telemetries.mapNotNull { t -> metric.getValue(t) }, + ) + } + } + } + } } - TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second) + val marker = + ChartStyling.rememberMarker( + valueFormatter = + ChartStyling.createColoredMarkerValueFormatter { value, color -> + val label = colorToLabel[color.copy(alpha = 1f)] ?: "" + "%s: %.1f".format(label, value) + }, + ) - Row(modifier = modifier.fillMaxWidth().fillMaxHeight()) { - BarometricPressureYAxisLabel( - modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(), - shouldPlotBarometricPressure = shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal], - minValue = graphData.leftMinMax.first, - maxValue = graphData.leftMinMax.second, + val layers = mutableListOf() + if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) { + layers.add( + rememberLineCartesianLayer( + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createGradientLine( + Environment.BAROMETRIC_PRESSURE.color, + ChartStyling.MEDIUM_POINT_SIZE_DP, + ), + ), + verticalAxisPosition = Axis.Position.Vertical.Start, + ), ) - ChartContent( - modifier = Modifier.weight(CHART_WEIGHT).fillMaxHeight(), - scrollState = scrollState, - dp = dp, - oldest = oldest, - newest = newest, - selectedTime = selectedTime, - telemetries = telemetries, - graphData = graphData, - rightMin = graphData.rightMinMax.first, - rightMax = graphData.rightMinMax.second, - timeDiff = timeDiff, - ) - YAxisLabels( - modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(), - MaterialTheme.colorScheme.onSurface, - minValue = graphData.rightMinMax.first, - maxValue = graphData.rightMinMax.second, + } + Environment.entries.forEach { metric -> + if (metric != Environment.BAROMETRIC_PRESSURE && shouldPlot[metric.ordinal]) { + layers.add( + rememberLineCartesianLayer( + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createGradientLine(metric.color, ChartStyling.MEDIUM_POINT_SIZE_DP), + ), + verticalAxisPosition = Axis.Position.Vertical.End, + ), + ) + } + } + + if (layers.isNotEmpty()) { + val otherMetricsPlotted = + Environment.entries.filter { it != Environment.BAROMETRIC_PRESSURE && shouldPlot[it.ordinal] } + val endAxisColor = if (otherMetricsPlotted.size == 1) otherMetricsPlotted.first().color else onSurfaceColor + + GenericMetricChart( + modelProducer = modelProducer, + modifier = modifier.padding(8.dp), + layers = layers, + startAxis = + if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) { + VerticalAxis.rememberStart( + label = ChartStyling.rememberAxisLabel(color = Environment.BAROMETRIC_PRESSURE.color), + valueFormatter = { _, value, _ -> "%.0f hPa".format(value) }, + ) + } else { + null + }, + endAxis = + VerticalAxis.rememberEnd( + label = ChartStyling.rememberAxisLabel(color = endAxisColor), + valueFormatter = { _, value, _ -> "%.0f".format(value) }, + ), + bottomAxis = + HorizontalAxis.rememberBottom( + label = ChartStyling.rememberAxisLabel(), + valueFormatter = CommonCharts.dynamicTimeFormatter, + itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50), + labelRotationDegrees = 45f, + ), + marker = marker, + selectedX = selectedX, + onPointSelected = onPointSelected, + vicoScrollState = vicoScrollState, ) } @@ -192,151 +235,6 @@ fun EnvironmentMetricsChart( Spacer(modifier = Modifier.height(16.dp)) } -@Suppress("detekt:LongMethod") -@Composable -private fun MetricPlottingCanvas( - modifier: Modifier = Modifier, - telemetries: List, - graphData: EnvironmentGraphingData, - selectedTime: TimeFrame, - oldest: Int, - timeDiff: Int, - rightMin: Float, - rightMax: Float, -) { - val (pressureMin, pressureMax) = graphData.leftMinMax - val shouldPlot = graphData.shouldPlot - val graphColor = MaterialTheme.colorScheme.onSurface - - Canvas(modifier = modifier) { - val height = size.height - val width = size.width - - var min: Float - var diff: Float - var index: Int - var first: Int - for (metric in Environment.entries) { - if (!shouldPlot[metric.ordinal]) { - continue - } - if (metric == Environment.BAROMETRIC_PRESSURE) { - diff = pressureMax - pressureMin - min = pressureMin - } else { // Reset for other metrics to use rightMin/rightMax - min = rightMin - diff = rightMax - rightMin - } - index = 0 - while (index < telemetries.size) { - first = index - val path = Path() - index = - createPath( - telemetries = telemetries, - index = index, - path = path, - oldestTime = oldest, - timeRange = timeDiff, - width = width, - timeThreshold = selectedTime.timeThreshold(), - ) { i -> - val telemetry = telemetries.getOrNull(i) ?: telemetries.last() - val rawValue = metric.getValue(telemetry) // This is Float? - - // Default to 0f if the actual value is null or NaN. This is a reasonable default for - // lux. - val pointValue = - if (rawValue != null && !rawValue.isNaN()) { - rawValue - } else { - 0f - } - - // Use 'min' and 'diff' from the outer scope, which are specific to the current metric's - // scale group. - val currentMin = min - // Avoid division by zero if all values in the current y-axis range are the same. - val currentDiff = if (diff == 0f) 1f else diff - - val ratio = (pointValue - currentMin) / currentDiff - var y = height - (ratio * height) - - // Final check to ensure y is a valid, plottable coordinate. - if (y.isNaN() || y.isInfinite()) { - y = height // Default to the bottom of the chart if calculation still results in an - // invalid number. - } else { - y = y.coerceIn(0f, height) // Clamp to chart bounds to be safe. - } - return@createPath y - } - drawPathWithGradient( - path = path, - color = metric.color, - height = height, - x1 = ((telemetries[index - 1].time - oldest).toFloat() / timeDiff) * width, - x2 = ((telemetries[first].time - oldest).toFloat() / timeDiff) * width, - ) - } - } - } -} - -@Composable -private fun BarometricPressureYAxisLabel( - modifier: Modifier, - shouldPlotBarometricPressure: Boolean, - minValue: Float, - maxValue: Float, -) { - if (shouldPlotBarometricPressure) { - YAxisLabels( - modifier = modifier, - Environment.BAROMETRIC_PRESSURE.color, - minValue = minValue, - maxValue = maxValue, - ) - } -} - -@Composable -private fun ChartContent( - modifier: Modifier = Modifier, - scrollState: ScrollState, - dp: Dp, - oldest: Int, - newest: Int, - selectedTime: TimeFrame, - telemetries: List, - graphData: EnvironmentGraphingData, - rightMin: Float, - rightMax: Float, - timeDiff: Int, -) { - val graphColor = MaterialTheme.colorScheme.onSurface - - Box( - contentAlignment = Alignment.TopStart, - modifier = modifier.horizontalScroll(state = scrollState, reverseScrolling = true), - ) { - HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor }) - - TimeAxisOverlay(modifier = modifier.width(dp), oldest = oldest, newest = newest, selectedTime.lineInterval()) - - MetricPlottingCanvas( - modifier = modifier.width(dp), - telemetries = telemetries, - graphData = graphData, - selectedTime = selectedTime, - oldest = oldest, - timeDiff = timeDiff, - rightMin = rightMin, - rightMax = rightMax, - ) - } -} - @Composable private fun MetricLegends(graphData: EnvironmentGraphingData, promptInfoDialog: () -> Unit) { Legend(LEGEND_DATA_1.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false) @@ -346,11 +244,3 @@ private fun MetricLegends(graphData: EnvironmentGraphingData, promptInfoDialog: promptInfoDialog = promptInfoDialog, ) } - -// private const val LINE_ON = 10f -// private const val LINE_OFF = 20f -// private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM) -// private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT) -// private const val DATE_Y = 32f -// private const val LINE_LIMIT = 4 -// private const val TEXT_PAINT_ALPHA = 192 diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 2e81842a0..cba619ff5 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -16,6 +16,8 @@ */ 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 @@ -23,12 +25,16 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight 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.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState 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.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -38,25 +44,28 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState 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.text.TextStyle -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.meshtastic.core.strings.getString +import com.patrykandpatrick.vico.compose.cartesian.Scroll +import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.current +import org.meshtastic.core.strings.env_metrics_log import org.meshtastic.core.strings.gas_resistance import org.meshtastic.core.strings.humidity import org.meshtastic.core.strings.iaq @@ -71,27 +80,30 @@ import org.meshtastic.core.strings.voltage import org.meshtastic.core.ui.component.IaqDisplayMode import org.meshtastic.core.ui.component.IndoorAirQuality import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.component.OptionLabel -import org.meshtastic.core.ui.component.SlidingSelector import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC -import org.meshtastic.feature.node.model.TimeFrame +import org.meshtastic.feature.node.metrics.CommonCharts.SCROLL_BIAS import org.meshtastic.proto.TelemetryProtos import org.meshtastic.proto.TelemetryProtos.Telemetry import org.meshtastic.proto.copy +@Suppress("LongMethod") @Composable fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { val state by viewModel.state.collectAsStateWithLifecycle() val environmentState by viewModel.environmentState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } - val selectedTimeFrame by viewModel.timeFrame.collectAsState() - val graphData = environmentState.environmentMetricsFiltered(selectedTimeFrame, state.isFahrenheit) + val graphData = environmentState.environmentMetricsForGraphing(state.isFahrenheit) val data = graphData.metrics + val lazyListState = rememberLazyListState() + val vicoScrollState = rememberVicoScrollState() + val coroutineScope = rememberCoroutineScope() + var selectedX by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { viewModel.effects.collect { effect -> when (effect) { @@ -126,6 +138,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa topBar = { MainAppBar( title = state.node?.user?.longName ?: "", + subtitle = stringResource(Res.string.env_metrics_log), ourNode = null, showNodeChip = false, canNavigateUp = true, @@ -157,20 +170,32 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f), telemetries = processedTelemetries.reversed(), graphData = graphData, - selectedTime = selectedTimeFrame, promptInfoDialog = { displayInfoDialog = true }, + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onPointSelected = { x -> + selectedX = x + val index = processedTelemetries.indexOfFirst { it.time.toDouble() == x } + if (index != -1) { + coroutineScope.launch { lazyListState.animateScrollToItem(index) } + } + }, ) - SlidingSelector( - TimeFrame.entries.toList(), - selectedTimeFrame, - onOptionSelected = { viewModel.setTimeFrame(it) }, - ) { - OptionLabel(stringResource(it.strRes)) - } - - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(processedTelemetries) { telemetry -> EnvironmentMetricsCard(telemetry, state.isFahrenheit) } + LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(processedTelemetries) { _, telemetry -> + EnvironmentMetricsCard( + telemetry = telemetry, + environmentDisplayFahrenheit = state.isFahrenheit, + isSelected = telemetry.time.toDouble() == selectedX, + onClick = { + selectedX = telemetry.time.toDouble() + coroutineScope.launch { + vicoScrollState.animateScroll(Scroll.Absolute.x(telemetry.time.toDouble(), SCROLL_BIAS)) + } + }, + ) + } } } } @@ -356,30 +381,49 @@ private fun RadiationDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) { } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun EnvironmentMetricsCard(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) { +private fun EnvironmentMetricsCard( + telemetry: Telemetry, + environmentDisplayFahrenheit: Boolean, + isSelected: Boolean, + onClick: () -> Unit, +) { val envMetrics = telemetry.environmentMetrics val time = telemetry.time * MS_PER_SEC - Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) { - Surface { SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) } } + 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) } + } } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) { val envMetrics = telemetry.environmentMetrics val time = telemetry.time * MS_PER_SEC - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { /* Time and Temperature */ Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text( - text = DATE_TIME_FORMAT.format(time), - style = TextStyle(fontWeight = FontWeight.Bold), - fontSize = MaterialTheme.typography.labelLarge.fontSize, - ) + Text(text = DATE_TIME_FORMAT.format(time), style = MaterialTheme.typography.titleMediumEmphasized) TemperatureDisplay(envMetrics, environmentDisplayFahrenheit) } + Spacer(modifier = Modifier.height(8.dp)) + HumidityAndBarometricPressureDisplay(envMetrics) SoilMetricsDisplay(envMetrics, environmentDisplayFahrenheit) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt index e7bc8c897..97274afd3 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-2026 Meshtastic LLC * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.node.metrics import androidx.compose.ui.graphics.Color @@ -28,7 +27,6 @@ import org.meshtastic.core.ui.theme.GraphColors.Pink import org.meshtastic.core.ui.theme.GraphColors.Purple import org.meshtastic.core.ui.theme.GraphColors.Red import org.meshtastic.core.ui.theme.GraphColors.Yellow -import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.proto.TelemetryProtos @Suppress("MagicNumber") @@ -44,7 +42,7 @@ enum class Environment(val color: Color) { }, SOIL_MOISTURE(Purple) { override fun getValue(telemetry: TelemetryProtos.Telemetry) = - telemetry.environmentMetrics.soilMoisture?.toFloat() + telemetry.environmentMetrics.soilMoisture.toFloat() }, BAROMETRIC_PRESSURE(Green) { override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.barometricPressure @@ -53,7 +51,7 @@ enum class Environment(val color: Color) { override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.gasResistance }, IAQ(Magenta) { - override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq?.toFloat() + override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq.toFloat() }, LUX(LightGreen) { override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.lux @@ -66,7 +64,7 @@ enum class Environment(val color: Color) { } /** - * @param metrics the filtered [List] + * @param metrics the [List] of [TelemetryProtos.Telemetry] * @param shouldPlot a [List] the size of [Environment] used to determine if a metric should be plotted * @param leftMinMax [Pair] with the min and max of the barometric pressure * @param rightMinMax [Pair] with the combined min and max of: the temperature, humidity, and IAQ @@ -84,15 +82,13 @@ data class EnvironmentMetricsState(val environmentMetrics: List= oldestTime } + fun environmentMetricsForGraphing(useFahrenheit: Boolean = false): EnvironmentGraphingData { + val telemetries = environmentMetrics val shouldPlot = BooleanArray(Environment.entries.size) { false } if (telemetries.isEmpty()) { return EnvironmentGraphingData(metrics = telemetries, shouldPlot = shouldPlot.toList()) @@ -103,7 +99,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List() // Temperature - val temperatures = telemetries.mapNotNull { it.environmentMetrics.temperature?.takeIf { !it.isNaN() } } + val temperatures = telemetries.mapNotNull { it.environmentMetrics.temperature.takeIf { !it.isNaN() } } if (temperatures.isNotEmpty()) { var minTempValue = temperatures.minOf { it } var maxTempValue = temperatures.maxOf { it } @@ -118,7 +114,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List. - */ - -package org.meshtastic.feature.node.metrics - -import android.content.res.Resources -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.asAndroidPath -import androidx.compose.ui.graphics.asComposePath -import androidx.compose.ui.graphics.drawscope.DrawContext -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.unit.dp -import org.meshtastic.proto.TelemetryProtos.Telemetry - -object GraphUtil { - - val RADIUS = Resources.getSystem().displayMetrics.density * 2 - - /** - * @param value Must be zero-scaled before passing. - * @param divisor The range for the data set. - */ - fun plotPoint(drawContext: DrawContext, color: Color, x: Float, value: Float, divisor: Float) { - val height = drawContext.size.height - val ratio = value / divisor - val y = height - (ratio * height) - drawContext.canvas.drawCircle( - center = Offset(x, y), - radius = RADIUS, - paint = androidx.compose.ui.graphics.Paint().apply { this.color = color }, - ) - } - - /** - * Creates a [Path] that could be used to draw a line from the `index` to the end of `telemetries` or the last point - * before a time separation between [Telemetry]s. - * - * @param telemetries data used to create the [Path] - * @param index current place in the [List] - * @param path [Path] that will be used to draw - * @param timeRange The time range for the data set - * @param width of the [DrawContext] - * @param timeThreshold to determine significant breaks in time between [Telemetry]s - * @param calculateY (`index`) -> `y` coordinate - * @return the current index after iterating - */ - fun createPath( - telemetries: List, - index: Int, - path: Path, - oldestTime: Int, - timeRange: Int, - width: Float, - timeThreshold: Long, - calculateY: (Int) -> Float, - ): Int { - var i = index - var isNewLine = true - with(path) { - while (i < telemetries.size) { - val telemetry = telemetries[i] - val nextTelemetry = telemetries.getOrNull(i + 1) ?: telemetries.last() - - /* Check to see if we have a significant time break between telemetries. */ - if (nextTelemetry.time - telemetry.time > timeThreshold) { - i++ - break - } - - val x1Ratio = (telemetry.time - oldestTime).toFloat() / timeRange - val x1 = x1Ratio * width - val y1 = calculateY(i) - - val x2Ratio = (nextTelemetry.time - oldestTime).toFloat() / timeRange - val x2 = x2Ratio * width - val y2 = calculateY(i + 1) - - if (isNewLine || i == 0) { - isNewLine = false - moveTo(x1, y1) - } - - quadraticTo(x1, y1, (x1 + x2) / 2f, (y1 + y2) / 2f) - i++ - } - } - return i - } - - fun DrawScope.drawPathWithGradient(path: Path, color: Color, height: Float, x1: Float, x2: Float) { - drawPath(path = path, color = color, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round)) - val fillPath = - android.graphics.Path(path.asAndroidPath()).asComposePath().apply { - lineTo(x1, height) - lineTo(x2, height) - close() - } - drawPath( - path = fillPath, - brush = Brush.verticalGradient(colors = listOf(color.copy(alpha = 0.5f), Color.Transparent), endY = height), - ) - } -} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt index 76ff6beb0..414d2beb5 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/HostMetricsLog.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.lazy.items 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.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -49,7 +50,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily -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 @@ -123,6 +123,7 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Suppress("LongMethod", "MagicNumber") @Composable fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: TelemetryProtos.Telemetry) { @@ -141,8 +142,7 @@ fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: TelemetryProtos.Te modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.End, text = DATE_TIME_FORMAT.format(time), - style = TextStyle(fontWeight = FontWeight.Bold), - fontSize = MaterialTheme.typography.labelLarge.fontSize, + style = MaterialTheme.typography.titleMediumEmphasized, ) LogLine( label = stringResource(Res.string.uptime), diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 18c7f31c4..97e09dc7d 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -62,7 +62,6 @@ import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.node.detail.NodeRequestActions import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.model.MetricsState -import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.proto.ConfigProtos.Config import org.meshtastic.proto.MeshProtos import org.meshtastic.proto.MeshProtos.MeshPacket @@ -107,6 +106,9 @@ constructor( private fun MeshLog.hasValidTraceroute(): Boolean = with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum } + private fun MeshLog.hasValidNeighborInfo(): Boolean = + with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum } + /** * Creates a fallback node for hidden clients or nodes not yet in the database. This prevents the detail screen from * freezing when viewing unknown nodes. @@ -198,9 +200,6 @@ constructor( private val _environmentState = MutableStateFlow(EnvironmentMetricsState()) val environmentState: StateFlow = _environmentState - private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS) - val timeFrame: StateFlow = _timeFrame - val effects: SharedFlow = nodeRequestActions.effects val lastTraceRouteTime: StateFlow = @@ -229,6 +228,12 @@ constructor( } } + fun requestNeighborInfo() { + destNum?.let { + nodeRequestActions.requestNeighborInfo(viewModelScope, it, state.value.node?.user?.longName ?: "") + } + } + init { initializeFlows() } @@ -334,6 +339,21 @@ constructor( .collect {} } + launch { + combine( + meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP_VALUE), + meshLogRepository.getLogsFrom(currentDestNum, PortNum.NEIGHBORINFO_APP_VALUE), + ) { request, response -> + _state.update { state -> + state.copy( + neighborInfoRequests = request.filter { it.hasValidNeighborInfo() }, + neighborInfoResults = response, + ) + } + } + .collect {} + } + launch { meshLogRepository.getMeshPacketsFrom( currentDestNum, @@ -395,10 +415,6 @@ constructor( Logger.d { "MetricsViewModel cleared" } } - fun setTimeFrame(timeFrame: TimeFrame) { - _timeFrame.value = timeFrame - } - /** Write the persisted Position data out to a CSV file in the specified location. */ fun savePositionCSV(uri: Uri) = viewModelScope.launch(dispatchers.main) { val positions = state.value.positionLogs diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt new file mode 100644 index 000000000..bd866ad71 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/NeighborInfoLog.kt @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import android.text.format.DateUtils +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.meshtastic.core.strings.getString +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.getNeighborInfoResponse +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.neighbor_info +import org.meshtastic.core.strings.routing_error_no_response +import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD +import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD +import org.meshtastic.core.ui.component.SimpleAlertDialog +import org.meshtastic.core.ui.icon.Groups +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.PersonOff +import org.meshtastic.core.ui.icon.Refresh +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.feature.node.component.CooldownIconButton +import org.meshtastic.feature.node.detail.NodeRequestEffect + +@OptIn(ExperimentalFoundationApi::class) +@Suppress("LongMethod", "CyclomaticComplexMethod") +@Composable +fun NeighborInfoLogScreen( + modifier: Modifier = Modifier, + viewModel: MetricsViewModel = hiltViewModel(), + onNavigateUp: () -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + is NodeRequestEffect.ShowFeedback -> { + @Suppress("SpreadOperator") + snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray())) + } + } + } + } + + fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" } + + var showDialog by remember { mutableStateOf(null) } + val context = LocalContext.current + + val statusGreen = MaterialTheme.colorScheme.StatusGreen + val statusYellow = MaterialTheme.colorScheme.StatusYellow + val statusOrange = MaterialTheme.colorScheme.StatusOrange + + showDialog?.let { message -> + SimpleAlertDialog( + title = Res.string.neighbor_info, + text = { SelectionContainer { Text(text = message) } }, + onConfirm = { showDialog = null }, + onDismiss = { showDialog = null }, + ) + } + + Scaffold( + topBar = { + val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsState() + MainAppBar( + title = state.node?.user?.longName ?: "", + subtitle = stringResource(Res.string.neighbor_info), + ourNode = null, + showNodeChip = false, + canNavigateUp = true, + onNavigateUp = onNavigateUp, + actions = { + if (!state.isLocal) { + CooldownIconButton( + onClick = { viewModel.requestNeighborInfo() }, + cooldownTimestamp = lastRequestNeighborsTime, + ) { + Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) + } + } + }, + onClickChip = {}, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + LazyColumn( + modifier = modifier.fillMaxSize().padding(innerPadding), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + items(state.neighborInfoRequests, key = { it.uuid }) { log -> + val result = + remember(state.neighborInfoResults, log.fromRadio.packet.id) { + state.neighborInfoResults.find { + it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id + } + } + + val time = + DateUtils.formatDateTime( + context, + log.received_date, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL, + ) + val text = if (result != null) "Success" else stringResource(Res.string.routing_error_no_response) + val icon = if (result != null) MeshtasticIcons.Groups else MeshtasticIcons.PersonOff + var expanded by remember { mutableStateOf(false) } + + Box { + MetricLogItem( + icon = icon, + text = "$time - $text", + contentDescription = stringResource(Res.string.neighbor_info), + modifier = + Modifier.combinedClickable(onLongClick = { expanded = true }) { + result + ?.fromRadio + ?.packet + ?.getNeighborInfoResponse( + ::getUsername, + header = getString(Res.string.neighbor_info), + ) + ?.let { + showDialog = + annotateNeighborInfo( + it, + statusGreen = statusGreen, + statusYellow = statusYellow, + statusOrange = statusOrange, + ) + } + }, + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DeleteItem { + viewModel.deleteLog(log.uuid) + expanded = false + } + } + } + } + } + } +} + +/** + * Converts a raw neighbor info string into an [AnnotatedString] with SNR values highlighted according to their quality. + */ +fun annotateNeighborInfo( + inString: String?, + statusGreen: Color, + statusYellow: Color, + statusOrange: Color, +): AnnotatedString { + if (inString == null) return buildAnnotatedString { append("") } + return buildAnnotatedString { + inString.lines().forEachIndexed { i, line -> + if (i > 0) append("\n") + // Example line: "• NodeName (SNR: 5.5)" + if (line.contains("(SNR: ")) { + val snrRegex = Regex("""\(SNR: ([\d.?-]+)\)""") + val snrMatch = snrRegex.find(line) + val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull() + + if (snrValue != null) { + val snrColor = + when { + snrValue >= SNR_GOOD_THRESHOLD -> statusGreen + snrValue >= SNR_FAIR_THRESHOLD -> statusYellow + else -> statusOrange + } + val snrPrefix = "(SNR: " + append(line.substring(0, line.indexOf(snrPrefix) + snrPrefix.length)) + withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append("$snrValue") } + append(")") + } else { + append(line) + } + } else { + append(line) + } + } + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index d0b501139..b2336b961 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -16,23 +16,21 @@ */ package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.horizontalScroll +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 import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight 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.items -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -45,19 +43,25 @@ import androidx.compose.runtime.LaunchedEffect 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.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.meshtastic.core.strings.getString +import com.patrykandpatrick.vico.compose.cartesian.Scroll +import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis +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 com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.entity.MeshLog @@ -72,22 +76,15 @@ import org.meshtastic.core.strings.uptime import org.meshtastic.core.strings.wifi_devices import org.meshtastic.core.ui.component.IconInfo import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.component.OptionLabel -import org.meshtastic.core.ui.component.SlidingSelector import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Paxcount import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.feature.node.detail.NodeRequestEffect -import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.proto.PaxcountProtos import org.meshtastic.proto.Portnums.PortNum import java.text.DateFormat import java.util.Date -private const val CHART_WEIGHT = 1f -private const val Y_AXIS_WEIGHT = 0.1f -private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT) - private enum class PaxSeries(val color: Color, val legendRes: StringResource) { PAX(Color.Black, Res.string.pax), BLE(Color.Cyan, Res.string.ble_devices), @@ -101,73 +98,77 @@ private fun PaxMetricsChart( totalSeries: List>, bleSeries: List>, wifiSeries: List>, - minValue: Float, - maxValue: Float, - timeFrame: TimeFrame, + vicoScrollState: VicoScrollState, + selectedX: Double?, + onPointSelected: (Double) -> Unit, ) { if (totalSeries.isEmpty()) return - val scrollState = rememberScrollState() - val screenWidth = LocalWindowInfo.current.containerSize.width - val times = totalSeries.map { it.first } - val minTime = times.minOrNull() ?: 0 - val maxTime = times.maxOrNull() ?: 1 - val timeDiff = maxTime - minTime - val dp = remember(timeFrame, screenWidth, timeDiff) { timeFrame.dp(screenWidth, time = timeDiff.toLong()) } - // Calculate visible time range based on scroll position and chart width - val visibleTimeRange = run { - val totalWidthPx = with(LocalDensity.current) { dp.toPx() } - val scrollPx = scrollState.value.toFloat() - val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO - val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f) - val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f) - val visibleOldest = minTime + (timeDiff * leftRatio).toInt() - val visibleNewest = minTime + (timeDiff * rightRatio).toInt() - visibleOldest to visibleNewest - } - TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second) - Spacer(modifier = Modifier.height(16.dp)) - Row(modifier = modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f)) { - YAxisLabels( - modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(start = 8.dp), - labelColor = MaterialTheme.colorScheme.onSurface, - minValue = minValue, - maxValue = maxValue, - ) - Box( - contentAlignment = Alignment.TopStart, - modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(CHART_WEIGHT), - ) { - HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { Color.LightGray }) - TimeAxisOverlay(modifier.width(dp), oldest = minTime, newest = maxTime, timeFrame.lineInterval()) - Canvas(modifier = Modifier.width(dp).fillMaxHeight()) { - val width = size.width - val height = size.height - fun xForTime(t: Int): Float = - if (maxTime == minTime) width / 2 else (t - minTime).toFloat() / (maxTime - minTime) * width - fun yForValue(v: Int): Float = height - (v - minValue) / (maxValue - minValue) * height - fun drawLine(series: List>, color: Color) { - for (i in 1 until series.size) { - drawLine( - color = color, - start = Offset(xForTime(series[i - 1].first), yForValue(series[i - 1].second)), - end = Offset(xForTime(series[i].first), yForValue(series[i].second)), - strokeWidth = 2.dp.toPx(), - ) - } - } - drawLine(bleSeries, PaxSeries.BLE.color) - drawLine(wifiSeries, PaxSeries.WIFI.color) - drawLine(totalSeries, PaxSeries.PAX.color) + + val modelProducer = remember { CartesianChartModelProducer() } + val paxColor = PaxSeries.PAX.color + val bleColor = PaxSeries.BLE.color + val wifiColor = PaxSeries.WIFI.color + + LaunchedEffect(totalSeries, bleSeries, wifiSeries) { + modelProducer.runTransaction { + lineSeries { + series(x = bleSeries.map { it.first }, y = bleSeries.map { it.second }) + series(x = wifiSeries.map { it.first }, y = wifiSeries.map { it.second }) + series(x = totalSeries.map { it.first }, y = totalSeries.map { it.second }) } } - YAxisLabels( - modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(end = 8.dp), - labelColor = MaterialTheme.colorScheme.onSurface, - minValue = minValue, - maxValue = maxValue, - ) } - Spacer(modifier = Modifier.height(16.dp)) + + val axisLabel = ChartStyling.rememberAxisLabel() + val marker = + ChartStyling.rememberMarker( + valueFormatter = + ChartStyling.createColoredMarkerValueFormatter { value, color -> + when (color.copy(1f)) { + bleColor -> "BLE: %.0f".format(value) + wifiColor -> "WiFi: %.0f".format(value) + paxColor -> "PAX: %.0f".format(value) + else -> "%.0f".format(value) + } + }, + ) + + GenericMetricChart( + modelProducer = modelProducer, + modifier = modifier.padding(8.dp), + layers = + listOf( + rememberLineCartesianLayer( + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createGradientLine( + lineColor = bleColor, + pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP, + ), + ChartStyling.createGradientLine( + lineColor = wifiColor, + pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP, + ), + ChartStyling.createBoldLine( + lineColor = paxColor, + pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP, + ), + ), + ), + ), + startAxis = VerticalAxis.rememberStart(label = axisLabel), + bottomAxis = + HorizontalAxis.rememberBottom( + label = axisLabel, + valueFormatter = CommonCharts.dynamicTimeFormatter, + itemPlacer = ChartStyling.rememberItemPlacer(spacing = 20), + labelRotationDegrees = 45f, + ), + marker = marker, + selectedX = selectedX, + onPointSelected = onPointSelected, + vicoScrollState = vicoScrollState, + ) } @Composable @@ -176,6 +177,11 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav val state by metricsViewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } + val lazyListState = rememberLazyListState() + val vicoScrollState = rememberVicoScrollState() + val coroutineScope = rememberCoroutineScope() + var selectedX by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { metricsViewModel.effects.collect { effect -> when (effect) { @@ -188,7 +194,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav } val dateFormat = DateFormat.getDateTimeInstance() - var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) } // Only show logs that can be decoded as PaxcountProtos.Paxcount val paxMetrics = state.paxMetrics.mapNotNull { log -> @@ -200,10 +205,8 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav } } // Prepare data for graph - val oldestTime = timeFrame.calculateOldestTime() val graphData = paxMetrics - .filter { it.first.received_date / 1000 >= oldestTime } .map { val t = (it.first.received_date / 1000).toInt() Triple(t, it.second.ble, it.second.wifi) @@ -212,8 +215,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav val totalSeries = graphData.map { it.first to (it.second + it.third) } val bleSeries = graphData.map { it.first to it.second } val wifiSeries = graphData.map { it.first to it.third } - val maxValue = (totalSeries.maxOfOrNull { it.second } ?: 1).toFloat().coerceAtLeast(1f) - val minValue = 0f val legendData = listOf( LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null), @@ -225,6 +226,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav topBar = { MainAppBar( title = state.node?.user?.longName ?: "", + subtitle = stringResource(Res.string.pax_metrics_log), ourNode = null, showNodeChip = false, canNavigateUp = true, @@ -242,14 +244,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) { - // Time frame selector - SlidingSelector( - options = TimeFrame.entries.toList(), - selectedOption = timeFrame, - onOptionSelected = { timeFrame = it }, - ) { tf: TimeFrame -> - OptionLabel(stringResource(tf.strRes)) - } // Graph if (graphData.isNotEmpty()) { ChartHeader(graphData.size) @@ -258,9 +252,15 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav totalSeries = totalSeries, bleSeries = bleSeries, wifiSeries = wifiSeries, - minValue = minValue, - maxValue = maxValue, - timeFrame = timeFrame, + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onPointSelected = { x -> + selectedX = x + val index = paxMetrics.indexOfFirst { (it.first.received_date / 1000).toDouble() == x } + if (index != -1) { + coroutineScope.launch { lazyListState.animateScrollToItem(index) } + } + }, ) } // List @@ -271,8 +271,27 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav textAlign = TextAlign.Center, ) } else { - LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) { - items(paxMetrics) { (log, pax) -> PaxMetricsItem(log, pax, dateFormat) } + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp), + state = lazyListState, + ) { + itemsIndexed(paxMetrics) { _, (log, pax) -> + PaxMetricsItem( + log = log, + pax = pax, + dateFormat = dateFormat, + isSelected = (log.received_date / 1000).toDouble() == selectedX, + onClick = { + selectedX = (log.received_date / 1000).toDouble() + coroutineScope.launch { + vicoScrollState.animateScroll( + Scroll.Absolute.x((log.received_date / 1000).toDouble(), 0.5f), + ) + } + }, + ) + } } } } @@ -349,29 +368,53 @@ fun PaxcountInfo( ) } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun PaxMetricsItem(log: MeshLog, pax: PaxcountProtos.Paxcount, dateFormat: DateFormat) { - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { - Text( - text = dateFormat.format(Date(log.received_date)), - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), - textAlign = TextAlign.End, - modifier = Modifier.fillMaxWidth(), - ) - val total = pax.ble + pax.wifi - val summary = "PAX: $total (B:${pax.ble} W:${pax.wifi})" - Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) { +fun PaxMetricsItem( + log: MeshLog, + pax: PaxcountProtos.Paxcount, + dateFormat: DateFormat, + 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 + }, + ), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { Text( - text = summary, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f, fill = true), - ) - Text( - text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime), - style = MaterialTheme.typography.bodyMedium, + text = dateFormat.format(Date(log.received_date)), + style = MaterialTheme.typography.titleMediumEmphasized, textAlign = TextAlign.End, - modifier = Modifier.alignByBaseline(), + modifier = Modifier.fillMaxWidth(), ) + val total = pax.ble + pax.wifi + val summary = "PAX: $total (B:${pax.ble} W:${pax.wifi})" + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = summary, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f, fill = true), + ) + Text( + text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.End, + modifier = Modifier.alignByBaseline(), + ) + } } } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index 1df1bba7b..1c816ebf3 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -14,12 +14,13 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ +@file:Suppress("MagicNumber") + package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.horizontalScroll +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 import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -28,14 +29,15 @@ 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.items -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -45,25 +47,30 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState 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.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.meshtastic.core.strings.getString +import com.patrykandpatrick.vico.compose.cartesian.Scroll +import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis +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 com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.TelemetryType @@ -72,28 +79,19 @@ import org.meshtastic.core.strings.channel_1 import org.meshtastic.core.strings.channel_2 import org.meshtastic.core.strings.channel_3 import org.meshtastic.core.strings.current +import org.meshtastic.core.strings.power_metrics_log import org.meshtastic.core.strings.voltage import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.component.OptionLabel -import org.meshtastic.core.ui.component.SlidingSelector import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue import org.meshtastic.core.ui.theme.GraphColors.Red import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC -import org.meshtastic.feature.node.metrics.GraphUtil.createPath -import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.proto.TelemetryProtos.Telemetry -import kotlin.math.ceil -import kotlin.math.floor -@Suppress("MagicNumber") -private enum class Power(val color: Color, val min: Float, val max: Float) { - CURRENT(InfantryBlue, -500f, 500f), - ; - - /** Difference between the metrics `max` and `min` values. */ - fun difference() = max - min +private enum class PowerMetric(val color: Color) { + CURRENT(InfantryBlue), + VOLTAGE(Red), } private enum class PowerChannel(val strRes: StringResource) { @@ -102,31 +100,20 @@ private enum class PowerChannel(val strRes: StringResource) { THREE(Res.string.channel_3), } -private const val CHART_WEIGHT = 1f -private const val Y_AXIS_WEIGHT = 0.1f -private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT) - -private const val VOLTAGE_STICK_TO_ZERO_RANGE = 2f - -private val VOLTAGE_COLOR = Red - -fun minMaxGraphVoltage(valueMin: Float, valueMax: Float): Pair { - val valueMin = floor(valueMin) - val min = - if (valueMin == 0f || (valueMin >= 0f && valueMin - VOLTAGE_STICK_TO_ZERO_RANGE <= 0f)) { - 0f - } else { - valueMin - VOLTAGE_STICK_TO_ZERO_RANGE - } - val max = ceil(valueMax) - - return Pair(min, max) -} - private val LEGEND_DATA = listOf( - LegendData(nameRes = Res.string.current, color = Power.CURRENT.color, isLine = true, environmentMetric = null), - LegendData(nameRes = Res.string.voltage, color = VOLTAGE_COLOR, isLine = true, environmentMetric = null), + 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, + ), ) @Suppress("LongMethod") @@ -134,10 +121,14 @@ private val LEGEND_DATA = fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } - val selectedTimeFrame by viewModel.timeFrame.collectAsState() - val data = state.powerMetricsFiltered(selectedTimeFrame) + val data = state.powerMetrics var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) } + val lazyListState = rememberLazyListState() + val vicoScrollState = rememberVicoScrollState() + val coroutineScope = rememberCoroutineScope() + var selectedX by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { viewModel.effects.collect { effect -> when (effect) { @@ -153,6 +144,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate topBar = { MainAppBar( title = state.node?.user?.longName ?: "", + subtitle = stringResource(Res.string.power_metrics_log), ourNode = null, showNodeChip = false, canNavigateUp = true, @@ -176,27 +168,32 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate PowerMetricsChart( modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f), telemetries = data.reversed(), - selectedTimeFrame, - selectedChannel, + selectedChannel = selectedChannel, + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onPointSelected = { x -> + selectedX = x + val index = data.indexOfFirst { it.time.toDouble() == x } + if (index != -1) { + coroutineScope.launch { lazyListState.animateScrollToItem(index) } + } + }, ) - SlidingSelector( - PowerChannel.entries.toList(), - selectedChannel, - onOptionSelected = { selectedChannel = it }, - ) { - OptionLabel(stringResource(it.strRes)) + LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(data) { _, telemetry -> + PowerMetricsCard( + telemetry = telemetry, + isSelected = telemetry.time.toDouble() == selectedX, + onClick = { + selectedX = telemetry.time.toDouble() + coroutineScope.launch { + vicoScrollState.animateScroll(Scroll.Absolute.x(telemetry.time.toDouble(), 0.5f)) + } + }, + ) + } } - Spacer(modifier = Modifier.height(2.dp)) - SlidingSelector( - TimeFrame.entries.toList(), - selectedTimeFrame, - onOptionSelected = { viewModel.setTimeFrame(it) }, - ) { - OptionLabel(stringResource(it.strRes)) - } - - LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> PowerMetricsCard(telemetry) } } } } } @@ -206,155 +203,119 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate private fun PowerMetricsChart( modifier: Modifier = Modifier, telemetries: List, - selectedTime: TimeFrame, selectedChannel: PowerChannel, + vicoScrollState: VicoScrollState, + selectedX: Double?, + onPointSelected: (Double) -> Unit, ) { ChartHeader(amount = telemetries.size) if (telemetries.isEmpty()) { return } - val (oldest, newest) = - remember(key1 = telemetries) { Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time }) } - val timeDiff = newest.time - oldest.time - - val scrollState = rememberScrollState() - val screenWidth = LocalWindowInfo.current.containerSize.width - val dp by - remember(key1 = selectedTime) { - mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong())) - } - - // Calculate visible time range based on scroll position and chart width - val visibleTimeRange = run { - val totalWidthPx = with(LocalDensity.current) { dp.toPx() } - val scrollPx = scrollState.value.toFloat() - // Calculate visible width based on actual weight distribution - val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO - val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f) - val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f) - // With reverseScrolling = true, scrolling right shows older data (left side of chart) - val visibleOldest = oldest.time + (timeDiff * (1f - rightRatio)).toInt() - val visibleNewest = oldest.time + (timeDiff * (1f - leftRatio)).toInt() - visibleOldest to visibleNewest - } - - TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second) - - Spacer(modifier = Modifier.height(16.dp)) - - val graphColor = MaterialTheme.colorScheme.onSurface - val currentDiff = Power.CURRENT.difference() - - val (voltageMin, voltageMax) = - minMaxGraphVoltage( - retrieveVoltage(selectedChannel, telemetries.minBy { retrieveVoltage(selectedChannel, it) }), - retrieveVoltage(selectedChannel, telemetries.maxBy { retrieveVoltage(selectedChannel, it) }), - ) - val voltageDiff = voltageMax - voltageMin - - Row { - YAxisLabels( - modifier = modifier.weight(weight = Y_AXIS_WEIGHT), - Power.CURRENT.color, - minValue = Power.CURRENT.min, - maxValue = Power.CURRENT.max, - ) - Box( - contentAlignment = Alignment.TopStart, - modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f), - ) { - HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor }) - - TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval()) - - /* Plot */ - Canvas(modifier = modifier.width(dp)) { - val width = size.width - val height = size.height - /* Voltage */ - var index = 0 - while (index < telemetries.size) { - val path = Path() - index = - createPath( - telemetries = telemetries, - index = index, - path = path, - oldestTime = oldest.time, - timeRange = timeDiff, - width = width, - timeThreshold = selectedTime.timeThreshold(), - ) { i -> - val telemetry = telemetries.getOrNull(i) ?: telemetries.last() - val ratio = (retrieveVoltage(selectedChannel, telemetry) - voltageMin) / voltageDiff - val y = height - (ratio * height) - return@createPath y - } - drawPath( - path = path, - color = VOLTAGE_COLOR, - style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round), - ) - } - /* Current */ - index = 0 - while (index < telemetries.size) { - val path = Path() - index = - createPath( - telemetries = telemetries, - index = index, - path = path, - oldestTime = oldest.time, - timeRange = timeDiff, - width = width, - timeThreshold = selectedTime.timeThreshold(), - ) { i -> - val telemetry = telemetries.getOrNull(i) ?: telemetries.last() - val ratio = (retrieveCurrent(selectedChannel, telemetry) - Power.CURRENT.min) / currentDiff - val y = height - (ratio * height) - return@createPath y - } - drawPath( - path = path, - color = Power.CURRENT.color, - style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round), - ) + val modelProducer = remember { CartesianChartModelProducer() } + val currentColor = PowerMetric.CURRENT.color + val voltageColor = PowerMetric.VOLTAGE.color + val marker = + ChartStyling.rememberMarker( + valueFormatter = + ChartStyling.createColoredMarkerValueFormatter { value, color -> + when (color.copy(1f)) { + currentColor -> "Current: %.0f mA".format(value) + voltageColor -> "Voltage: %.1f V".format(value) + else -> "%.1f".format(value) } + }, + ) + + LaunchedEffect(telemetries, selectedChannel) { + modelProducer.runTransaction { + lineSeries { + series(x = telemetries.map { it.time }, y = telemetries.map { retrieveCurrent(selectedChannel, it) }) + } + lineSeries { + series(x = telemetries.map { it.time }, y = telemetries.map { retrieveVoltage(selectedChannel, it) }) } } - YAxisLabels( - modifier = modifier.weight(weight = Y_AXIS_WEIGHT), - VOLTAGE_COLOR, - minValue = voltageMin, - maxValue = voltageMax, - ) } - Spacer(modifier = Modifier.height(16.dp)) + GenericMetricChart( + modelProducer = modelProducer, + modifier = modifier.padding(8.dp), + layers = + listOf( + rememberLineCartesianLayer( + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createBoldLine(currentColor, ChartStyling.MEDIUM_POINT_SIZE_DP), + ), + verticalAxisPosition = Axis.Position.Vertical.Start, + ), + rememberLineCartesianLayer( + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createGradientLine(voltageColor, ChartStyling.MEDIUM_POINT_SIZE_DP), + ), + verticalAxisPosition = Axis.Position.Vertical.End, + ), + ), + startAxis = + VerticalAxis.rememberStart( + label = ChartStyling.rememberAxisLabel(color = currentColor), + valueFormatter = { _, value, _ -> "%.0f mA".format(value) }, + ), + endAxis = + VerticalAxis.rememberEnd( + label = ChartStyling.rememberAxisLabel(color = voltageColor), + valueFormatter = { _, value, _ -> "%.1f V".format(value) }, + ), + bottomAxis = + HorizontalAxis.rememberBottom( + label = ChartStyling.rememberAxisLabel(), + valueFormatter = CommonCharts.dynamicTimeFormatter, + itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50), + labelRotationDegrees = 45f, + ), + marker = marker, + selectedX = selectedX, + onPointSelected = onPointSelected, + vicoScrollState = vicoScrollState, + ) Legend(legendData = LEGEND_DATA, displayInfoIcon = false) - - Spacer(modifier = Modifier.height(16.dp)) } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun PowerMetricsCard(telemetry: Telemetry) { +private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) { val time = telemetry.time * MS_PER_SEC - Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) { + 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(8.dp)) { + Column(modifier = Modifier.padding(12.dp)) { /* Time */ Row { Text( text = DATE_TIME_FORMAT.format(time), - style = TextStyle(fontWeight = FontWeight.Bold), - fontSize = MaterialTheme.typography.labelLarge.fontSize, + style = MaterialTheme.typography.titleMediumEmphasized, ) } + + Spacer(modifier = Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { if (telemetry.powerMetrics.hasCh1Current() || telemetry.powerMetrics.hasCh1Voltage()) { PowerChannelColumn( diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index 327620321..6f4e91d18 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -16,8 +16,8 @@ */ package org.meshtastic.feature.node.metrics -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.horizontalScroll +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 @@ -29,12 +29,13 @@ 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.items -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState 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.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -44,61 +45,57 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState 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.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.meshtastic.core.strings.getString +import com.patrykandpatrick.vico.compose.cartesian.Scroll +import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis +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 com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.request_telemetry import org.meshtastic.core.strings.rssi import org.meshtastic.core.strings.rssi_definition +import org.meshtastic.core.strings.signal_quality import org.meshtastic.core.strings.snr import org.meshtastic.core.strings.snr_definition import org.meshtastic.core.ui.component.LoraSignalIndicator import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.component.OptionLabel -import org.meshtastic.core.ui.component.SlidingSelector import org.meshtastic.core.ui.component.SnrAndRssi import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC -import org.meshtastic.feature.node.metrics.GraphUtil.plotPoint -import org.meshtastic.feature.node.model.TimeFrame +import org.meshtastic.feature.node.metrics.CommonCharts.SCROLL_BIAS import org.meshtastic.proto.MeshProtos.MeshPacket -@Suppress("MagicNumber") -private enum class Metric(val color: Color, val min: Float, val max: Float) { - SNR(Color.Green, -20f, 12f), /* Selected 12 as the max to get 4 equal vertical sections. */ - RSSI(Color.Blue, -140f, -20f), - ; - - /** Difference between the metrics `max` and `min` values. */ - fun difference() = max - min +private enum class SignalMetric(val color: Color) { + SNR(Color.Green), + RSSI(Color.Blue), } -private const val CHART_WEIGHT = 1f -private const val Y_AXIS_WEIGHT = 0.1f -private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT) - private val LEGEND_DATA = listOf( - LegendData(nameRes = Res.string.rssi, color = Metric.RSSI.color, environmentMetric = null), - LegendData(nameRes = Res.string.snr, color = Metric.SNR.color, environmentMetric = null), + LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color, environmentMetric = null), + LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color, environmentMetric = null), ) @Suppress("LongMethod") @@ -107,8 +104,12 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } var displayInfoDialog by remember { mutableStateOf(false) } - val selectedTimeFrame by viewModel.timeFrame.collectAsState() - val data = state.signalMetricsFiltered(selectedTimeFrame) + val data = state.signalMetrics + + val lazyListState = rememberLazyListState() + val vicoScrollState = rememberVicoScrollState() + val coroutineScope = rememberCoroutineScope() + var selectedX by remember { mutableStateOf(null) } LaunchedEffect(Unit) { viewModel.effects.collect { effect -> @@ -125,6 +126,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat topBar = { MainAppBar( title = state.node?.user?.longName ?: "", + subtitle = stringResource(Res.string.signal_quality), ourNode = null, showNodeChip = false, canNavigateUp = true, @@ -134,7 +136,10 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) }) { androidx.compose.material3.Icon( imageVector = MeshtasticIcons.Refresh, - contentDescription = null, + contentDescription = + stringResource(Res.string.signal_quality) + + " " + + stringResource(Res.string.request_telemetry), ) } } @@ -159,20 +164,33 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat SignalMetricsChart( modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f), meshPackets = data.reversed(), - selectedTimeFrame, promptInfoDialog = { displayInfoDialog = true }, + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onPointSelected = { x -> + selectedX = x + val index = data.indexOfFirst { it.rxTime.toDouble() == x } + if (index != -1) { + coroutineScope.launch { lazyListState.animateScrollToItem(index) } + } + }, ) - SlidingSelector( - TimeFrame.entries.toList(), - selectedTimeFrame, - onOptionSelected = { viewModel.setTimeFrame(it) }, - ) { - OptionLabel(stringResource(it.strRes)) - } - - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(data) { meshPacket -> SignalMetricsCard(meshPacket) } + LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(data) { _, meshPacket -> + SignalMetricsCard( + meshPacket = meshPacket, + isSelected = meshPacket.rxTime.toDouble() == selectedX, + onClick = { + selectedX = meshPacket.rxTime.toDouble() + coroutineScope.launch { + vicoScrollState.animateScroll( + Scroll.Absolute.x(meshPacket.rxTime.toDouble(), SCROLL_BIAS), + ) + } + }, + ) + } } } } @@ -183,126 +201,115 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat private fun SignalMetricsChart( modifier: Modifier = Modifier, meshPackets: List, - selectedTime: TimeFrame, promptInfoDialog: () -> Unit, + vicoScrollState: VicoScrollState, + selectedX: Double?, + onPointSelected: (Double) -> Unit, ) { ChartHeader(amount = meshPackets.size) if (meshPackets.isEmpty()) { return } - val (oldest, newest) = - remember(key1 = meshPackets) { Pair(meshPackets.minBy { it.rxTime }, meshPackets.maxBy { it.rxTime }) } - val timeDiff = newest.rxTime - oldest.rxTime + val modelProducer = remember { CartesianChartModelProducer() } - val scrollState = rememberScrollState() - val screenWidth = LocalWindowInfo.current.containerSize.width - val dp by - remember(key1 = selectedTime) { - mutableStateOf(selectedTime.dp(screenWidth, time = (newest.rxTime - oldest.rxTime).toLong())) + LaunchedEffect(meshPackets) { + modelProducer.runTransaction { + /* Use separate lineSeries calls to associate them with different vertical axes */ + lineSeries { series(x = meshPackets.map { it.rxTime }, y = meshPackets.map { it.rxRssi }) } + lineSeries { series(x = meshPackets.map { it.rxTime }, y = meshPackets.map { it.rxSnr }) } } - - // Calculate visible time range based on scroll position and chart width - val visibleTimeRange = run { - val totalWidthPx = with(LocalDensity.current) { dp.toPx() } - val scrollPx = scrollState.value.toFloat() - // Calculate visible width based on actual weight distribution - val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO - val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f) - val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f) - // With reverseScrolling = true, scrolling right shows older data (left side of chart) - val visibleOldest = oldest.rxTime + (timeDiff * (1f - rightRatio)).toInt() - val visibleNewest = oldest.rxTime + (timeDiff * (1f - leftRatio)).toInt() - visibleOldest to visibleNewest } - TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second) + val rssiColor = SignalMetric.RSSI.color + val snrColor = SignalMetric.SNR.color - Spacer(modifier = Modifier.height(16.dp)) - - val graphColor = MaterialTheme.colorScheme.onSurface - val snrDiff = Metric.SNR.difference() - val rssiDiff = Metric.RSSI.difference() - - Row { - YAxisLabels( - modifier = modifier.weight(weight = Y_AXIS_WEIGHT), - Metric.RSSI.color, - minValue = Metric.RSSI.min, - maxValue = Metric.RSSI.max, - ) - Box( - contentAlignment = Alignment.TopStart, - modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f), - ) { - HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor }) - - TimeAxisOverlay( - modifier.width(dp), - oldest = oldest.rxTime, - newest = newest.rxTime, - selectedTime.lineInterval(), - ) - - /* Plot SNR and RSSI */ - Canvas(modifier = modifier.width(dp)) { - val width = size.width - /* Plot */ - for (packet in meshPackets) { - val xRatio = (packet.rxTime - oldest.rxTime).toFloat() / timeDiff - val x = xRatio * width - - /* SNR */ - plotPoint( - drawContext = drawContext, - color = Metric.SNR.color, - x = x, - value = packet.rxSnr - Metric.SNR.min, - divisor = snrDiff, - ) - - /* RSSI */ - plotPoint( - drawContext = drawContext, - color = Metric.RSSI.color, - x = x, - value = packet.rxRssi - Metric.RSSI.min, - divisor = rssiDiff, - ) + val marker = + ChartStyling.rememberMarker( + valueFormatter = + ChartStyling.createColoredMarkerValueFormatter { value, color -> + if (color.copy(alpha = 1f) == rssiColor) { + "RSSI: %.0f dBm".format(value) + } else { + "SNR: %.1f dB".format(value) } - } - } - YAxisLabels( - modifier = modifier.weight(weight = Y_AXIS_WEIGHT), - Metric.SNR.color, - minValue = Metric.SNR.min, - maxValue = Metric.SNR.max, + }, ) - } - Spacer(modifier = Modifier.height(16.dp)) + GenericMetricChart( + modelProducer = modelProducer, + modifier = modifier.padding(8.dp), + layers = + listOf( + rememberLineCartesianLayer( + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createPointOnlyLine(rssiColor, ChartStyling.LARGE_POINT_SIZE_DP), + ), + verticalAxisPosition = Axis.Position.Vertical.Start, + ), + rememberLineCartesianLayer( + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createPointOnlyLine(snrColor, ChartStyling.LARGE_POINT_SIZE_DP), + ), + verticalAxisPosition = Axis.Position.Vertical.End, + ), + ), + startAxis = + VerticalAxis.rememberStart( + label = ChartStyling.rememberAxisLabel(color = rssiColor), + valueFormatter = { _, value, _ -> "%.0f dBm".format(value) }, + ), + endAxis = + VerticalAxis.rememberEnd( + label = ChartStyling.rememberAxisLabel(color = snrColor), + valueFormatter = { _, value, _ -> "%.1f dB".format(value) }, + ), + bottomAxis = + HorizontalAxis.rememberBottom( + label = ChartStyling.rememberAxisLabel(), + valueFormatter = CommonCharts.dynamicTimeFormatter, + itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50), + labelRotationDegrees = 45f, + ), + marker = marker, + selectedX = selectedX, + onPointSelected = onPointSelected, + vicoScrollState = vicoScrollState, + ) Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog) - - Spacer(modifier = Modifier.height(16.dp)) } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun SignalMetricsCard(meshPacket: MeshPacket) { +private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) { val time = meshPacket.rxTime * MS_PER_SEC - Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) { - Surface { + 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()) { /* Data */ Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) { - Column(modifier = Modifier.padding(8.dp)) { + Column(modifier = Modifier.padding(12.dp)) { /* Time */ Row(horizontalArrangement = Arrangement.SpaceBetween) { Text( text = DATE_TIME_FORMAT.format(time), - style = TextStyle(fontWeight = FontWeight.Bold), - fontSize = MaterialTheme.typography.labelLarge.fontSize, + style = MaterialTheme.typography.titleMediumEmphasized, ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt index 8bf789261..60ca71d99 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TracerouteLog.kt @@ -20,21 +20,13 @@ import android.text.format.DateUtils import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.material3.Card import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -48,7 +40,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.vector.ImageVector import androidx.compose.ui.platform.LocalContext @@ -71,13 +62,13 @@ import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.model.toMessageRes import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.close -import org.meshtastic.core.strings.delete import org.meshtastic.core.strings.routing_error_no_response import org.meshtastic.core.strings.traceroute import org.meshtastic.core.strings.traceroute_diff import org.meshtastic.core.strings.traceroute_direct import org.meshtastic.core.strings.traceroute_duration import org.meshtastic.core.strings.traceroute_hops +import org.meshtastic.core.strings.traceroute_log import org.meshtastic.core.strings.traceroute_route_back_to_us import org.meshtastic.core.strings.traceroute_route_towards_dest import org.meshtastic.core.strings.traceroute_time_and_text @@ -86,7 +77,6 @@ import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD import org.meshtastic.core.ui.component.SimpleAlertDialog -import org.meshtastic.core.ui.icon.Delete import org.meshtastic.core.ui.icon.Group import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.PersonOff @@ -153,6 +143,7 @@ fun TracerouteLogScreen( val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsState() MainAppBar( title = state.node?.user?.longName ?: "", + subtitle = stringResource(Res.string.traceroute_log), ourNode = null, showNodeChip = false, canNavigateUp = true, @@ -233,9 +224,10 @@ fun TracerouteLogScreen( } Box { - TracerouteItem( + MetricLogItem( icon = icon, text = stringResource(Res.string.traceroute_time_and_text, time, text), + contentDescription = stringResource(Res.string.traceroute), modifier = Modifier.combinedClickable(onLongClick = { expanded = true }) { val dialogMessage = @@ -322,37 +314,6 @@ private fun TracerouteLogDialogs( } } -@Composable -private fun DeleteItem(onClick: () -> Unit) { - DropdownMenuItem( - onClick = onClick, - text = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = MeshtasticIcons.Delete, - contentDescription = stringResource(Res.string.delete), - tint = MaterialTheme.colorScheme.error, - ) - Spacer(modifier = Modifier.width(12.dp)) - Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error) - } - }, - ) -} - -@Composable -private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier = Modifier) { - Card(modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).padding(vertical = 2.dp)) { - Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(imageVector = icon, contentDescription = stringResource(Res.string.traceroute)) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = text, style = MaterialTheme.typography.bodyLarge) - } - } - } -} - /** Generates a display string and icon based on the route discovery information. */ @Composable private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair = when { @@ -424,5 +385,11 @@ private fun TracerouteItemPreview() { System.currentTimeMillis(), DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL, ) - AppTheme { TracerouteItem(icon = MeshtasticIcons.Group, text = "$time - Direct") } + AppTheme { + MetricLogItem( + icon = MeshtasticIcons.Group, + text = "$time - Direct", + contentDescription = stringResource(Res.string.traceroute), + ) + } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt index 38044a722..502eac84d 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/LogsType.kt @@ -18,6 +18,7 @@ package org.meshtastic.feature.node.model import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.ChargingStation +import androidx.compose.material.icons.rounded.Groups import androidx.compose.material.icons.rounded.LocationOn import androidx.compose.material.icons.rounded.Map import androidx.compose.material.icons.rounded.Memory @@ -32,11 +33,12 @@ import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.device_metrics_log import org.meshtastic.core.strings.env_metrics_log import org.meshtastic.core.strings.host_metrics_log +import org.meshtastic.core.strings.neighbor_info import org.meshtastic.core.strings.node_map import org.meshtastic.core.strings.pax_metrics_log import org.meshtastic.core.strings.position_log import org.meshtastic.core.strings.power_metrics_log -import org.meshtastic.core.strings.sig_metrics_log +import org.meshtastic.core.strings.signal_quality import org.meshtastic.core.strings.traceroute_log import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Paxcount @@ -47,9 +49,10 @@ enum class LogsType(val titleRes: StringResource, val icon: ImageVector, val rou NODE_MAP(Res.string.node_map, Icons.Rounded.Map, { NodeDetailRoutes.NodeMap(it) }), POSITIONS(Res.string.position_log, Icons.Rounded.LocationOn, { NodeDetailRoutes.PositionLog(it) }), ENVIRONMENT(Res.string.env_metrics_log, Icons.Rounded.Thermostat, { NodeDetailRoutes.EnvironmentMetrics(it) }), - SIGNAL(Res.string.sig_metrics_log, Icons.Rounded.SignalCellularAlt, { NodeDetailRoutes.SignalMetrics(it) }), + SIGNAL(Res.string.signal_quality, Icons.Rounded.SignalCellularAlt, { NodeDetailRoutes.SignalMetrics(it) }), POWER(Res.string.power_metrics_log, Icons.Rounded.Power, { NodeDetailRoutes.PowerMetrics(it) }), TRACEROUTE(Res.string.traceroute_log, MeshtasticIcons.Route, { NodeDetailRoutes.TracerouteLog(it) }), + NEIGHBOR_INFO(Res.string.neighbor_info, Icons.Rounded.Groups, { NodeDetailRoutes.NeighborInfoLog(it) }), HOST(Res.string.host_metrics_log, Icons.Rounded.Memory, { NodeDetailRoutes.HostMetricsLog(it) }), PAX(Res.string.pax_metrics_log, MeshtasticIcons.Paxcount, { NodeDetailRoutes.PaxMetrics(it) }), } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt index 47962376c..460a43e6d 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt @@ -16,24 +16,13 @@ */ package org.meshtastic.feature.node.model -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.model.Node import org.meshtastic.core.model.DeviceHardware -import org.meshtastic.core.strings.Res -import org.meshtastic.core.strings.forty_eight_hours -import org.meshtastic.core.strings.four_weeks -import org.meshtastic.core.strings.max -import org.meshtastic.core.strings.one_week -import org.meshtastic.core.strings.twenty_four_hours -import org.meshtastic.core.strings.two_weeks import org.meshtastic.proto.ConfigProtos import org.meshtastic.proto.MeshProtos import org.meshtastic.proto.TelemetryProtos -import java.util.concurrent.TimeUnit data class MetricsState( val isLocal: Boolean = false, @@ -48,9 +37,10 @@ data class MetricsState( val hostMetrics: List = emptyList(), val tracerouteRequests: List = emptyList(), val tracerouteResults: List = emptyList(), + val neighborInfoRequests: List = emptyList(), + val neighborInfoResults: List = emptyList(), val positionLogs: List = emptyList(), val deviceHardware: DeviceHardware? = null, - val isLocalDevice: Boolean = false, val firmwareEdition: MeshProtos.FirmwareEdition? = null, val latestStableFirmware: FirmwareRelease = FirmwareRelease(), val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(), @@ -66,84 +56,15 @@ data class MetricsState( fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty() + fun hasNeighborInfoLogs() = neighborInfoRequests.isNotEmpty() + fun hasPositionLogs() = positionLogs.isNotEmpty() fun hasHostMetrics() = hostMetrics.isNotEmpty() fun hasPaxMetrics() = paxMetrics.isNotEmpty() - fun deviceMetricsFiltered(timeFrame: TimeFrame): List { - val oldestTime = timeFrame.calculateOldestTime() - return deviceMetrics.filter { it.time >= oldestTime } - } - - fun signalMetricsFiltered(timeFrame: TimeFrame): List { - val oldestTime = timeFrame.calculateOldestTime() - return signalMetrics.filter { it.rxTime >= oldestTime } - } - - fun powerMetricsFiltered(timeFrame: TimeFrame): List { - val oldestTime = timeFrame.calculateOldestTime() - return powerMetrics.filter { it.time >= oldestTime } - } - companion object { val Empty = MetricsState() } } - -/** Supported time frames used to display data. */ -@Suppress("MagicNumber") -enum class TimeFrame(val seconds: Long, val strRes: StringResource) { - TWENTY_FOUR_HOURS(TimeUnit.DAYS.toSeconds(1), Res.string.twenty_four_hours), - FORTY_EIGHT_HOURS(TimeUnit.DAYS.toSeconds(2), Res.string.forty_eight_hours), - ONE_WEEK(TimeUnit.DAYS.toSeconds(7), Res.string.one_week), - TWO_WEEKS(TimeUnit.DAYS.toSeconds(14), Res.string.two_weeks), - FOUR_WEEKS(TimeUnit.DAYS.toSeconds(28), Res.string.four_weeks), - MAX(0L, Res.string.max), - ; - - fun calculateOldestTime(): Long = if (this == MAX) { - MAX.seconds - } else { - System.currentTimeMillis() / 1000 - this.seconds - } - - /** - * The time interval to draw the vertical lines representing time on the x-axis. - * - * @return seconds epoch seconds - */ - fun lineInterval(): Long = when (this.ordinal) { - TWENTY_FOUR_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(6) - - FORTY_EIGHT_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(12) - - ONE_WEEK.ordinal, - TWO_WEEKS.ordinal, - -> TimeUnit.DAYS.toSeconds(1) - - else -> TimeUnit.DAYS.toSeconds(7) - } - - /** Used to detect a significant time separation between [TelemetryProtos.Telemetry]s. */ - fun timeThreshold(): Long = when (this.ordinal) { - TWENTY_FOUR_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(6) - - FORTY_EIGHT_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(12) - - else -> TimeUnit.DAYS.toSeconds(1) - } - - /** - * Calculates the needed [androidx.compose.ui.unit.Dp] depending on the amount of time being plotted. - * - * @param time in seconds - */ - fun dp(screenWidth: Int, time: Long): Dp { - val timePerScreen = this.lineInterval() - val multiplier = time / timePerScreen - val dp = (screenWidth * multiplier).toInt().dp - return dp.takeIf { it != 0.dp } ?: screenWidth.dp - } -} diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt index 5bec4745b..6ae9f873f 100644 --- a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt +++ b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsStateTest.kt @@ -19,14 +19,13 @@ package org.meshtastic.feature.node.metrics import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics import org.meshtastic.proto.TelemetryProtos.Telemetry class EnvironmentMetricsStateTest { @Test - fun `environmentMetricsFiltered correctly calculates times`() { + fun `environmentMetricsForGraphing correctly calculates times`() { val now = (System.currentTimeMillis() / 1000).toInt() val metrics = listOf( @@ -44,37 +43,14 @@ class EnvironmentMetricsStateTest { .build(), ) val state = EnvironmentMetricsState(metrics) - val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS) + val result = state.environmentMetricsForGraphing() assertEquals(now - 100, result.times.first) assertEquals(now, result.times.second) } @Test - fun `environmentMetricsFiltered ignores invalid timestamps`() { - val now = (System.currentTimeMillis() / 1000).toInt() - val metrics = - listOf( - Telemetry.newBuilder() - .setTime(0) - .setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(20f)) - .build(), - Telemetry.newBuilder() - .setTime(now) - .setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(21f)) - .build(), - ) - val state = EnvironmentMetricsState(metrics) - val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS) - - // Only the valid timestamp should be considered for filters - assertEquals(now, result.times.first) - assertEquals(now, result.times.second) - assertEquals(1, result.metrics.size) - } - - @Test - fun `environmentMetricsFiltered handles valid zero temperatures`() { + fun `environmentMetricsForGraphing handles valid zero temperatures`() { val now = (System.currentTimeMillis() / 1000).toInt() val metrics = listOf( @@ -84,7 +60,7 @@ class EnvironmentMetricsStateTest { .build(), ) val state = EnvironmentMetricsState(metrics) - val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS) + val result = state.environmentMetricsForGraphing() assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal]) assertEquals(0.0f, result.rightMinMax.first, 0.01f) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c8d9aa8fa..070246ca9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,7 @@ devtools-ksp = "2.3.5" markdownRenderer = "0.39.1" osmdroid-android = "6.1.20" protobuf = "4.33.4" +vico = "3.0.0-beta.3" [libraries] @@ -181,6 +182,9 @@ streamsupport-minifuture = { module = "net.sourceforge.streamsupport:streamsuppo usb-serial-android = { module = "com.github.mik3y:usb-serial-for-android", version = "3.10.0" } zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version = "4.3.0" } +vico-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico" } +vico-compose-m2 = { group = "com.patrykandpatrick.vico", name = "compose-m2", version.ref = "vico" } +vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" } # Build Logic android-gradleApiPlugin = { module = "com.android.tools.build:gradle-api", version.ref = "agp" }