mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Telemetry charts (#1198)
This commit is contained in:
parent
cb2ea2804a
commit
9f136fb31b
9 changed files with 962 additions and 1 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
176
app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt
Normal file
176
app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue