From d252fde289c2dc56399c75cf98742ff9577d88db Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 10 Feb 2026 17:02:06 -0600 Subject: [PATCH] feat(charts): More charts ui/ux tweaks (#4520) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../composeResources/values/strings.xml | 3 + feature/node/build.gradle.kts | 3 + .../feature/node/metrics/BaseMetricChart.kt | 37 ++- .../feature/node/metrics/CommonCharts.kt | 18 +- .../feature/node/metrics/DeviceMetrics.kt | 35 ++- .../node/metrics/EnvironmentMetrics.kt | 167 ++++---------- .../node/metrics/EnvironmentMetricsState.kt | 11 +- .../feature/node/metrics/MetricsViewModel.kt | 170 ++++++++++---- .../feature/node/metrics/PaxMetrics.kt | 217 +++++------------- .../feature/node/metrics/PowerMetrics.kt | 144 +++++------- .../feature/node/metrics/SignalMetrics.kt | 35 ++- .../feature/node/metrics/TimeFrameSelector.kt | 51 ++++ .../feature/node/model/MetricsState.kt | 15 ++ .../feature/node/model/TimeFrame.kt | 52 +++++ .../node/metrics/BaseMetricScreenTest.kt | 95 ++++++++ 15 files changed, 586 insertions(+), 467 deletions(-) create mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TimeFrameSelector.kt create mode 100644 feature/node/src/main/kotlin/org/meshtastic/feature/node/model/TimeFrame.kt create mode 100644 feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/BaseMetricScreenTest.kt diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 1356b4875..120aa7680 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -446,11 +446,13 @@ %1$s - %2$s Route traced toward destination:\n\n Route traced back to us:\n\n + 1H 24H 48H 1W 2W 4W + 1M Max Unknown Age Copy @@ -1175,4 +1177,5 @@ Bring your device close to the NFC tag to scan. Generate QR Code NFC is disabled. Please enable it in system settings. + All diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts index 4c3e6e849..24b65d6a5 100644 --- a/feature/node/build.gradle.kts +++ b/feature/node/build.gradle.kts @@ -27,6 +27,8 @@ configure { namespace = "org.meshtastic.feature.node" defaultConfig { manifestPlaceholders["MAPS_API_KEY"] = "DEBUG_KEY" } + + testOptions { unitTests { isIncludeAndroidResources = true } } } dependencies { @@ -66,4 +68,5 @@ dependencies { testImplementation(libs.androidx.compose.ui.test.junit4) testImplementation(libs.androidx.test.ext.junit) testImplementation(libs.robolectric) + debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt index b6d6d0bc7..275a0a1eb 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/BaseMetricChart.kt @@ -23,6 +23,7 @@ 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.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Info @@ -32,16 +33,14 @@ 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.platform.testTag 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 @@ -66,7 +65,6 @@ 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 @@ -147,18 +145,19 @@ fun AdaptiveMetricLayout( @Composable @Suppress("LongMethod") fun BaseMetricScreen( - viewModel: MetricsViewModel, onNavigateUp: () -> Unit, telemetryType: TelemetryType?, titleRes: StringResource, + nodeName: String, data: List, timeProvider: (T) -> Double, infoData: List = emptyList(), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + onRequestTelemetry: (() -> Unit)? = null, chartPart: @Composable (Modifier, Double?, VicoScrollState, (Double) -> Unit) -> Unit, - listPart: @Composable (Modifier, Double?, (Double) -> Unit) -> Unit, + listPart: @Composable (Modifier, Double?, LazyListState, (Double) -> Unit) -> Unit, + controlPart: @Composable () -> Unit = {}, ) { - val state by viewModel.state.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } var displayInfoDialog by remember { mutableStateOf(false) } val lazyListState = rememberLazyListState() @@ -166,21 +165,10 @@ fun BaseMetricScreen( val coroutineScope = rememberCoroutineScope() var selectedX by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - viewModel.effects.collect { effect -> - when (effect) { - is NodeRequestEffect.ShowFeedback -> { - @Suppress("SpreadOperator") - snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray())) - } - } - } - } - Scaffold( topBar = { MainAppBar( - title = state.node?.user?.long_name ?: "", + title = nodeName, subtitle = stringResource(titleRes) + " (${data.size} ${stringResource(Res.string.logs)})", ourNode = null, showNodeChip = false, @@ -193,7 +181,10 @@ fun BaseMetricScreen( } } if (telemetryType != null) { - IconButton(onClick = { viewModel.requestTelemetry(telemetryType) }) { + IconButton( + onClick = { onRequestTelemetry?.invoke() }, + modifier = Modifier.testTag("refresh_button"), + ) { Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) } } @@ -208,6 +199,8 @@ fun BaseMetricScreen( LegendInfoDialog(infoData = infoData, onDismiss = { displayInfoDialog = false }) } + controlPart() + AdaptiveMetricLayout( chartPart = { modifier -> chartPart(modifier, selectedX, vicoScrollState) { x -> @@ -219,7 +212,7 @@ fun BaseMetricScreen( } }, listPart = { modifier -> - listPart(modifier, selectedX) { x -> + listPart(modifier, selectedX, lazyListState) { x -> selectedX = x coroutineScope.launch { vicoScrollState.animateScroll(Scroll.Absolute.x(x, CommonCharts.SCROLL_BIAS)) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt index 8c486bb92..1e782d39d 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt @@ -76,7 +76,6 @@ object CommonCharts { val DATE_TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) val TIME_MINUTE_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.SHORT) val TIME_SECONDS_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM) - val DATE_TIME_MINUTE_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT) const val MS_PER_SEC = 1000L const val MAX_PERCENT_VALUE = 100f @@ -105,14 +104,17 @@ object CommonCharts { val zoom = if (context is CartesianDrawingContext) context.zoom else 1f val visibleSpan = xLength / zoom - val formatter = - when { - visibleSpan <= 3600 -> TIME_SECONDS_FORMAT // < 1 hour visible - visibleSpan <= 86400 * 2 -> TIME_MINUTE_FORMAT // < 2 days visible - visibleSpan <= 86400 * 14 -> DATE_TIME_MINUTE_FORMAT // < 2 weeks visible - else -> DATE_FORMAT + when { + visibleSpan <= 3600 -> TIME_SECONDS_FORMAT.format(date) // < 1 hour visible + visibleSpan <= 86400 * 2 -> TIME_MINUTE_FORMAT.format(date) // < 2 days visible + visibleSpan <= 86400 * 14 -> { + // < 2 weeks visible: separate date and time with a newline + val dateStr = DATE_FORMAT.format(date) + val timeStr = TIME_MINUTE_FORMAT.format(date) + "$dateStr\n$timeStr" } - formatter.format(date) + else -> DATE_FORMAT.format(date) + } } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt index 7fc17e597..8f5d5723f 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/DeviceMetrics.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -52,6 +53,7 @@ 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.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.Axis import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis @@ -79,6 +81,7 @@ 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.Purple +import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.proto.Telemetry @@ -122,13 +125,27 @@ private val LEGEND_DATA = @Composable fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { val state by viewModel.state.collectAsStateWithLifecycle() - val data = state.deviceMetrics + val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() + val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() + val data = state.deviceMetrics.filter { (it.time ?: 0).toLong() >= timeFrame.timeThreshold() } + val snackbarHostState = remember { SnackbarHostState() } val hasBattery = remember(data) { data.any { it.device_metrics?.battery_level != null } } val hasVoltage = remember(data) { data.any { it.device_metrics?.voltage != null } } val hasChUtil = remember(data) { data.any { it.device_metrics?.channel_utilization != null } } val hasAirUtil = remember(data) { data.any { it.device_metrics?.air_util_tx != null } } + 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 -> @@ -167,13 +184,23 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat } BaseMetricScreen( - viewModel = viewModel, onNavigateUp = onNavigateUp, telemetryType = TelemetryType.DEVICE, titleRes = Res.string.device_metrics_log, + nodeName = state.node?.user?.long_name ?: "", data = data, timeProvider = { (it.time ?: 0).toDouble() }, infoData = infoItems, + snackbarHostState = snackbarHostState, + onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.DEVICE) }, + controlPart = { + TimeFrameSelector( + selectedTimeFrame = timeFrame, + availableTimeFrames = availableTimeFrames, + onTimeFrameSelected = viewModel::setTimeFrame, + modifier = Modifier.padding(horizontal = 16.dp), + ) + }, chartPart = { modifier, selectedX, vicoScrollState, onPointSelected -> DeviceMetricsChart( modifier = modifier, @@ -184,8 +211,8 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat onPointSelected = onPointSelected, ) }, - listPart = { modifier, selectedX, onCardClick -> - LazyColumn(modifier = modifier.fillMaxSize()) { + listPart = { modifier, selectedX, lazyListState, onCardClick -> + LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) { itemsIndexed(data) { _, telemetry -> DeviceMetricsCard( telemetry = telemetry, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt index 750b3159d..f1eacf50f 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt @@ -29,28 +29,18 @@ 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.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 -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 @@ -59,12 +49,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.meshtastic.core.strings.getString -import com.patrykandpatrick.vico.compose.cartesian.Scroll -import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState -import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.current import org.meshtastic.core.strings.env_metrics_log @@ -72,8 +58,6 @@ 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 @@ -83,28 +67,19 @@ import org.meshtastic.core.strings.uv_lux import org.meshtastic.core.strings.voltage import org.meshtastic.core.ui.component.IaqDisplayMode import org.meshtastic.core.ui.component.IndoorAirQuality -import org.meshtastic.core.ui.component.MainAppBar -import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC -import org.meshtastic.feature.node.metrics.CommonCharts.SCROLL_BIAS import org.meshtastic.proto.Telemetry -@Suppress("LongMethod") @Composable fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { val state by viewModel.state.collectAsStateWithLifecycle() - val environmentState by viewModel.environmentState.collectAsStateWithLifecycle() + val graphData by viewModel.environmentGraphingData.collectAsStateWithLifecycle() + val filteredTelemetries by viewModel.filteredEnvironmentMetrics.collectAsStateWithLifecycle() + val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() + val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } - val graphData = environmentState.environmentMetricsForGraphing(state.isFahrenheit) - val data = graphData.metrics - - val lazyListState = rememberLazyListState() - val vicoScrollState = rememberVicoScrollState() - val coroutineScope = rememberCoroutineScope() - var selectedX by remember { mutableStateOf(null) } LaunchedEffect(Unit) { viewModel.effects.collect { effect -> @@ -117,99 +92,47 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa } } - val processedTelemetries: List = - if (state.isFahrenheit) { - data.map { telemetry -> - val em = telemetry.environment_metrics ?: return@map telemetry - val temperatureFahrenheit = em.temperature?.let { celsiusToFahrenheit(it) } - val soilTemperatureFahrenheit = em.soil_temperature?.let { celsiusToFahrenheit(it) } - telemetry.copy( - environment_metrics = - em.copy(temperature = temperatureFahrenheit, soil_temperature = soilTemperatureFahrenheit), - ) - } - } else { - data - } - - var displayInfoDialog by remember { mutableStateOf(false) } - - Scaffold( - topBar = { - MainAppBar( - title = state.node?.user?.long_name ?: "", - 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( - imageVector = MeshtasticIcons.Refresh, - contentDescription = null, - ) - } - } - }, - onClickChip = {}, + BaseMetricScreen( + onNavigateUp = onNavigateUp, + telemetryType = TelemetryType.ENVIRONMENT, + titleRes = Res.string.env_metrics_log, + nodeName = state.node?.user?.long_name ?: "", + data = filteredTelemetries, + timeProvider = { (it.time ?: 0).toDouble() }, + infoData = listOf(InfoDialogData(Res.string.iaq, Res.string.iaq_definition, Environment.IAQ.color)), + snackbarHostState = snackbarHostState, + onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) }, + controlPart = { + TimeFrameSelector( + selectedTimeFrame = timeFrame, + availableTimeFrames = availableTimeFrames, + onTimeFrameSelected = viewModel::setTimeFrame, + modifier = Modifier.padding(horizontal = 16.dp), ) }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { innerPadding -> - Column(modifier = Modifier.padding(innerPadding)) { - if (displayInfoDialog) { - LegendInfoDialog( - infoData = listOf(InfoDialogData(Res.string.iaq, Res.string.iaq_definition, Environment.IAQ.color)), - onDismiss = { displayInfoDialog = false }, - ) - } - - 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 ?: 0).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 ?: 0).toDouble() == selectedX, - onClick = { - selectedX = (telemetry.time ?: 0).toDouble() - coroutineScope.launch { - vicoScrollState.animateScroll( - Scroll.Absolute.x((telemetry.time ?: 0).toDouble(), SCROLL_BIAS), - ) - } - }, - ) - } - } - }, + chartPart = { modifier, selectedX, vicoScrollState, onPointSelected -> + EnvironmentMetricsChart( + modifier = modifier, + telemetries = filteredTelemetries.reversed(), + graphData = graphData, + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onPointSelected = onPointSelected, ) - } - } + }, + listPart = { modifier, selectedX, lazyListState, onCardClick -> + LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(filteredTelemetries) { _, telemetry -> + EnvironmentMetricsCard( + telemetry = telemetry, + environmentDisplayFahrenheit = state.isFahrenheit, + isSelected = (telemetry.time ?: 0).toDouble() == selectedX, + onClick = { onCardClick((telemetry.time ?: 0).toDouble()) }, + ) + } + } + }, + ) } @Composable @@ -374,9 +297,9 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe ) } if (hasCurrent) { - val current = envMetrics.current!! + val currentValue = envMetrics.current!! Text( - text = "%s %.2f mA".format(stringResource(Res.string.current), current), + text = "%s %.2f mA".format(stringResource(Res.string.current), currentValue), color = MaterialTheme.colorScheme.onSurface, fontSize = MaterialTheme.typography.labelLarge.fontSize, ) @@ -443,8 +366,6 @@ private fun EnvironmentMetricsCard( isSelected: Boolean, onClick: () -> Unit, ) { - val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics() - val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC Card( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() }, border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt index 4333ffc30..0e042d9e7 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt @@ -41,7 +41,8 @@ enum class Environment(val color: Color) { override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.soil_temperature }, SOIL_MOISTURE(Purple) { - override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.soil_moisture?.toFloat() + override fun getValue(telemetry: Telemetry) = + telemetry.environment_metrics?.soil_moisture?.takeIf { it != Int.MIN_VALUE }?.toFloat() }, BAROMETRIC_PRESSURE(Green) { override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.barometric_pressure @@ -50,7 +51,8 @@ enum class Environment(val color: Color) { override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.gas_resistance }, IAQ(Cyan) { - override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.iaq?.toFloat() + override fun getValue(telemetry: Telemetry) = + telemetry.environment_metrics?.iaq?.takeIf { it != Int.MIN_VALUE }?.toFloat() }, LUX(Gold) { override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.lux @@ -136,7 +138,8 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp } // Soil Moisture - val soilMoistures = telemetries.mapNotNull { it.environment_metrics?.soil_moisture?.takeIf { it != 0 } } + val soilMoistures = + telemetries.mapNotNull { it.environment_metrics?.soil_moisture?.takeIf { it != Int.MIN_VALUE } } if (soilMoistures.isNotEmpty()) { minValues.add(soilMoistures.minOf { it.toFloat() }) maxValues.add(soilMoistures.maxOf { it.toFloat() }) @@ -144,7 +147,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp } // IAQ - val iaqs = telemetries.mapNotNull { it.environment_metrics?.iaq?.takeIf { it != 0 } } + val iaqs = telemetries.mapNotNull { it.environment_metrics?.iaq?.takeIf { it != Int.MIN_VALUE } } if (iaqs.isNotEmpty()) { minValues.add(iaqs.minOf { it.toFloat() }) maxValues.add(iaqs.maxOf { it.toFloat() }) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 2fe570184..b04692af7 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -54,10 +54,9 @@ import org.meshtastic.core.database.model.Node import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.TelemetryType -import org.meshtastic.core.model.TracerouteMapAvailability import org.meshtastic.core.model.evaluateTracerouteMapAvailability +import org.meshtastic.core.model.util.UnitConversions import org.meshtastic.core.navigation.NodesRoutes -import org.meshtastic.core.service.ServiceAction import org.meshtastic.core.service.ServiceRepository import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.fallback_node_name @@ -72,23 +71,30 @@ import org.meshtastic.feature.map.model.TracerouteOverlay import org.meshtastic.feature.node.detail.NodeRequestActions import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.model.MetricsState +import org.meshtastic.feature.node.model.TimeFrame import org.meshtastic.proto.Config import org.meshtastic.proto.HardwareModel import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User import java.io.BufferedWriter import java.io.FileNotFoundException import java.io.FileWriter +import java.io.IOException import java.text.SimpleDateFormat import java.util.Locale import javax.inject.Inject +import org.meshtastic.proto.Paxcount as ProtoPaxcount private const val DEFAULT_ID_SUFFIX_LENGTH = 4 -private fun MeshPacket.hasValidSignal(): Boolean = (rx_time ?: 0) > 0 && - ((rx_snr ?: 0f) != 0f && (rx_rssi ?: 0) != 0) && - ((hop_start ?: 0) > 0 && (hop_start ?: 0) - (hop_limit ?: 0) == 0) +private fun MeshPacket.hasValidSignal(): Boolean = (rx_time ?: 0) > 0 && ((rx_snr ?: 0f) != 0f || (rx_rssi ?: 0) != 0) + +private fun Telemetry.hasValidEnvironmentMetrics(): Boolean { + val metrics = this.environment_metrics ?: return false + return metrics.relative_humidity != null && metrics.temperature != null && metrics.temperature?.isNaN() != true +} @Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel @@ -115,11 +121,11 @@ constructor( private val tracerouteOverlayCache = MutableStateFlow>(emptyMap()) - private fun MeshLog.hasValidTraceroute(): Boolean = - with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == destNum } + private fun MeshLog.hasValidTraceroute(dest: Int?): Boolean = + with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == dest } - private fun MeshLog.hasValidNeighborInfo(): Boolean = - with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == destNum } + private fun MeshLog.hasValidNeighborInfo(dest: Int?): Boolean = + with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == dest } /** * Creates a fallback node for hidden clients or nodes not yet in the database. This prevents the detail screen from @@ -166,16 +172,6 @@ constructor( fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse() - fun tracerouteMapAvailability(forwardRoute: List, returnRoute: List): TracerouteMapAvailability = - evaluateTracerouteMapAvailability( - forwardRoute = forwardRoute, - returnRoute = returnRoute, - positionedNodeNums = positionedNodeNums(), - ) - - fun tracerouteMapAvailability(overlay: TracerouteOverlay): TracerouteMapAvailability = - tracerouteMapAvailability(overlay.forwardRoute, overlay.returnRoute) - fun positionedNodeNums(): Set = nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet() @@ -199,13 +195,65 @@ constructor( destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value) } } - fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) } - private val _state = MutableStateFlow(MetricsState.Empty) val state: StateFlow = _state - private val _environmentState = MutableStateFlow(EnvironmentMetricsState()) - val environmentState: StateFlow = _environmentState + private val environmentState = MutableStateFlow(EnvironmentMetricsState()) + + private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS) + val timeFrame: StateFlow = _timeFrame + + val availableTimeFrames: StateFlow> = + combine(_state, environmentState) { state, envState -> + val stateOldest = state.oldestTimestampSeconds() + val envOldest = envState.environmentMetrics.minOfOrNull { (it.time ?: 0).toLong() }?.takeIf { it > 0 } + val oldest = listOfNotNull(stateOldest, envOldest).minOrNull() ?: (System.currentTimeMillis() / 1000L) + TimeFrame.entries.filter { it.isAvailable(oldest) } + } + .stateInWhileSubscribed(TimeFrame.entries) + + fun setTimeFrame(timeFrame: TimeFrame) { + _timeFrame.value = timeFrame + } + + /** Exposes filtered and unit-converted environment metrics for the UI. */ + val filteredEnvironmentMetrics: StateFlow> = + combine(environmentState, _timeFrame, _state) { envState, timeFrame, state -> + val threshold = timeFrame.timeThreshold() + val data = envState.environmentMetrics.filter { (it.time ?: 0).toLong() >= threshold } + if (state.isFahrenheit) { + data.map { telemetry -> + val em = telemetry.environment_metrics ?: return@map telemetry + telemetry.copy( + environment_metrics = + em.copy( + temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) }, + soil_temperature = + em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) }, + ), + ) + } + } else { + data + } + } + .stateInWhileSubscribed(emptyList()) + + /** Exposes graphing data specifically for the filtered environment metrics. */ + val environmentGraphingData: StateFlow = + filteredEnvironmentMetrics + .map { filtered -> EnvironmentMetricsState(filtered).environmentMetricsForGraphing(useFahrenheit = false) } + .stateInWhileSubscribed(EnvironmentGraphingData(emptyList(), emptyList())) + + /** Exposes filtered and decoded pax metrics for the UI. */ + val filteredPaxMetrics: StateFlow>> = + combine(_state, _timeFrame) { state, timeFrame -> + val threshold = timeFrame.timeThreshold() + state.paxMetrics + .filter { (it.received_date / 1000) >= threshold } + .mapNotNull { log -> decodePaxFromLog(log)?.let { log to it } } + } + .stateInWhileSubscribed(emptyList()) val effects: SharedFlow = nodeRequestActions.effects @@ -215,10 +263,6 @@ constructor( val lastRequestNeighborsTime: StateFlow = nodeRequestActions.lastRequestNeighborTimes.map { it[destNum] }.stateInWhileSubscribed(null) - fun requestUserInfo() { - destNum?.let { nodeRequestActions.requestUserInfo(viewModelScope, it, state.value.node?.user?.long_name ?: "") } - } - fun requestPosition() { destNum?.let { nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.long_name ?: "") } } @@ -298,7 +342,7 @@ constructor( } } - @Suppress("LongMethod") + @Suppress("LongMethod", "CyclomaticComplexMethod") private fun initializeFlows() { jobs?.cancel() val currentDestNum = destNum @@ -315,7 +359,7 @@ constructor( // Create a fallback node if not found in database (for hidden clients, etc.) val actualNode = node ?: createFallbackNode(currentDestNum) val pioEnv = if (currentDestNum == ourNodeNum) myInfo?.pioEnv else null - val hwModel = actualNode.user.hw_model?.value ?: 0 + val hwModel = actualNode.user.hw_model.value val deviceHardware = deviceHardwareRepository.getDeviceHardwareByModel(hwModel, target = pioEnv) @@ -348,24 +392,22 @@ constructor( launch { meshLogRepository.getTelemetryFrom(currentDestNum).collect { telemetry -> + val device = mutableListOf() + val power = mutableListOf() + val host = mutableListOf() + val env = mutableListOf() + + for (item in telemetry) { + if (item.device_metrics != null) device.add(item) + if (item.power_metrics != null) power.add(item) + if (item.host_metrics != null) host.add(item) + if (item.hasValidEnvironmentMetrics()) env.add(item) + } + _state.update { state -> - state.copy( - deviceMetrics = telemetry.filter { it.device_metrics != null }, - powerMetrics = telemetry.filter { it.power_metrics != null }, - hostMetrics = telemetry.filter { it.host_metrics != null }, - ) - } - _environmentState.update { state -> - state.copy( - environmentMetrics = - telemetry.filter { - it.environment_metrics != null && - it.environment_metrics?.relative_humidity != null && - it.environment_metrics?.temperature != null && - it.environment_metrics?.temperature?.isNaN()?.not() == true - }, - ) + state.copy(deviceMetrics = device, powerMetrics = power, hostMetrics = host) } + environmentState.update { it.copy(environmentMetrics = env) } } } @@ -384,7 +426,7 @@ constructor( ) { request, response -> _state.update { state -> state.copy( - tracerouteRequests = request.filter { it.hasValidTraceroute() }, + tracerouteRequests = request.filter { it.hasValidTraceroute(currentDestNum) }, tracerouteResults = response, ) } @@ -399,7 +441,8 @@ constructor( ) { request, response -> _state.update { state -> state.copy( - neighborInfoRequests = request.filter { it.hasValidNeighborInfo() }, + neighborInfoRequests = + request.filter { it.hasValidNeighborInfo(currentDestNum) }, neighborInfoResults = response, ) } @@ -504,4 +547,37 @@ constructor( Logger.e(ex) { "Can't write file error" } } } + + @Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount") + fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? { + // First, try to parse from the binary fromRadio field (robust, like telemetry) + try { + val packet = log.fromRadio.packet + val decoded = packet?.decoded + if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) { + val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload) + if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0 || (pax.uptime ?: 0) != 0) return pax + } + } catch (e: IOException) { + Logger.e(e) { "Failed to parse Paxcount from binary data" } + } + // Fallback: Try direct base64 or bytes from raw_message + try { + val base64 = log.raw_message.trim() + if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) { + val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT) + return ProtoPaxcount.ADAPTER.decode(bytes) + } else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) { + val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + return ProtoPaxcount.ADAPTER.decode(bytes) + } + } catch (e: IllegalArgumentException) { + Logger.e(e) { "Failed to parse Paxcount from decoded data" } + } catch (e: IOException) { + Logger.e(e) { "Failed to parse Paxcount from decoded data" } + } catch (e: NumberFormatException) { + Logger.e(e) { "Failed to parse Paxcount from decoded data" } + } + return null + } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt index 5a1f7db02..014776031 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PaxMetrics.kt @@ -28,24 +28,16 @@ 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.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.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 @@ -54,7 +46,6 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.meshtastic.core.strings.getString -import com.patrykandpatrick.vico.compose.cartesian.Scroll import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis @@ -62,8 +53,6 @@ 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.jetbrains.compose.resources.stringResource import org.meshtastic.core.database.entity.MeshLog @@ -71,21 +60,17 @@ 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 import org.meshtastic.core.strings.uptime import org.meshtastic.core.strings.wifi_devices import org.meshtastic.core.ui.component.IconInfo -import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.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.PortNum import java.text.DateFormat import java.util.Date import org.meshtastic.proto.Paxcount as ProtoPaxcount @@ -191,12 +176,12 @@ private fun PaxMetricsChart( @Suppress("MagicNumber", "LongMethod") fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { val state by metricsViewModel.state.collectAsStateWithLifecycle() + val paxMetrics by metricsViewModel.filteredPaxMetrics.collectAsStateWithLifecycle() + val timeFrame by metricsViewModel.timeFrame.collectAsStateWithLifecycle() + val availableTimeFrames by metricsViewModel.availableTimeFrames.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } - val lazyListState = rememberLazyListState() - val vicoScrollState = rememberVicoScrollState() - val coroutineScope = rememberCoroutineScope() - var selectedX by remember { mutableStateOf(null) } + val dateFormat = DateFormat.getDateTimeInstance() LaunchedEffect(Unit) { metricsViewModel.effects.collect { effect -> @@ -209,17 +194,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav } } - val dateFormat = DateFormat.getDateTimeInstance() - // Only show logs that can be decoded as ProtoPaxcount - val paxMetrics = - state.paxMetrics.mapNotNull { log -> - val pax = decodePaxFromLog(log) - if (pax != null) { - Pair(log, pax) - } else { - null - } - } // Prepare data for graph val graphData = paxMetrics @@ -232,147 +206,62 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav val bleSeries = graphData.map { it.first to it.second } val wifiSeries = graphData.map { it.first to it.third } - Scaffold( - topBar = { - MainAppBar( - title = state.node?.user?.long_name ?: "", - subtitle = - stringResource(Res.string.pax_metrics_log) + - " (${paxMetrics.size} ${stringResource(Res.string.logs)})", - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = { - if (!state.isLocal) { - IconButton(onClick = { metricsViewModel.requestTelemetry(TelemetryType.PAX) }) { - Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null) - } - } - }, - onClickChip = {}, + BaseMetricScreen( + onNavigateUp = onNavigateUp, + telemetryType = TelemetryType.PAX, + titleRes = Res.string.pax_metrics_log, + nodeName = state.node?.user?.long_name ?: "", + data = paxMetrics, + timeProvider = { (it.first.received_date / 1000).toDouble() }, + snackbarHostState = snackbarHostState, + onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.PAX) }, + controlPart = { + TimeFrameSelector( + selectedTimeFrame = timeFrame, + availableTimeFrames = availableTimeFrames, + onTimeFrameSelected = metricsViewModel::setTimeFrame, + modifier = Modifier.padding(horizontal = 16.dp), ) }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { innerPadding -> - Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) { - // Graph + chartPart = { modifier, selectedX, vicoScrollState, onPointSelected -> if (graphData.isNotEmpty()) { - 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), - ) - } - }, - ) - } - } - } - }, + PaxMetricsChart( + modifier = modifier, + totalSeries = totalSeries, + bleSeries = bleSeries, + wifiSeries = wifiSeries, + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onPointSelected = onPointSelected, + ) + } + }, + listPart = { modifier, selectedX, lazyListState, onCardClick -> + if (paxMetrics.isEmpty()) { + Text( + text = stringResource(Res.string.no_pax_metrics_logs), + modifier = modifier.fillMaxSize().padding(16.dp), + textAlign = TextAlign.Center, ) } 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, - ) + LazyColumn( + modifier = modifier.fillMaxSize(), + state = lazyListState, + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + itemsIndexed(paxMetrics) { _, (log, pax) -> + PaxMetricsItem( + log = log, + pax = pax, + dateFormat = dateFormat, + isSelected = (log.received_date / 1000).toDouble() == selectedX, + onClick = { onCardClick((log.received_date / 1000).toDouble()) }, + ) + } } } - } - } -} - -@Suppress("MagicNumber", "CyclomaticComplexMethod") -fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? { - var result: ProtoPaxcount? = null - // First, try to parse from the binary fromRadio field (robust, like telemetry) - try { - val packet = log.fromRadio.packet - val decoded = packet?.decoded - if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) { - val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload) - if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0 || (pax.uptime ?: 0) != 0) result = pax - } - } catch (e: Exception) { - android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from binary data", e) - } - // Fallback: Try direct base64 or bytes from raw_message - if (result == null) { - try { - val base64 = log.raw_message.trim() - if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) { - val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT) - val pax = ProtoPaxcount.ADAPTER.decode(bytes) - result = pax - } else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) { - val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - val pax = ProtoPaxcount.ADAPTER.decode(bytes) - result = pax - } - } catch (e: Exception) { - android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from decoded data", e) - } - } - return result -} - -@Suppress("MagicNumber") -fun unescapeProtoString(escaped: String): ByteArray { - val out = mutableListOf() - var i = 0 - while (i < escaped.length) { - if (escaped[i] == '\\' && i + 3 < escaped.length && escaped[i + 1].isDigit()) { - // Octal escape: \\ddd - val octal = escaped.substring(i + 1, i + 4) - out.add(octal.toInt(8).toByte()) - i += 4 - } else { - out.add(escaped[i].code.toByte()) - i++ - } - } - return out.toByteArray() + }, + ) } @Composable diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt index 97f5a933e..1c779a02f 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/PowerMetrics.kt @@ -31,20 +31,12 @@ 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.material.icons.Icons -import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.Card 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 -import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -53,7 +45,6 @@ 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 @@ -64,7 +55,6 @@ 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 @@ -73,8 +63,6 @@ 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.jetbrains.compose.resources.stringResource import org.meshtastic.core.model.TelemetryType @@ -83,10 +71,8 @@ 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.feature.node.detail.NodeRequestEffect @@ -121,19 +107,15 @@ private val LEGEND_DATA = ), ) -@OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod") @Composable fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { val state by viewModel.state.collectAsStateWithLifecycle() - val snackbarHostState = remember { SnackbarHostState() } - val data = state.powerMetrics + val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() + val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() + val data = state.powerMetrics.filter { (it.time ?: 0).toLong() >= timeFrame.timeThreshold() } var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) } - - val lazyListState = rememberLazyListState() - val vicoScrollState = rememberVicoScrollState() - val coroutineScope = rememberCoroutineScope() - var selectedX by remember { mutableStateOf(null) } + val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(Unit) { viewModel.effects.collect { effect -> @@ -146,80 +128,60 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate } } - Scaffold( - topBar = { - MainAppBar( - title = state.node?.user?.long_name ?: "", - subtitle = - stringResource(Res.string.power_metrics_log) + " (${data.size} ${stringResource(Res.string.logs)})", - ourNode = null, - showNodeChip = false, - canNavigateUp = true, - onNavigateUp = onNavigateUp, - actions = { - if (!state.isLocal) { - IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.POWER) }) { - Icon(imageVector = Icons.Rounded.Refresh, contentDescription = null) - } + BaseMetricScreen( + onNavigateUp = onNavigateUp, + telemetryType = TelemetryType.POWER, + titleRes = Res.string.power_metrics_log, + nodeName = state.node?.user?.long_name ?: "", + data = data, + timeProvider = { (it.time ?: 0).toDouble() }, + snackbarHostState = snackbarHostState, + onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.POWER) }, + controlPart = { + Column { + TimeFrameSelector( + selectedTimeFrame = timeFrame, + availableTimeFrames = availableTimeFrames, + onTimeFrameSelected = viewModel::setTimeFrame, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PowerChannel.entries.forEach { channel -> + FilterChip( + selected = selectedChannel == channel, + onClick = { selectedChannel = channel }, + label = { Text(stringResource(channel.strRes)) }, + ) } - }, - onClickChip = {}, + } + } + }, + chartPart = { modifier, selectedX, vicoScrollState, onPointSelected -> + PowerMetricsChart( + modifier = modifier, + telemetries = data.reversed(), + selectedChannel = selectedChannel, + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onPointSelected = onPointSelected, ) }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { innerPadding -> - Column(modifier = Modifier.padding(innerPadding)) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - PowerChannel.entries.forEach { channel -> - FilterChip( - selected = selectedChannel == channel, - onClick = { selectedChannel = channel }, - label = { Text(stringResource(channel.strRes)) }, + listPart = { modifier, selectedX, lazyListState, onCardClick -> + LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(data) { _, telemetry -> + PowerMetricsCard( + telemetry = telemetry, + isSelected = (telemetry.time ?: 0).toDouble() == selectedX, + onClick = { onCardClick((telemetry.time ?: 0).toDouble()) }, ) } } - - 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 ?: 0).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 ?: 0).toDouble() == selectedX, - onClick = { - selectedX = (telemetry.time ?: 0).toDouble() - coroutineScope.launch { - vicoScrollState.animateScroll( - Scroll.Absolute.x((telemetry.time ?: 0).toDouble(), 0.5f), - ) - } - }, - ) - } - } - }, - ) - } - } + }, + ) } @Suppress("LongMethod") @@ -242,7 +204,7 @@ private fun PowerMetricsChart( ChartStyling.rememberMarker( valueFormatter = ChartStyling.createColoredMarkerValueFormatter { value, color -> - when (color.copy(1f)) { + when (color.copy(alpha = 1f)) { currentColor -> "Current: %.0f mA".format(value) voltageColor -> "Voltage: %.1f V".format(value) else -> "%.1f".format(value) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index 4597ff7cd..40d3e7691 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -48,6 +49,7 @@ 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.VicoScrollState import com.patrykandpatrick.vico.compose.cartesian.axis.Axis import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis @@ -66,6 +68,7 @@ import org.meshtastic.core.strings.snr_definition import org.meshtastic.core.ui.component.LoraSignalIndicator import org.meshtastic.core.ui.theme.GraphColors.Blue import org.meshtastic.core.ui.theme.GraphColors.Green +import org.meshtastic.feature.node.detail.NodeRequestEffect import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC import org.meshtastic.proto.MeshPacket @@ -85,20 +88,44 @@ private val LEGEND_DATA = @Composable fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) { val state by viewModel.state.collectAsStateWithLifecycle() - val data = state.signalMetrics + val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() + val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() + val data = state.signalMetrics.filter { (it.rx_time ?: 0).toLong() >= timeFrame.timeThreshold() } + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(Unit) { + viewModel.effects.collect { effect -> + when (effect) { + is NodeRequestEffect.ShowFeedback -> { + @Suppress("SpreadOperator") + snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray())) + } + } + } + } BaseMetricScreen( - viewModel = viewModel, onNavigateUp = onNavigateUp, telemetryType = TelemetryType.LOCAL_STATS, titleRes = Res.string.signal_quality, + nodeName = state.node?.user?.long_name ?: "", data = data, timeProvider = { (it.rx_time ?: 0).toDouble() }, + snackbarHostState = snackbarHostState, + onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) }, infoData = listOf( InfoDialogData(Res.string.snr, Res.string.snr_definition, SignalMetric.SNR.color), InfoDialogData(Res.string.rssi, Res.string.rssi_definition, SignalMetric.RSSI.color), ), + controlPart = { + TimeFrameSelector( + selectedTimeFrame = timeFrame, + availableTimeFrames = availableTimeFrames, + onTimeFrameSelected = viewModel::setTimeFrame, + modifier = Modifier.padding(horizontal = 16.dp), + ) + }, chartPart = { modifier, selectedX, vicoScrollState, onPointSelected -> SignalMetricsChart( modifier = modifier, @@ -108,8 +135,8 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat onPointSelected = onPointSelected, ) }, - listPart = { modifier, selectedX, onCardClick -> - LazyColumn(modifier = modifier.fillMaxSize()) { + listPart = { modifier, selectedX, lazyListState, onCardClick -> + LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) { itemsIndexed(data) { _, meshPacket -> SignalMetricsCard( meshPacket = meshPacket, diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TimeFrameSelector.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TimeFrameSelector.kt new file mode 100644 index 000000000..f441623e3 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/metrics/TimeFrameSelector.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.feature.node.model.TimeFrame + +@Suppress("LambdaParameterEventTrailing") +@Composable +fun TimeFrameSelector( + selectedTimeFrame: TimeFrame, + availableTimeFrames: List, + onTimeFrameSelected: (TimeFrame) -> Unit, + modifier: Modifier = Modifier, +) { + if (availableTimeFrames.size <= 1) return + + SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) { + availableTimeFrames.forEachIndexed { index, timeFrame -> + val text = stringResource(timeFrame.strRes) + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index, availableTimeFrames.size), + onClick = { onTimeFrameSelected(timeFrame) }, + selected = timeFrame == selectedTimeFrame, + label = { Text(text = text, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) + } + } +} diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt index f0808e911..966aec158 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/MetricsState.kt @@ -65,6 +65,21 @@ data class MetricsState( fun hasPaxMetrics() = paxMetrics.isNotEmpty() + /** Finds the oldest timestamp (in seconds) among all collected metric types. */ + @Suppress("MagicNumber") + fun oldestTimestampSeconds(): Long? { + val telemetryTimes = (deviceMetrics + powerMetrics + hostMetrics).mapNotNull { it.time?.toLong() } + val signalTimes = signalMetrics.mapNotNull { it.rx_time?.toLong() } + val logTimes = + (tracerouteRequests + tracerouteResults + neighborInfoRequests + neighborInfoResults + paxMetrics).map { + it.received_date / 1000L + } + val positionTimes = positionLogs.mapNotNull { it.time?.toLong() } + + val allTimes = telemetryTimes + signalTimes + logTimes + positionTimes + return allTimes.minOrNull() + } + companion object { val Empty = MetricsState() } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/TimeFrame.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/TimeFrame.kt new file mode 100644 index 000000000..a07e9cb80 --- /dev/null +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/model/TimeFrame.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2025-2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.model + +import org.jetbrains.compose.resources.StringResource +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.all_time +import org.meshtastic.core.strings.one_hour_short +import org.meshtastic.core.strings.one_month +import org.meshtastic.core.strings.one_week +import org.meshtastic.core.strings.twenty_four_hours +import org.meshtastic.core.strings.two_weeks + +@Suppress("MagicNumber") +enum class TimeFrame(val strRes: StringResource, val seconds: Long) { + ONE_HOUR(Res.string.one_hour_short, 3600), + TWENTY_FOUR_HOURS(Res.string.twenty_four_hours, 86400), + SEVEN_DAYS(Res.string.one_week, 604800), + TWO_WEEKS(Res.string.two_weeks, 1209600), + ONE_MONTH(Res.string.one_month, 2592000), + ALL_TIME(Res.string.all_time, 0), + ; + + fun timeThreshold(now: Long = System.currentTimeMillis() / 1000L): Long { + if (this == ALL_TIME) return 0 + return now - seconds + } + + /** + * Checks if this time frame is relevant given the oldest available data point. We show the option if the data + * extends at least into this timeframe. + */ + fun isAvailable(oldestTimestampSeconds: Long, now: Long = System.currentTimeMillis() / 1000L): Boolean { + if (this == ALL_TIME || this == ONE_HOUR) return true + val rangeSeconds = now - oldestTimestampSeconds + return rangeSeconds >= seconds + } +} diff --git a/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/BaseMetricScreenTest.kt b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/BaseMetricScreenTest.kt new file mode 100644 index 000000000..e66335580 --- /dev/null +++ b/feature/node/src/test/kotlin/org/meshtastic/feature/node/metrics/BaseMetricScreenTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.metrics + +import androidx.compose.material3.Text +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.device_metrics_log +import org.meshtastic.core.ui.theme.AppTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BaseMetricScreenTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun baseMetricScreen_displaysTitleAndNodeName() { + val nodeName = "Test Node 123" + val testData = listOf("Item 1", "Item 2") + + composeTestRule.setContent { + AppTheme { + BaseMetricScreen( + onNavigateUp = {}, + telemetryType = TelemetryType.DEVICE, + titleRes = Res.string.device_metrics_log, + nodeName = nodeName, + data = testData, + timeProvider = { 0.0 }, + chartPart = { _, _, _, _ -> Text("Chart Placeholder") }, + listPart = { _, _, _, _ -> Text("List Placeholder") }, + ) + } + } + + // Verify Node Name is displayed (MainAppBar title) + composeTestRule.onNodeWithText(nodeName).assertIsDisplayed() + + // Verify Placeholders are displayed + composeTestRule.onNodeWithText("Chart Placeholder").assertIsDisplayed() + composeTestRule.onNodeWithText("List Placeholder").assertIsDisplayed() + } + + @Test + fun baseMetricScreen_refreshButtonTriggersCallback() { + var refreshClicked = false + val testData = emptyList() + + composeTestRule.setContent { + AppTheme { + BaseMetricScreen( + onNavigateUp = {}, + telemetryType = TelemetryType.DEVICE, + titleRes = Res.string.device_metrics_log, + nodeName = "Node", + data = testData, + timeProvider = { 0.0 }, + onRequestTelemetry = { refreshClicked = true }, + chartPart = { _, _, _, _ -> }, + listPart = { _, _, _, _ -> }, + ) + } + } + + composeTestRule.onNodeWithTag("refresh_button").performClick() + + assertTrue("Refresh callback should be triggered", refreshClicked) + } +}