From 9a8a31b2988495e853cee4f1c34f87001108f9db Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Fri, 30 Jan 2026 17:20:57 -0600
Subject: [PATCH] feat(charts): voltage, colors, legends, and adaptive ui
(#4383)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
---
.../meshtastic/core/ui/theme/CustomColors.kt | 15 +-
.../feature/node/metrics/BaseMetricChart.kt | 160 +++++++-
.../feature/node/metrics/ChartStyling.kt | 15 +-
.../feature/node/metrics/CommonCharts.kt | 147 +++----
.../feature/node/metrics/DeviceMetrics.kt | 371 ++++++++++--------
.../feature/node/metrics/EnvironmentCharts.kt | 207 +++++-----
.../node/metrics/EnvironmentMetrics.kt | 210 ++++++----
.../node/metrics/EnvironmentMetricsState.kt | 14 +-
.../feature/node/metrics/PaxMetrics.kt | 273 +++++++------
.../feature/node/metrics/PowerMetrics.kt | 249 ++++++------
.../feature/node/metrics/SignalMetrics.kt | 280 +++++--------
11 files changed, 1061 insertions(+), 880 deletions(-)
diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
index f15d1fb82..38338a555 100644
--- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
+++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/theme/CustomColors.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.core.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
@@ -51,13 +50,11 @@ object GraphColors {
val Purple = Color(0xFF9C27B0)
val Pink = Color(red = 255, green = 102, blue = 204)
val Orange = Color(0xFFFF8800)
-
- val Green = Color.Green
- val Red = Color.Red
- val Blue = Color.Blue
- val Yellow = Color.Yellow
- val Magenta = Color.Magenta
- val Cyan = Color.Cyan
+ val Gold = Color(0xFFFFD700)
+ val Cyan = Color(0xFF00BCD4)
+ val Red = Color(0xFFE91E63)
+ val Blue = Color(0xFF2196F3)
+ val Green = Color(0xFF4CAF50)
}
object StatusColors {
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
index 8cd100143..a83a60773 100644
--- 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
@@ -16,10 +16,34 @@
*/
package org.meshtastic.feature.node.metrics
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
+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.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.meshtastic.core.strings.getString
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
+import com.patrykandpatrick.vico.compose.cartesian.Scroll
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.Zoom
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
@@ -32,21 +56,21 @@ import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarkerVisibil
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.resources.StringResource
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.strings.Res
+import org.meshtastic.core.strings.info
+import org.meshtastic.core.strings.logs
+import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+import org.meshtastic.core.ui.icon.Refresh
+import org.meshtastic.feature.node.detail.NodeRequestEffect
/**
* 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(
@@ -92,3 +116,117 @@ fun GenericMetricChart(
zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content),
)
}
+
+/**
+ * An adaptive layout for metric screens. Uses a split Row for wide screens (tablets/landscape) and a stacked Column for
+ * narrow screens (phones).
+ */
+@Composable
+fun AdaptiveMetricLayout(
+ chartPart: @Composable (Modifier) -> Unit,
+ listPart: @Composable (Modifier) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ BoxWithConstraints(modifier = modifier) {
+ val isExpanded = maxWidth >= 600.dp
+ if (isExpanded) {
+ Row(modifier = Modifier.fillMaxSize()) {
+ chartPart(Modifier.weight(1f).fillMaxHeight())
+ listPart(Modifier.weight(1f).fillMaxHeight())
+ }
+ } else {
+ Column(modifier = Modifier.fillMaxSize()) {
+ chartPart(Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f))
+ listPart(Modifier.fillMaxWidth().weight(1f))
+ }
+ }
+ }
+}
+
+/** A high-level template for metric screens that handles the Scaffold, AppBar, adaptive layout, and synchronization. */
+@Composable
+@Suppress("LongMethod")
+fun BaseMetricScreen(
+ viewModel: MetricsViewModel,
+ onNavigateUp: () -> Unit,
+ telemetryType: TelemetryType?,
+ titleRes: StringResource,
+ data: List,
+ timeProvider: (T) -> Double,
+ infoData: List = emptyList(),
+ chartPart: @Composable (Modifier, Double?, VicoScrollState, (Double) -> Unit) -> Unit,
+ listPart: @Composable (Modifier, Double?, (Double) -> Unit) -> Unit,
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val snackbarHostState = remember { SnackbarHostState() }
+ var displayInfoDialog by remember { mutableStateOf(false) }
+
+ val lazyListState = rememberLazyListState()
+ val vicoScrollState = rememberVicoScrollState()
+ val coroutineScope = rememberCoroutineScope()
+ var selectedX by remember { mutableStateOf(null) }
+
+ LaunchedEffect(Unit) {
+ viewModel.effects.collect { effect ->
+ when (effect) {
+ is NodeRequestEffect.ShowFeedback -> {
+ @Suppress("SpreadOperator")
+ snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
+ }
+ }
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ MainAppBar(
+ title = state.node?.user?.longName ?: "",
+ subtitle = stringResource(titleRes) + " (${data.size} ${stringResource(Res.string.logs)})",
+ ourNode = null,
+ showNodeChip = false,
+ canNavigateUp = true,
+ onNavigateUp = onNavigateUp,
+ actions = {
+ if (infoData.isNotEmpty()) {
+ IconButton(onClick = { displayInfoDialog = true }) {
+ Icon(imageVector = Icons.Rounded.Info, contentDescription = stringResource(Res.string.info))
+ }
+ }
+ if (telemetryType != null) {
+ IconButton(onClick = { viewModel.requestTelemetry(telemetryType) }) {
+ Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
+ }
+ }
+ },
+ onClickChip = {},
+ )
+ },
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ ) { innerPadding ->
+ Column(modifier = Modifier.padding(innerPadding)) {
+ if (displayInfoDialog) {
+ LegendInfoDialog(infoData = infoData, onDismiss = { displayInfoDialog = false })
+ }
+
+ AdaptiveMetricLayout(
+ chartPart = { modifier ->
+ chartPart(modifier, selectedX, vicoScrollState) { x ->
+ selectedX = x
+ val index = data.indexOfFirst { timeProvider(it) == x }
+ if (index != -1) {
+ coroutineScope.launch { lazyListState.animateScrollToItem(index) }
+ }
+ }
+ },
+ listPart = { modifier ->
+ listPart(modifier, selectedX) { x ->
+ selectedX = x
+ coroutineScope.launch {
+ vicoScrollState.animateScroll(Scroll.Absolute.x(x, CommonCharts.SCROLL_BIAS))
+ }
+ }
+ },
+ )
+ }
+ }
+}
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
index 11d758038..1624f1673 100644
--- 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
@@ -28,7 +28,6 @@ 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
@@ -252,12 +251,16 @@ object ChartStyling {
}
/**
- * Creates a standard [HorizontalAxis.ItemPlacer] with optimized spacing.
- *
- * @param spacing The number of data points to skip between labels.
+ * Creates a standard [com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis.ItemPlacer] with optimized
+ * spacing.
*/
- fun rememberItemPlacer(spacing: Int = 50): HorizontalAxis.ItemPlacer =
- HorizontalAxis.ItemPlacer.aligned(spacing = { spacing }, addExtremeLabelPadding = true)
+ fun rememberItemPlacer(
+ spacing: Int = 50,
+ ): com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis.ItemPlacer =
+ com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis.ItemPlacer.aligned(
+ spacing = { spacing },
+ addExtremeLabelPadding = true,
+ )
/**
* Creates and remembers a [com.patrykandpatrick.vico.compose.common.component.TextComponent] styled for axis
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 90846e9a9..8c486bb92 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
@@ -20,19 +20,18 @@ package org.meshtastic.feature.node.metrics
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.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
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
@@ -54,10 +53,8 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
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 com.patrykandpatrick.vico.compose.cartesian.CartesianDrawingContext
@@ -68,7 +65,6 @@ 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.core.ui.icon.Delete
@@ -86,39 +82,19 @@ object CommonCharts {
const val MAX_PERCENT_VALUE = 100f
const val SCROLL_BIAS = 0.5f
- /**
- * 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.
- */
+ /** Gets the Material 3 primary color with optional opacity adjustment. */
@Composable
fun getMaterial3PrimaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.primary.copy(alpha = alpha)
- /**
- * Gets the Material 3 secondary color with optional opacity adjustment.
- *
- * @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque).
- * @return Color based on current theme's secondary color.
- */
+ /** Gets the Material 3 secondary color with optional opacity adjustment. */
@Composable
fun getMaterial3SecondaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha)
- /**
- * Gets the Material 3 tertiary color with optional opacity adjustment.
- *
- * @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque).
- * @return Color based on current theme's tertiary color.
- */
+ /** Gets the Material 3 tertiary color with optional opacity adjustment. */
@Composable
fun getMaterial3TertiaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.tertiary.copy(alpha = alpha)
- /**
- * Gets the Material 3 error color with optional opacity adjustment.
- *
- * @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque).
- * @return Color based on current theme's error color.
- */
+ /** Gets the Material 3 error color with optional opacity adjustment. */
@Composable
fun getMaterial3ErrorColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.error.copy(alpha = alpha)
@@ -147,96 +123,80 @@ data class LegendData(
val environmentMetric: Environment? = null,
)
+data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color)
+
+/** Creates the legend that identifies the colors used for the graph. */
+@OptIn(ExperimentalLayoutApi::class)
@Composable
-fun ChartHeader(amount: Int) {
- Row(
- modifier = Modifier.fillMaxWidth(),
+fun Legend(legendData: List, modifier: Modifier = Modifier) {
+ FlowRow(
+ modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
- verticalAlignment = Alignment.CenterVertically,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
) {
- Text(
- text = "$amount ${stringResource(Res.string.logs)}",
- modifier = Modifier.wrapContentWidth(),
- style = TextStyle(fontWeight = FontWeight.Bold),
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
- }
-}
-
-/**
- * Creates the legend that identifies the colors used for the graph.
- *
- * @param legendData A list containing the `LegendData` to build the labels.
- * @param promptInfoDialog Executes when the user presses the info icon.
- */
-@Composable
-fun Legend(legendData: List, displayInfoIcon: Boolean = true, promptInfoDialog: () -> Unit = {}) {
- Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
- Spacer(modifier = Modifier.weight(1f))
- legendData.forEachIndexed { index, data ->
- LegendLabel(text = stringResource(data.nameRes), color = data.color, isLine = data.isLine)
-
- if (index != legendData.lastIndex) {
- Spacer(modifier = Modifier.weight(1f))
+ legendData.forEach { data ->
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp)) {
+ LegendLabel(text = stringResource(data.nameRes), color = data.color, isLine = data.isLine)
}
}
- if (displayInfoIcon) {
- Spacer(modifier = Modifier.width(4.dp))
- Icon(
- imageVector = Icons.Rounded.Info,
- modifier = Modifier.clickable { promptInfoDialog() },
- contentDescription = stringResource(Res.string.info),
- )
- }
-
- Spacer(modifier = Modifier.weight(1f))
}
}
-/**
- * Displays a dialog with information about the legend items.
- *
- * @param pairedRes A list of `Pair`s containing (term, definition).
- * @param onDismiss Executes when the user presses the close button.
- */
+/** Displays a dialog with information about the legend items. */
@Composable
-fun LegendInfoDialog(pairedRes: List>, onDismiss: () -> Unit) {
+fun LegendInfoDialog(infoData: List, onDismiss: () -> Unit) {
AlertDialog(
+ icon = { Icon(imageVector = Icons.Rounded.Info, contentDescription = null) },
title = {
Text(
text = stringResource(Res.string.info),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.headlineSmall,
)
},
text = {
- Column {
- for (pair in pairedRes) {
- Text(
- text = stringResource(pair.first),
- style = TextStyle(fontWeight = FontWeight.Bold),
- textDecoration = TextDecoration.Underline,
- )
- Text(text = stringResource(pair.second), style = TextStyle.Default)
-
- Spacer(modifier = Modifier.height(24.dp))
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ for (item in infoData) {
+ Column {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(item.color)
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = stringResource(item.titleRes),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = item.color,
+ )
+ }
+ Text(
+ text = stringResource(item.definitionRes),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 16.dp),
+ )
+ }
}
}
},
onDismissRequest = onDismiss,
- confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.close)) } },
- shape = RoundedCornerShape(16.dp),
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text(text = stringResource(Res.string.close), fontWeight = FontWeight.Bold)
+ }
+ },
+ shape = RoundedCornerShape(28.dp),
)
}
@Composable
private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
- Canvas(modifier = Modifier.size(4.dp)) {
+ Canvas(modifier = Modifier.size(height = 4.dp, width = if (isLine) 16.dp else 4.dp)) {
if (isLine) {
drawLine(
color = color,
start = Offset(x = 0f, y = size.height / 2f),
- end = Offset(x = 16f, y = size.height / 2f),
+ end = Offset(x = size.width, y = size.height / 2f),
strokeWidth = 2.dp.toPx(),
cap = StrokeCap.Round,
)
@@ -248,10 +208,15 @@ private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ fontSize = MaterialTheme.typography.labelSmall.fontSize,
)
}
+@Composable
+fun MetricIndicator(color: Color, modifier: Modifier = Modifier) {
+ Box(modifier = modifier.size(8.dp).clip(CircleShape).background(color))
+}
+
@Composable
fun DeleteItem(onClick: () -> Unit) {
DropdownMenuItem(
@@ -311,5 +276,5 @@ private fun LegendPreview() {
LegendData(nameRes = Res.string.rssi, color = Color.Red),
LegendData(nameRes = Res.string.snr, color = Color.Green),
)
- Legend(legendData = data, promptInfoDialog = {})
+ Legend(legendData = data)
}
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 f5b86c3e5..4e7f83da8 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
@@ -29,19 +29,14 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.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
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -49,17 +44,16 @@ 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.graphics.Color
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.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
@@ -67,26 +61,24 @@ 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.model.util.formatUptime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.air_util_definition
import org.meshtastic.core.strings.air_utilization
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.strings.uptime
+import org.meshtastic.core.strings.voltage
import org.meshtastic.core.ui.component.MaterialBatteryInfo
-import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.GraphColors.Cyan
+import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.Green
-import org.meshtastic.core.ui.theme.GraphColors.Magenta
-import org.meshtastic.feature.node.detail.NodeRequestEffect
+import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.TelemetryProtos
@@ -96,7 +88,10 @@ private enum class Device(val color: Color) {
BATTERY(Green) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.batteryLevel.toFloat()
},
- CH_UTIL(Magenta) {
+ VOLTAGE(Gold) {
+ override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.voltage
+ },
+ CH_UTIL(Purple) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.channelUtilization
},
AIR_UTIL(Cyan) {
@@ -109,6 +104,7 @@ private enum class Device(val color: Color) {
private val LEGEND_DATA =
listOf(
LegendData(nameRes = Res.string.battery, color = Device.BATTERY.color, isLine = true, environmentMetric = null),
+ LegendData(nameRes = Res.string.voltage, color = Device.VOLTAGE.color, isLine = true, environmentMetric = null),
LegendData(
nameRes = Res.string.channel_utilization,
color = Device.CH_UTIL.color,
@@ -127,91 +123,80 @@ private val LEGEND_DATA =
@Composable
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
- val snackbarHostState = remember { SnackbarHostState() }
- var displayInfoDialog by remember { mutableStateOf(false) }
val data = state.deviceMetrics
- val lazyListState = rememberLazyListState()
- val vicoScrollState = rememberVicoScrollState()
- val coroutineScope = rememberCoroutineScope()
- var selectedX by remember { mutableStateOf(null) }
+ val hasBattery = remember(data) { data.any { it.deviceMetrics.hasBatteryLevel() } }
+ val hasVoltage = remember(data) { data.any { it.deviceMetrics.hasVoltage() } }
+ val hasChUtil = remember(data) { data.any { it.deviceMetrics.hasChannelUtilization() } }
+ val hasAirUtil = remember(data) { data.any { it.deviceMetrics.hasAirUtilTx() } }
- LaunchedEffect(Unit) {
- viewModel.effects.collect { effect ->
- when (effect) {
- is NodeRequestEffect.ShowFeedback -> {
- @Suppress("SpreadOperator")
- snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
+ val filteredLegendData =
+ remember(hasBattery, hasVoltage, hasChUtil, hasAirUtil) {
+ LEGEND_DATA.filter { d ->
+ when (d.nameRes) {
+ Res.string.battery -> hasBattery
+ Res.string.voltage -> hasVoltage
+ Res.string.channel_utilization -> hasChUtil
+ Res.string.air_utilization -> hasAirUtil
+ else -> true
}
}
}
- }
- Scaffold(
- topBar = {
- MainAppBar(
- title = state.node?.user?.longName ?: "",
- subtitle = stringResource(Res.string.device_metrics_log),
- ourNode = null,
- showNodeChip = false,
- canNavigateUp = true,
- onNavigateUp = onNavigateUp,
- actions = {
- if (!state.isLocal) {
- IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.DEVICE) }) {
- Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
- }
- }
- },
- onClickChip = {},
- )
- },
- snackbarHost = { SnackbarHost(snackbarHostState) },
- ) { innerPadding ->
- Column(modifier = Modifier.padding(innerPadding)) {
- if (displayInfoDialog) {
- LegendInfoDialog(
- pairedRes =
- listOf(
- Pair(Res.string.channel_utilization, Res.string.ch_util_definition),
- Pair(Res.string.air_utilization, Res.string.air_util_definition),
- ),
- onDismiss = { displayInfoDialog = false },
- )
- }
-
- DeviceMetricsChart(
- modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
- telemetries = data.reversed(),
- 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) }
- }
- },
- )
-
- /* Device Metric Cards */
- 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))
- }
- },
+ val infoItems =
+ remember(hasChUtil, hasAirUtil) {
+ buildList {
+ if (hasChUtil) {
+ add(
+ InfoDialogData(
+ Res.string.channel_utilization,
+ Res.string.ch_util_definition,
+ Device.CH_UTIL.color,
+ ),
+ )
+ }
+ if (hasAirUtil) {
+ add(
+ InfoDialogData(
+ Res.string.air_utilization,
+ Res.string.air_util_definition,
+ Device.AIR_UTIL.color,
+ ),
)
}
}
}
- }
+
+ BaseMetricScreen(
+ viewModel = viewModel,
+ onNavigateUp = onNavigateUp,
+ telemetryType = TelemetryType.DEVICE,
+ titleRes = Res.string.device_metrics_log,
+ data = data,
+ timeProvider = { it.time.toDouble() },
+ infoData = infoItems,
+ chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
+ DeviceMetricsChart(
+ modifier = modifier,
+ telemetries = data.reversed(),
+ legendData = filteredLegendData,
+ vicoScrollState = vicoScrollState,
+ selectedX = selectedX,
+ onPointSelected = onPointSelected,
+ )
+ },
+ listPart = { modifier, selectedX, onCardClick ->
+ LazyColumn(modifier = modifier.fillMaxSize()) {
+ itemsIndexed(data) { _, telemetry ->
+ DeviceMetricsCard(
+ telemetry = telemetry,
+ isSelected = telemetry.time.toDouble() == selectedX,
+ onClick = { onCardClick(telemetry.time.toDouble()) },
+ )
+ }
+ }
+ },
+ )
}
@Suppress("LongMethod")
@@ -219,82 +204,105 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
private fun DeviceMetricsChart(
modifier: Modifier = Modifier,
telemetries: List,
- promptInfoDialog: () -> Unit,
+ legendData: List,
vicoScrollState: VicoScrollState,
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- ChartHeader(amount = telemetries.size)
- if (telemetries.isEmpty()) return
+ Column(modifier = modifier) {
+ if (telemetries.isEmpty()) return@Column
- 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)
+ val modelProducer = remember { CartesianChartModelProducer() }
+ val batteryColor = Device.BATTERY.color
+ val voltageColor = Device.VOLTAGE.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)
+ voltageColor -> "Voltage: %.1f V".format(value)
+ chUtilColor -> "ChUtil: %.1f%%".format(value)
+ airUtilColor -> "AirUtil: %.1f%%".format(value)
+ else -> "%.1f".format(value)
+ }
+ },
+ )
+
+ LaunchedEffect(telemetries) {
+ modelProducer.runTransaction {
+ /* Series for Left Axis (0-100%) */
+ 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 })
}
- },
- )
-
- 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 })
+ /* Series for Right Axis (Voltage) */
+ lineSeries { series(x = telemetries.map { it.time }, y = telemetries.map { it.deviceMetrics.voltage }) }
}
}
- }
- 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,
+ GenericMetricChart(
+ modelProducer = modelProducer,
+ modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.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,
+ ),
),
- ChartStyling.createPointOnlyLine(
- pointColor = chUtilColor,
- pointSize = ChartStyling.LARGE_POINT_SIZE_DP,
- ),
- ChartStyling.createPointOnlyLine(
- pointColor = airUtilColor,
- pointSize = ChartStyling.LARGE_POINT_SIZE_DP,
+ verticalAxisPosition = Axis.Position.Vertical.Start,
+ ),
+ rememberLineCartesianLayer(
+ lineProvider =
+ LineCartesianLayer.LineProvider.series(
+ ChartStyling.createGradientLine(
+ lineColor = voltageColor,
+ pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP,
+ ),
),
+ verticalAxisPosition = Axis.Position.Vertical.End,
),
),
- ),
- 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,
- )
+ startAxis =
+ VerticalAxis.rememberStart(
+ label = ChartStyling.rememberAxisLabel(color = batteryColor),
+ valueFormatter = { _, value, _ -> "%.0f%%".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 = 20),
+ labelRotationDegrees = 45f,
+ ),
+ marker = marker,
+ selectedX = selectedX,
+ onPointSelected = onPointSelected,
+ vicoScrollState = vicoScrollState,
+ )
- Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog)
+ Legend(legendData = legendData, modifier = Modifier.padding(top = 0.dp))
+ }
}
@Suppress("detekt:MagicNumber") // fake data
@@ -320,7 +328,7 @@ private fun DeviceMetricsChartPreview() {
DeviceMetricsChart(
modifier = Modifier.height(400.dp),
telemetries = telemetries,
- promptInfoDialog = {},
+ legendData = LEGEND_DATA,
vicoScrollState = rememberVicoScrollState(),
selectedX = null,
onPointSelected = {},
@@ -330,6 +338,7 @@ private fun DeviceMetricsChartPreview() {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
+@Suppress("LongMethod")
private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val deviceMetrics = telemetry.deviceMetrics
val time = telemetry.time * MS_PER_SEC
@@ -356,18 +365,46 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
style = MaterialTheme.typography.titleMediumEmphasized,
)
- MaterialBatteryInfo(level = deviceMetrics.batteryLevel, voltage = deviceMetrics.voltage)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (deviceMetrics.hasBatteryLevel()) {
+ MetricIndicator(Device.BATTERY.color)
+ Spacer(Modifier.width(4.dp))
+ }
+ if (deviceMetrics.hasVoltage()) {
+ MetricIndicator(Device.VOLTAGE.color)
+ Spacer(Modifier.width(8.dp))
+ }
+ MaterialBatteryInfo(level = deviceMetrics.batteryLevel, voltage = deviceMetrics.voltage)
+ }
}
Spacer(modifier = Modifier.height(8.dp))
/* Channel Utilization and Air Utilization Tx */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
- val text =
- stringResource(Res.string.channel_air_util)
- .format(deviceMetrics.channelUtilization, deviceMetrics.airUtilTx)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (deviceMetrics.hasChannelUtilization()) {
+ MetricIndicator(Device.CH_UTIL.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "Ch: %.1f%%".format(deviceMetrics.channelUtilization),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ Spacer(Modifier.width(12.dp))
+ }
+ if (deviceMetrics.hasAirUtilTx()) {
+ MetricIndicator(Device.AIR_UTIL.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "Air: %.1f%%".format(deviceMetrics.airUtilTx),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ }
+ }
Text(
- text = text,
+ text = stringResource(Res.string.uptime) + ": " + formatUptime(deviceMetrics.uptimeSeconds),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@@ -425,10 +462,18 @@ private fun DeviceMetricsScreenPreview() {
if (displayInfoDialog) {
LegendInfoDialog(
- pairedRes =
+ infoData =
listOf(
- Pair(Res.string.channel_utilization, Res.string.ch_util_definition),
- Pair(Res.string.air_utilization, Res.string.air_util_definition),
+ InfoDialogData(
+ Res.string.channel_utilization,
+ Res.string.ch_util_definition,
+ Device.CH_UTIL.color,
+ ),
+ InfoDialogData(
+ Res.string.air_utilization,
+ Res.string.air_util_definition,
+ Device.AIR_UTIL.color,
+ ),
),
onDismiss = { displayInfoDialog = false },
)
@@ -437,7 +482,7 @@ private fun DeviceMetricsScreenPreview() {
DeviceMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = telemetries.reversed(),
- promptInfoDialog = { displayInfoDialog = true },
+ legendData = LEGEND_DATA,
vicoScrollState = rememberVicoScrollState(),
selectedX = null,
onPointSelected = {},
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 5e5c39996..353ad4706 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
@@ -16,8 +16,7 @@
*/
package org.meshtastic.feature.node.metrics
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -111,136 +110,128 @@ fun EnvironmentMetricsChart(
modifier: Modifier = Modifier,
telemetries: List,
graphData: EnvironmentGraphingData,
- promptInfoDialog: () -> Unit,
vicoScrollState: VicoScrollState,
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- ChartHeader(amount = telemetries.size)
- if (telemetries.isEmpty()) {
- return
- }
+ Column(modifier = modifier) {
+ if (telemetries.isEmpty()) {
+ return@Column
+ }
- val modelProducer = remember { CartesianChartModelProducer() }
- val shouldPlot = graphData.shouldPlot
- val onSurfaceColor = MaterialTheme.colorScheme.onSurface
+ val modelProducer = remember { CartesianChartModelProducer() }
+ val shouldPlot = graphData.shouldPlot
+ val onSurfaceColor = MaterialTheme.colorScheme.onSurface
- 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) },
- )
- }
+ val allLegendData =
+ (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3).filter {
+ graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0]
}
- /* Everything else on the default axis */
- Environment.entries.forEach { metric ->
- if (metric != Environment.BAROMETRIC_PRESSURE && shouldPlot[metric.ordinal]) {
+ 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 -> metric.getValue(t)?.let { t.time } },
- y = telemetries.mapNotNull { t -> metric.getValue(t) },
+ 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) },
+ )
+ }
+ }
+ }
}
}
- }
- val marker =
- ChartStyling.rememberMarker(
- valueFormatter =
- ChartStyling.createColoredMarkerValueFormatter { value, color ->
- val label = colorToLabel[color.copy(alpha = 1f)] ?: ""
- "%s: %.1f".format(label, value)
- },
- )
+ val marker =
+ ChartStyling.rememberMarker(
+ valueFormatter =
+ ChartStyling.createColoredMarkerValueFormatter { value, color ->
+ val label = colorToLabel[color.copy(alpha = 1f)] ?: ""
+ "%s: %.1f".format(label, value)
+ },
+ )
- 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,
- ),
- )
- }
- Environment.entries.forEach { metric ->
- if (metric != Environment.BAROMETRIC_PRESSURE && shouldPlot[metric.ordinal]) {
+ val layers = mutableListOf()
+ if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
layers.add(
rememberLineCartesianLayer(
lineProvider =
LineCartesianLayer.LineProvider.series(
- ChartStyling.createGradientLine(metric.color, ChartStyling.MEDIUM_POINT_SIZE_DP),
+ ChartStyling.createGradientLine(
+ Environment.BAROMETRIC_PRESSURE.color,
+ ChartStyling.MEDIUM_POINT_SIZE_DP,
+ ),
),
- verticalAxisPosition = Axis.Position.Vertical.End,
+ verticalAxisPosition = Axis.Position.Vertical.Start,
),
)
}
- }
-
- 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) },
+ 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,
+ ),
)
- } 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,
- )
+ }
+ }
+
+ 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.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.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,
+ )
+ }
+
+ Legend(legendData = allLegendData, modifier = Modifier.padding(top = 0.dp))
}
-
- Spacer(modifier = Modifier.height(16.dp))
-
- MetricLegends(graphData = graphData, promptInfoDialog = promptInfoDialog)
-
- Spacer(modifier = Modifier.height(16.dp))
-}
-
-@Composable
-private fun MetricLegends(graphData: EnvironmentGraphingData, promptInfoDialog: () -> Unit) {
- Legend(LEGEND_DATA_1.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false)
- Legend(LEGEND_DATA_3.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false)
- Legend(
- LEGEND_DATA_2.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] },
- promptInfoDialog = promptInfoDialog,
- )
}
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 cba619ff5..ec14998cc 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
@@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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
@@ -32,9 +31,12 @@ import androidx.compose.foundation.lazy.LazyColumn
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.Info
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
import androidx.compose.material3.Scaffold
@@ -70,6 +72,8 @@ import org.meshtastic.core.strings.gas_resistance
import org.meshtastic.core.strings.humidity
import org.meshtastic.core.strings.iaq
import org.meshtastic.core.strings.iaq_definition
+import org.meshtastic.core.strings.info
+import org.meshtastic.core.strings.logs
import org.meshtastic.core.strings.lux
import org.meshtastic.core.strings.radiation
import org.meshtastic.core.strings.soil_moisture
@@ -138,12 +142,17 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
- subtitle = stringResource(Res.string.env_metrics_log),
+ subtitle =
+ stringResource(Res.string.env_metrics_log) +
+ " (${processedTelemetries.size} ${stringResource(Res.string.logs)})",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
onNavigateUp = onNavigateUp,
actions = {
+ IconButton(onClick = { displayInfoDialog = true }) {
+ Icon(imageVector = Icons.Rounded.Info, contentDescription = stringResource(Res.string.info))
+ }
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) }) {
androidx.compose.material3.Icon(
@@ -161,42 +170,48 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
Column(modifier = Modifier.padding(innerPadding)) {
if (displayInfoDialog) {
LegendInfoDialog(
- pairedRes = listOf(Pair(Res.string.iaq, Res.string.iaq_definition)),
+ infoData = listOf(InfoDialogData(Res.string.iaq, Res.string.iaq_definition, Environment.IAQ.color)),
onDismiss = { displayInfoDialog = false },
)
}
- EnvironmentMetricsChart(
- modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
- telemetries = processedTelemetries.reversed(),
- graphData = graphData,
- 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) }
- }
- },
- )
-
- 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))
+ AdaptiveMetricLayout(
+ chartPart = { modifier ->
+ EnvironmentMetricsChart(
+ modifier = modifier,
+ telemetries = processedTelemetries.reversed(),
+ graphData = graphData,
+ vicoScrollState = vicoScrollState,
+ selectedX = selectedX,
+ onPointSelected = { x ->
+ selectedX = x
+ val index = processedTelemetries.indexOfFirst { it.time.toDouble() == x }
+ if (index != -1) {
+ coroutineScope.launch { lazyListState.animateScrollToItem(index) }
}
},
)
- }
- }
+ },
+ listPart = { modifier ->
+ 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),
+ )
+ }
+ },
+ )
+ }
+ }
+ },
+ )
}
}
}
@@ -206,11 +221,15 @@ private fun TemperatureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, e
envMetrics.temperature?.let { temperature ->
if (!temperature.isNaN()) {
val textFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
- Text(
- text = textFormat.format(stringResource(Res.string.temperature), temperature),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(Environment.TEMPERATURE.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = textFormat.format(stringResource(Res.string.temperature), temperature),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ }
}
}
}
@@ -227,21 +246,29 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.Env
) {
if (hasHumidity) {
val humidity = envMetrics.relativeHumidity!!
- Text(
- text = "%s %.2f%%".format(stringResource(Res.string.humidity), humidity),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- modifier = Modifier.padding(vertical = 0.dp),
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(Environment.HUMIDITY.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "%s %.2f%%".format(stringResource(Res.string.humidity), humidity),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ modifier = Modifier.padding(vertical = 0.dp),
+ )
+ }
}
if (hasPressure) {
val pressure = envMetrics.barometricPressure!!
- Text(
- text = "%.2f hPa".format(pressure),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- modifier = Modifier.padding(vertical = 0.dp),
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(Environment.BAROMETRIC_PRESSURE.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "%.2f hPa".format(pressure),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ modifier = Modifier.padding(vertical = 0.dp),
+ )
+ }
}
}
}
@@ -258,25 +285,36 @@ private fun SoilMetricsDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, e
val soilMoistureTextFormat = "%s %d%%"
envMetrics.soilMoisture?.let { soilMoistureValue ->
if (soilMoistureValue != Int.MIN_VALUE) {
- Text(
- text =
- soilMoistureTextFormat.format(stringResource(Res.string.soil_moisture), soilMoistureValue),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(Environment.SOIL_MOISTURE.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text =
+ soilMoistureTextFormat.format(
+ stringResource(Res.string.soil_moisture),
+ soilMoistureValue,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ }
}
}
envMetrics.soilTemperature?.let { soilTemperature ->
if (!soilTemperature.isNaN()) {
- Text(
- text =
- soilTemperatureTextFormat.format(
- stringResource(Res.string.soil_temperature),
- soilTemperature,
- ),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(Environment.SOIL_TEMPERATURE.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text =
+ soilTemperatureTextFormat.format(
+ stringResource(Res.string.soil_temperature),
+ soilTemperature,
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ }
}
}
}
@@ -292,19 +330,27 @@ private fun LuxUVLuxDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (hasLux) {
val luxValue = envMetrics.lux!!
- Text(
- text = "%s %.0f lx".format(stringResource(Res.string.lux), luxValue),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(Environment.LUX.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "%s %.0f lx".format(stringResource(Res.string.lux), luxValue),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ }
}
if (hasUvLux) {
val uvLuxValue = envMetrics.uvLux!!
- Text(
- text = "%s %.0f UVlx".format(stringResource(Res.string.uv_lux), uvLuxValue),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(Environment.UV_LUX.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "%s %.0f UVlx".format(stringResource(Res.string.uv_lux), uvLuxValue),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ }
}
}
}
@@ -346,21 +392,27 @@ private fun GasCompositionDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
if (iaqValue != null && iaqValue != Int.MIN_VALUE) {
Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(Environment.IAQ.color)
+ Spacer(Modifier.width(4.dp))
Text(
text = stringResource(Res.string.iaq),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
- Spacer(modifier = Modifier.width(4.dp))
+ Spacer(Modifier.width(4.dp))
IndoorAirQuality(iaq = iaqValue, displayMode = IaqDisplayMode.Dot)
}
}
if (gasResistance != null && !gasResistance.isNaN()) {
- Text(
- text = "%s %.2f Ohm".format(stringResource(Res.string.gas_resistance), gasResistance),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(Environment.GAS_RESISTANCE.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "%s %.2f Ohm".format(stringResource(Res.string.gas_resistance), gasResistance),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ }
}
}
}
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 97274afd3..76134db50 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
@@ -18,15 +18,15 @@ package org.meshtastic.feature.node.metrics
import androidx.compose.ui.graphics.Color
import org.meshtastic.core.model.util.UnitConversions
+import org.meshtastic.core.ui.theme.GraphColors.Blue
+import org.meshtastic.core.ui.theme.GraphColors.Cyan
+import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
-import org.meshtastic.core.ui.theme.GraphColors.LightGreen
-import org.meshtastic.core.ui.theme.GraphColors.Magenta
import org.meshtastic.core.ui.theme.GraphColors.Orange
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.proto.TelemetryProtos
@Suppress("MagicNumber")
@@ -34,7 +34,7 @@ enum class Environment(val color: Color) {
TEMPERATURE(Red) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.temperature
},
- HUMIDITY(InfantryBlue) {
+ HUMIDITY(Blue) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.relativeHumidity
},
SOIL_TEMPERATURE(Pink) {
@@ -47,13 +47,13 @@ enum class Environment(val color: Color) {
BAROMETRIC_PRESSURE(Green) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.barometricPressure
},
- GAS_RESISTANCE(Yellow) {
+ GAS_RESISTANCE(InfantryBlue) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.gasResistance
},
- IAQ(Magenta) {
+ IAQ(Cyan) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq.toFloat()
},
- LUX(LightGreen) {
+ LUX(Gold) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.lux
},
UV_LUX(Orange) {
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 b2336b961..85f84352d 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
@@ -18,13 +18,14 @@ 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.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.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -45,6 +46,7 @@ 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.text.style.TextAlign
@@ -69,6 +71,7 @@ import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.ble_devices
+import org.meshtastic.core.strings.logs
import org.meshtastic.core.strings.no_pax_metrics_logs
import org.meshtastic.core.strings.pax
import org.meshtastic.core.strings.pax_metrics_log
@@ -79,6 +82,8 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Paxcount
import org.meshtastic.core.ui.icon.Refresh
+import org.meshtastic.core.ui.theme.GraphColors.Orange
+import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.Portnums.PortNum
@@ -86,11 +91,18 @@ import java.text.DateFormat
import java.util.Date
private enum class PaxSeries(val color: Color, val legendRes: StringResource) {
- PAX(Color.Black, Res.string.pax),
- BLE(Color.Cyan, Res.string.ble_devices),
- WIFI(Color.Green, Res.string.wifi_devices),
+ PAX(Color.Gray, Res.string.pax),
+ BLE(Purple, Res.string.ble_devices),
+ WIFI(Orange, Res.string.wifi_devices),
}
+private val LEGEND_DATA =
+ listOf(
+ LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null),
+ LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color, environmentMetric = null),
+ LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color, environmentMetric = null),
+ )
+
@Suppress("LongMethod")
@Composable
private fun PaxMetricsChart(
@@ -102,73 +114,77 @@ private fun PaxMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- if (totalSeries.isEmpty()) return
+ Column(modifier = modifier) {
+ if (totalSeries.isEmpty()) return@Column
- val modelProducer = remember { CartesianChartModelProducer() }
- val paxColor = PaxSeries.PAX.color
- val bleColor = PaxSeries.BLE.color
- val wifiColor = PaxSeries.WIFI.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 })
+ 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 })
+ }
}
}
- }
- 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)
- }
- },
- )
+ 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,
+ GenericMetricChart(
+ modelProducer = modelProducer,
+ modifier = Modifier.weight(1f).padding(horizontal = 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,
- )
+ 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,
+ )
+
+ Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 4.dp))
+ }
}
@Composable
@@ -215,18 +231,14 @@ 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 legendData =
- listOf(
- LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null),
- LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color, environmentMetric = null),
- LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color, environmentMetric = null),
- )
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
- subtitle = stringResource(Res.string.pax_metrics_log),
+ subtitle =
+ stringResource(Res.string.pax_metrics_log) +
+ " (${paxMetrics.size} ${stringResource(Res.string.logs)})",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@@ -246,52 +258,65 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
// Graph
if (graphData.isNotEmpty()) {
- ChartHeader(graphData.size)
- Legend(legendData = legendData)
- PaxMetricsChart(
- totalSeries = totalSeries,
- bleSeries = bleSeries,
- wifiSeries = wifiSeries,
- 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
- if (paxMetrics.isEmpty()) {
- Text(
- text = stringResource(Res.string.no_pax_metrics_logs),
- modifier = Modifier.fillMaxSize().padding(16.dp),
- textAlign = TextAlign.Center,
- )
- } else {
- 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),
- )
+ AdaptiveMetricLayout(
+ chartPart = { modifier ->
+ PaxMetricsChart(
+ modifier = modifier,
+ totalSeries = totalSeries,
+ bleSeries = bleSeries,
+ wifiSeries = wifiSeries,
+ 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) }
}
},
)
- }
+ },
+ listPart = { modifier ->
+ if (paxMetrics.isEmpty()) {
+ Text(
+ text = stringResource(Res.string.no_pax_metrics_logs),
+ modifier = modifier.fillMaxSize().padding(16.dp),
+ textAlign = TextAlign.Center,
+ )
+ } else {
+ 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),
+ )
+ }
+ },
+ )
+ }
+ }
+ }
+ },
+ )
+ } else {
+ // Empty state if no graph data
+ if (paxMetrics.isEmpty()) {
+ Text(
+ text = stringResource(Res.string.no_pax_metrics_logs),
+ modifier = Modifier.fillMaxSize().padding(16.dp),
+ textAlign = TextAlign.Center,
+ )
}
}
}
@@ -397,22 +422,28 @@ fun PaxMetricsItem(
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,
+ verticalAlignment = Alignment.CenterVertically,
) {
- Text(
- text = summary,
- style = MaterialTheme.typography.bodyLarge,
- modifier = Modifier.weight(1f, fill = true),
- )
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
+ MetricIndicator(PaxSeries.PAX.color)
+ Spacer(Modifier.width(4.dp))
+ Text(text = "PAX: ${pax.ble + pax.wifi}", style = MaterialTheme.typography.bodyLarge)
+ Spacer(Modifier.width(8.dp))
+ MetricIndicator(PaxSeries.BLE.color)
+ Spacer(Modifier.width(4.dp))
+ Text(text = "B:${pax.ble}", style = MaterialTheme.typography.bodyLarge)
+ Spacer(Modifier.width(8.dp))
+ MetricIndicator(PaxSeries.WIFI.color)
+ Spacer(Modifier.width(4.dp))
+ Text(text = "W:${pax.wifi}", style = MaterialTheme.typography.bodyLarge)
+ }
+
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 b73c0b68c..e82df3f07 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
@@ -24,11 +24,11 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -40,6 +40,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -54,6 +55,7 @@ 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.text.TextStyle
@@ -81,11 +83,12 @@ 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.logs
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.theme.GraphColors.Gold
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
@@ -93,7 +96,7 @@ import org.meshtastic.proto.TelemetryProtos.Telemetry
private enum class PowerMetric(val color: Color) {
CURRENT(InfantryBlue),
- VOLTAGE(Red),
+ VOLTAGE(Gold),
}
private enum class PowerChannel(val strRes: StringResource) {
@@ -147,7 +150,8 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
- subtitle = stringResource(Res.string.power_metrics_log),
+ subtitle =
+ stringResource(Res.string.power_metrics_log) + " (${data.size} ${stringResource(Res.string.logs)})",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@@ -155,10 +159,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
actions = {
if (!state.isLocal) {
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.POWER) }) {
- androidx.compose.material3.Icon(
- imageVector = Icons.Rounded.Refresh,
- contentDescription = null,
- )
+ Icon(imageVector = Icons.Rounded.Refresh, contentDescription = null)
}
}
},
@@ -181,35 +182,42 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
}
}
- PowerMetricsChart(
- modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
- telemetries = data.reversed(),
- 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) }
- }
- },
- )
-
- 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))
+ AdaptiveMetricLayout(
+ chartPart = { modifier ->
+ PowerMetricsChart(
+ modifier = modifier,
+ telemetries = data.reversed(),
+ 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) }
}
},
)
- }
- }
+ },
+ listPart = { modifier ->
+ 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),
+ )
+ }
+ },
+ )
+ }
+ }
+ },
+ )
}
}
}
@@ -224,81 +232,86 @@ private fun PowerMetricsChart(
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- ChartHeader(amount = telemetries.size)
- if (telemetries.isEmpty()) {
- return
- }
+ Column(modifier = modifier) {
+ if (telemetries.isEmpty()) return@Column
- 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)
+ 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) },
+ )
}
- },
- )
-
- 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) })
}
}
+
+ GenericMetricChart(
+ modelProducer = modelProducer,
+ modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.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, modifier = Modifier.padding(top = 0.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)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -370,16 +383,24 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
- Text(
- text = "%.2fV".format(voltage),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
- Text(
- text = "%.1fmA".format(current),
- color = MaterialTheme.colorScheme.onSurface,
- fontSize = MaterialTheme.typography.labelLarge.fontSize,
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(PowerMetric.VOLTAGE.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "%.2fV".format(voltage),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(PowerMetric.CURRENT.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "%.1fmA".format(current),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ }
}
}
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 6f4e91d18..96ebd08fc 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
@@ -24,39 +24,30 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
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.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
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Color
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
@@ -65,31 +56,23 @@ import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProdu
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.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.core.ui.theme.GraphColors.Blue
+import org.meshtastic.core.ui.theme.GraphColors.Green
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.CommonCharts.SCROLL_BIAS
import org.meshtastic.proto.MeshProtos.MeshPacket
private enum class SignalMetric(val color: Color) {
- SNR(Color.Green),
- RSSI(Color.Blue),
+ SNR(Green),
+ RSSI(Blue),
}
private val LEGEND_DATA =
@@ -102,98 +85,41 @@ private val LEGEND_DATA =
@Composable
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
- val snackbarHostState = remember { SnackbarHostState() }
- var displayInfoDialog by remember { mutableStateOf(false) }
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 ->
- when (effect) {
- is NodeRequestEffect.ShowFeedback -> {
- @Suppress("SpreadOperator")
- snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
- }
- }
- }
- }
-
- Scaffold(
- topBar = {
- MainAppBar(
- title = state.node?.user?.longName ?: "",
- subtitle = stringResource(Res.string.signal_quality),
- ourNode = null,
- showNodeChip = false,
- canNavigateUp = true,
- onNavigateUp = onNavigateUp,
- actions = {
- if (!state.isLocal) {
- IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) }) {
- androidx.compose.material3.Icon(
- imageVector = MeshtasticIcons.Refresh,
- contentDescription =
- stringResource(Res.string.signal_quality) +
- " " +
- stringResource(Res.string.request_telemetry),
- )
- }
- }
- },
- onClickChip = {},
- )
- },
- snackbarHost = { SnackbarHost(snackbarHostState) },
- ) { innerPadding ->
- Column(modifier = Modifier.padding(innerPadding)) {
- if (displayInfoDialog) {
- LegendInfoDialog(
- pairedRes =
- listOf(
- Pair(Res.string.snr, Res.string.snr_definition),
- Pair(Res.string.rssi, Res.string.rssi_definition),
- ),
- onDismiss = { displayInfoDialog = false },
- )
- }
-
+ BaseMetricScreen(
+ viewModel = viewModel,
+ onNavigateUp = onNavigateUp,
+ telemetryType = TelemetryType.LOCAL_STATS,
+ titleRes = Res.string.signal_quality,
+ data = data,
+ timeProvider = { it.rxTime.toDouble() },
+ infoData =
+ listOf(
+ InfoDialogData(Res.string.snr, Res.string.snr_definition, SignalMetric.SNR.color),
+ InfoDialogData(Res.string.rssi, Res.string.rssi_definition, SignalMetric.RSSI.color),
+ ),
+ chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
SignalMetricsChart(
- modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
+ modifier = modifier,
meshPackets = data.reversed(),
- 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) }
- }
- },
+ onPointSelected = onPointSelected,
)
-
- LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) {
+ },
+ listPart = { modifier, selectedX, onCardClick ->
+ LazyColumn(modifier = modifier.fillMaxSize()) {
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),
- )
- }
- },
+ onClick = { onCardClick(meshPacket.rxTime.toDouble()) },
)
}
}
- }
- }
+ },
+ )
}
@Suppress("LongMethod")
@@ -201,85 +127,83 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
private fun SignalMetricsChart(
modifier: Modifier = Modifier,
meshPackets: List,
- promptInfoDialog: () -> Unit,
vicoScrollState: VicoScrollState,
selectedX: Double?,
onPointSelected: (Double) -> Unit,
) {
- ChartHeader(amount = meshPackets.size)
- if (meshPackets.isEmpty()) {
- return
- }
+ Column(modifier = modifier) {
+ if (meshPackets.isEmpty()) return@Column
- val modelProducer = remember { CartesianChartModelProducer() }
+ val modelProducer = remember { CartesianChartModelProducer() }
- 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 }) }
+ 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 }) }
+ }
}
- }
- val rssiColor = SignalMetric.RSSI.color
- val snrColor = SignalMetric.SNR.color
+ val rssiColor = SignalMetric.RSSI.color
+ val snrColor = SignalMetric.SNR.color
- 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)
- }
- },
+ 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)
+ }
+ },
+ )
+
+ GenericMetricChart(
+ modelProducer = modelProducer,
+ modifier = Modifier.weight(1f).padding(horizontal = 8.dp).padding(bottom = 0.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,
)
- 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)
+ Legend(legendData = LEGEND_DATA, modifier = Modifier.padding(top = 0.dp))
+ }
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -301,7 +225,7 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
) {
Surface(color = Color.Transparent) {
SelectionContainer {
- Row(modifier = Modifier.fillMaxWidth()) {
+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
/* Data */
Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
Column(modifier = Modifier.padding(12.dp)) {
@@ -316,7 +240,21 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
Spacer(modifier = Modifier.height(8.dp))
/* SNR and RSSI */
- SnrAndRssi(meshPacket.rxSnr, meshPacket.rxRssi)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(SignalMetric.RSSI.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "%.0f dBm".format(meshPacket.rxRssi.toFloat()),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ Spacer(Modifier.width(12.dp))
+ MetricIndicator(SignalMetric.SNR.color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = "%.1f dB".format(meshPacket.rxSnr),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
}
}