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, + ) + } } }