mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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:
parent
8941643f69
commit
9a8a31b298
11 changed files with 1061 additions and 880 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue