feat: Add IAQ to environment metrics (#1258)

Adds Indoor Air Quality (IAQ) to the environment metrics chart and card.

The IAQ is displayed as a green line on the chart and as a dot with a numerical value on the card.
The IAQ scale is available as a legend in the IAQ component.
pull/1259/head
James Rich 2024-09-18 16:42:58 -05:00 zatwierdzone przez GitHub
rodzic 843e423648
commit 9b90c128fb
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
3 zmienionych plików z 324 dodań i 8 usunięć

Wyświetl plik

@ -57,24 +57,25 @@ fun EnvironmentMetricsScreen(telemetries: List<Telemetry>) {
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(telemetries) { telemetry -> EnvironmentMetricsCard(telemetry)}
items(telemetries) { telemetry -> EnvironmentMetricsCard(telemetry) }
}
}
}
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: List<Telemetry>) {
ChartHeader(amount = telemetries.size)
if (telemetries.isEmpty())
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 transparentIAQColor = remember { ENVIRONMENT_METRICS_COLORS[2].copy(alpha = 0.5f) }
val spacing = LEFT_CHART_SPACING
/* Since both temperature and humidity are being plotted we need a combined min and max. */
@ -90,8 +91,22 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries:
telemetries.maxBy { it.environmentMetrics.relativeHumidity }
)
}
val min = minOf(minTemp.environmentMetrics.temperature, minHumidity.environmentMetrics.relativeHumidity)
val max = maxOf(maxTemp.environmentMetrics.temperature, maxHumidity.environmentMetrics.relativeHumidity)
val (minIAQ, maxIAQ) = remember(key1 = telemetries) {
Pair(
telemetries.minBy { it.environmentMetrics.iaq },
telemetries.maxBy { it.environmentMetrics.iaq }
)
}
val min = minOf(
minTemp.environmentMetrics.temperature,
minHumidity.environmentMetrics.relativeHumidity,
minIAQ.environmentMetrics.iaq.toFloat()
)
val max = maxOf(
maxTemp.environmentMetrics.temperature,
maxHumidity.environmentMetrics.relativeHumidity,
maxIAQ.environmentMetrics.iaq.toFloat()
)
val diff = max - min
Box(contentAlignment = Alignment.TopStart) {
@ -106,7 +121,6 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries:
/* 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
@ -216,6 +230,62 @@ private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries:
cap = StrokeCap.Round
)
)
/* Air Quality */
var lastIaqX = 0f
val iaqPath = Path().apply {
for (i in telemetries.indices) {
val envMetrics = telemetries[i].environmentMetrics
val nextEnvMetrics =
(telemetries.getOrNull(i + 1) ?: telemetries.last()).environmentMetrics
val leftRatio = (envMetrics.iaq - min) / diff
val rightRatio = (nextEnvMetrics.iaq - 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)
}
lastIaqX = (x1 + x2) / 2f
quadraticBezierTo(
x1,
y1,
lastIaqX,
(y1 + y2) / 2f
)
}
}
val fillIaqPath = android.graphics.Path(iaqPath.asAndroidPath())
.asComposePath()
.apply {
lineTo(lastIaqX, height - spacing)
lineTo(spacing, height - spacing)
close()
}
drawPath(
path = fillIaqPath,
brush = Brush.verticalGradient(
colors = listOf(
transparentIAQColor,
Color.Transparent
),
endY = height - spacing
),
)
drawPath(
path = iaqPath,
color = ENVIRONMENT_METRICS_COLORS[2],
style = Stroke(
width = 2.dp.toPx(),
cap = StrokeCap.Round
)
)
}
TimeLabels(
modifier = modifier,
@ -294,6 +364,26 @@ private fun EnvironmentMetricsCard(telemetry: Telemetry) {
)
}
}
if (telemetry.environmentMetrics.hasIaq()) {
Spacer(modifier = Modifier.height(4.dp))
/* Air Quality */
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.iaq),
color = MaterialTheme.colors.onSurface,
fontSize = MaterialTheme.typography.button.fontSize
)
Spacer(modifier = Modifier.width(4.dp))
IndoorAirQuality(
iaq = telemetry.environmentMetrics.iaq,
displayMode = IaqDisplayMode.Dot
)
}
}
}
}
}
@ -311,10 +401,18 @@ private fun EnvironmentLegend() {
LegendLabel(text = stringResource(R.string.temperature), color = ENVIRONMENT_METRICS_COLORS[0], isLine = true)
Spacer(modifier = Modifier.width(4.dp))
Spacer(modifier = Modifier.width(8.dp))
LegendLabel(text = stringResource(R.string.humidity), color = ENVIRONMENT_METRICS_COLORS[1], isLine = true)
Spacer(modifier = Modifier.width(8.dp))
LegendLabel(
text = stringResource(R.string.iaq),
color = ENVIRONMENT_METRICS_COLORS[2],
isLine = true
)
Spacer(modifier = Modifier.weight(1f))
}
}

Wyświetl plik

@ -0,0 +1,217 @@
package com.geeksville.mesh.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.LinearProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material.icons.filled.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
enum class Iaq(val color: Color, val description: String) {
Excellent(Color.Green, "Excellent"),
Good(Color.Green, "Good"),
LightlyPolluted(Color.Yellow, "Lightly Polluted"),
ModeratelyPolluted(Color.Orange, "Moderately Polluted"),
HeavilyPolluted(Color.Red, "Heavily Polluted"),
SeverelyPolluted(Color.Purple, "Severely Polluted"),
ExtremelyPolluted(Color.Purple, "Extremely Polluted"),
DangerouslyPolluted(Color.Brown, "Dangerously Polluted")
}
val Color.Companion.Mint: Color
get() = Color(0xFF98FB98)
val Color.Companion.Purple: Color
get() = Color(0xFF800080)
val Color.Companion.Brown: Color
get() = Color(0xFFA52A2A)
val Color.Companion.Orange: Color
get() = Color(0xFFFFA500)
@Suppress("MagicNumber")
fun getIaq(iaq: Int): Iaq {
return when {
iaq <= 50 -> Iaq.Excellent
iaq <= 100 -> Iaq.Good
iaq <= 150 -> Iaq.LightlyPolluted
iaq <= 200 -> Iaq.ModeratelyPolluted
iaq <= 300 -> Iaq.HeavilyPolluted
iaq <= 400 -> Iaq.SeverelyPolluted
iaq <= 500 -> Iaq.ExtremelyPolluted
else -> Iaq.DangerouslyPolluted
}
}
enum class IaqDisplayMode {
Pill, Dot, Text, Gauge, Gradient
}
@Suppress("LongMethod", "UnusedPrivateProperty")
@Composable
fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill) {
var isLegendOpen by remember { mutableStateOf(false) }
val iaqEnum = getIaq(iaq)
val gradient = Brush.linearGradient(
colors = listOf(
Color.Green, Color.Mint, Color.Yellow, Color.Orange, Color.Red,
Color.Purple, Color.Purple, Color.Brown, Color.Brown, Color.Brown, Color.Brown
)
)
Column {
when (displayMode) {
IaqDisplayMode.Pill -> {
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(iaqEnum.color)
.width(125.dp)
.height(30.dp)
.clickable { isLegendOpen = true }
) {
Row(
modifier = Modifier
.padding(4.dp)
.align(Alignment.CenterStart),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "IAQ $iaq",
color = Color.White,
fontWeight = FontWeight.Bold
)
Icon(
imageVector = if (iaq < 100) Icons.Default.ThumbUp else Icons.Filled.Warning,
contentDescription = "AQI Icon",
tint = Color.White
)
}
}
}
IaqDisplayMode.Dot -> {
Column(modifier = Modifier.clickable { isLegendOpen = true }) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "$iaq")
Spacer(modifier = Modifier.width(4.dp))
Box(
modifier = Modifier
.size(10.dp)
.background(iaqEnum.color, shape = CircleShape)
)
}
}
}
IaqDisplayMode.Text -> {
Text(
text = iaqEnum.description,
fontSize = 12.sp,
modifier = Modifier.clickable { isLegendOpen = true }
)
}
IaqDisplayMode.Gauge -> {
CircularProgressIndicator(
progress = iaq / 500f,
modifier = Modifier
.size(60.dp)
.clickable { isLegendOpen = true },
strokeWidth = 8.dp,
color = iaqEnum.color
)
Text(text = "$iaq")
}
IaqDisplayMode.Gradient -> {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.clickable { isLegendOpen = true }
) {
LinearProgressIndicator(
progress = iaq / 500f,
modifier = Modifier
.fillMaxWidth()
.height(20.dp),
color = iaqEnum.color,
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = iaqEnum.description, fontSize = 12.sp)
}
}
}
if (isLegendOpen) {
AlertDialog(
onDismissRequest = { isLegendOpen = false },
title = { Text("IAQ Scale") },
text = {
IAQScale()
},
confirmButton = {
Button(onClick = { isLegendOpen = false }) {
Text("Close")
}
}
)
}
}
}
// Assuming Iaq is an enum class with color and description properties
// and that it conforms to CaseIterable.
// Replace with your actual implementation
@Composable
fun IAQScale(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.padding(16.dp),
horizontalAlignment = Alignment.Start
) {
Text("Indoor Air Quality (IAQ)", style = MaterialTheme.typography.h6)
Spacer(modifier = Modifier.height(8.dp))
for (iaq in Iaq.entries) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(20.dp, 15.dp)
.clip(RoundedCornerShape(5.dp))
.background(iaq.color)
)
Spacer(modifier = Modifier.width(8.dp))
Text(iaq.description, style = MaterialTheme.typography.body2)
}
Spacer(modifier = Modifier.height(4.dp))
}
}
}

Wyświetl plik

@ -230,4 +230,5 @@
<string name="info">Information</string>
<string name="ch_util_definition">Utilization for the current channel, including well formed TX, RX and malformed RX (aka noise).</string>
<string name="air_util_definition">Percent of airtime for transmission used within the last hour.</string>
<string name="iaq">IAQ</string>
</resources>