feat(charts): voltage, colors, legends, and adaptive ui (#4383)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-01-30 17:20:57 -06:00 committed by GitHub
parent 8941643f69
commit 9a8a31b298
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1061 additions and 880 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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 {

View file

@ -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 <T> BaseMetricScreen(
viewModel: MetricsViewModel,
onNavigateUp: () -> Unit,
telemetryType: TelemetryType?,
titleRes: StringResource,
data: List<T>,
timeProvider: (T) -> Double,
infoData: List<InfoDialogData> = 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<Double?>(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))
}
}
},
)
}
}
}

View file

@ -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

View file

@ -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<LegendData>, 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<LegendData>, 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<Pair<StringResource, StringResource>>, onDismiss: () -> Unit) {
fun LegendInfoDialog(infoData: List<InfoDialogData>, 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)
}

View file

@ -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<Double?>(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<Telemetry>,
promptInfoDialog: () -> Unit,
legendData: List<LegendData>,
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 = {},

View file

@ -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<Telemetry>,
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<LineCartesianLayer>()
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<LineCartesianLayer>()
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,
)
}

View file

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

View file

@ -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) {

View file

@ -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(),
)
}
}

View file

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

View file

@ -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<Double?>(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<MeshPacket>,
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,
)
}
}
}