mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
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>
This commit is contained in:
parent
53b5707a41
commit
d252fde289
15 changed files with 586 additions and 467 deletions
|
|
@ -446,11 +446,13 @@
|
|||
<string name="traceroute_time_and_text">%1$s - %2$s</string>
|
||||
<string name="traceroute_route_towards_dest">Route traced toward destination:\n\n</string>
|
||||
<string name="traceroute_route_back_to_us">Route traced back to us:\n\n</string>
|
||||
<string name="one_hour_short">1H</string>
|
||||
<string name="twenty_four_hours">24H</string>
|
||||
<string name="forty_eight_hours">48H</string>
|
||||
<string name="one_week">1W</string>
|
||||
<string name="two_weeks">2W</string>
|
||||
<string name="four_weeks">4W</string>
|
||||
<string name="one_month">1M</string>
|
||||
<string name="max">Max</string>
|
||||
<string name="unknown_age">Unknown Age</string>
|
||||
<string name="copy">Copy</string>
|
||||
|
|
@ -1175,4 +1177,5 @@
|
|||
<string name="scan_nfc_text">Bring your device close to the NFC tag to scan.</string>
|
||||
<string name="generate_qr_code">Generate QR Code</string>
|
||||
<string name="nfc_disabled">NFC is disabled. Please enable it in system settings.</string>
|
||||
<string name="all_time">All</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ configure<LibraryExtension> {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <T> BaseMetricScreen(
|
||||
viewModel: MetricsViewModel,
|
||||
onNavigateUp: () -> Unit,
|
||||
telemetryType: TelemetryType?,
|
||||
titleRes: StringResource,
|
||||
nodeName: String,
|
||||
data: List<T>,
|
||||
timeProvider: (T) -> Double,
|
||||
infoData: List<InfoDialogData> = 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 <T> BaseMetricScreen(
|
|||
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?.long_name ?: "",
|
||||
title = nodeName,
|
||||
subtitle = stringResource(titleRes) + " (${data.size} ${stringResource(Res.string.logs)})",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
|
|
@ -193,7 +181,10 @@ fun <T> 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 <T> BaseMetricScreen(
|
|||
LegendInfoDialog(infoData = infoData, onDismiss = { displayInfoDialog = false })
|
||||
}
|
||||
|
||||
controlPart()
|
||||
|
||||
AdaptiveMetricLayout(
|
||||
chartPart = { modifier ->
|
||||
chartPart(modifier, selectedX, vicoScrollState) { x ->
|
||||
|
|
@ -219,7 +212,7 @@ fun <T> 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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Double?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.effects.collect { effect ->
|
||||
|
|
@ -117,99 +92,47 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
|
|||
}
|
||||
}
|
||||
|
||||
val processedTelemetries: List<Telemetry> =
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<Telemetry> = 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<Telemetry> = 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() })
|
||||
|
|
|
|||
|
|
@ -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<Map<Int, TracerouteOverlay>>(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<Int>, returnRoute: List<Int>): TracerouteMapAvailability =
|
||||
evaluateTracerouteMapAvailability(
|
||||
forwardRoute = forwardRoute,
|
||||
returnRoute = returnRoute,
|
||||
positionedNodeNums = positionedNodeNums(),
|
||||
)
|
||||
|
||||
fun tracerouteMapAvailability(overlay: TracerouteOverlay): TracerouteMapAvailability =
|
||||
tracerouteMapAvailability(overlay.forwardRoute, overlay.returnRoute)
|
||||
|
||||
fun positionedNodeNums(): Set<Int> =
|
||||
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<MetricsState> = _state
|
||||
|
||||
private val _environmentState = MutableStateFlow(EnvironmentMetricsState())
|
||||
val environmentState: StateFlow<EnvironmentMetricsState> = _environmentState
|
||||
private val environmentState = MutableStateFlow(EnvironmentMetricsState())
|
||||
|
||||
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
|
||||
val timeFrame: StateFlow<TimeFrame> = _timeFrame
|
||||
|
||||
val availableTimeFrames: StateFlow<List<TimeFrame>> =
|
||||
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<List<Telemetry>> =
|
||||
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<EnvironmentGraphingData> =
|
||||
filteredEnvironmentMetrics
|
||||
.map { filtered -> EnvironmentMetricsState(filtered).environmentMetricsForGraphing(useFahrenheit = false) }
|
||||
.stateInWhileSubscribed(EnvironmentGraphingData(emptyList(), emptyList()))
|
||||
|
||||
/** Exposes filtered and decoded pax metrics for the UI. */
|
||||
val filteredPaxMetrics: StateFlow<List<Pair<MeshLog, ProtoPaxcount>>> =
|
||||
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<NodeRequestEffect> = nodeRequestActions.effects
|
||||
|
||||
|
|
@ -215,10 +263,6 @@ constructor(
|
|||
val lastRequestNeighborsTime: StateFlow<Long?> =
|
||||
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<Telemetry>()
|
||||
val power = mutableListOf<Telemetry>()
|
||||
val host = mutableListOf<Telemetry>()
|
||||
val env = mutableListOf<Telemetry>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Double?>(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<Byte>()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<Double?>(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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<TimeFrame>,
|
||||
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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<String>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue