feat: Telemetry charts (#1198)

This commit is contained in:
Robert-0410 2024-08-31 04:05:42 -07:00 committed by GitHub
parent cb2ea2804a
commit 9f136fb31b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 962 additions and 1 deletions

View file

@ -0,0 +1,51 @@
package com.geeksville.mesh.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.database.MeshLogRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class NodeDetailsViewModel @Inject constructor(
val nodeDB: NodeDB,
private val meshLogRepository: MeshLogRepository
) : ViewModel() {
private val _deviceMetrics = MutableStateFlow<List<Telemetry>>(emptyList())
val deviceMetrics: StateFlow<List<Telemetry>> = _deviceMetrics
private val _environmentMetrics = MutableStateFlow<List<Telemetry>>(emptyList())
val environmentMetrics: StateFlow<List<Telemetry>> = _environmentMetrics
/**
* Gets the short name of the node identified by `nodeNum`.
*/
fun getNodeName(nodeNum: Int): String? = nodeDB.nodeDBbyNum.value[nodeNum]?.user?.shortName
/**
* Used to set the Node for which the user will see charts for.
*/
fun setSelectedNode(nodeNum: Int) {
viewModelScope.launch {
meshLogRepository.getTelemetryFrom(nodeNum).collect {
val deviceList = mutableListOf<Telemetry>()
val environmentList = mutableListOf<Telemetry>()
for (telemetry in it) {
if (telemetry.hasDeviceMetrics())
deviceList.add(telemetry)
/* Avoiding negative outliers */
if (telemetry.hasEnvironmentMetrics() && telemetry.environmentMetrics.relativeHumidity >= 0f)
environmentList.add(telemetry)
}
_deviceMetrics.value = deviceList
_environmentMetrics.value = environmentList
}
}
}
}

View file

@ -0,0 +1,176 @@
package com.geeksville.mesh.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.R
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.model.NodeDetailsViewModel
import com.geeksville.mesh.ui.components.DeviceMetricsScreen
import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen
import com.geeksville.mesh.ui.theme.AppTheme
import dagger.hilt.android.AndroidEntryPoint
internal fun FragmentManager.navigateToNodeDetails(nodeNum: Int? = null) {
val nodeDetailsFragment = NodeDetailsFragment().apply {
arguments = bundleOf("nodeNum" to nodeNum)
}
beginTransaction()
.replace(R.id.mainActivityLayout, nodeDetailsFragment)
.addToBackStack(null)
.commit()
}
@AndroidEntryPoint
class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging {
private val model: NodeDetailsViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val nodeNum = arguments?.getInt("nodeNum")
if (nodeNum != null)
model.setSelectedNode(nodeNum)
val nodeName = model.getNodeName(nodeNum ?: 0)
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
NodeDetailsScreen(
model = model,
nodeName = nodeName,
navigateBack = {
parentFragmentManager.popBackStack()
}
)
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NodeDetailsScreen(
model: NodeDetailsViewModel = hiltViewModel(),
nodeName: String?,
navigateBack: () -> Unit,
) {
val deviceMetrics by model.deviceMetrics.collectAsStateWithLifecycle()
val environmentMetrics by model.environmentMetrics.collectAsStateWithLifecycle()
val pagerState = rememberPagerState(pageCount = { 2 })
Scaffold(
/*
* NOTE: The bottom bar could be used to enable other actions such as clear or export data.
*/
topBar = {
TopAppBar(
backgroundColor = colorResource(R.color.toolbarBackground),
contentColor = colorResource(R.color.toolbarText),
title = {
Text(
text = "${stringResource(R.string.node_details)}: $nodeName",
)
HorizontalTabs(pagerState)
},
navigationIcon = {
IconButton(onClick = navigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.navigate_back),
)
}
}
)
},
) { innerPadding ->
HorizontalPager(state = pagerState) { page ->
when (page) {
0 -> DeviceMetricsScreen(
innerPadding = innerPadding,
telemetries = deviceMetrics
)
1 -> EnvironmentMetricsScreen(
innerPadding = innerPadding,
telemetries = environmentMetrics
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HorizontalTabs(pagerState: PagerState) {
Row(
Modifier
.wrapContentHeight()
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.Center
) {
repeat(pagerState.pageCount) { iteration ->
val color = if (pagerState.currentPage == iteration)
colorResource(R.color.toolbarText)
else
Color.LightGray
val (imageVector, contentDescription) = if (iteration == 0)
Pair(ImageVector.vectorResource(
R.drawable.baseline_charging_station_24),
stringResource(R.string.device_metrics)
)
else
Pair(
ImageVector.vectorResource(R.drawable.baseline_thermostat_24),
stringResource(R.string.environment_metrics)
)
Icon(
imageVector,
contentDescription,
tint = color
)
}
}
}

View file

@ -82,6 +82,10 @@ class UsersFragment : ScreenFragment("Users"), Logging {
R.id.remote_admin -> {
navigateToRadioConfig(node)
}
R.id.more_details -> {
navigateToMoreDetails(node)
}
}
}
}
@ -97,6 +101,11 @@ class UsersFragment : ScreenFragment("Users"), Logging {
parentFragmentManager.navigateToRadioConfig(node.num)
}
private fun navigateToMoreDetails(node: NodeInfo) {
info("calling MoreDetails --> destNum: ${node.num}")
parentFragmentManager.navigateToNodeDetails(node.num)
}
override fun onCreateView(
inflater: LayoutInflater,

View file

@ -0,0 +1,687 @@
@file:Suppress("TooManyFunctions")
package com.geeksville.mesh.ui.components
import android.graphics.Paint
import android.graphics.Typeface
import androidx.compose.foundation.Canvas
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.Card
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.asComposePath
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.ui.BatteryInfo
import com.geeksville.mesh.ui.components.ChartConstants.DEVICE_METRICS_COLORS
import com.geeksville.mesh.ui.components.ChartConstants.ENVIRONMENT_METRICS_COLORS
import com.geeksville.mesh.ui.components.ChartConstants.LEFT_CHART_SPACING
import com.geeksville.mesh.ui.components.ChartConstants.LINE_OFF
import com.geeksville.mesh.ui.components.ChartConstants.LINE_ON
import com.geeksville.mesh.ui.components.ChartConstants.TIME_FORMAT
import com.geeksville.mesh.ui.components.ChartConstants.MAX_PERCENT_VALUE
import com.geeksville.mesh.ui.components.ChartConstants.LINE_LIMIT
import com.geeksville.mesh.ui.components.ChartConstants.MS_PER_SEC
import com.geeksville.mesh.ui.components.ChartConstants.TEXT_PAINT_ALPHA
import com.geeksville.mesh.ui.theme.Orange
import java.text.DateFormat
private object ChartConstants {
val DEVICE_METRICS_COLORS = listOf(Color.Green, Color.Magenta, Color.Cyan)
val ENVIRONMENT_METRICS_COLORS = listOf(Color.Red, Color.Blue)
val TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
const val MAX_PERCENT_VALUE = 100f
const val LINE_LIMIT = 4
const val TEXT_PAINT_ALPHA = 192
const val LINE_ON = 10f
const val LINE_OFF = 20f
const val LEFT_CHART_SPACING = 8f
const val MS_PER_SEC = 1000.0f
}
@Composable
fun DeviceMetricsScreen(innerPadding: PaddingValues, telemetries: List<Telemetry>) {
Column {
DeviceMetricsChart(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(fraction = 0.33f),
telemetries
)
/* Device Metric Cards */
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
items(telemetries.reversed()) { telemetry -> DeviceMetricsCard(telemetry) }
}
}
}
@Composable
fun EnvironmentMetricsScreen(innerPadding: PaddingValues, telemetries: List<Telemetry>) {
Column {
EnvironmentMetricsChart(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(fraction = 0.33f),
telemetries = telemetries
)
/* Environment Metric Cards */
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
items(telemetries.reversed()) { telemetry -> EnvironmentMetricsCard(telemetry)}
}
}
}
@Suppress("LongMethod")
@Composable
private fun DeviceMetricsChart(modifier: Modifier = Modifier, telemetries: List<Telemetry>) {
ChartHeader(amount = telemetries.size, title = stringResource(R.string.device_metrics))
if (telemetries.isEmpty())
return
Spacer(modifier = Modifier.height(16.dp))
val graphColor = MaterialTheme.colors.onSurface
val spacing = LEFT_CHART_SPACING
Box(contentAlignment = Alignment.TopStart) {
ChartOverlay(modifier, graphColor, minValue = 0f, maxValue = 100f)
/* Plot Battery Line, ChUtil, and AirUtilTx */
Canvas(modifier = modifier) {
val height = size.height
val width = size.width - 28.dp.toPx()
val spacePerEntry = (width - spacing) / telemetries.size
val dataPointRadius = 2.dp.toPx()
var lastX: Float
val strokePath = Path().apply {
for (i in telemetries.indices) {
val telemetry = telemetries[i]
val nextTelemetry = telemetries.getOrNull(i + 1) ?: telemetries.last()
val leftRatio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE
val rightRatio = nextTelemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE
val x1 = spacing + i * spacePerEntry
val y1 = height - spacing - (leftRatio * height)
/* Channel Utilization */
val chUtilRatio = telemetry.deviceMetrics.channelUtilization / MAX_PERCENT_VALUE
val yChUtil = height - spacing - (chUtilRatio * height)
drawCircle(
color = DEVICE_METRICS_COLORS[1],
radius = dataPointRadius,
center = Offset(x1, yChUtil)
)
/* Air Utilization Transmit */
val airUtilRatio = telemetry.deviceMetrics.airUtilTx / MAX_PERCENT_VALUE
val yAirUtil = height - spacing - (airUtilRatio * height)
drawCircle(
color = DEVICE_METRICS_COLORS[2],
radius = dataPointRadius,
center = Offset(x1, yAirUtil)
)
val x2 = spacing + (i + 1) * spacePerEntry
val y2 = height - spacing - (rightRatio * height)
if (i == 0)
moveTo(x1, y1)
lastX = (x1 + x2) / 2f
quadraticBezierTo(x1, y1, lastX, (y1 + y2) / 2f)
}
}
/* Battery Line */
drawPath(
path = strokePath,
color = DEVICE_METRICS_COLORS[0],
style = Stroke(
width = dataPointRadius,
cap = StrokeCap.Round
)
)
}
TimeLabels(
modifier = modifier,
graphColor = graphColor,
oldest = telemetries.first().time * MS_PER_SEC,
newest = telemetries.last().time * MS_PER_SEC
)
}
Spacer(modifier = Modifier.height(16.dp))
DeviceLegend()
Spacer(modifier = Modifier.height(16.dp))
}
@Suppress("LongMethod")
@Composable
private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: List<Telemetry>) {
ChartHeader(amount = telemetries.size, title = stringResource(R.string.environment_metrics))
if (telemetries.isEmpty())
return
Spacer(modifier = Modifier.height(16.dp))
val graphColor = MaterialTheme.colors.onSurface
val transparentTemperatureColor = remember { ENVIRONMENT_METRICS_COLORS[0].copy(alpha = 0.5f) }
val transparentHumidityColor = remember { ENVIRONMENT_METRICS_COLORS[1].copy(alpha = 0.5f) }
val spacing = LEFT_CHART_SPACING
/* Since both temperature and humidity are being plotted we need a combined min and max. */
val (minTemp, maxTemp) = remember(key1 = telemetries) {
Pair(
telemetries.minBy { it.environmentMetrics.temperature },
telemetries.maxBy { it.environmentMetrics.temperature }
)
}
val (minHumidity, maxHumidity) = remember(key1 = telemetries) {
Pair(
telemetries.minBy { it.environmentMetrics.relativeHumidity },
telemetries.maxBy { it.environmentMetrics.relativeHumidity }
)
}
val min = minOf(minTemp.environmentMetrics.temperature, minHumidity.environmentMetrics.relativeHumidity)
val max = maxOf(maxTemp.environmentMetrics.temperature, maxHumidity.environmentMetrics.relativeHumidity)
val diff = max - min
Box(contentAlignment = Alignment.TopStart) {
ChartOverlay(modifier = modifier, graphColor = graphColor, minValue = min, maxValue = max)
/* Plot Temperature and Relative Humidity */
Canvas(modifier = modifier) {
val height = size.height
val width = size.width - 28.dp.toPx()
val spacePerEntry = (width - spacing) / telemetries.size
/* Temperature */
var lastTempX = 0f
val temperaturePath = Path().apply {
for (i in telemetries.indices) {
val envMetrics = telemetries[i].environmentMetrics
val nextEnvMetrics =
(telemetries.getOrNull(i + 1) ?: telemetries.last()).environmentMetrics
val leftRatio = (envMetrics.temperature - min) / diff
val rightRatio = (nextEnvMetrics.temperature - min) / diff
val x1 = spacing + i * spacePerEntry
val y1 = height - spacing - (leftRatio * height)
val x2 = spacing + (i + 1) * spacePerEntry
val y2 = height - spacing - (rightRatio * height)
if (i == 0) {
moveTo(x1, y1)
}
lastTempX = (x1 + x2) / 2f
quadraticBezierTo(
x1, y1, lastTempX, (y1 + y2) / 2f
)
}
}
val fillPath = android.graphics.Path(temperaturePath.asAndroidPath())
.asComposePath()
.apply {
lineTo(lastTempX, height - spacing)
lineTo(spacing, height - spacing)
close()
}
drawPath(
path = fillPath,
brush = Brush.verticalGradient(
colors = listOf(
transparentTemperatureColor,
Color.Transparent
),
endY = height - spacing
),
)
drawPath(
path = temperaturePath,
color = ENVIRONMENT_METRICS_COLORS[0],
style = Stroke(
width = 2.dp.toPx(),
cap = StrokeCap.Round
)
)
/* Relative Humidity */
var lastHumidityX = 0f
val humidityPath = Path().apply {
for (i in telemetries.indices) {
val envMetrics = telemetries[i].environmentMetrics
val nextEnvMetrics =
(telemetries.getOrNull(i + 1) ?: telemetries.last()).environmentMetrics
val leftRatio = (envMetrics.relativeHumidity - min) / diff
val rightRatio = (nextEnvMetrics.relativeHumidity - min) / diff
val x1 = spacing + i * spacePerEntry
val y1 = height - spacing - (leftRatio * height)
val x2 = spacing + (i + 1) * spacePerEntry
val y2 = height - spacing - (rightRatio * height)
if (i == 0) {
moveTo(x1, y1)
}
lastHumidityX = (x1 + x2) / 2f
quadraticBezierTo(
x1, y1, lastHumidityX, (y1 + y2) / 2f
)
}
}
val fillHumidityPath = android.graphics.Path(humidityPath.asAndroidPath())
.asComposePath()
.apply {
lineTo(lastHumidityX, height - spacing)
lineTo(spacing, height - spacing)
close()
}
drawPath(
path = fillHumidityPath,
brush = Brush.verticalGradient(
colors = listOf(
transparentHumidityColor,
Color.Transparent
),
endY = height - spacing
),
)
drawPath(
path = humidityPath,
color = ENVIRONMENT_METRICS_COLORS[1],
style = Stroke(
width = 2.dp.toPx(),
cap = StrokeCap.Round
)
)
}
TimeLabels(
modifier = modifier,
graphColor = graphColor,
oldest = telemetries.first().time * MS_PER_SEC,
newest = telemetries.last().time * MS_PER_SEC
)
}
Spacer(modifier = Modifier.height(16.dp))
EnvironmentLegend()
Spacer(modifier = Modifier.height(16.dp))
}
@Composable
private fun DeviceMetricsCard(telemetry: Telemetry) {
val deviceMetrics = telemetry.deviceMetrics
val time = telemetry.time * MS_PER_SEC
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Surface {
SelectionContainer {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
/* Time, Battery, and Voltage */
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.button.fontSize
)
BatteryInfo(
batteryLevel = deviceMetrics.batteryLevel,
voltage = deviceMetrics.voltage
)
}
Spacer(modifier = Modifier.height(4.dp))
/* Channel Utilization and Air Utilization Tx */
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
val text = "%s %.2f%% %s %.2f%%".format(
stringResource(R.string.channel_utilization),
deviceMetrics.channelUtilization,
stringResource(R.string.air_utilization),
deviceMetrics.airUtilTx
)
Text(
text = text,
color = MaterialTheme.colors.onSurface,
fontSize = MaterialTheme.typography.button.fontSize
)
}
}
}
}
}
}
@Suppress("LongMethod")
@Composable
private fun EnvironmentMetricsCard(telemetry: Telemetry) {
val envMetrics = telemetry.environmentMetrics
val time = telemetry.time * MS_PER_SEC
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Surface {
SelectionContainer {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
/* Time and Temperature */
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.button.fontSize
)
Text(
text = "%s %.1f°C".format(
stringResource(id = R.string.temperature),
envMetrics.temperature
),
color = MaterialTheme.colors.onSurface,
fontSize = MaterialTheme.typography.button.fontSize
)
}
Spacer(modifier = Modifier.height(4.dp))
/* Humidity and Barometric Pressure */
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "%s %.2f%%".format(
stringResource(id = R.string.humidity),
envMetrics.relativeHumidity,
),
color = MaterialTheme.colors.onSurface,
fontSize = MaterialTheme.typography.button.fontSize
)
if (envMetrics.barometricPressure > 0) {
Text(
text = "%.2f hPa".format(envMetrics.barometricPressure),
color = MaterialTheme.colors.onSurface,
fontSize = MaterialTheme.typography.button.fontSize
)
}
}
}
}
}
}
}
@Composable
private fun ChartHeader(amount: Int, title: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "$amount $title",
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.button.fontSize
)
}
}
/**
* Draws chart lines and labels with respect to the Y-axis range; defined by (`maxValue` - `minValue`).
*/
@Composable
private fun ChartOverlay(
modifier: Modifier,
graphColor: 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
val width = size.width - 28.dp.toPx()
/* Horizontal Lines */
var lineY = minValue
for (i in 0..LINE_LIMIT) {
val ratio = (lineY - minValue) / range
val y = height - (ratio * height)
val color: Color = when (i) {
1 -> Color.Red
2 -> Orange
else -> graphColor
}
drawLine(
start = Offset(0f, y),
end = Offset(width, y),
color = color,
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f)
)
lineY += verticalSpacing
}
/* Y Labels */
val textPaint = Paint().apply {
color = graphColor.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
for (i in 0..LINE_LIMIT) {
val ratio = (label - minValue) / range
val y = height - (ratio * height)
drawText(
"${label.toInt()}",
width + 4.dp.toPx(),
y + 4.dp.toPx(),
textPaint
)
label += verticalSpacing
}
}
}
}
/**
* Draws the `oldest` and `newest` times for the respective telemetry data.
* Expects time in milliseconds
*/
@Composable
private fun TimeLabels(
modifier: Modifier,
graphColor: Color,
oldest: Float,
newest: Float
) {
val density = LocalDensity.current
Canvas(modifier = modifier) {
val textPaint = Paint().apply {
color = graphColor.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 {
drawText(
TIME_FORMAT.format(oldest),
8.dp.toPx(),
12.dp.toPx(),
textPaint
)
drawText(
TIME_FORMAT.format(newest),
size.width - 140.dp.toPx(),
12.dp.toPx(),
textPaint
)
}
}
}
@Composable
private fun DeviceLegend() {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Spacer(modifier = Modifier.weight(1f))
LegendLabel(text = stringResource(R.string.battery), color = DEVICE_METRICS_COLORS[0], isLine = true)
Spacer(modifier = Modifier.width(4.dp))
LegendLabel(text = stringResource(R.string.channel_utilization), color = DEVICE_METRICS_COLORS[1])
Spacer(modifier = Modifier.width(4.dp))
LegendLabel(text = stringResource(R.string.air_utilization), color = DEVICE_METRICS_COLORS[2])
Spacer(modifier = Modifier.weight(1f))
}
}
@Composable
private fun EnvironmentLegend() {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Spacer(modifier = Modifier.weight(1f))
LegendLabel(text = stringResource(R.string.temperature), color = ENVIRONMENT_METRICS_COLORS[0], isLine = true)
Spacer(modifier = Modifier.width(4.dp))
LegendLabel(text = stringResource(R.string.humidity), color = ENVIRONMENT_METRICS_COLORS[1], isLine = true)
Spacer(modifier = Modifier.weight(1f))
}
}
@Composable
private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
Canvas(
modifier = Modifier.size(4.dp)
) {
if (isLine) {
drawLine(
color = color,
start = Offset(x = 0f, y = size.height / 2f),
end = Offset(x = 16f, y = size.height / 2f),
strokeWidth = 2.dp.toPx(),
cap = StrokeCap.Round,
)
} else {
drawCircle(
color = color
)
}
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = text,
color = MaterialTheme.colors.onSurface,
fontSize = MaterialTheme.typography.button.fontSize,
)
}

View file

@ -19,4 +19,5 @@ val MeshtasticGreen = Color(0xFF67EA94)
val AlmostWhite = Color(0xB3FFFFFF)
val AlmostBlack = Color(0x8A000000)
val HyperlinkBlue = Color(0xFF43C3B0)
val HyperlinkBlue = Color(0xFF43C3B0)
val Orange = Color(255, 153, 0)