mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor: Replace custom charts with Vico library (#4348)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
d651bbeaa2
commit
70a1b3c479
27 changed files with 1689 additions and 1338 deletions
|
|
@ -18,6 +18,7 @@ package com.geeksville.mesh.navigation
|
|||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.CellTower
|
||||
import androidx.compose.material.icons.rounded.Groups
|
||||
import androidx.compose.material.icons.rounded.LightMode
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Memory
|
||||
|
|
@ -49,6 +50,7 @@ import org.meshtastic.core.strings.Res
|
|||
import org.meshtastic.core.strings.device
|
||||
import org.meshtastic.core.strings.environment
|
||||
import org.meshtastic.core.strings.host
|
||||
import org.meshtastic.core.strings.neighbor_info
|
||||
import org.meshtastic.core.strings.pax
|
||||
import org.meshtastic.core.strings.position_log
|
||||
import org.meshtastic.core.strings.power
|
||||
|
|
@ -61,6 +63,7 @@ import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
|
|||
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
|
||||
import org.meshtastic.feature.node.metrics.MetricsViewModel
|
||||
import org.meshtastic.feature.node.metrics.NeighborInfoLogScreen
|
||||
import org.meshtastic.feature.node.metrics.PaxMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.PositionLogScreen
|
||||
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
|
||||
|
|
@ -234,6 +237,14 @@ fun NavGraphBuilder.nodeDetailGraph(navController: NavHostController, scrollToTo
|
|||
) {
|
||||
it.destNum
|
||||
}
|
||||
NodeDetailRoutes.NeighborInfoLog::class ->
|
||||
addNodeDetailScreenComposable<NodeDetailRoutes.NeighborInfoLog>(
|
||||
navController,
|
||||
entry,
|
||||
entry.screenComposable,
|
||||
) {
|
||||
it.destNum
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -312,6 +323,12 @@ enum class NodeDetailRoute(
|
|||
Icons.Rounded.PermScanWifi,
|
||||
{ metricsVM, onNavigateUp -> TracerouteLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
|
||||
),
|
||||
NEIGHBOR_INFO(
|
||||
Res.string.neighbor_info,
|
||||
NodeDetailRoutes.NeighborInfoLog::class,
|
||||
Icons.Rounded.Groups,
|
||||
{ metricsVM, onNavigateUp -> NeighborInfoLogScreen(viewModel = metricsVM, onNavigateUp = onNavigateUp) },
|
||||
),
|
||||
POWER(
|
||||
Res.string.power,
|
||||
NodeDetailRoutes.PowerMetrics::class,
|
||||
|
|
|
|||
|
|
@ -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.core.model
|
||||
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
|
||||
val MeshProtos.MeshPacket.neighborInfo: MeshProtos.NeighborInfo?
|
||||
get() =
|
||||
if (hasDecoded() && decoded.portnumValue == 71) { // NEIGHBORINFO_APP_VALUE = 71
|
||||
runCatching { MeshProtos.NeighborInfo.parseFrom(decoded.payload) }.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
fun MeshProtos.NeighborInfo.getNeighborInfoResponse(
|
||||
getUser: (nodeNum: Int) -> String,
|
||||
header: String = "Neighbors:",
|
||||
): String = buildString {
|
||||
append(header)
|
||||
append("\n\n")
|
||||
if (neighborsList.isEmpty()) {
|
||||
append("No neighbors reported.")
|
||||
} else {
|
||||
neighborsList.forEach { n ->
|
||||
append("• ")
|
||||
append(getUser(n.nodeId))
|
||||
append(" (SNR: ")
|
||||
append(n.snr)
|
||||
append(")\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun MeshProtos.MeshPacket.getNeighborInfoResponse(
|
||||
getUser: (nodeNum: Int) -> String,
|
||||
header: String = "Neighbors:",
|
||||
): String? = neighborInfo?.getNeighborInfoResponse(getUser, header)
|
||||
|
|
@ -82,6 +82,8 @@ object NodeDetailRoutes {
|
|||
@Serializable data class HostMetricsLog(val destNum: Int) : Route
|
||||
|
||||
@Serializable data class PaxMetrics(val destNum: Int) : Route
|
||||
|
||||
@Serializable data class NeighborInfoLog(val destNum: Int) : Route
|
||||
}
|
||||
|
||||
object SettingsRoutes {
|
||||
|
|
|
|||
|
|
@ -425,7 +425,6 @@
|
|||
<string name="position_log">Position</string>
|
||||
<string name="last_position_update">Last position update</string>
|
||||
<string name="env_metrics_log">Environment Metrics</string>
|
||||
<string name="sig_metrics_log">Signal Metrics</string>
|
||||
<string name="administration">Administration</string>
|
||||
<string name="remote_admin">Remote Administration</string>
|
||||
<string name="bad">Bad</string>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ dependencies {
|
|||
implementation(libs.markdown.renderer.android)
|
||||
implementation(libs.markdown.renderer.m3)
|
||||
implementation(libs.markdown.renderer)
|
||||
implementation(libs.vico.compose)
|
||||
implementation(libs.vico.compose.m2)
|
||||
implementation(libs.vico.compose.m3)
|
||||
|
||||
googleImplementation(libs.location.services)
|
||||
googleImplementation(libs.maps.compose)
|
||||
|
|
|
|||
|
|
@ -51,19 +51,15 @@ import org.meshtastic.core.database.model.Node
|
|||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.logs
|
||||
import org.meshtastic.core.strings.neighbor_info
|
||||
import org.meshtastic.core.strings.request_air_quality_metrics
|
||||
import org.meshtastic.core.strings.request_local_stats
|
||||
import org.meshtastic.core.strings.request_telemetry
|
||||
import org.meshtastic.core.strings.telemetry
|
||||
import org.meshtastic.core.strings.userinfo
|
||||
import org.meshtastic.core.ui.icon.AirQuality
|
||||
import org.meshtastic.core.ui.icon.Chart
|
||||
import org.meshtastic.core.ui.icon.Groups
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Person
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
import org.meshtastic.core.ui.icon.Speed
|
||||
import org.meshtastic.core.ui.icon.Temperature
|
||||
import org.meshtastic.feature.node.model.LogsType
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
|
|
@ -137,9 +133,10 @@ private fun rememberTelemetricFeatures(
|
|||
isVisible = { !isLocal },
|
||||
),
|
||||
TelemetricFeature(
|
||||
titleRes = Res.string.neighbor_info,
|
||||
icon = MeshtasticIcons.Groups,
|
||||
titleRes = LogsType.NEIGHBOR_INFO.titleRes,
|
||||
icon = LogsType.NEIGHBOR_INFO.icon,
|
||||
requestAction = { NodeMenuAction.RequestNeighborInfo(it) },
|
||||
logsType = LogsType.NEIGHBOR_INFO,
|
||||
isVisible = { it.capabilities.canRequestNeighborInfo },
|
||||
cooldownTimestamp = lastRequestNeighborsTime,
|
||||
cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS,
|
||||
|
|
@ -147,9 +144,9 @@ private fun rememberTelemetricFeatures(
|
|||
TelemetricFeature(
|
||||
titleRes = LogsType.SIGNAL.titleRes,
|
||||
icon = LogsType.SIGNAL.icon,
|
||||
requestAction = null,
|
||||
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
|
||||
logsType = LogsType.SIGNAL,
|
||||
isVisible = { it.hopsAway == 0 && !isLocal },
|
||||
isVisible = { !isLocal },
|
||||
),
|
||||
TelemetricFeature(
|
||||
titleRes = LogsType.DEVICE.titleRes,
|
||||
|
|
@ -178,11 +175,6 @@ private fun rememberTelemetricFeatures(
|
|||
content = { PowerMetrics(it) },
|
||||
hasContent = { it.hasPowerMetrics },
|
||||
),
|
||||
TelemetricFeature(
|
||||
titleRes = Res.string.request_local_stats,
|
||||
icon = MeshtasticIcons.Speed,
|
||||
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) },
|
||||
),
|
||||
TelemetricFeature(
|
||||
titleRes = LogsType.HOST.titleRes,
|
||||
icon = LogsType.HOST.icon,
|
||||
|
|
|
|||
|
|
@ -126,6 +126,19 @@ constructor(
|
|||
.distinctUntilChanged()
|
||||
val trResFlow =
|
||||
meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP_VALUE).distinctUntilChanged()
|
||||
val niReqsFlow =
|
||||
meshLogRepository
|
||||
.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP_VALUE)
|
||||
.map { logs ->
|
||||
logs.filter { log ->
|
||||
with(log.fromRadio.packet) {
|
||||
hasDecoded() && decoded.wantResponse && from == 0 && to == nodeId
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
val niResFlow =
|
||||
meshLogRepository.getLogsFrom(nodeId, PortNum.NEIGHBORINFO_APP_VALUE).distinctUntilChanged()
|
||||
|
||||
combine(
|
||||
nodeRepository.ourNodeInfo,
|
||||
|
|
@ -139,6 +152,8 @@ constructor(
|
|||
paxLogsFlow,
|
||||
trReqsFlow,
|
||||
trResFlow,
|
||||
niReqsFlow,
|
||||
niResFlow,
|
||||
meshLogRepository.getMyNodeInfo().map { it?.firmwareEdition }.distinctUntilChanged(),
|
||||
firmwareReleaseRepository.stableRelease,
|
||||
firmwareReleaseRepository.alphaRelease,
|
||||
|
|
@ -159,11 +174,13 @@ constructor(
|
|||
paxLogs = args[8] as List<MeshLog>,
|
||||
tracerouteRequests = args[9] as List<MeshLog>,
|
||||
tracerouteResults = args[10] as List<MeshLog>,
|
||||
firmwareEdition = args[11] as MeshProtos.FirmwareEdition?,
|
||||
stable = args[12] as FirmwareRelease?,
|
||||
alpha = args[13] as FirmwareRelease?,
|
||||
lastTracerouteTime = (args[14] as Map<Int, Long>)[nodeId],
|
||||
lastRequestNeighborsTime = (args[15] as Map<Int, Long>)[nodeId],
|
||||
neighborInfoRequests = args[11] as List<MeshLog>,
|
||||
neighborInfoResults = args[12] as List<MeshLog>,
|
||||
firmwareEdition = args[13] as MeshProtos.FirmwareEdition?,
|
||||
stable = args[14] as FirmwareRelease?,
|
||||
alpha = args[15] as FirmwareRelease?,
|
||||
lastTracerouteTime = (args[16] as Map<Int, Long>)[nodeId],
|
||||
lastRequestNeighborsTime = (args[17] as Map<Int, Long>)[nodeId],
|
||||
)
|
||||
}
|
||||
.flatMapLatest { data ->
|
||||
|
|
@ -194,6 +211,8 @@ constructor(
|
|||
paxMetrics = data.paxLogs,
|
||||
tracerouteRequests = data.tracerouteRequests,
|
||||
tracerouteResults = data.tracerouteResults,
|
||||
neighborInfoRequests = data.neighborInfoRequests,
|
||||
neighborInfoResults = data.neighborInfoResults,
|
||||
firmwareEdition = data.firmwareEdition,
|
||||
latestStableFirmware = data.stable ?: FirmwareRelease(),
|
||||
latestAlphaFirmware = data.alpha ?: FirmwareRelease(),
|
||||
|
|
@ -220,6 +239,7 @@ constructor(
|
|||
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
|
||||
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
|
||||
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
|
||||
if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO)
|
||||
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
|
||||
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
|
||||
}
|
||||
|
|
@ -314,6 +334,8 @@ private data class NodeDetailUiStateData(
|
|||
val paxLogs: List<MeshLog>,
|
||||
val tracerouteRequests: List<MeshLog>,
|
||||
val tracerouteResults: List<MeshLog>,
|
||||
val neighborInfoRequests: List<MeshLog>,
|
||||
val neighborInfoResults: List<MeshLog>,
|
||||
val firmwareEdition: MeshProtos.FirmwareEdition?,
|
||||
val stable: FirmwareRelease?,
|
||||
val alpha: FirmwareRelease?,
|
||||
|
|
|
|||
|
|
@ -38,10 +38,10 @@ import org.meshtastic.core.strings.request_air_quality_metrics
|
|||
import org.meshtastic.core.strings.request_device_metrics
|
||||
import org.meshtastic.core.strings.request_environment_metrics
|
||||
import org.meshtastic.core.strings.request_host_metrics
|
||||
import org.meshtastic.core.strings.request_local_stats
|
||||
import org.meshtastic.core.strings.request_pax_metrics
|
||||
import org.meshtastic.core.strings.request_power_metrics
|
||||
import org.meshtastic.core.strings.requesting_from
|
||||
import org.meshtastic.core.strings.signal_quality
|
||||
import org.meshtastic.core.strings.traceroute
|
||||
import org.meshtastic.core.strings.user_info
|
||||
import javax.inject.Inject
|
||||
|
|
@ -128,7 +128,7 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
|
|||
TelemetryType.ENVIRONMENT -> Res.string.request_environment_metrics
|
||||
TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics
|
||||
TelemetryType.POWER -> Res.string.request_power_metrics
|
||||
TelemetryType.LOCAL_STATS -> Res.string.request_local_stats
|
||||
TelemetryType.LOCAL_STATS -> Res.string.signal_quality
|
||||
TelemetryType.HOST -> Res.string.request_host_metrics
|
||||
TelemetryType.PAX -> Res.string.request_pax_metrics
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* 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.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost
|
||||
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
|
||||
import com.patrykandpatrick.vico.compose.cartesian.Zoom
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
|
||||
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
|
||||
import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker
|
||||
import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarkerVisibilityListener
|
||||
import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart
|
||||
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
|
||||
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoZoomState
|
||||
|
||||
/**
|
||||
* A generic chart host for Meshtastic metric charts. Handles common boilerplate for markers, scrolling, and point
|
||||
* selection synchronization.
|
||||
*
|
||||
* @param modelProducer The [CartesianChartModelProducer] for the chart.
|
||||
* @param layers The chart layers (e.g., LineCartesianLayer).
|
||||
* @param modifier The modifier for the chart host.
|
||||
* @param startAxis The start vertical axis.
|
||||
* @param endAxis The end vertical axis.
|
||||
* @param bottomAxis The bottom horizontal axis.
|
||||
* @param marker The marker to show on interaction.
|
||||
* @param selectedX The currently selected X value (used for persistent markers).
|
||||
* @param onPointSelected Callback when a point is selected via interaction.
|
||||
* @param vicoScrollState The scroll state for the chart.
|
||||
*/
|
||||
@Composable
|
||||
fun GenericMetricChart(
|
||||
modelProducer: CartesianChartModelProducer,
|
||||
layers: List<LineCartesianLayer>,
|
||||
modifier: Modifier = Modifier,
|
||||
startAxis: VerticalAxis<Axis.Position.Vertical.Start>? = null,
|
||||
endAxis: VerticalAxis<Axis.Position.Vertical.End>? = null,
|
||||
bottomAxis: HorizontalAxis<Axis.Position.Horizontal.Bottom>? = null,
|
||||
marker: CartesianMarker? = null,
|
||||
selectedX: Double? = null,
|
||||
onPointSelected: ((Double) -> Unit)? = null,
|
||||
vicoScrollState: VicoScrollState = rememberVicoScrollState(),
|
||||
) {
|
||||
val markerVisibilityListener =
|
||||
remember(onPointSelected) {
|
||||
object : CartesianMarkerVisibilityListener {
|
||||
override fun onShown(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
|
||||
targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) }
|
||||
}
|
||||
|
||||
override fun onUpdated(marker: CartesianMarker, targets: List<CartesianMarker.Target>) {
|
||||
targets.firstOrNull()?.let { onPointSelected?.invoke(it.x) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CartesianChartHost(
|
||||
chart =
|
||||
@Suppress("SpreadOperator")
|
||||
rememberCartesianChart(
|
||||
*layers.toTypedArray(),
|
||||
startAxis = startAxis,
|
||||
endAxis = endAxis,
|
||||
bottomAxis = bottomAxis,
|
||||
marker = marker,
|
||||
markerVisibilityListener = markerVisibilityListener,
|
||||
persistentMarkers = { _ -> if (selectedX != null && marker != null) marker at selectedX else null },
|
||||
),
|
||||
modelProducer = modelProducer,
|
||||
modifier = modifier,
|
||||
scrollState = vicoScrollState,
|
||||
zoomState = rememberVicoZoomState(zoomEnabled = true, initialZoom = Zoom.Content),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
/*
|
||||
* 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.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
|
||||
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLine
|
||||
import com.patrykandpatrick.vico.compose.cartesian.marker.CartesianMarker
|
||||
import com.patrykandpatrick.vico.compose.cartesian.marker.DefaultCartesianMarker
|
||||
import com.patrykandpatrick.vico.compose.cartesian.marker.LineCartesianLayerMarkerTarget
|
||||
import com.patrykandpatrick.vico.compose.cartesian.marker.rememberDefaultCartesianMarker
|
||||
import com.patrykandpatrick.vico.compose.common.Fill
|
||||
import com.patrykandpatrick.vico.compose.common.Insets
|
||||
import com.patrykandpatrick.vico.compose.common.MarkerCornerBasedShape
|
||||
import com.patrykandpatrick.vico.compose.common.component.ShapeComponent
|
||||
import com.patrykandpatrick.vico.compose.common.component.TextComponent
|
||||
import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent
|
||||
import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent
|
||||
import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent
|
||||
|
||||
/**
|
||||
* Utility object for chart styling and component creation. Provides reusable styled lines, points, and axes for Vico
|
||||
* charts.
|
||||
*/
|
||||
object ChartStyling {
|
||||
// Point sizes
|
||||
const val SMALL_POINT_SIZE_DP = 6f
|
||||
const val MEDIUM_POINT_SIZE_DP = 8f
|
||||
const val LARGE_POINT_SIZE_DP = 10f
|
||||
|
||||
// Line stroke widths
|
||||
const val THIN_LINE_WIDTH_DP = 1.5f
|
||||
const val MEDIUM_LINE_WIDTH_DP = 2f
|
||||
const val THICK_LINE_WIDTH_DP = 2.5f
|
||||
|
||||
/**
|
||||
* Creates a solid line with optional point markers.
|
||||
*
|
||||
* @param lineColor The color of the line
|
||||
* @param pointSize Size of point markers (in dp). If null, no point markers are shown.
|
||||
* @param lineWidth Width of the line in dp
|
||||
* @return Configured [LineCartesianLayer.Line]
|
||||
*/
|
||||
@Composable
|
||||
fun createStyledLine(
|
||||
lineColor: Color,
|
||||
pointSize: Float? = MEDIUM_POINT_SIZE_DP,
|
||||
lineWidth: Float = MEDIUM_LINE_WIDTH_DP,
|
||||
): LineCartesianLayer.Line = LineCartesianLayer.rememberLine(
|
||||
fill = LineCartesianLayer.LineFill.single(Fill(lineColor)),
|
||||
pointProvider =
|
||||
pointSize?.let {
|
||||
LineCartesianLayer.PointProvider.single(
|
||||
LineCartesianLayer.Point(
|
||||
rememberShapeComponent(fill = Fill(lineColor), shape = CircleShape),
|
||||
size = it.dp,
|
||||
),
|
||||
)
|
||||
},
|
||||
stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp),
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a transparent line (no line, only points). Useful for distinguishing multiple metrics on the same chart.
|
||||
*
|
||||
* @param pointColor The color of the point markers
|
||||
* @param pointSize Size of point markers in dp
|
||||
* @return Configured [LineCartesianLayer.Line]
|
||||
*/
|
||||
@Composable
|
||||
fun createPointOnlyLine(pointColor: Color, pointSize: Float = MEDIUM_POINT_SIZE_DP): LineCartesianLayer.Line =
|
||||
LineCartesianLayer.rememberLine(
|
||||
// we still need to give the line a color, the Marker derives the label color from the line
|
||||
fill = LineCartesianLayer.LineFill.single(Fill(pointColor)),
|
||||
// magic sauce to make the line disappear
|
||||
stroke = LineCartesianLayer.LineStroke.Dashed(thickness = 0.dp, dashLength = 0.dp),
|
||||
pointProvider =
|
||||
LineCartesianLayer.PointProvider.single(
|
||||
LineCartesianLayer.Point(
|
||||
rememberShapeComponent(fill = Fill(pointColor), shape = CircleShape),
|
||||
size = pointSize.dp,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a line with a gradient fill effect. The gradient goes from the line color to transparent.
|
||||
*
|
||||
* @param lineColor The primary color of the line
|
||||
* @param pointSize Size of point markers (in dp). If null, no point markers are shown.
|
||||
* @param lineWidth Width of the line in dp
|
||||
* @return Configured [LineCartesianLayer.Line]
|
||||
*/
|
||||
@Composable
|
||||
fun createGradientLine(
|
||||
lineColor: Color,
|
||||
pointSize: Float? = MEDIUM_POINT_SIZE_DP,
|
||||
lineWidth: Float = MEDIUM_LINE_WIDTH_DP,
|
||||
): LineCartesianLayer.Line {
|
||||
val gradientBrush =
|
||||
Brush.verticalGradient(colors = listOf(lineColor.copy(alpha = 0.3f), lineColor.copy(alpha = 0.1f)))
|
||||
return LineCartesianLayer.rememberLine(
|
||||
fill = LineCartesianLayer.LineFill.single(Fill(lineColor)),
|
||||
areaFill = LineCartesianLayer.AreaFill.single(Fill(gradientBrush)),
|
||||
pointProvider =
|
||||
pointSize?.let {
|
||||
LineCartesianLayer.PointProvider.single(
|
||||
LineCartesianLayer.Point(
|
||||
rememberShapeComponent(fill = Fill(lineColor), shape = CircleShape),
|
||||
size = it.dp,
|
||||
),
|
||||
)
|
||||
},
|
||||
stroke = LineCartesianLayer.LineStroke.Continuous(lineWidth.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a bold line suitable for highlighting primary metrics.
|
||||
*
|
||||
* @param lineColor The color of the line
|
||||
* @param pointSize Size of point markers (in dp). If null, no point markers are shown.
|
||||
* @return Configured [LineCartesianLayer.Line]
|
||||
*/
|
||||
@Composable
|
||||
fun createBoldLine(lineColor: Color, pointSize: Float? = LARGE_POINT_SIZE_DP): LineCartesianLayer.Line =
|
||||
createStyledLine(lineColor = lineColor, pointSize = pointSize, lineWidth = THICK_LINE_WIDTH_DP)
|
||||
|
||||
/**
|
||||
* Creates a subtle line suitable for secondary metrics.
|
||||
*
|
||||
* @param lineColor The color of the line
|
||||
* @param pointSize Size of point markers (in dp). If null, no point markers are shown.
|
||||
* @return Configured [LineCartesianLayer.Line]
|
||||
*/
|
||||
@Composable
|
||||
fun createSubtleLine(lineColor: Color, pointSize: Float? = SMALL_POINT_SIZE_DP): LineCartesianLayer.Line =
|
||||
createStyledLine(lineColor = lineColor, pointSize = pointSize, lineWidth = THIN_LINE_WIDTH_DP)
|
||||
|
||||
/**
|
||||
* Gets Material 3 theme-aware colors with opacity. Useful for creating color variants while respecting the current
|
||||
* theme.
|
||||
*
|
||||
* @param baseColor The base color
|
||||
* @param alpha The alpha/opacity value (0f-1f)
|
||||
* @return Color with adjusted alpha
|
||||
*/
|
||||
fun createThemedColor(baseColor: Color, alpha: Float = 1f): Color = baseColor.copy(alpha = alpha)
|
||||
|
||||
/**
|
||||
* Creates and remembers a default [CartesianMarker] styled for the Meshtastic theme.
|
||||
*
|
||||
* @param valueFormatter The formatter for the marker label content.
|
||||
* @param showIndicator Whether to show the point indicator on the line/column.
|
||||
* @return A configured [CartesianMarker]
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMarker(
|
||||
valueFormatter: DefaultCartesianMarker.ValueFormatter =
|
||||
DefaultCartesianMarker.ValueFormatter.default(colorCode = true),
|
||||
showIndicator: Boolean = true,
|
||||
): CartesianMarker {
|
||||
val labelBackground =
|
||||
rememberShapeComponent(
|
||||
fill = Fill(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
shape = MarkerCornerBasedShape(MaterialTheme.shapes.extraSmall),
|
||||
strokeFill = Fill(MaterialTheme.colorScheme.outlineVariant),
|
||||
strokeThickness = 1.dp,
|
||||
)
|
||||
val label =
|
||||
rememberTextComponent(
|
||||
style =
|
||||
TextStyle(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp,
|
||||
),
|
||||
background = labelBackground,
|
||||
padding = Insets(horizontal = 8.dp, vertical = 4.dp),
|
||||
margins = Insets(bottom = 4.dp),
|
||||
)
|
||||
val guideline =
|
||||
rememberLineComponent(
|
||||
fill = Fill(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)),
|
||||
thickness = 1.dp,
|
||||
)
|
||||
|
||||
val indicator =
|
||||
if (showIndicator) {
|
||||
// Force alpha to 1f so the indicator is visible even for "invisible" lines
|
||||
{ color: Color -> ShapeComponent(fill = Fill(color.copy(alpha = 1f)), shape = CircleShape) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
return rememberDefaultCartesianMarker(
|
||||
label = label,
|
||||
valueFormatter = valueFormatter,
|
||||
guideline = guideline,
|
||||
indicator = indicator,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [DefaultCartesianMarker.ValueFormatter] that colors the text to match the series color.
|
||||
*
|
||||
* @param format A lambda that provides the string content for a given Y value and its series color.
|
||||
*/
|
||||
fun createColoredMarkerValueFormatter(
|
||||
format: (value: Double, color: Color) -> String,
|
||||
): DefaultCartesianMarker.ValueFormatter = DefaultCartesianMarker.ValueFormatter { _, targets ->
|
||||
buildAnnotatedString {
|
||||
targets.forEachIndexed { index, target ->
|
||||
if (index > 0) append(", ")
|
||||
if (target is LineCartesianLayerMarkerTarget) {
|
||||
target.points.forEachIndexed { pointIndex, point ->
|
||||
if (pointIndex > 0) append(", ")
|
||||
// Force alpha to 1f so text is readable even if the line is transparent/subtle
|
||||
val color = point.color.copy(alpha = .8f)
|
||||
val text = format(point.entry.y, color)
|
||||
withStyle(SpanStyle(color = color, fontWeight = FontWeight.Bold)) { append(text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standard [HorizontalAxis.ItemPlacer] with optimized spacing.
|
||||
*
|
||||
* @param spacing The number of data points to skip between labels.
|
||||
*/
|
||||
fun rememberItemPlacer(spacing: Int = 50): HorizontalAxis.ItemPlacer =
|
||||
HorizontalAxis.ItemPlacer.aligned(spacing = { spacing }, addExtremeLabelPadding = true)
|
||||
|
||||
/**
|
||||
* Creates and remembers a [com.patrykandpatrick.vico.compose.common.component.TextComponent] styled for axis
|
||||
* labels.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberAxisLabel(color: Color = MaterialTheme.colorScheme.onSurfaceVariant): TextComponent =
|
||||
rememberTextComponent(style = TextStyle(color = color, fontSize = 10.sp, fontWeight = FontWeight.Medium))
|
||||
}
|
||||
|
|
@ -14,25 +14,34 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
|
@ -40,48 +49,96 @@ import androidx.compose.material3.TextButton
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.patrykandpatrick.vico.compose.cartesian.CartesianDrawingContext
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianValueFormatter
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.close
|
||||
import org.meshtastic.core.strings.delete
|
||||
import org.meshtastic.core.strings.info
|
||||
import org.meshtastic.core.strings.logs
|
||||
import org.meshtastic.core.strings.rssi
|
||||
import org.meshtastic.core.strings.snr
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_MINUTE_FORMAT
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MAX_PERCENT_VALUE
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.core.ui.icon.Delete
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
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
|
||||
}
|
||||
const val SCROLL_BIAS = 0.5f
|
||||
|
||||
private const val LINE_ON = 10f
|
||||
private const val LINE_OFF = 20f
|
||||
private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
|
||||
private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
|
||||
private const val DATE_Y = 32f
|
||||
private const val LINE_LIMIT = 4
|
||||
private const val TEXT_PAINT_ALPHA = 192
|
||||
/**
|
||||
* Gets the Material 3 primary color with optional opacity adjustment.
|
||||
*
|
||||
* @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque).
|
||||
* @return Color based on current theme's primary color.
|
||||
*/
|
||||
@Composable
|
||||
fun getMaterial3PrimaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.primary.copy(alpha = alpha)
|
||||
|
||||
/**
|
||||
* Gets the Material 3 secondary color with optional opacity adjustment.
|
||||
*
|
||||
* @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque).
|
||||
* @return Color based on current theme's secondary color.
|
||||
*/
|
||||
@Composable
|
||||
fun getMaterial3SecondaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.secondary.copy(alpha = alpha)
|
||||
|
||||
/**
|
||||
* Gets the Material 3 tertiary color with optional opacity adjustment.
|
||||
*
|
||||
* @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque).
|
||||
* @return Color based on current theme's tertiary color.
|
||||
*/
|
||||
@Composable
|
||||
fun getMaterial3TertiaryColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.tertiary.copy(alpha = alpha)
|
||||
|
||||
/**
|
||||
* Gets the Material 3 error color with optional opacity adjustment.
|
||||
*
|
||||
* @param alpha The alpha/opacity value (0f-1f). Defaults to 1f (fully opaque).
|
||||
* @return Color based on current theme's error color.
|
||||
*/
|
||||
@Composable
|
||||
fun getMaterial3ErrorColor(alpha: Float = 1f): Color = MaterialTheme.colorScheme.error.copy(alpha = alpha)
|
||||
|
||||
/** A dynamic [CartesianValueFormatter] that adjusts the time format based on the visible X range. */
|
||||
val dynamicTimeFormatter = CartesianValueFormatter { context, value, _ ->
|
||||
val date = Date((value * MS_PER_SEC.toDouble()).toLong())
|
||||
val xLength = context.ranges.xLength
|
||||
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
|
||||
}
|
||||
formatter.format(date)
|
||||
}
|
||||
}
|
||||
|
||||
data class LegendData(
|
||||
val nameRes: StringResource,
|
||||
|
|
@ -106,137 +163,6 @@ fun ChartHeader(amount: Int) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws chart lines with respect to the Y-axis.
|
||||
*
|
||||
* @param lineColors A list of 5 [Color]s for the chart lines, 0 being the lowest line on the chart.
|
||||
*/
|
||||
@Composable
|
||||
fun HorizontalLinesOverlay(modifier: Modifier, lineColors: List<Color>) {
|
||||
/* 100 is a good number to divide into quarters */
|
||||
val verticalSpacing = MAX_PERCENT_VALUE / LINE_LIMIT
|
||||
Canvas(modifier = modifier) {
|
||||
val lineStart = 0f
|
||||
val height = size.height
|
||||
val width = size.width
|
||||
/* Horizontal Lines */
|
||||
var lineY = 0f
|
||||
for (i in 0..LINE_LIMIT) {
|
||||
val ratio = lineY / MAX_PERCENT_VALUE
|
||||
val y = height - (ratio * height)
|
||||
drawLine(
|
||||
start = Offset(lineStart, y),
|
||||
end = Offset(width, y),
|
||||
color = lineColors[i],
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f),
|
||||
)
|
||||
lineY += verticalSpacing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws labels on the Y-axis with respect to the range. Defined by (`maxValue` - `minValue`). */
|
||||
@Composable
|
||||
fun YAxisLabels(modifier: Modifier, labelColor: Color, minValue: Float, maxValue: Float) {
|
||||
val range = maxValue - minValue
|
||||
val verticalSpacing = range / LINE_LIMIT
|
||||
val density = LocalDensity.current
|
||||
Canvas(modifier = modifier) {
|
||||
val height = size.height
|
||||
|
||||
/* Y Labels */
|
||||
val textPaint =
|
||||
Paint().apply {
|
||||
color = labelColor.toArgb()
|
||||
textAlign = Paint.Align.LEFT
|
||||
textSize = density.run { 12.dp.toPx() }
|
||||
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
|
||||
alpha = TEXT_PAINT_ALPHA
|
||||
}
|
||||
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
var label = minValue
|
||||
repeat(LINE_LIMIT + 1) {
|
||||
val ratio = (label - minValue) / range
|
||||
val y = height - (ratio * height)
|
||||
drawText("${label.toInt()}", 0f, y + 4.dp.toPx(), textPaint)
|
||||
label += verticalSpacing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws the vertical lines to help the user relate the plotted data within a time frame. */
|
||||
@Composable
|
||||
fun TimeAxisOverlay(modifier: Modifier, oldest: Int, newest: Int, timeInterval: Long) {
|
||||
val range = newest - oldest
|
||||
val density = LocalDensity.current
|
||||
val lineColor = MaterialTheme.colorScheme.onSurface
|
||||
Canvas(modifier = modifier) {
|
||||
val height = size.height
|
||||
val width = size.width
|
||||
|
||||
/* Cut out the time remaining in order to place the lines on the dot. */
|
||||
val timeRemaining = oldest % timeInterval
|
||||
var current = oldest.toLong()
|
||||
current -= timeRemaining
|
||||
current += timeInterval
|
||||
|
||||
val textPaint =
|
||||
Paint().apply {
|
||||
color = lineColor.toArgb()
|
||||
textAlign = Paint.Align.LEFT
|
||||
textSize = density.run { 12.dp.toPx() }
|
||||
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
|
||||
alpha = TEXT_PAINT_ALPHA
|
||||
}
|
||||
|
||||
/* Vertical Lines with labels */
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
while (current <= newest) {
|
||||
val ratio = (current - oldest).toFloat() / range
|
||||
val x = (ratio * width)
|
||||
drawLine(
|
||||
start = Offset(x, 0f),
|
||||
end = Offset(x, height),
|
||||
color = lineColor,
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f),
|
||||
)
|
||||
|
||||
/* Time */
|
||||
drawText(TIME_FORMAT.format(current * MS_PER_SEC), x, 0f, textPaint)
|
||||
/* Date */
|
||||
drawText(DATE_FORMAT.format(current * MS_PER_SEC), x, DATE_Y, textPaint)
|
||||
current += timeInterval
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws the `oldest` and `newest` times for the respective telemetry data. Expects time in seconds. */
|
||||
@Composable
|
||||
fun TimeLabels(oldest: Int, newest: Int) {
|
||||
Row {
|
||||
Text(
|
||||
text = DATE_TIME_MINUTE_FORMAT.format(oldest * MS_PER_SEC),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = DATE_TIME_MINUTE_FORMAT.format(newest * MS_PER_SEC),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the legend that identifies the colors used for the graph.
|
||||
*
|
||||
|
|
@ -326,6 +252,57 @@ private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteItem(onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
onClick = onClick,
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Delete,
|
||||
contentDescription = stringResource(Res.string.delete),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun MetricLogItem(icon: ImageVector, text: String, contentDescription: String, modifier: Modifier = Modifier) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth().heightIn(min = 64.dp).padding(vertical = 4.dp, horizontal = 8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun LegendPreview() {
|
||||
|
|
|
|||
|
|
@ -14,12 +14,13 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -28,12 +29,13 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -44,26 +46,28 @@ import androidx.compose.material3.Surface
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.meshtastic.core.strings.getString
|
||||
import com.patrykandpatrick.vico.compose.cartesian.Scroll
|
||||
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
|
||||
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
|
||||
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
|
||||
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.strings.Res
|
||||
|
|
@ -73,10 +77,9 @@ import org.meshtastic.core.strings.battery
|
|||
import org.meshtastic.core.strings.ch_util_definition
|
||||
import org.meshtastic.core.strings.channel_air_util
|
||||
import org.meshtastic.core.strings.channel_utilization
|
||||
import org.meshtastic.core.strings.device_metrics_log
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MaterialBatteryInfo
|
||||
import org.meshtastic.core.ui.component.OptionLabel
|
||||
import org.meshtastic.core.ui.component.SlidingSelector
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
|
@ -85,18 +88,10 @@ import org.meshtastic.core.ui.theme.GraphColors.Green
|
|||
import org.meshtastic.core.ui.theme.GraphColors.Magenta
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MAX_PERCENT_VALUE
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.feature.node.metrics.GraphUtil.createPath
|
||||
import org.meshtastic.feature.node.metrics.GraphUtil.plotPoint
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.TelemetryProtos
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
|
||||
|
||||
private enum class Device(val color: Color) {
|
||||
BATTERY(Green) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.batteryLevel.toFloat()
|
||||
|
|
@ -134,8 +129,12 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
|
|||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var displayInfoDialog by remember { mutableStateOf(false) }
|
||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||
val data = state.deviceMetricsFiltered(selectedTimeFrame)
|
||||
val data = state.deviceMetrics
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val vicoScrollState = rememberVicoScrollState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var selectedX by remember { mutableStateOf<Double?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.effects.collect { effect ->
|
||||
|
|
@ -152,6 +151,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
|
|||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
subtitle = stringResource(Res.string.device_metrics_log),
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
|
|
@ -183,20 +183,33 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
|
|||
DeviceMetricsChart(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
telemetries = data.reversed(),
|
||||
selectedTimeFrame,
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
vicoScrollState = vicoScrollState,
|
||||
selectedX = selectedX,
|
||||
onPointSelected = { x ->
|
||||
selectedX = x
|
||||
val index = data.indexOfFirst { it.time.toDouble() == x }
|
||||
if (index != -1) {
|
||||
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedTimeFrame,
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
/* Device Metric Cards */
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> DeviceMetricsCard(telemetry) } }
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) {
|
||||
itemsIndexed(data) { _, telemetry ->
|
||||
DeviceMetricsCard(
|
||||
telemetry = telemetry,
|
||||
isSelected = telemetry.time.toDouble() == selectedX,
|
||||
onClick = {
|
||||
selectedX = telemetry.time.toDouble()
|
||||
coroutineScope.launch {
|
||||
vicoScrollState.animateScroll(Scroll.Absolute.x(telemetry.time.toDouble(), 0.5f))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -206,121 +219,82 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
|
|||
private fun DeviceMetricsChart(
|
||||
modifier: Modifier = Modifier,
|
||||
telemetries: List<Telemetry>,
|
||||
selectedTime: TimeFrame,
|
||||
promptInfoDialog: () -> Unit,
|
||||
vicoScrollState: VicoScrollState,
|
||||
selectedX: Double?,
|
||||
onPointSelected: (Double) -> Unit,
|
||||
) {
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
ChartHeader(amount = telemetries.size)
|
||||
if (telemetries.isEmpty()) return
|
||||
|
||||
val (oldest, newest) =
|
||||
remember(key1 = telemetries) { Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time }) }
|
||||
val timeDiff = newest.time - oldest.time
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
|
||||
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
|
||||
val scrollPx = scrollState.value.toFloat()
|
||||
// Calculate visible width based on actual weight distribution
|
||||
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
|
||||
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
|
||||
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
|
||||
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
|
||||
val visibleOldest = oldest.time + (timeDiff * (1f - rightRatio)).toInt()
|
||||
val visibleNewest = oldest.time + (timeDiff * (1f - leftRatio)).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row {
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(weight = 1f),
|
||||
) {
|
||||
/*
|
||||
* The order of the colors are with respect to the ChUtil.
|
||||
* 25 - 49 Orange
|
||||
* 50 - 100 Red
|
||||
*/
|
||||
HorizontalLinesOverlay(
|
||||
modifier.width(dp),
|
||||
lineColors = listOf(graphColor, Color.Yellow, Color.Red, graphColor, graphColor),
|
||||
)
|
||||
|
||||
TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())
|
||||
|
||||
/* Plot Battery Line, ChUtil, and AirUtilTx */
|
||||
Canvas(modifier = modifier.width(dp)) {
|
||||
val height = size.height
|
||||
val width = size.width
|
||||
for (i in telemetries.indices) {
|
||||
val telemetry = telemetries[i]
|
||||
|
||||
/* x-value time */
|
||||
val xRatio = (telemetry.time - oldest.time).toFloat() / timeDiff
|
||||
val x = xRatio * width
|
||||
|
||||
/* Channel Utilization */
|
||||
plotPoint(
|
||||
drawContext = drawContext,
|
||||
color = Device.CH_UTIL.color,
|
||||
x = x,
|
||||
value = telemetry.deviceMetrics.channelUtilization,
|
||||
divisor = MAX_PERCENT_VALUE,
|
||||
)
|
||||
|
||||
/* Air Utilization Transmit */
|
||||
plotPoint(
|
||||
drawContext = drawContext,
|
||||
color = Device.AIR_UTIL.color,
|
||||
x = x,
|
||||
value = telemetry.deviceMetrics.airUtilTx,
|
||||
divisor = MAX_PERCENT_VALUE,
|
||||
)
|
||||
val modelProducer = remember { CartesianChartModelProducer() }
|
||||
val batteryColor = Device.BATTERY.color
|
||||
val chUtilColor = Device.CH_UTIL.color
|
||||
val airUtilColor = Device.AIR_UTIL.color
|
||||
val marker =
|
||||
ChartStyling.rememberMarker(
|
||||
valueFormatter =
|
||||
ChartStyling.createColoredMarkerValueFormatter { value, color ->
|
||||
when (color.copy(alpha = 1f)) {
|
||||
batteryColor -> "Battery: %.1f%%".format(value)
|
||||
chUtilColor -> "ChUtil: %.1f%%".format(value)
|
||||
airUtilColor -> "AirUtil: %.1f%%".format(value)
|
||||
else -> "%.1f%%".format(value)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/* Battery Line */
|
||||
var index = 0
|
||||
while (index < telemetries.size) {
|
||||
val path = Path()
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest.time,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = Device.BATTERY.color,
|
||||
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
LaunchedEffect(telemetries) {
|
||||
modelProducer.runTransaction {
|
||||
lineSeries {
|
||||
series(x = telemetries.map { it.time }, y = telemetries.map { it.deviceMetrics.batteryLevel })
|
||||
series(x = telemetries.map { it.time }, y = telemetries.map { it.deviceMetrics.channelUtilization })
|
||||
series(x = telemetries.map { it.time }, y = telemetries.map { it.deviceMetrics.airUtilTx })
|
||||
}
|
||||
}
|
||||
YAxisLabels(modifier = modifier.weight(weight = Y_AXIS_WEIGHT), graphColor, minValue = 0f, maxValue = 100f)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val axisLabel = ChartStyling.rememberAxisLabel()
|
||||
|
||||
GenericMetricChart(
|
||||
modelProducer = modelProducer,
|
||||
modifier = modifier.padding(8.dp),
|
||||
layers =
|
||||
listOf(
|
||||
rememberLineCartesianLayer(
|
||||
lineProvider =
|
||||
LineCartesianLayer.LineProvider.series(
|
||||
ChartStyling.createBoldLine(
|
||||
lineColor = batteryColor,
|
||||
pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP,
|
||||
),
|
||||
ChartStyling.createPointOnlyLine(
|
||||
pointColor = chUtilColor,
|
||||
pointSize = ChartStyling.LARGE_POINT_SIZE_DP,
|
||||
),
|
||||
ChartStyling.createPointOnlyLine(
|
||||
pointColor = airUtilColor,
|
||||
pointSize = ChartStyling.LARGE_POINT_SIZE_DP,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
startAxis =
|
||||
VerticalAxis.rememberStart(label = axisLabel, valueFormatter = { _, value, _ -> "%.0f%%".format(value) }),
|
||||
bottomAxis =
|
||||
HorizontalAxis.rememberBottom(
|
||||
label = axisLabel,
|
||||
valueFormatter = CommonCharts.dynamicTimeFormatter,
|
||||
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 20),
|
||||
labelRotationDegrees = 45f,
|
||||
),
|
||||
marker = marker,
|
||||
selectedX = selectedX,
|
||||
onPointSelected = onPointSelected,
|
||||
vicoScrollState = vicoScrollState,
|
||||
)
|
||||
|
||||
Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@Suppress("detekt:MagicNumber") // fake data
|
||||
|
|
@ -346,32 +320,46 @@ private fun DeviceMetricsChartPreview() {
|
|||
DeviceMetricsChart(
|
||||
modifier = Modifier.height(400.dp),
|
||||
telemetries = telemetries,
|
||||
selectedTime = TimeFrame.TWENTY_FOUR_HOURS,
|
||||
promptInfoDialog = {},
|
||||
vicoScrollState = rememberVicoScrollState(),
|
||||
selectedX = null,
|
||||
onPointSelected = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun DeviceMetricsCard(telemetry: Telemetry) {
|
||||
private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val deviceMetrics = telemetry.deviceMetrics
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Surface {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Surface(color = Color.Transparent) {
|
||||
SelectionContainer {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
/* Time, Battery, and Voltage */
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
|
||||
MaterialBatteryInfo(level = deviceMetrics.batteryLevel, voltage = deviceMetrics.voltage)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
/* Channel Utilization and Air Utilization Tx */
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
|
|
@ -407,7 +395,7 @@ private fun DeviceMetricsCardPreview() {
|
|||
.setUptimeSeconds(7200),
|
||||
)
|
||||
.build()
|
||||
AppTheme { DeviceMetricsCard(telemetry = telemetry) }
|
||||
AppTheme { DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) }
|
||||
}
|
||||
|
||||
@Suppress("detekt:MagicNumber") // fake data
|
||||
|
|
@ -448,22 +436,18 @@ private fun DeviceMetricsScreenPreview() {
|
|||
|
||||
DeviceMetricsChart(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
telemetries.reversed(),
|
||||
TimeFrame.TWENTY_FOUR_HOURS,
|
||||
telemetries = telemetries.reversed(),
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
vicoScrollState = rememberVicoScrollState(),
|
||||
selectedX = null,
|
||||
onPointSelected = {},
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
TimeFrame.TWENTY_FOUR_HOURS,
|
||||
onOptionSelected = { /* Preview only */ },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
/* Device Metric Cards */
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(telemetries) { telemetry -> DeviceMetricsCard(telemetry) }
|
||||
itemsIndexed(telemetries) { _, telemetry ->
|
||||
DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -14,32 +14,26 @@
|
|||
* 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.Canvas
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
|
||||
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 org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.baro_pressure
|
||||
import org.meshtastic.core.strings.humidity
|
||||
|
|
@ -49,14 +43,8 @@ import org.meshtastic.core.strings.soil_moisture
|
|||
import org.meshtastic.core.strings.soil_temperature
|
||||
import org.meshtastic.core.strings.temperature
|
||||
import org.meshtastic.core.strings.uv_lux
|
||||
import org.meshtastic.feature.node.metrics.GraphUtil.createPath
|
||||
import org.meshtastic.feature.node.metrics.GraphUtil.drawPathWithGradient
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private val LEGEND_DATA_1 =
|
||||
listOf(
|
||||
|
|
@ -117,71 +105,126 @@ private val LEGEND_DATA_3 =
|
|||
),
|
||||
)
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun EnvironmentMetricsChart(
|
||||
modifier: Modifier = Modifier,
|
||||
telemetries: List<Telemetry>,
|
||||
graphData: EnvironmentGraphingData,
|
||||
selectedTime: TimeFrame,
|
||||
promptInfoDialog: () -> Unit,
|
||||
vicoScrollState: VicoScrollState,
|
||||
selectedX: Double?,
|
||||
onPointSelected: (Double) -> Unit,
|
||||
) {
|
||||
ChartHeader(amount = telemetries.size)
|
||||
if (telemetries.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val (oldest, newest) = graphData.times
|
||||
val timeDiff = newest - oldest
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
|
||||
|
||||
val modelProducer = remember { CartesianChartModelProducer() }
|
||||
val shouldPlot = graphData.shouldPlot
|
||||
val onSurfaceColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
|
||||
val scrollPx = scrollState.value.toFloat()
|
||||
// Calculate chart width ratio dynamically based on whether barometric pressure is plotted
|
||||
val yAxisCount = if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) 2 else 1
|
||||
val chartWidthRatio = CHART_WEIGHT / (CHART_WEIGHT + (Y_AXIS_WEIGHT * yAxisCount))
|
||||
val visibleWidthPx = screenWidth * chartWidthRatio
|
||||
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
|
||||
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
|
||||
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
|
||||
val visibleOldest = oldest + (timeDiff * (1f - rightRatio)).toInt()
|
||||
val visibleNewest = oldest + (timeDiff * (1f - leftRatio)).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
val allLegendData = LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3
|
||||
val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) }
|
||||
|
||||
LaunchedEffect(telemetries, graphData) {
|
||||
modelProducer.runTransaction {
|
||||
/* Pressure on its own layer/axis */
|
||||
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
|
||||
lineSeries {
|
||||
series(
|
||||
x = telemetries.mapNotNull { t -> Environment.BAROMETRIC_PRESSURE.getValue(t)?.let { t.time } },
|
||||
y = telemetries.mapNotNull { t -> Environment.BAROMETRIC_PRESSURE.getValue(t) },
|
||||
)
|
||||
}
|
||||
}
|
||||
/* Everything else on the default axis */
|
||||
Environment.entries.forEach { metric ->
|
||||
if (metric != Environment.BAROMETRIC_PRESSURE && shouldPlot[metric.ordinal]) {
|
||||
lineSeries {
|
||||
series(
|
||||
x = telemetries.mapNotNull { t -> metric.getValue(t)?.let { t.time } },
|
||||
y = telemetries.mapNotNull { t -> metric.getValue(t) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
val marker =
|
||||
ChartStyling.rememberMarker(
|
||||
valueFormatter =
|
||||
ChartStyling.createColoredMarkerValueFormatter { value, color ->
|
||||
val label = colorToLabel[color.copy(alpha = 1f)] ?: ""
|
||||
"%s: %.1f".format(label, value)
|
||||
},
|
||||
)
|
||||
|
||||
Row(modifier = modifier.fillMaxWidth().fillMaxHeight()) {
|
||||
BarometricPressureYAxisLabel(
|
||||
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(),
|
||||
shouldPlotBarometricPressure = shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal],
|
||||
minValue = graphData.leftMinMax.first,
|
||||
maxValue = graphData.leftMinMax.second,
|
||||
val layers = mutableListOf<LineCartesianLayer>()
|
||||
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
|
||||
layers.add(
|
||||
rememberLineCartesianLayer(
|
||||
lineProvider =
|
||||
LineCartesianLayer.LineProvider.series(
|
||||
ChartStyling.createGradientLine(
|
||||
Environment.BAROMETRIC_PRESSURE.color,
|
||||
ChartStyling.MEDIUM_POINT_SIZE_DP,
|
||||
),
|
||||
),
|
||||
verticalAxisPosition = Axis.Position.Vertical.Start,
|
||||
),
|
||||
)
|
||||
ChartContent(
|
||||
modifier = Modifier.weight(CHART_WEIGHT).fillMaxHeight(),
|
||||
scrollState = scrollState,
|
||||
dp = dp,
|
||||
oldest = oldest,
|
||||
newest = newest,
|
||||
selectedTime = selectedTime,
|
||||
telemetries = telemetries,
|
||||
graphData = graphData,
|
||||
rightMin = graphData.rightMinMax.first,
|
||||
rightMax = graphData.rightMinMax.second,
|
||||
timeDiff = timeDiff,
|
||||
)
|
||||
YAxisLabels(
|
||||
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(),
|
||||
MaterialTheme.colorScheme.onSurface,
|
||||
minValue = graphData.rightMinMax.first,
|
||||
maxValue = graphData.rightMinMax.second,
|
||||
}
|
||||
Environment.entries.forEach { metric ->
|
||||
if (metric != Environment.BAROMETRIC_PRESSURE && shouldPlot[metric.ordinal]) {
|
||||
layers.add(
|
||||
rememberLineCartesianLayer(
|
||||
lineProvider =
|
||||
LineCartesianLayer.LineProvider.series(
|
||||
ChartStyling.createGradientLine(metric.color, ChartStyling.MEDIUM_POINT_SIZE_DP),
|
||||
),
|
||||
verticalAxisPosition = Axis.Position.Vertical.End,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (layers.isNotEmpty()) {
|
||||
val otherMetricsPlotted =
|
||||
Environment.entries.filter { it != Environment.BAROMETRIC_PRESSURE && shouldPlot[it.ordinal] }
|
||||
val endAxisColor = if (otherMetricsPlotted.size == 1) otherMetricsPlotted.first().color else onSurfaceColor
|
||||
|
||||
GenericMetricChart(
|
||||
modelProducer = modelProducer,
|
||||
modifier = modifier.padding(8.dp),
|
||||
layers = layers,
|
||||
startAxis =
|
||||
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
|
||||
VerticalAxis.rememberStart(
|
||||
label = ChartStyling.rememberAxisLabel(color = Environment.BAROMETRIC_PRESSURE.color),
|
||||
valueFormatter = { _, value, _ -> "%.0f hPa".format(value) },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
endAxis =
|
||||
VerticalAxis.rememberEnd(
|
||||
label = ChartStyling.rememberAxisLabel(color = endAxisColor),
|
||||
valueFormatter = { _, value, _ -> "%.0f".format(value) },
|
||||
),
|
||||
bottomAxis =
|
||||
HorizontalAxis.rememberBottom(
|
||||
label = ChartStyling.rememberAxisLabel(),
|
||||
valueFormatter = CommonCharts.dynamicTimeFormatter,
|
||||
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50),
|
||||
labelRotationDegrees = 45f,
|
||||
),
|
||||
marker = marker,
|
||||
selectedX = selectedX,
|
||||
onPointSelected = onPointSelected,
|
||||
vicoScrollState = vicoScrollState,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -192,151 +235,6 @@ fun EnvironmentMetricsChart(
|
|||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@Suppress("detekt:LongMethod")
|
||||
@Composable
|
||||
private fun MetricPlottingCanvas(
|
||||
modifier: Modifier = Modifier,
|
||||
telemetries: List<Telemetry>,
|
||||
graphData: EnvironmentGraphingData,
|
||||
selectedTime: TimeFrame,
|
||||
oldest: Int,
|
||||
timeDiff: Int,
|
||||
rightMin: Float,
|
||||
rightMax: Float,
|
||||
) {
|
||||
val (pressureMin, pressureMax) = graphData.leftMinMax
|
||||
val shouldPlot = graphData.shouldPlot
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
val height = size.height
|
||||
val width = size.width
|
||||
|
||||
var min: Float
|
||||
var diff: Float
|
||||
var index: Int
|
||||
var first: Int
|
||||
for (metric in Environment.entries) {
|
||||
if (!shouldPlot[metric.ordinal]) {
|
||||
continue
|
||||
}
|
||||
if (metric == Environment.BAROMETRIC_PRESSURE) {
|
||||
diff = pressureMax - pressureMin
|
||||
min = pressureMin
|
||||
} else { // Reset for other metrics to use rightMin/rightMax
|
||||
min = rightMin
|
||||
diff = rightMax - rightMin
|
||||
}
|
||||
index = 0
|
||||
while (index < telemetries.size) {
|
||||
first = index
|
||||
val path = Path()
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val rawValue = metric.getValue(telemetry) // This is Float?
|
||||
|
||||
// Default to 0f if the actual value is null or NaN. This is a reasonable default for
|
||||
// lux.
|
||||
val pointValue =
|
||||
if (rawValue != null && !rawValue.isNaN()) {
|
||||
rawValue
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
// Use 'min' and 'diff' from the outer scope, which are specific to the current metric's
|
||||
// scale group.
|
||||
val currentMin = min
|
||||
// Avoid division by zero if all values in the current y-axis range are the same.
|
||||
val currentDiff = if (diff == 0f) 1f else diff
|
||||
|
||||
val ratio = (pointValue - currentMin) / currentDiff
|
||||
var y = height - (ratio * height)
|
||||
|
||||
// Final check to ensure y is a valid, plottable coordinate.
|
||||
if (y.isNaN() || y.isInfinite()) {
|
||||
y = height // Default to the bottom of the chart if calculation still results in an
|
||||
// invalid number.
|
||||
} else {
|
||||
y = y.coerceIn(0f, height) // Clamp to chart bounds to be safe.
|
||||
}
|
||||
return@createPath y
|
||||
}
|
||||
drawPathWithGradient(
|
||||
path = path,
|
||||
color = metric.color,
|
||||
height = height,
|
||||
x1 = ((telemetries[index - 1].time - oldest).toFloat() / timeDiff) * width,
|
||||
x2 = ((telemetries[first].time - oldest).toFloat() / timeDiff) * width,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BarometricPressureYAxisLabel(
|
||||
modifier: Modifier,
|
||||
shouldPlotBarometricPressure: Boolean,
|
||||
minValue: Float,
|
||||
maxValue: Float,
|
||||
) {
|
||||
if (shouldPlotBarometricPressure) {
|
||||
YAxisLabels(
|
||||
modifier = modifier,
|
||||
Environment.BAROMETRIC_PRESSURE.color,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChartContent(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollState: ScrollState,
|
||||
dp: Dp,
|
||||
oldest: Int,
|
||||
newest: Int,
|
||||
selectedTime: TimeFrame,
|
||||
telemetries: List<Telemetry>,
|
||||
graphData: EnvironmentGraphingData,
|
||||
rightMin: Float,
|
||||
rightMax: Float,
|
||||
timeDiff: Int,
|
||||
) {
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = modifier.horizontalScroll(state = scrollState, reverseScrolling = true),
|
||||
) {
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
|
||||
|
||||
TimeAxisOverlay(modifier = modifier.width(dp), oldest = oldest, newest = newest, selectedTime.lineInterval())
|
||||
|
||||
MetricPlottingCanvas(
|
||||
modifier = modifier.width(dp),
|
||||
telemetries = telemetries,
|
||||
graphData = graphData,
|
||||
selectedTime = selectedTime,
|
||||
oldest = oldest,
|
||||
timeDiff = timeDiff,
|
||||
rightMin = rightMin,
|
||||
rightMax = rightMax,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetricLegends(graphData: EnvironmentGraphingData, promptInfoDialog: () -> Unit) {
|
||||
Legend(LEGEND_DATA_1.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false)
|
||||
|
|
@ -346,11 +244,3 @@ private fun MetricLegends(graphData: EnvironmentGraphingData, promptInfoDialog:
|
|||
promptInfoDialog = promptInfoDialog,
|
||||
)
|
||||
}
|
||||
|
||||
// private const val LINE_ON = 10f
|
||||
// private const val LINE_OFF = 20f
|
||||
// private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
|
||||
// private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
|
||||
// private const val DATE_Y = 32f
|
||||
// private const val LINE_LIMIT = 4
|
||||
// private const val TEXT_PAINT_ALPHA = 192
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
*/
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -23,12 +25,16 @@ import androidx.compose.foundation.layout.Spacer
|
|||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
|
@ -38,25 +44,28 @@ import androidx.compose.material3.Surface
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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
|
||||
import org.meshtastic.core.strings.gas_resistance
|
||||
import org.meshtastic.core.strings.humidity
|
||||
import org.meshtastic.core.strings.iaq
|
||||
|
|
@ -71,27 +80,30 @@ 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.component.OptionLabel
|
||||
import org.meshtastic.core.ui.component.SlidingSelector
|
||||
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.model.TimeFrame
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.SCROLL_BIAS
|
||||
import org.meshtastic.proto.TelemetryProtos
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
import org.meshtastic.proto.copy
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||
val graphData = environmentState.environmentMetricsFiltered(selectedTimeFrame, state.isFahrenheit)
|
||||
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 ->
|
||||
when (effect) {
|
||||
|
|
@ -126,6 +138,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
|
|||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
subtitle = stringResource(Res.string.env_metrics_log),
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
|
|
@ -157,20 +170,32 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
|
|||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
telemetries = processedTelemetries.reversed(),
|
||||
graphData = graphData,
|
||||
selectedTime = selectedTimeFrame,
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
vicoScrollState = vicoScrollState,
|
||||
selectedX = selectedX,
|
||||
onPointSelected = { x ->
|
||||
selectedX = x
|
||||
val index = processedTelemetries.indexOfFirst { it.time.toDouble() == x }
|
||||
if (index != -1) {
|
||||
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedTimeFrame,
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(processedTelemetries) { telemetry -> EnvironmentMetricsCard(telemetry, state.isFahrenheit) }
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) {
|
||||
itemsIndexed(processedTelemetries) { _, telemetry ->
|
||||
EnvironmentMetricsCard(
|
||||
telemetry = telemetry,
|
||||
environmentDisplayFahrenheit = state.isFahrenheit,
|
||||
isSelected = telemetry.time.toDouble() == selectedX,
|
||||
onClick = {
|
||||
selectedX = telemetry.time.toDouble()
|
||||
coroutineScope.launch {
|
||||
vicoScrollState.animateScroll(Scroll.Absolute.x(telemetry.time.toDouble(), SCROLL_BIAS))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -356,30 +381,49 @@ private fun RadiationDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun EnvironmentMetricsCard(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
|
||||
private fun EnvironmentMetricsCard(
|
||||
telemetry: Telemetry,
|
||||
environmentDisplayFahrenheit: Boolean,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val envMetrics = telemetry.environmentMetrics
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Surface { SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) } }
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Surface(color = Color.Transparent) {
|
||||
SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
|
||||
val envMetrics = telemetry.environmentMetrics
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
/* Time and Temperature */
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Text(text = DATE_TIME_FORMAT.format(time), style = MaterialTheme.typography.titleMediumEmphasized)
|
||||
TemperatureDisplay(envMetrics, environmentDisplayFahrenheit)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
HumidityAndBarometricPressureDisplay(envMetrics)
|
||||
|
||||
SoilMetricsDisplay(envMetrics, environmentDisplayFahrenheit)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
* Copyright (c) 2025-2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
|
@ -28,7 +27,6 @@ import org.meshtastic.core.ui.theme.GraphColors.Pink
|
|||
import org.meshtastic.core.ui.theme.GraphColors.Purple
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Red
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Yellow
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.TelemetryProtos
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
|
|
@ -44,7 +42,7 @@ enum class Environment(val color: Color) {
|
|||
},
|
||||
SOIL_MOISTURE(Purple) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) =
|
||||
telemetry.environmentMetrics.soilMoisture?.toFloat()
|
||||
telemetry.environmentMetrics.soilMoisture.toFloat()
|
||||
},
|
||||
BAROMETRIC_PRESSURE(Green) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.barometricPressure
|
||||
|
|
@ -53,7 +51,7 @@ enum class Environment(val color: Color) {
|
|||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.gasResistance
|
||||
},
|
||||
IAQ(Magenta) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq?.toFloat()
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq.toFloat()
|
||||
},
|
||||
LUX(LightGreen) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.lux
|
||||
|
|
@ -66,7 +64,7 @@ enum class Environment(val color: Color) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @param metrics the filtered [List]
|
||||
* @param metrics the [List] of [TelemetryProtos.Telemetry]
|
||||
* @param shouldPlot a [List] the size of [Environment] used to determine if a metric should be plotted
|
||||
* @param leftMinMax [Pair] with the min and max of the barometric pressure
|
||||
* @param rightMinMax [Pair] with the combined min and max of: the temperature, humidity, and IAQ
|
||||
|
|
@ -84,15 +82,13 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
|
|||
fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty()
|
||||
|
||||
/**
|
||||
* Filters [environmentMetrics] based on a [org.meshtastic.feature.node.model.TimeFrame].
|
||||
* Prepares [environmentMetrics] for graphing.
|
||||
*
|
||||
* @param timeFrame used to filter
|
||||
* @return [EnvironmentGraphingData]
|
||||
*/
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
|
||||
fun environmentMetricsFiltered(timeFrame: TimeFrame, useFahrenheit: Boolean = false): EnvironmentGraphingData {
|
||||
val oldestTime = timeFrame.calculateOldestTime()
|
||||
val telemetries = environmentMetrics.filter { it.time >= oldestTime }
|
||||
fun environmentMetricsForGraphing(useFahrenheit: Boolean = false): EnvironmentGraphingData {
|
||||
val telemetries = environmentMetrics
|
||||
val shouldPlot = BooleanArray(Environment.entries.size) { false }
|
||||
if (telemetries.isEmpty()) {
|
||||
return EnvironmentGraphingData(metrics = telemetries, shouldPlot = shouldPlot.toList())
|
||||
|
|
@ -103,7 +99,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
|
|||
val maxValues = mutableListOf<Float>()
|
||||
|
||||
// Temperature
|
||||
val temperatures = telemetries.mapNotNull { it.environmentMetrics.temperature?.takeIf { !it.isNaN() } }
|
||||
val temperatures = telemetries.mapNotNull { it.environmentMetrics.temperature.takeIf { !it.isNaN() } }
|
||||
if (temperatures.isNotEmpty()) {
|
||||
var minTempValue = temperatures.minOf { it }
|
||||
var maxTempValue = temperatures.maxOf { it }
|
||||
|
|
@ -118,7 +114,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
|
|||
|
||||
// Relative Humidity
|
||||
val humidities =
|
||||
telemetries.mapNotNull { it.environmentMetrics.relativeHumidity?.takeIf { !it.isNaN() && it != 0.0f } }
|
||||
telemetries.mapNotNull { it.environmentMetrics.relativeHumidity.takeIf { !it.isNaN() && it != 0.0f } }
|
||||
if (humidities.isNotEmpty()) {
|
||||
minValues.add(humidities.minOf { it })
|
||||
maxValues.add(humidities.maxOf { it })
|
||||
|
|
@ -126,7 +122,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
|
|||
}
|
||||
|
||||
// Soil Temperature
|
||||
val soilTemperatures = telemetries.mapNotNull { it.environmentMetrics.soilTemperature?.takeIf { !it.isNaN() } }
|
||||
val soilTemperatures = telemetries.mapNotNull { it.environmentMetrics.soilTemperature.takeIf { !it.isNaN() } }
|
||||
if (soilTemperatures.isNotEmpty()) {
|
||||
var minSoilTemperatureValue = soilTemperatures.minOf { it }
|
||||
var maxSoilTemperatureValue = soilTemperatures.maxOf { it }
|
||||
|
|
@ -140,8 +136,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
|
|||
}
|
||||
|
||||
// Soil Moisture
|
||||
val soilMoistures =
|
||||
telemetries.mapNotNull { it.environmentMetrics.soilMoisture?.takeIf { it != Int.MIN_VALUE } }
|
||||
val soilMoistures = telemetries.mapNotNull { it.environmentMetrics.soilMoisture.takeIf { it != Int.MIN_VALUE } }
|
||||
if (soilMoistures.isNotEmpty()) {
|
||||
minValues.add(soilMoistures.minOf { it.toFloat() })
|
||||
maxValues.add(soilMoistures.maxOf { it.toFloat() })
|
||||
|
|
@ -149,7 +144,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
|
|||
}
|
||||
|
||||
// IAQ
|
||||
val iaqs = telemetries.mapNotNull { it.environmentMetrics.iaq?.takeIf { it != Int.MIN_VALUE } }
|
||||
val iaqs = telemetries.mapNotNull { it.environmentMetrics.iaq.takeIf { it != Int.MIN_VALUE } }
|
||||
if (iaqs.isNotEmpty()) {
|
||||
minValues.add(iaqs.minOf { it.toFloat() })
|
||||
maxValues.add(iaqs.maxOf { it.toFloat() })
|
||||
|
|
@ -157,7 +152,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
|
|||
}
|
||||
|
||||
// Barometric Pressure
|
||||
val pressures = telemetries.mapNotNull { it.environmentMetrics.barometricPressure?.takeIf { !it.isNaN() } }
|
||||
val pressures = telemetries.mapNotNull { it.environmentMetrics.barometricPressure.takeIf { !it.isNaN() } }
|
||||
var minPressureValue = 0f
|
||||
var maxPressureValue = 0f
|
||||
if (pressures.isNotEmpty()) {
|
||||
|
|
@ -167,7 +162,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
|
|||
}
|
||||
|
||||
// Lux
|
||||
val luxValues = telemetries.mapNotNull { it.environmentMetrics.lux?.takeIf { !it.isNaN() } }
|
||||
val luxValues = telemetries.mapNotNull { it.environmentMetrics.lux.takeIf { !it.isNaN() } }
|
||||
if (luxValues.isNotEmpty()) {
|
||||
minValues.add(luxValues.minOf { it })
|
||||
maxValues.add(luxValues.maxOf { it })
|
||||
|
|
@ -175,7 +170,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
|
|||
}
|
||||
|
||||
// UVLux
|
||||
val uvLuxValues = telemetries.mapNotNull { it.environmentMetrics.uvLux?.takeIf { !it.isNaN() } }
|
||||
val uvLuxValues = telemetries.mapNotNull { it.environmentMetrics.uvLux.takeIf { !it.isNaN() } }
|
||||
if (uvLuxValues.isNotEmpty()) {
|
||||
minValues.add(uvLuxValues.minOf { it })
|
||||
maxValues.add(uvLuxValues.maxOf { it })
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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 android.content.res.Resources
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.asComposePath
|
||||
import androidx.compose.ui.graphics.drawscope.DrawContext
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
|
||||
object GraphUtil {
|
||||
|
||||
val RADIUS = Resources.getSystem().displayMetrics.density * 2
|
||||
|
||||
/**
|
||||
* @param value Must be zero-scaled before passing.
|
||||
* @param divisor The range for the data set.
|
||||
*/
|
||||
fun plotPoint(drawContext: DrawContext, color: Color, x: Float, value: Float, divisor: Float) {
|
||||
val height = drawContext.size.height
|
||||
val ratio = value / divisor
|
||||
val y = height - (ratio * height)
|
||||
drawContext.canvas.drawCircle(
|
||||
center = Offset(x, y),
|
||||
radius = RADIUS,
|
||||
paint = androidx.compose.ui.graphics.Paint().apply { this.color = color },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [Path] that could be used to draw a line from the `index` to the end of `telemetries` or the last point
|
||||
* before a time separation between [Telemetry]s.
|
||||
*
|
||||
* @param telemetries data used to create the [Path]
|
||||
* @param index current place in the [List]
|
||||
* @param path [Path] that will be used to draw
|
||||
* @param timeRange The time range for the data set
|
||||
* @param width of the [DrawContext]
|
||||
* @param timeThreshold to determine significant breaks in time between [Telemetry]s
|
||||
* @param calculateY (`index`) -> `y` coordinate
|
||||
* @return the current index after iterating
|
||||
*/
|
||||
fun createPath(
|
||||
telemetries: List<Telemetry>,
|
||||
index: Int,
|
||||
path: Path,
|
||||
oldestTime: Int,
|
||||
timeRange: Int,
|
||||
width: Float,
|
||||
timeThreshold: Long,
|
||||
calculateY: (Int) -> Float,
|
||||
): Int {
|
||||
var i = index
|
||||
var isNewLine = true
|
||||
with(path) {
|
||||
while (i < telemetries.size) {
|
||||
val telemetry = telemetries[i]
|
||||
val nextTelemetry = telemetries.getOrNull(i + 1) ?: telemetries.last()
|
||||
|
||||
/* Check to see if we have a significant time break between telemetries. */
|
||||
if (nextTelemetry.time - telemetry.time > timeThreshold) {
|
||||
i++
|
||||
break
|
||||
}
|
||||
|
||||
val x1Ratio = (telemetry.time - oldestTime).toFloat() / timeRange
|
||||
val x1 = x1Ratio * width
|
||||
val y1 = calculateY(i)
|
||||
|
||||
val x2Ratio = (nextTelemetry.time - oldestTime).toFloat() / timeRange
|
||||
val x2 = x2Ratio * width
|
||||
val y2 = calculateY(i + 1)
|
||||
|
||||
if (isNewLine || i == 0) {
|
||||
isNewLine = false
|
||||
moveTo(x1, y1)
|
||||
}
|
||||
|
||||
quadraticTo(x1, y1, (x1 + x2) / 2f, (y1 + y2) / 2f)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
fun DrawScope.drawPathWithGradient(path: Path, color: Color, height: Float, x1: Float, x2: Float) {
|
||||
drawPath(path = path, color = color, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round))
|
||||
val fillPath =
|
||||
android.graphics.Path(path.asAndroidPath()).asComposePath().apply {
|
||||
lineTo(x1, height)
|
||||
lineTo(x2, height)
|
||||
close()
|
||||
}
|
||||
drawPath(
|
||||
path = fillPath,
|
||||
brush = Brush.verticalGradient(colors = listOf(color.copy(alpha = 0.5f), Color.Transparent), endY = height),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import androidx.compose.foundation.lazy.items
|
|||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
|
|
@ -49,7 +50,6 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -123,6 +123,7 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o
|
|||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Suppress("LongMethod", "MagicNumber")
|
||||
@Composable
|
||||
fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: TelemetryProtos.Telemetry) {
|
||||
|
|
@ -141,8 +142,7 @@ fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: TelemetryProtos.Te
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.End,
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
LogLine(
|
||||
label = stringResource(Res.string.uptime),
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ 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.ConfigProtos.Config
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import org.meshtastic.proto.MeshProtos.MeshPacket
|
||||
|
|
@ -107,6 +106,9 @@ constructor(
|
|||
private fun MeshLog.hasValidTraceroute(): Boolean =
|
||||
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
|
||||
|
||||
private fun MeshLog.hasValidNeighborInfo(): Boolean =
|
||||
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
|
||||
|
||||
/**
|
||||
* Creates a fallback node for hidden clients or nodes not yet in the database. This prevents the detail screen from
|
||||
* freezing when viewing unknown nodes.
|
||||
|
|
@ -198,9 +200,6 @@ constructor(
|
|||
private val _environmentState = MutableStateFlow(EnvironmentMetricsState())
|
||||
val environmentState: StateFlow<EnvironmentMetricsState> = _environmentState
|
||||
|
||||
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
|
||||
val timeFrame: StateFlow<TimeFrame> = _timeFrame
|
||||
|
||||
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
|
||||
|
||||
val lastTraceRouteTime: StateFlow<Long?> =
|
||||
|
|
@ -229,6 +228,12 @@ constructor(
|
|||
}
|
||||
}
|
||||
|
||||
fun requestNeighborInfo() {
|
||||
destNum?.let {
|
||||
nodeRequestActions.requestNeighborInfo(viewModelScope, it, state.value.node?.user?.longName ?: "")
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
initializeFlows()
|
||||
}
|
||||
|
|
@ -334,6 +339,21 @@ constructor(
|
|||
.collect {}
|
||||
}
|
||||
|
||||
launch {
|
||||
combine(
|
||||
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP_VALUE),
|
||||
meshLogRepository.getLogsFrom(currentDestNum, PortNum.NEIGHBORINFO_APP_VALUE),
|
||||
) { request, response ->
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
neighborInfoRequests = request.filter { it.hasValidNeighborInfo() },
|
||||
neighborInfoResults = response,
|
||||
)
|
||||
}
|
||||
}
|
||||
.collect {}
|
||||
}
|
||||
|
||||
launch {
|
||||
meshLogRepository.getMeshPacketsFrom(
|
||||
currentDestNum,
|
||||
|
|
@ -395,10 +415,6 @@ constructor(
|
|||
Logger.d { "MetricsViewModel cleared" }
|
||||
}
|
||||
|
||||
fun setTimeFrame(timeFrame: TimeFrame) {
|
||||
_timeFrame.value = timeFrame
|
||||
}
|
||||
|
||||
/** Write the persisted Position data out to a CSV file in the specified location. */
|
||||
fun savePositionCSV(uri: Uri) = viewModelScope.launch(dispatchers.main) {
|
||||
val positions = state.value.positionLogs
|
||||
|
|
|
|||
|
|
@ -0,0 +1,236 @@
|
|||
/*
|
||||
* 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 android.text.format.DateUtils
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.Icon
|
||||
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.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
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 org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.getNeighborInfoResponse
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.neighbor_info
|
||||
import org.meshtastic.core.strings.routing_error_no_response
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
|
||||
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
import org.meshtastic.core.ui.icon.Groups
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.PersonOff
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
import org.meshtastic.feature.node.component.CooldownIconButton
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun NeighborInfoLogScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
onNavigateUp: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
|
||||
|
||||
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
|
||||
val context = LocalContext.current
|
||||
|
||||
val statusGreen = MaterialTheme.colorScheme.StatusGreen
|
||||
val statusYellow = MaterialTheme.colorScheme.StatusYellow
|
||||
val statusOrange = MaterialTheme.colorScheme.StatusOrange
|
||||
|
||||
showDialog?.let { message ->
|
||||
SimpleAlertDialog(
|
||||
title = Res.string.neighbor_info,
|
||||
text = { SelectionContainer { Text(text = message) } },
|
||||
onConfirm = { showDialog = null },
|
||||
onDismiss = { showDialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsState()
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
subtitle = stringResource(Res.string.neighbor_info),
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {
|
||||
if (!state.isLocal) {
|
||||
CooldownIconButton(
|
||||
onClick = { viewModel.requestNeighborInfo() },
|
||||
cooldownTimestamp = lastRequestNeighborsTime,
|
||||
) {
|
||||
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().padding(innerPadding),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
items(state.neighborInfoRequests, key = { it.uuid }) { log ->
|
||||
val result =
|
||||
remember(state.neighborInfoResults, log.fromRadio.packet.id) {
|
||||
state.neighborInfoResults.find {
|
||||
it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id
|
||||
}
|
||||
}
|
||||
|
||||
val time =
|
||||
DateUtils.formatDateTime(
|
||||
context,
|
||||
log.received_date,
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
|
||||
)
|
||||
val text = if (result != null) "Success" else stringResource(Res.string.routing_error_no_response)
|
||||
val icon = if (result != null) MeshtasticIcons.Groups else MeshtasticIcons.PersonOff
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box {
|
||||
MetricLogItem(
|
||||
icon = icon,
|
||||
text = "$time - $text",
|
||||
contentDescription = stringResource(Res.string.neighbor_info),
|
||||
modifier =
|
||||
Modifier.combinedClickable(onLongClick = { expanded = true }) {
|
||||
result
|
||||
?.fromRadio
|
||||
?.packet
|
||||
?.getNeighborInfoResponse(
|
||||
::getUsername,
|
||||
header = getString(Res.string.neighbor_info),
|
||||
)
|
||||
?.let {
|
||||
showDialog =
|
||||
annotateNeighborInfo(
|
||||
it,
|
||||
statusGreen = statusGreen,
|
||||
statusYellow = statusYellow,
|
||||
statusOrange = statusOrange,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DeleteItem {
|
||||
viewModel.deleteLog(log.uuid)
|
||||
expanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw neighbor info string into an [AnnotatedString] with SNR values highlighted according to their quality.
|
||||
*/
|
||||
fun annotateNeighborInfo(
|
||||
inString: String?,
|
||||
statusGreen: Color,
|
||||
statusYellow: Color,
|
||||
statusOrange: Color,
|
||||
): AnnotatedString {
|
||||
if (inString == null) return buildAnnotatedString { append("") }
|
||||
return buildAnnotatedString {
|
||||
inString.lines().forEachIndexed { i, line ->
|
||||
if (i > 0) append("\n")
|
||||
// Example line: "• NodeName (SNR: 5.5)"
|
||||
if (line.contains("(SNR: ")) {
|
||||
val snrRegex = Regex("""\(SNR: ([\d.?-]+)\)""")
|
||||
val snrMatch = snrRegex.find(line)
|
||||
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
|
||||
|
||||
if (snrValue != null) {
|
||||
val snrColor =
|
||||
when {
|
||||
snrValue >= SNR_GOOD_THRESHOLD -> statusGreen
|
||||
snrValue >= SNR_FAIR_THRESHOLD -> statusYellow
|
||||
else -> statusOrange
|
||||
}
|
||||
val snrPrefix = "(SNR: "
|
||||
append(line.substring(0, line.indexOf(snrPrefix) + snrPrefix.length))
|
||||
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append("$snrValue") }
|
||||
append(")")
|
||||
} else {
|
||||
append(line)
|
||||
}
|
||||
} else {
|
||||
append(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,23 +16,21 @@
|
|||
*/
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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
|
||||
|
|
@ -45,19 +43,25 @@ 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.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
|
||||
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
|
||||
|
|
@ -72,22 +76,15 @@ 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.component.OptionLabel
|
||||
import org.meshtastic.core.ui.component.SlidingSelector
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Paxcount
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.PaxcountProtos
|
||||
import org.meshtastic.proto.Portnums.PortNum
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
|
||||
|
||||
private enum class PaxSeries(val color: Color, val legendRes: StringResource) {
|
||||
PAX(Color.Black, Res.string.pax),
|
||||
BLE(Color.Cyan, Res.string.ble_devices),
|
||||
|
|
@ -101,73 +98,77 @@ private fun PaxMetricsChart(
|
|||
totalSeries: List<Pair<Int, Int>>,
|
||||
bleSeries: List<Pair<Int, Int>>,
|
||||
wifiSeries: List<Pair<Int, Int>>,
|
||||
minValue: Float,
|
||||
maxValue: Float,
|
||||
timeFrame: TimeFrame,
|
||||
vicoScrollState: VicoScrollState,
|
||||
selectedX: Double?,
|
||||
onPointSelected: (Double) -> Unit,
|
||||
) {
|
||||
if (totalSeries.isEmpty()) return
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val times = totalSeries.map { it.first }
|
||||
val minTime = times.minOrNull() ?: 0
|
||||
val maxTime = times.maxOrNull() ?: 1
|
||||
val timeDiff = maxTime - minTime
|
||||
val dp = remember(timeFrame, screenWidth, timeDiff) { timeFrame.dp(screenWidth, time = timeDiff.toLong()) }
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
|
||||
val scrollPx = scrollState.value.toFloat()
|
||||
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
|
||||
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
|
||||
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
|
||||
val visibleOldest = minTime + (timeDiff * leftRatio).toInt()
|
||||
val visibleNewest = minTime + (timeDiff * rightRatio).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
}
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(modifier = modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f)) {
|
||||
YAxisLabels(
|
||||
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(start = 8.dp),
|
||||
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue,
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(CHART_WEIGHT),
|
||||
) {
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { Color.LightGray })
|
||||
TimeAxisOverlay(modifier.width(dp), oldest = minTime, newest = maxTime, timeFrame.lineInterval())
|
||||
Canvas(modifier = Modifier.width(dp).fillMaxHeight()) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
fun xForTime(t: Int): Float =
|
||||
if (maxTime == minTime) width / 2 else (t - minTime).toFloat() / (maxTime - minTime) * width
|
||||
fun yForValue(v: Int): Float = height - (v - minValue) / (maxValue - minValue) * height
|
||||
fun drawLine(series: List<Pair<Int, Int>>, color: Color) {
|
||||
for (i in 1 until series.size) {
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(xForTime(series[i - 1].first), yForValue(series[i - 1].second)),
|
||||
end = Offset(xForTime(series[i].first), yForValue(series[i].second)),
|
||||
strokeWidth = 2.dp.toPx(),
|
||||
)
|
||||
}
|
||||
}
|
||||
drawLine(bleSeries, PaxSeries.BLE.color)
|
||||
drawLine(wifiSeries, PaxSeries.WIFI.color)
|
||||
drawLine(totalSeries, PaxSeries.PAX.color)
|
||||
|
||||
val modelProducer = remember { CartesianChartModelProducer() }
|
||||
val paxColor = PaxSeries.PAX.color
|
||||
val bleColor = PaxSeries.BLE.color
|
||||
val wifiColor = PaxSeries.WIFI.color
|
||||
|
||||
LaunchedEffect(totalSeries, bleSeries, wifiSeries) {
|
||||
modelProducer.runTransaction {
|
||||
lineSeries {
|
||||
series(x = bleSeries.map { it.first }, y = bleSeries.map { it.second })
|
||||
series(x = wifiSeries.map { it.first }, y = wifiSeries.map { it.second })
|
||||
series(x = totalSeries.map { it.first }, y = totalSeries.map { it.second })
|
||||
}
|
||||
}
|
||||
YAxisLabels(
|
||||
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(end = 8.dp),
|
||||
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val axisLabel = ChartStyling.rememberAxisLabel()
|
||||
val marker =
|
||||
ChartStyling.rememberMarker(
|
||||
valueFormatter =
|
||||
ChartStyling.createColoredMarkerValueFormatter { value, color ->
|
||||
when (color.copy(1f)) {
|
||||
bleColor -> "BLE: %.0f".format(value)
|
||||
wifiColor -> "WiFi: %.0f".format(value)
|
||||
paxColor -> "PAX: %.0f".format(value)
|
||||
else -> "%.0f".format(value)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
GenericMetricChart(
|
||||
modelProducer = modelProducer,
|
||||
modifier = modifier.padding(8.dp),
|
||||
layers =
|
||||
listOf(
|
||||
rememberLineCartesianLayer(
|
||||
lineProvider =
|
||||
LineCartesianLayer.LineProvider.series(
|
||||
ChartStyling.createGradientLine(
|
||||
lineColor = bleColor,
|
||||
pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP,
|
||||
),
|
||||
ChartStyling.createGradientLine(
|
||||
lineColor = wifiColor,
|
||||
pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP,
|
||||
),
|
||||
ChartStyling.createBoldLine(
|
||||
lineColor = paxColor,
|
||||
pointSize = ChartStyling.MEDIUM_POINT_SIZE_DP,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
startAxis = VerticalAxis.rememberStart(label = axisLabel),
|
||||
bottomAxis =
|
||||
HorizontalAxis.rememberBottom(
|
||||
label = axisLabel,
|
||||
valueFormatter = CommonCharts.dynamicTimeFormatter,
|
||||
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 20),
|
||||
labelRotationDegrees = 45f,
|
||||
),
|
||||
marker = marker,
|
||||
selectedX = selectedX,
|
||||
onPointSelected = onPointSelected,
|
||||
vicoScrollState = vicoScrollState,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -176,6 +177,11 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
|
|||
val state by metricsViewModel.state.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val vicoScrollState = rememberVicoScrollState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var selectedX by remember { mutableStateOf<Double?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
metricsViewModel.effects.collect { effect ->
|
||||
when (effect) {
|
||||
|
|
@ -188,7 +194,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
|
|||
}
|
||||
|
||||
val dateFormat = DateFormat.getDateTimeInstance()
|
||||
var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
|
||||
// Only show logs that can be decoded as PaxcountProtos.Paxcount
|
||||
val paxMetrics =
|
||||
state.paxMetrics.mapNotNull { log ->
|
||||
|
|
@ -200,10 +205,8 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
|
|||
}
|
||||
}
|
||||
// Prepare data for graph
|
||||
val oldestTime = timeFrame.calculateOldestTime()
|
||||
val graphData =
|
||||
paxMetrics
|
||||
.filter { it.first.received_date / 1000 >= oldestTime }
|
||||
.map {
|
||||
val t = (it.first.received_date / 1000).toInt()
|
||||
Triple(t, it.second.ble, it.second.wifi)
|
||||
|
|
@ -212,8 +215,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
|
|||
val totalSeries = graphData.map { it.first to (it.second + it.third) }
|
||||
val bleSeries = graphData.map { it.first to it.second }
|
||||
val wifiSeries = graphData.map { it.first to it.third }
|
||||
val maxValue = (totalSeries.maxOfOrNull { it.second } ?: 1).toFloat().coerceAtLeast(1f)
|
||||
val minValue = 0f
|
||||
val legendData =
|
||||
listOf(
|
||||
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null),
|
||||
|
|
@ -225,6 +226,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
|
|||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
subtitle = stringResource(Res.string.pax_metrics_log),
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
|
|
@ -242,14 +244,6 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
|
|||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
// Time frame selector
|
||||
SlidingSelector(
|
||||
options = TimeFrame.entries.toList(),
|
||||
selectedOption = timeFrame,
|
||||
onOptionSelected = { timeFrame = it },
|
||||
) { tf: TimeFrame ->
|
||||
OptionLabel(stringResource(tf.strRes))
|
||||
}
|
||||
// Graph
|
||||
if (graphData.isNotEmpty()) {
|
||||
ChartHeader(graphData.size)
|
||||
|
|
@ -258,9 +252,15 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
|
|||
totalSeries = totalSeries,
|
||||
bleSeries = bleSeries,
|
||||
wifiSeries = wifiSeries,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue,
|
||||
timeFrame = timeFrame,
|
||||
vicoScrollState = vicoScrollState,
|
||||
selectedX = selectedX,
|
||||
onPointSelected = { x ->
|
||||
selectedX = x
|
||||
val index = paxMetrics.indexOfFirst { (it.first.received_date / 1000).toDouble() == x }
|
||||
if (index != -1) {
|
||||
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
// List
|
||||
|
|
@ -271,8 +271,27 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
|
|||
textAlign = TextAlign.Center,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) {
|
||||
items(paxMetrics) { (log, pax) -> PaxMetricsItem(log, pax, dateFormat) }
|
||||
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),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -349,29 +368,53 @@ fun PaxcountInfo(
|
|||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun PaxMetricsItem(log: MeshLog, pax: PaxcountProtos.Paxcount, dateFormat: DateFormat) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Text(
|
||||
text = dateFormat.format(Date(log.received_date)),
|
||||
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold),
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
val total = pax.ble + pax.wifi
|
||||
val summary = "PAX: $total (B:${pax.ble} W:${pax.wifi})"
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
fun PaxMetricsItem(
|
||||
log: MeshLog,
|
||||
pax: PaxcountProtos.Paxcount,
|
||||
dateFormat: DateFormat,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f, fill = true),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = dateFormat.format(Date(log.received_date)),
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
val total = pax.ble + pax.wifi
|
||||
val summary = "PAX: $total (B:${pax.ble} W:${pax.wifi})"
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f, fill = true),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,13 @@
|
|||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
|
@ -28,14 +29,15 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
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.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
|
@ -45,25 +47,30 @@ import androidx.compose.material3.Surface
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.meshtastic.core.strings.getString
|
||||
import com.patrykandpatrick.vico.compose.cartesian.Scroll
|
||||
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
|
||||
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
|
||||
|
|
@ -72,28 +79,19 @@ 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.power_metrics_log
|
||||
import org.meshtastic.core.strings.voltage
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.OptionLabel
|
||||
import org.meshtastic.core.ui.component.SlidingSelector
|
||||
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Red
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.feature.node.metrics.GraphUtil.createPath
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private enum class Power(val color: Color, val min: Float, val max: Float) {
|
||||
CURRENT(InfantryBlue, -500f, 500f),
|
||||
;
|
||||
|
||||
/** Difference between the metrics `max` and `min` values. */
|
||||
fun difference() = max - min
|
||||
private enum class PowerMetric(val color: Color) {
|
||||
CURRENT(InfantryBlue),
|
||||
VOLTAGE(Red),
|
||||
}
|
||||
|
||||
private enum class PowerChannel(val strRes: StringResource) {
|
||||
|
|
@ -102,31 +100,20 @@ private enum class PowerChannel(val strRes: StringResource) {
|
|||
THREE(Res.string.channel_3),
|
||||
}
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
|
||||
|
||||
private const val VOLTAGE_STICK_TO_ZERO_RANGE = 2f
|
||||
|
||||
private val VOLTAGE_COLOR = Red
|
||||
|
||||
fun minMaxGraphVoltage(valueMin: Float, valueMax: Float): Pair<Float, Float> {
|
||||
val valueMin = floor(valueMin)
|
||||
val min =
|
||||
if (valueMin == 0f || (valueMin >= 0f && valueMin - VOLTAGE_STICK_TO_ZERO_RANGE <= 0f)) {
|
||||
0f
|
||||
} else {
|
||||
valueMin - VOLTAGE_STICK_TO_ZERO_RANGE
|
||||
}
|
||||
val max = ceil(valueMax)
|
||||
|
||||
return Pair(min, max)
|
||||
}
|
||||
|
||||
private val LEGEND_DATA =
|
||||
listOf(
|
||||
LegendData(nameRes = Res.string.current, color = Power.CURRENT.color, isLine = true, environmentMetric = null),
|
||||
LegendData(nameRes = Res.string.voltage, color = VOLTAGE_COLOR, isLine = true, environmentMetric = null),
|
||||
LegendData(
|
||||
nameRes = Res.string.current,
|
||||
color = PowerMetric.CURRENT.color,
|
||||
isLine = true,
|
||||
environmentMetric = null,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = Res.string.voltage,
|
||||
color = PowerMetric.VOLTAGE.color,
|
||||
isLine = true,
|
||||
environmentMetric = null,
|
||||
),
|
||||
)
|
||||
|
||||
@Suppress("LongMethod")
|
||||
|
|
@ -134,10 +121,14 @@ private val LEGEND_DATA =
|
|||
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||
val data = state.powerMetricsFiltered(selectedTimeFrame)
|
||||
val data = state.powerMetrics
|
||||
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val vicoScrollState = rememberVicoScrollState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var selectedX by remember { mutableStateOf<Double?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.effects.collect { effect ->
|
||||
when (effect) {
|
||||
|
|
@ -153,6 +144,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
|
|||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
subtitle = stringResource(Res.string.power_metrics_log),
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
|
|
@ -176,27 +168,32 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
|
|||
PowerMetricsChart(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
telemetries = data.reversed(),
|
||||
selectedTimeFrame,
|
||||
selectedChannel,
|
||||
selectedChannel = selectedChannel,
|
||||
vicoScrollState = vicoScrollState,
|
||||
selectedX = selectedX,
|
||||
onPointSelected = { x ->
|
||||
selectedX = x
|
||||
val index = data.indexOfFirst { it.time.toDouble() == x }
|
||||
if (index != -1) {
|
||||
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
PowerChannel.entries.toList(),
|
||||
selectedChannel,
|
||||
onOptionSelected = { selectedChannel = it },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) {
|
||||
itemsIndexed(data) { _, telemetry ->
|
||||
PowerMetricsCard(
|
||||
telemetry = telemetry,
|
||||
isSelected = telemetry.time.toDouble() == selectedX,
|
||||
onClick = {
|
||||
selectedX = telemetry.time.toDouble()
|
||||
coroutineScope.launch {
|
||||
vicoScrollState.animateScroll(Scroll.Absolute.x(telemetry.time.toDouble(), 0.5f))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedTimeFrame,
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> PowerMetricsCard(telemetry) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -206,155 +203,119 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
|
|||
private fun PowerMetricsChart(
|
||||
modifier: Modifier = Modifier,
|
||||
telemetries: List<Telemetry>,
|
||||
selectedTime: TimeFrame,
|
||||
selectedChannel: PowerChannel,
|
||||
vicoScrollState: VicoScrollState,
|
||||
selectedX: Double?,
|
||||
onPointSelected: (Double) -> Unit,
|
||||
) {
|
||||
ChartHeader(amount = telemetries.size)
|
||||
if (telemetries.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val (oldest, newest) =
|
||||
remember(key1 = telemetries) { Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time }) }
|
||||
val timeDiff = newest.time - oldest.time
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by
|
||||
remember(key1 = selectedTime) {
|
||||
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong()))
|
||||
}
|
||||
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
|
||||
val scrollPx = scrollState.value.toFloat()
|
||||
// Calculate visible width based on actual weight distribution
|
||||
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
|
||||
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
|
||||
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
|
||||
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
|
||||
val visibleOldest = oldest.time + (timeDiff * (1f - rightRatio)).toInt()
|
||||
val visibleNewest = oldest.time + (timeDiff * (1f - leftRatio)).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
val currentDiff = Power.CURRENT.difference()
|
||||
|
||||
val (voltageMin, voltageMax) =
|
||||
minMaxGraphVoltage(
|
||||
retrieveVoltage(selectedChannel, telemetries.minBy { retrieveVoltage(selectedChannel, it) }),
|
||||
retrieveVoltage(selectedChannel, telemetries.maxBy { retrieveVoltage(selectedChannel, it) }),
|
||||
)
|
||||
val voltageDiff = voltageMax - voltageMin
|
||||
|
||||
Row {
|
||||
YAxisLabels(
|
||||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
Power.CURRENT.color,
|
||||
minValue = Power.CURRENT.min,
|
||||
maxValue = Power.CURRENT.max,
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f),
|
||||
) {
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
|
||||
|
||||
TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())
|
||||
|
||||
/* Plot */
|
||||
Canvas(modifier = modifier.width(dp)) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
/* Voltage */
|
||||
var index = 0
|
||||
while (index < telemetries.size) {
|
||||
val path = Path()
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest.time,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = (retrieveVoltage(selectedChannel, telemetry) - voltageMin) / voltageDiff
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = VOLTAGE_COLOR,
|
||||
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
/* Current */
|
||||
index = 0
|
||||
while (index < telemetries.size) {
|
||||
val path = Path()
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest.time,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = (retrieveCurrent(selectedChannel, telemetry) - Power.CURRENT.min) / currentDiff
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = Power.CURRENT.color,
|
||||
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
|
||||
)
|
||||
val modelProducer = remember { CartesianChartModelProducer() }
|
||||
val currentColor = PowerMetric.CURRENT.color
|
||||
val voltageColor = PowerMetric.VOLTAGE.color
|
||||
val marker =
|
||||
ChartStyling.rememberMarker(
|
||||
valueFormatter =
|
||||
ChartStyling.createColoredMarkerValueFormatter { value, color ->
|
||||
when (color.copy(1f)) {
|
||||
currentColor -> "Current: %.0f mA".format(value)
|
||||
voltageColor -> "Voltage: %.1f V".format(value)
|
||||
else -> "%.1f".format(value)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
LaunchedEffect(telemetries, selectedChannel) {
|
||||
modelProducer.runTransaction {
|
||||
lineSeries {
|
||||
series(x = telemetries.map { it.time }, y = telemetries.map { retrieveCurrent(selectedChannel, it) })
|
||||
}
|
||||
lineSeries {
|
||||
series(x = telemetries.map { it.time }, y = telemetries.map { retrieveVoltage(selectedChannel, it) })
|
||||
}
|
||||
}
|
||||
YAxisLabels(
|
||||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
VOLTAGE_COLOR,
|
||||
minValue = voltageMin,
|
||||
maxValue = voltageMax,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
GenericMetricChart(
|
||||
modelProducer = modelProducer,
|
||||
modifier = modifier.padding(8.dp),
|
||||
layers =
|
||||
listOf(
|
||||
rememberLineCartesianLayer(
|
||||
lineProvider =
|
||||
LineCartesianLayer.LineProvider.series(
|
||||
ChartStyling.createBoldLine(currentColor, ChartStyling.MEDIUM_POINT_SIZE_DP),
|
||||
),
|
||||
verticalAxisPosition = Axis.Position.Vertical.Start,
|
||||
),
|
||||
rememberLineCartesianLayer(
|
||||
lineProvider =
|
||||
LineCartesianLayer.LineProvider.series(
|
||||
ChartStyling.createGradientLine(voltageColor, ChartStyling.MEDIUM_POINT_SIZE_DP),
|
||||
),
|
||||
verticalAxisPosition = Axis.Position.Vertical.End,
|
||||
),
|
||||
),
|
||||
startAxis =
|
||||
VerticalAxis.rememberStart(
|
||||
label = ChartStyling.rememberAxisLabel(color = currentColor),
|
||||
valueFormatter = { _, value, _ -> "%.0f mA".format(value) },
|
||||
),
|
||||
endAxis =
|
||||
VerticalAxis.rememberEnd(
|
||||
label = ChartStyling.rememberAxisLabel(color = voltageColor),
|
||||
valueFormatter = { _, value, _ -> "%.1f V".format(value) },
|
||||
),
|
||||
bottomAxis =
|
||||
HorizontalAxis.rememberBottom(
|
||||
label = ChartStyling.rememberAxisLabel(),
|
||||
valueFormatter = CommonCharts.dynamicTimeFormatter,
|
||||
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50),
|
||||
labelRotationDegrees = 45f,
|
||||
),
|
||||
marker = marker,
|
||||
selectedX = selectedX,
|
||||
onPointSelected = onPointSelected,
|
||||
vicoScrollState = vicoScrollState,
|
||||
)
|
||||
|
||||
Legend(legendData = LEGEND_DATA, displayInfoIcon = false)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun PowerMetricsCard(telemetry: Telemetry) {
|
||||
private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Surface {
|
||||
SelectionContainer {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
/* Time */
|
||||
Row {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
if (telemetry.powerMetrics.hasCh1Current() || telemetry.powerMetrics.hasCh1Voltage()) {
|
||||
PowerChannelColumn(
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@
|
|||
*/
|
||||
package org.meshtastic.feature.node.metrics
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -29,12 +29,13 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
|
@ -44,61 +45,57 @@ import androidx.compose.material3.Surface
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.meshtastic.core.strings.getString
|
||||
import com.patrykandpatrick.vico.compose.cartesian.Scroll
|
||||
import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.Axis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.HorizontalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.axis.VerticalAxis
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProducer
|
||||
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
|
||||
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
|
||||
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
|
||||
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.request_telemetry
|
||||
import org.meshtastic.core.strings.rssi
|
||||
import org.meshtastic.core.strings.rssi_definition
|
||||
import org.meshtastic.core.strings.signal_quality
|
||||
import org.meshtastic.core.strings.snr
|
||||
import org.meshtastic.core.strings.snr_definition
|
||||
import org.meshtastic.core.ui.component.LoraSignalIndicator
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.OptionLabel
|
||||
import org.meshtastic.core.ui.component.SlidingSelector
|
||||
import org.meshtastic.core.ui.component.SnrAndRssi
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.feature.node.metrics.GraphUtil.plotPoint
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.SCROLL_BIAS
|
||||
import org.meshtastic.proto.MeshProtos.MeshPacket
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private enum class Metric(val color: Color, val min: Float, val max: Float) {
|
||||
SNR(Color.Green, -20f, 12f), /* Selected 12 as the max to get 4 equal vertical sections. */
|
||||
RSSI(Color.Blue, -140f, -20f),
|
||||
;
|
||||
|
||||
/** Difference between the metrics `max` and `min` values. */
|
||||
fun difference() = max - min
|
||||
private enum class SignalMetric(val color: Color) {
|
||||
SNR(Color.Green),
|
||||
RSSI(Color.Blue),
|
||||
}
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
|
||||
|
||||
private val LEGEND_DATA =
|
||||
listOf(
|
||||
LegendData(nameRes = Res.string.rssi, color = Metric.RSSI.color, environmentMetric = null),
|
||||
LegendData(nameRes = Res.string.snr, color = Metric.SNR.color, environmentMetric = null),
|
||||
LegendData(nameRes = Res.string.rssi, color = SignalMetric.RSSI.color, environmentMetric = null),
|
||||
LegendData(nameRes = Res.string.snr, color = SignalMetric.SNR.color, environmentMetric = null),
|
||||
)
|
||||
|
||||
@Suppress("LongMethod")
|
||||
|
|
@ -107,8 +104,12 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
|
|||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var displayInfoDialog by remember { mutableStateOf(false) }
|
||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||
val data = state.signalMetricsFiltered(selectedTimeFrame)
|
||||
val data = state.signalMetrics
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val vicoScrollState = rememberVicoScrollState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var selectedX by remember { mutableStateOf<Double?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.effects.collect { effect ->
|
||||
|
|
@ -125,6 +126,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
|
|||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
subtitle = stringResource(Res.string.signal_quality),
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
|
|
@ -134,7 +136,10 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
|
|||
IconButton(onClick = { viewModel.requestTelemetry(TelemetryType.LOCAL_STATS) }) {
|
||||
androidx.compose.material3.Icon(
|
||||
imageVector = MeshtasticIcons.Refresh,
|
||||
contentDescription = null,
|
||||
contentDescription =
|
||||
stringResource(Res.string.signal_quality) +
|
||||
" " +
|
||||
stringResource(Res.string.request_telemetry),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -159,20 +164,33 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
|
|||
SignalMetricsChart(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
meshPackets = data.reversed(),
|
||||
selectedTimeFrame,
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
vicoScrollState = vicoScrollState,
|
||||
selectedX = selectedX,
|
||||
onPointSelected = { x ->
|
||||
selectedX = x
|
||||
val index = data.indexOfFirst { it.rxTime.toDouble() == x }
|
||||
if (index != -1) {
|
||||
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedTimeFrame,
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(data) { meshPacket -> SignalMetricsCard(meshPacket) }
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), state = lazyListState) {
|
||||
itemsIndexed(data) { _, meshPacket ->
|
||||
SignalMetricsCard(
|
||||
meshPacket = meshPacket,
|
||||
isSelected = meshPacket.rxTime.toDouble() == selectedX,
|
||||
onClick = {
|
||||
selectedX = meshPacket.rxTime.toDouble()
|
||||
coroutineScope.launch {
|
||||
vicoScrollState.animateScroll(
|
||||
Scroll.Absolute.x(meshPacket.rxTime.toDouble(), SCROLL_BIAS),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -183,126 +201,115 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
|
|||
private fun SignalMetricsChart(
|
||||
modifier: Modifier = Modifier,
|
||||
meshPackets: List<MeshPacket>,
|
||||
selectedTime: TimeFrame,
|
||||
promptInfoDialog: () -> Unit,
|
||||
vicoScrollState: VicoScrollState,
|
||||
selectedX: Double?,
|
||||
onPointSelected: (Double) -> Unit,
|
||||
) {
|
||||
ChartHeader(amount = meshPackets.size)
|
||||
if (meshPackets.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val (oldest, newest) =
|
||||
remember(key1 = meshPackets) { Pair(meshPackets.minBy { it.rxTime }, meshPackets.maxBy { it.rxTime }) }
|
||||
val timeDiff = newest.rxTime - oldest.rxTime
|
||||
val modelProducer = remember { CartesianChartModelProducer() }
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by
|
||||
remember(key1 = selectedTime) {
|
||||
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.rxTime - oldest.rxTime).toLong()))
|
||||
LaunchedEffect(meshPackets) {
|
||||
modelProducer.runTransaction {
|
||||
/* Use separate lineSeries calls to associate them with different vertical axes */
|
||||
lineSeries { series(x = meshPackets.map { it.rxTime }, y = meshPackets.map { it.rxRssi }) }
|
||||
lineSeries { series(x = meshPackets.map { it.rxTime }, y = meshPackets.map { it.rxSnr }) }
|
||||
}
|
||||
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
|
||||
val scrollPx = scrollState.value.toFloat()
|
||||
// Calculate visible width based on actual weight distribution
|
||||
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
|
||||
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
|
||||
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
|
||||
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
|
||||
val visibleOldest = oldest.rxTime + (timeDiff * (1f - rightRatio)).toInt()
|
||||
val visibleNewest = oldest.rxTime + (timeDiff * (1f - leftRatio)).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
val rssiColor = SignalMetric.RSSI.color
|
||||
val snrColor = SignalMetric.SNR.color
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
val snrDiff = Metric.SNR.difference()
|
||||
val rssiDiff = Metric.RSSI.difference()
|
||||
|
||||
Row {
|
||||
YAxisLabels(
|
||||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
Metric.RSSI.color,
|
||||
minValue = Metric.RSSI.min,
|
||||
maxValue = Metric.RSSI.max,
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f),
|
||||
) {
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
|
||||
|
||||
TimeAxisOverlay(
|
||||
modifier.width(dp),
|
||||
oldest = oldest.rxTime,
|
||||
newest = newest.rxTime,
|
||||
selectedTime.lineInterval(),
|
||||
)
|
||||
|
||||
/* Plot SNR and RSSI */
|
||||
Canvas(modifier = modifier.width(dp)) {
|
||||
val width = size.width
|
||||
/* Plot */
|
||||
for (packet in meshPackets) {
|
||||
val xRatio = (packet.rxTime - oldest.rxTime).toFloat() / timeDiff
|
||||
val x = xRatio * width
|
||||
|
||||
/* SNR */
|
||||
plotPoint(
|
||||
drawContext = drawContext,
|
||||
color = Metric.SNR.color,
|
||||
x = x,
|
||||
value = packet.rxSnr - Metric.SNR.min,
|
||||
divisor = snrDiff,
|
||||
)
|
||||
|
||||
/* RSSI */
|
||||
plotPoint(
|
||||
drawContext = drawContext,
|
||||
color = Metric.RSSI.color,
|
||||
x = x,
|
||||
value = packet.rxRssi - Metric.RSSI.min,
|
||||
divisor = rssiDiff,
|
||||
)
|
||||
val marker =
|
||||
ChartStyling.rememberMarker(
|
||||
valueFormatter =
|
||||
ChartStyling.createColoredMarkerValueFormatter { value, color ->
|
||||
if (color.copy(alpha = 1f) == rssiColor) {
|
||||
"RSSI: %.0f dBm".format(value)
|
||||
} else {
|
||||
"SNR: %.1f dB".format(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
YAxisLabels(
|
||||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
Metric.SNR.color,
|
||||
minValue = Metric.SNR.min,
|
||||
maxValue = Metric.SNR.max,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
GenericMetricChart(
|
||||
modelProducer = modelProducer,
|
||||
modifier = modifier.padding(8.dp),
|
||||
layers =
|
||||
listOf(
|
||||
rememberLineCartesianLayer(
|
||||
lineProvider =
|
||||
LineCartesianLayer.LineProvider.series(
|
||||
ChartStyling.createPointOnlyLine(rssiColor, ChartStyling.LARGE_POINT_SIZE_DP),
|
||||
),
|
||||
verticalAxisPosition = Axis.Position.Vertical.Start,
|
||||
),
|
||||
rememberLineCartesianLayer(
|
||||
lineProvider =
|
||||
LineCartesianLayer.LineProvider.series(
|
||||
ChartStyling.createPointOnlyLine(snrColor, ChartStyling.LARGE_POINT_SIZE_DP),
|
||||
),
|
||||
verticalAxisPosition = Axis.Position.Vertical.End,
|
||||
),
|
||||
),
|
||||
startAxis =
|
||||
VerticalAxis.rememberStart(
|
||||
label = ChartStyling.rememberAxisLabel(color = rssiColor),
|
||||
valueFormatter = { _, value, _ -> "%.0f dBm".format(value) },
|
||||
),
|
||||
endAxis =
|
||||
VerticalAxis.rememberEnd(
|
||||
label = ChartStyling.rememberAxisLabel(color = snrColor),
|
||||
valueFormatter = { _, value, _ -> "%.1f dB".format(value) },
|
||||
),
|
||||
bottomAxis =
|
||||
HorizontalAxis.rememberBottom(
|
||||
label = ChartStyling.rememberAxisLabel(),
|
||||
valueFormatter = CommonCharts.dynamicTimeFormatter,
|
||||
itemPlacer = ChartStyling.rememberItemPlacer(spacing = 50),
|
||||
labelRotationDegrees = 45f,
|
||||
),
|
||||
marker = marker,
|
||||
selectedX = selectedX,
|
||||
onPointSelected = onPointSelected,
|
||||
vicoScrollState = vicoScrollState,
|
||||
)
|
||||
|
||||
Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun SignalMetricsCard(meshPacket: MeshPacket) {
|
||||
private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val time = meshPacket.rxTime * MS_PER_SEC
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Surface {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Surface(color = Color.Transparent) {
|
||||
SelectionContainer {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
/* Data */
|
||||
Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
/* Time */
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,21 +20,13 @@ import android.text.format.DateUtils
|
|||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
|
@ -48,7 +40,6 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
|
@ -71,13 +62,13 @@ import org.meshtastic.core.model.getTracerouteResponse
|
|||
import org.meshtastic.core.model.toMessageRes
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.close
|
||||
import org.meshtastic.core.strings.delete
|
||||
import org.meshtastic.core.strings.routing_error_no_response
|
||||
import org.meshtastic.core.strings.traceroute
|
||||
import org.meshtastic.core.strings.traceroute_diff
|
||||
import org.meshtastic.core.strings.traceroute_direct
|
||||
import org.meshtastic.core.strings.traceroute_duration
|
||||
import org.meshtastic.core.strings.traceroute_hops
|
||||
import org.meshtastic.core.strings.traceroute_log
|
||||
import org.meshtastic.core.strings.traceroute_route_back_to_us
|
||||
import org.meshtastic.core.strings.traceroute_route_towards_dest
|
||||
import org.meshtastic.core.strings.traceroute_time_and_text
|
||||
|
|
@ -86,7 +77,6 @@ import org.meshtastic.core.ui.component.MainAppBar
|
|||
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
|
||||
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
import org.meshtastic.core.ui.icon.Delete
|
||||
import org.meshtastic.core.ui.icon.Group
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.PersonOff
|
||||
|
|
@ -153,6 +143,7 @@ fun TracerouteLogScreen(
|
|||
val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsState()
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
subtitle = stringResource(Res.string.traceroute_log),
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
|
|
@ -233,9 +224,10 @@ fun TracerouteLogScreen(
|
|||
}
|
||||
|
||||
Box {
|
||||
TracerouteItem(
|
||||
MetricLogItem(
|
||||
icon = icon,
|
||||
text = stringResource(Res.string.traceroute_time_and_text, time, text),
|
||||
contentDescription = stringResource(Res.string.traceroute),
|
||||
modifier =
|
||||
Modifier.combinedClickable(onLongClick = { expanded = true }) {
|
||||
val dialogMessage =
|
||||
|
|
@ -322,37 +314,6 @@ private fun TracerouteLogDialogs(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteItem(onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
onClick = onClick,
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.Delete,
|
||||
contentDescription = stringResource(Res.string.delete),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(text = stringResource(Res.string.delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).padding(vertical = 2.dp)) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(imageVector = icon, contentDescription = stringResource(Res.string.traceroute))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = text, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generates a display string and icon based on the route discovery information. */
|
||||
@Composable
|
||||
private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
|
||||
|
|
@ -424,5 +385,11 @@ private fun TracerouteItemPreview() {
|
|||
System.currentTimeMillis(),
|
||||
DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_ABBREV_ALL,
|
||||
)
|
||||
AppTheme { TracerouteItem(icon = MeshtasticIcons.Group, text = "$time - Direct") }
|
||||
AppTheme {
|
||||
MetricLogItem(
|
||||
icon = MeshtasticIcons.Group,
|
||||
text = "$time - Direct",
|
||||
contentDescription = stringResource(Res.string.traceroute),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ package org.meshtastic.feature.node.model
|
|||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ChargingStation
|
||||
import androidx.compose.material.icons.rounded.Groups
|
||||
import androidx.compose.material.icons.rounded.LocationOn
|
||||
import androidx.compose.material.icons.rounded.Map
|
||||
import androidx.compose.material.icons.rounded.Memory
|
||||
|
|
@ -32,11 +33,12 @@ import org.meshtastic.core.strings.Res
|
|||
import org.meshtastic.core.strings.device_metrics_log
|
||||
import org.meshtastic.core.strings.env_metrics_log
|
||||
import org.meshtastic.core.strings.host_metrics_log
|
||||
import org.meshtastic.core.strings.neighbor_info
|
||||
import org.meshtastic.core.strings.node_map
|
||||
import org.meshtastic.core.strings.pax_metrics_log
|
||||
import org.meshtastic.core.strings.position_log
|
||||
import org.meshtastic.core.strings.power_metrics_log
|
||||
import org.meshtastic.core.strings.sig_metrics_log
|
||||
import org.meshtastic.core.strings.signal_quality
|
||||
import org.meshtastic.core.strings.traceroute_log
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Paxcount
|
||||
|
|
@ -47,9 +49,10 @@ enum class LogsType(val titleRes: StringResource, val icon: ImageVector, val rou
|
|||
NODE_MAP(Res.string.node_map, Icons.Rounded.Map, { NodeDetailRoutes.NodeMap(it) }),
|
||||
POSITIONS(Res.string.position_log, Icons.Rounded.LocationOn, { NodeDetailRoutes.PositionLog(it) }),
|
||||
ENVIRONMENT(Res.string.env_metrics_log, Icons.Rounded.Thermostat, { NodeDetailRoutes.EnvironmentMetrics(it) }),
|
||||
SIGNAL(Res.string.sig_metrics_log, Icons.Rounded.SignalCellularAlt, { NodeDetailRoutes.SignalMetrics(it) }),
|
||||
SIGNAL(Res.string.signal_quality, Icons.Rounded.SignalCellularAlt, { NodeDetailRoutes.SignalMetrics(it) }),
|
||||
POWER(Res.string.power_metrics_log, Icons.Rounded.Power, { NodeDetailRoutes.PowerMetrics(it) }),
|
||||
TRACEROUTE(Res.string.traceroute_log, MeshtasticIcons.Route, { NodeDetailRoutes.TracerouteLog(it) }),
|
||||
NEIGHBOR_INFO(Res.string.neighbor_info, Icons.Rounded.Groups, { NodeDetailRoutes.NeighborInfoLog(it) }),
|
||||
HOST(Res.string.host_metrics_log, Icons.Rounded.Memory, { NodeDetailRoutes.HostMetricsLog(it) }),
|
||||
PAX(Res.string.pax_metrics_log, MeshtasticIcons.Paxcount, { NodeDetailRoutes.PaxMetrics(it) }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,24 +16,13 @@
|
|||
*/
|
||||
package org.meshtastic.feature.node.model
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.DeviceHardware
|
||||
import org.meshtastic.core.strings.Res
|
||||
import org.meshtastic.core.strings.forty_eight_hours
|
||||
import org.meshtastic.core.strings.four_weeks
|
||||
import org.meshtastic.core.strings.max
|
||||
import org.meshtastic.core.strings.one_week
|
||||
import org.meshtastic.core.strings.twenty_four_hours
|
||||
import org.meshtastic.core.strings.two_weeks
|
||||
import org.meshtastic.proto.ConfigProtos
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import org.meshtastic.proto.TelemetryProtos
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class MetricsState(
|
||||
val isLocal: Boolean = false,
|
||||
|
|
@ -48,9 +37,10 @@ data class MetricsState(
|
|||
val hostMetrics: List<TelemetryProtos.Telemetry> = emptyList(),
|
||||
val tracerouteRequests: List<MeshLog> = emptyList(),
|
||||
val tracerouteResults: List<MeshLog> = emptyList(),
|
||||
val neighborInfoRequests: List<MeshLog> = emptyList(),
|
||||
val neighborInfoResults: List<MeshLog> = emptyList(),
|
||||
val positionLogs: List<MeshProtos.Position> = emptyList(),
|
||||
val deviceHardware: DeviceHardware? = null,
|
||||
val isLocalDevice: Boolean = false,
|
||||
val firmwareEdition: MeshProtos.FirmwareEdition? = null,
|
||||
val latestStableFirmware: FirmwareRelease = FirmwareRelease(),
|
||||
val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(),
|
||||
|
|
@ -66,84 +56,15 @@ data class MetricsState(
|
|||
|
||||
fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty()
|
||||
|
||||
fun hasNeighborInfoLogs() = neighborInfoRequests.isNotEmpty()
|
||||
|
||||
fun hasPositionLogs() = positionLogs.isNotEmpty()
|
||||
|
||||
fun hasHostMetrics() = hostMetrics.isNotEmpty()
|
||||
|
||||
fun hasPaxMetrics() = paxMetrics.isNotEmpty()
|
||||
|
||||
fun deviceMetricsFiltered(timeFrame: TimeFrame): List<TelemetryProtos.Telemetry> {
|
||||
val oldestTime = timeFrame.calculateOldestTime()
|
||||
return deviceMetrics.filter { it.time >= oldestTime }
|
||||
}
|
||||
|
||||
fun signalMetricsFiltered(timeFrame: TimeFrame): List<MeshProtos.MeshPacket> {
|
||||
val oldestTime = timeFrame.calculateOldestTime()
|
||||
return signalMetrics.filter { it.rxTime >= oldestTime }
|
||||
}
|
||||
|
||||
fun powerMetricsFiltered(timeFrame: TimeFrame): List<TelemetryProtos.Telemetry> {
|
||||
val oldestTime = timeFrame.calculateOldestTime()
|
||||
return powerMetrics.filter { it.time >= oldestTime }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Empty = MetricsState()
|
||||
}
|
||||
}
|
||||
|
||||
/** Supported time frames used to display data. */
|
||||
@Suppress("MagicNumber")
|
||||
enum class TimeFrame(val seconds: Long, val strRes: StringResource) {
|
||||
TWENTY_FOUR_HOURS(TimeUnit.DAYS.toSeconds(1), Res.string.twenty_four_hours),
|
||||
FORTY_EIGHT_HOURS(TimeUnit.DAYS.toSeconds(2), Res.string.forty_eight_hours),
|
||||
ONE_WEEK(TimeUnit.DAYS.toSeconds(7), Res.string.one_week),
|
||||
TWO_WEEKS(TimeUnit.DAYS.toSeconds(14), Res.string.two_weeks),
|
||||
FOUR_WEEKS(TimeUnit.DAYS.toSeconds(28), Res.string.four_weeks),
|
||||
MAX(0L, Res.string.max),
|
||||
;
|
||||
|
||||
fun calculateOldestTime(): Long = if (this == MAX) {
|
||||
MAX.seconds
|
||||
} else {
|
||||
System.currentTimeMillis() / 1000 - this.seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* The time interval to draw the vertical lines representing time on the x-axis.
|
||||
*
|
||||
* @return seconds epoch seconds
|
||||
*/
|
||||
fun lineInterval(): Long = when (this.ordinal) {
|
||||
TWENTY_FOUR_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(6)
|
||||
|
||||
FORTY_EIGHT_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(12)
|
||||
|
||||
ONE_WEEK.ordinal,
|
||||
TWO_WEEKS.ordinal,
|
||||
-> TimeUnit.DAYS.toSeconds(1)
|
||||
|
||||
else -> TimeUnit.DAYS.toSeconds(7)
|
||||
}
|
||||
|
||||
/** Used to detect a significant time separation between [TelemetryProtos.Telemetry]s. */
|
||||
fun timeThreshold(): Long = when (this.ordinal) {
|
||||
TWENTY_FOUR_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(6)
|
||||
|
||||
FORTY_EIGHT_HOURS.ordinal -> TimeUnit.HOURS.toSeconds(12)
|
||||
|
||||
else -> TimeUnit.DAYS.toSeconds(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the needed [androidx.compose.ui.unit.Dp] depending on the amount of time being plotted.
|
||||
*
|
||||
* @param time in seconds
|
||||
*/
|
||||
fun dp(screenWidth: Int, time: Long): Dp {
|
||||
val timePerScreen = this.lineInterval()
|
||||
val multiplier = time / timePerScreen
|
||||
val dp = (screenWidth * multiplier).toInt().dp
|
||||
return dp.takeIf { it != 0.dp } ?: screenWidth.dp
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,14 +19,13 @@ package org.meshtastic.feature.node.metrics
|
|||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
|
||||
class EnvironmentMetricsStateTest {
|
||||
|
||||
@Test
|
||||
fun `environmentMetricsFiltered correctly calculates times`() {
|
||||
fun `environmentMetricsForGraphing correctly calculates times`() {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val metrics =
|
||||
listOf(
|
||||
|
|
@ -44,37 +43,14 @@ class EnvironmentMetricsStateTest {
|
|||
.build(),
|
||||
)
|
||||
val state = EnvironmentMetricsState(metrics)
|
||||
val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS)
|
||||
val result = state.environmentMetricsForGraphing()
|
||||
|
||||
assertEquals(now - 100, result.times.first)
|
||||
assertEquals(now, result.times.second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `environmentMetricsFiltered ignores invalid timestamps`() {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val metrics =
|
||||
listOf(
|
||||
Telemetry.newBuilder()
|
||||
.setTime(0)
|
||||
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(20f))
|
||||
.build(),
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now)
|
||||
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(21f))
|
||||
.build(),
|
||||
)
|
||||
val state = EnvironmentMetricsState(metrics)
|
||||
val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS)
|
||||
|
||||
// Only the valid timestamp should be considered for filters
|
||||
assertEquals(now, result.times.first)
|
||||
assertEquals(now, result.times.second)
|
||||
assertEquals(1, result.metrics.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `environmentMetricsFiltered handles valid zero temperatures`() {
|
||||
fun `environmentMetricsForGraphing handles valid zero temperatures`() {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val metrics =
|
||||
listOf(
|
||||
|
|
@ -84,7 +60,7 @@ class EnvironmentMetricsStateTest {
|
|||
.build(),
|
||||
)
|
||||
val state = EnvironmentMetricsState(metrics)
|
||||
val result = state.environmentMetricsFiltered(TimeFrame.TWENTY_FOUR_HOURS)
|
||||
val result = state.environmentMetricsForGraphing()
|
||||
|
||||
assertTrue(result.shouldPlot[Environment.TEMPERATURE.ordinal])
|
||||
assertEquals(0.0f, result.rightMinMax.first, 0.01f)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ devtools-ksp = "2.3.5"
|
|||
markdownRenderer = "0.39.1"
|
||||
osmdroid-android = "6.1.20"
|
||||
protobuf = "4.33.4"
|
||||
vico = "3.0.0-beta.3"
|
||||
|
||||
|
||||
[libraries]
|
||||
|
|
@ -181,6 +182,9 @@ streamsupport-minifuture = { module = "net.sourceforge.streamsupport:streamsuppo
|
|||
|
||||
usb-serial-android = { module = "com.github.mik3y:usb-serial-for-android", version = "3.10.0" }
|
||||
zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version = "4.3.0" }
|
||||
vico-compose = { group = "com.patrykandpatrick.vico", name = "compose", version.ref = "vico" }
|
||||
vico-compose-m2 = { group = "com.patrykandpatrick.vico", name = "compose-m2", version.ref = "vico" }
|
||||
vico-compose-m3 = { group = "com.patrykandpatrick.vico", name = "compose-m3", version.ref = "vico" }
|
||||
|
||||
# Build Logic
|
||||
android-gradleApiPlugin = { module = "com.android.tools.build:gradle-api", version.ref = "agp" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue