diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt index 7e02e7607..f9973956f 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt @@ -27,7 +27,7 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L } @OptIn(ExperimentalCoroutinesApi::class) - fun getMeshPacketsFrom(nodeNum: Int) = meshLogDao.getAllLogsInReceiveOrder(Int.MAX_VALUE) + fun getMeshPacketsFrom(nodeNum: Int) = meshLogDao.getAllLogs(MAX_MESH_PACKETS) .mapLatest { list -> list.mapNotNull { it.meshPacket }.filter { it.from == nodeNum } } .distinctUntilChanged() .flowOn(Dispatchers.IO) @@ -48,5 +48,6 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L companion object { private const val MAX_ITEMS = 500 + private const val MAX_MESH_PACKETS = 10000 } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/MetricsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MetricsFragment.kt index 3540c374d..e3dbf235f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MetricsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MetricsFragment.kt @@ -4,7 +4,6 @@ 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -93,7 +92,6 @@ class MetricsFragment : ScreenFragment("Metrics"), Logging { } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun MetricsScreen( model: MetricsViewModel = hiltViewModel(), @@ -135,7 +133,6 @@ fun MetricsScreen( } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun MetricsPagerScreen( state: MetricsState, @@ -186,7 +183,6 @@ fun MetricsPagerScreen( } } -@OptIn(ExperimentalFoundationApi::class) @PreviewLightDark @Composable private fun MetricsPreview() { diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt b/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt new file mode 100644 index 000000000..c766b86a0 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt @@ -0,0 +1,198 @@ +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.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.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.ui.components.CommonCharts.LINE_OFF +import com.geeksville.mesh.ui.components.CommonCharts.LINE_ON +import com.geeksville.mesh.ui.components.CommonCharts.TIME_FORMAT +import com.geeksville.mesh.ui.components.CommonCharts.LINE_LIMIT +import com.geeksville.mesh.ui.components.CommonCharts.TEXT_PAINT_ALPHA +import com.geeksville.mesh.ui.theme.Orange +import java.text.DateFormat + + +object CommonCharts { + 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 ChartHeader(amount: Int) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "$amount ${stringResource(R.string.logs)}", + 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 +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 +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 +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, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt b/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt deleted file mode 100644 index 1e5e6c93c..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt +++ /dev/null @@ -1,680 +0,0 @@ -@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.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(telemetries: List) { - Column { - DeviceMetricsChart( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(fraction = 0.33f), - telemetries - ) - /* Device Metric Cards */ - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - items(telemetries.reversed()) { telemetry -> DeviceMetricsCard(telemetry) } - } - } -} - -@Composable -fun EnvironmentMetricsScreen(telemetries: List) { - Column { - EnvironmentMetricsChart( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(fraction = 0.33f), - telemetries = telemetries - ) - - /* Environment Metric Cards */ - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - items(telemetries.reversed()) { telemetry -> EnvironmentMetricsCard(telemetry)} - } - } -} - -@Suppress("LongMethod") -@Composable -private fun DeviceMetricsChart(modifier: Modifier = Modifier, telemetries: List) { - - ChartHeader(amount = telemetries.size) - 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) { - - ChartHeader(amount = telemetries.size) - 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 = stringResource(R.string.channel_air_util).format( - deviceMetrics.channelUtilization, - 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) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "$amount ${stringResource(R.string.logs)}", - 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, - ) -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt new file mode 100644 index 000000000..e73577d0e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt @@ -0,0 +1,228 @@ +package com.geeksville.mesh.ui.components + +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.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.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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +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.CommonCharts.DEVICE_METRICS_COLORS +import com.geeksville.mesh.ui.components.CommonCharts.LEFT_CHART_SPACING +import com.geeksville.mesh.ui.components.CommonCharts.MAX_PERCENT_VALUE +import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC +import com.geeksville.mesh.ui.components.CommonCharts.TIME_FORMAT + + +@Composable +fun DeviceMetricsScreen(telemetries: List) { + Column { + DeviceMetricsChart( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.33f), + telemetries.reversed() + ) + /* Device Metric Cards */ + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(telemetries) { telemetry -> DeviceMetricsCard(telemetry) } + } + } +} + +@Suppress("LongMethod") +@Composable +private fun DeviceMetricsChart(modifier: Modifier = Modifier, telemetries: List) { + + ChartHeader(amount = telemetries.size) + 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)) +} + +@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 = stringResource(R.string.channel_air_util).format( + deviceMetrics.channelUtilization, + deviceMetrics.airUtilTx + ) + Text( + text = text, + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + } + } + } + } + } +} + +@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)) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt new file mode 100644 index 000000000..7937adf2b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt @@ -0,0 +1,313 @@ +package com.geeksville.mesh.ui.components + +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.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.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.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.Stroke +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.components.CommonCharts.ENVIRONMENT_METRICS_COLORS +import com.geeksville.mesh.ui.components.CommonCharts.LEFT_CHART_SPACING +import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC +import com.geeksville.mesh.ui.components.CommonCharts.TIME_FORMAT + + +@Composable +fun EnvironmentMetricsScreen(telemetries: List) { + Column { + EnvironmentMetricsChart( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.33f), + telemetries = telemetries.reversed() + ) + + /* Environment Metric Cards */ + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items(telemetries) { telemetry -> EnvironmentMetricsCard(telemetry)} + } + } +} + +@Suppress("LongMethod") +@Composable +private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: List) { + + ChartHeader(amount = telemetries.size) + 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)) +} + +@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 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)) + } +}