From ca5cf8c439142fa59f782f9811eb7f231234e581 Mon Sep 17 00:00:00 2001 From: beetzung Date: Wed, 4 Mar 2026 19:34:59 +0100 Subject: [PATCH] feat(metric): reimplement RoundedDiagram/RingDiagram in Compose - Add MetricGauge, RingGauge, MetricGaugeRow composables - Add MetricGaugeContract with SensorType enum and AQI color mapping - Add AQI colors (SensorGreen, SensorYellow, etc.) to theme - Copy ic_temperature, ic_humidity, ic_pressure drawables from legacy - Integrate MetricGaugeRow into DashboardScreen - Add 9 preview composables for portfolio readiness Made-with: Cursor --- .../features/common/metric/MetricGauge.kt | 351 ++++++++++++++++++ .../common/metric/MetricGaugeContract.kt | 54 +++ .../airmq/features/common/metric/RingGauge.kt | 97 +++++ .../features/dashboard/DashboardScreen.kt | 56 ++- .../kotlin/org/db3/airmq/ui/theme/Color.kt | 11 +- app/src/main/res/drawable/ic_humidity.xml | 15 + app/src/main/res/drawable/ic_pressure.xml | 36 ++ app/src/main/res/drawable/ic_temperature.xml | 9 + 8 files changed, 612 insertions(+), 17 deletions(-) create mode 100644 app/src/main/kotlin/org/db3/airmq/features/common/metric/MetricGauge.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/common/metric/MetricGaugeContract.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/common/metric/RingGauge.kt create mode 100644 app/src/main/res/drawable/ic_humidity.xml create mode 100644 app/src/main/res/drawable/ic_pressure.xml create mode 100644 app/src/main/res/drawable/ic_temperature.xml diff --git a/app/src/main/kotlin/org/db3/airmq/features/common/metric/MetricGauge.kt b/app/src/main/kotlin/org/db3/airmq/features/common/metric/MetricGauge.kt new file mode 100644 index 0000000..4d35378 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/common/metric/MetricGauge.kt @@ -0,0 +1,351 @@ +package org.db3.airmq.features.common.metric + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import org.db3.airmq.features.common.metric.getColorFromMetrics +import org.db3.airmq.features.common.metric.getSensorIconRes +import org.db3.airmq.ui.theme.AirMQTheme +import org.db3.airmq.ui.theme.LegacyNavGradientEnd +import org.db3.airmq.ui.theme.LegacyNavGradientStart + +/** + * Single metric gauge with ring, value, units, and icon. + * Mirrors legacy [RoundedDiagram] layout and behavior. + * + * @param value Current value; null shows "?" + * @param sensorType Sensor type for icon, units, and progress logic + * @param selected When true, shows frosted background + * @param onClick Called when gauge is tapped + * @param modifier Modifier + */ +@Composable +fun MetricGauge( + value: Float?, + sensorType: SensorType, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val (progress, progressColor) = when (sensorType) { + SensorType.HUMIDITY -> { + val p = value?.coerceIn(0f, 100f) ?: 0f + p to Color.White + } + SensorType.DUST -> { + val p = value?.coerceIn(0f, 100f) ?: 0f + val color = value?.let { getColorFromMetrics(it, sensorType) } ?: Color.White + p to color + } + else -> 0f to Color.White + } + + val valueText = when { + value == null -> "?" + sensorType == SensorType.RADIOACTIVITY -> "%.2f".format(value) + else -> kotlin.math.round(value).toInt().toString() + } + + Box( + modifier = modifier + .widthIn(min = 96.dp) + .padding(horizontal = 4.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClick() } + ) { + if (selected) { + Box( + modifier = Modifier + .matchParentSize() + .clip(RoundedCornerShape(18.dp)) + .background(Color.White.copy(alpha = 0.1f)) + ) + } + Box( + modifier = Modifier + .align(Alignment.Center) + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + RingGauge( + progress = progress, + progressColor = progressColor, + modifier = Modifier.size(77.dp) + ) + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = valueText, + color = Color.White, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ) + Text( + text = sensorType.units(), + color = Color.White.copy(alpha = 0.54f), + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + Icon( + painter = painterResource(id = getSensorIconRes(sensorType)), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .offset(y = 38.dp) + .size(24.dp), + tint = Color.White + ) + } + } +} + +/** Default sensor order for dashboard gauge row. */ +private val DashboardGaugeOrder = listOf( + SensorType.DUST, + SensorType.RADIOACTIVITY, + SensorType.TEMPERATURE +) + +/** + * Horizontal row of metric gauges (dust, radioactivity, temperature). + * + * @param selectedSensor Currently selected sensor; null if none + * @param values Map of sensor type to value + * @param onGaugeSelected Called when a gauge is tapped + * @param modifier Modifier + */ +@Composable +fun MetricGaugeRow( + selectedSensor: SensorType?, + values: Map, + onGaugeSelected: (SensorType) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + for (sensorType in DashboardGaugeOrder) { + MetricGauge( + value = values[sensorType], + sensorType = sensorType, + selected = selectedSensor == sensorType, + onClick = { onGaugeSelected(sensorType) } + ) + } + } +} + +@Preview(showBackground = true, name = "Dust – value 6") +@Composable +private fun PreviewMetricGaugeDust() { + AirMQTheme { + Box( + modifier = Modifier + .background( + androidx.compose.ui.graphics.Brush.verticalGradient( + colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd) + ) + ) + .padding(16.dp) + ) { + MetricGauge( + value = 6f, + sensorType = SensorType.DUST, + selected = false, + onClick = {} + ) + } + } +} + +@Preview(showBackground = true, name = "Radiation – value 0") +@Composable +private fun PreviewMetricGaugeRadiation() { + AirMQTheme { + Box( + modifier = Modifier + .background( + androidx.compose.ui.graphics.Brush.verticalGradient( + colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd) + ) + ) + .padding(16.dp) + ) { + MetricGauge( + value = 0f, + sensorType = SensorType.RADIOACTIVITY, + selected = false, + onClick = {} + ) + } + } +} + +@Preview(showBackground = true, name = "Temperature – value 3") +@Composable +private fun PreviewMetricGaugeTemperature() { + AirMQTheme { + Box( + modifier = Modifier + .background( + androidx.compose.ui.graphics.Brush.verticalGradient( + colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd) + ) + ) + .padding(16.dp) + ) { + MetricGauge( + value = 3f, + sensorType = SensorType.TEMPERATURE, + selected = false, + onClick = {} + ) + } + } +} + +@Preview(showBackground = true, name = "Humidity – value 65") +@Composable +private fun PreviewMetricGaugeHumidity() { + AirMQTheme { + Box( + modifier = Modifier + .background( + androidx.compose.ui.graphics.Brush.verticalGradient( + colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd) + ) + ) + .padding(16.dp) + ) { + MetricGauge( + value = 65f, + sensorType = SensorType.HUMIDITY, + selected = false, + onClick = {} + ) + } + } +} + +@Preview(showBackground = true, name = "No data") +@Composable +private fun PreviewMetricGaugeNoData() { + AirMQTheme { + Box( + modifier = Modifier + .background( + androidx.compose.ui.graphics.Brush.verticalGradient( + colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd) + ) + ) + .padding(16.dp) + ) { + MetricGauge( + value = null, + sensorType = SensorType.DUST, + selected = false, + onClick = {} + ) + } + } +} + +@Preview(showBackground = true, name = "Selected") +@Composable +private fun PreviewMetricGaugeSelected() { + AirMQTheme { + Box( + modifier = Modifier + .background( + androidx.compose.ui.graphics.Brush.verticalGradient( + colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd) + ) + ) + .padding(16.dp) + ) { + MetricGauge( + value = 6f, + sensorType = SensorType.DUST, + selected = true, + onClick = {} + ) + } + } +} + +@Preview(showBackground = true, name = "Gauge row") +@Composable +private fun PreviewMetricGaugeRow() { + AirMQTheme { + Box( + modifier = Modifier + .background( + androidx.compose.ui.graphics.Brush.verticalGradient( + colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd) + ) + ) + .padding(16.dp) + ) { + MetricGaugeRow( + selectedSensor = SensorType.DUST, + values = mapOf( + SensorType.DUST to 6f, + SensorType.RADIOACTIVITY to 0f, + SensorType.TEMPERATURE to 3f + ), + onGaugeSelected = {} + ) + } + } +} + +@Preview(showBackground = true, name = "Dust orange band") +@Composable +private fun PreviewMetricGaugeDustOrange() { + AirMQTheme { + Box( + modifier = Modifier + .background( + androidx.compose.ui.graphics.Brush.verticalGradient( + colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd) + ) + ) + .padding(16.dp) + ) { + MetricGauge( + value = 40f, + sensorType = SensorType.DUST, + selected = false, + onClick = {} + ) + } + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/common/metric/MetricGaugeContract.kt b/app/src/main/kotlin/org/db3/airmq/features/common/metric/MetricGaugeContract.kt new file mode 100644 index 0000000..fe876f5 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/common/metric/MetricGaugeContract.kt @@ -0,0 +1,54 @@ +package org.db3.airmq.features.common.metric + +import androidx.compose.ui.graphics.Color +import org.db3.airmq.R +import org.db3.airmq.features.common.chart.getUnitsForSensor +import org.db3.airmq.ui.theme.SensorGreen +import org.db3.airmq.ui.theme.SensorOrange +import org.db3.airmq.ui.theme.SensorPink +import org.db3.airmq.ui.theme.SensorPurple +import org.db3.airmq.ui.theme.SensorRed +import org.db3.airmq.ui.theme.SensorYellow + +/** + * Sensor types supported by the metric gauge. + * Maps to legacy Chart sensor constants. + */ +enum class SensorType(val legacyKey: String) { + DUST("sensor_dust"), + RADIOACTIVITY("sensor_radioactivity"), + TEMPERATURE("sensor_temperature"), + HUMIDITY("sensor_humidity"), + PRESSURE("sensor_pressure"), + CO2("sensor_co2"), + VOC("sensor_voc"); + + fun units(): String = getUnitsForSensor(legacyKey) +} + +/** + * AQI color bands for dust (PM2.5 µg/m³) per EPA scale. + * Returns progress ring color based on value. + */ +fun getColorFromMetrics(value: Float, sensorType: SensorType): Color = when (sensorType) { + SensorType.DUST -> when { + value <= 12f -> SensorGreen + value <= 35.4f -> SensorYellow + value <= 55.4f -> SensorOrange + value <= 150.4f -> SensorRed + value <= 250.4f -> SensorPink + else -> SensorPurple + } + else -> Color.White +} + +/** + * Drawable resource ID for the sensor icon. + */ +fun getSensorIconRes(sensorType: SensorType): Int = when (sensorType) { + SensorType.TEMPERATURE -> R.drawable.ic_temperature + SensorType.HUMIDITY -> R.drawable.ic_humidity + SensorType.PRESSURE -> R.drawable.ic_pressure + SensorType.RADIOACTIVITY -> R.drawable.ic_radiation + else -> R.drawable.ic_dust +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/common/metric/RingGauge.kt b/app/src/main/kotlin/org/db3/airmq/features/common/metric/RingGauge.kt new file mode 100644 index 0000000..f9424a9 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/common/metric/RingGauge.kt @@ -0,0 +1,97 @@ +package org.db3.airmq.features.common.metric + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.db3.airmq.ui.theme.SensorGreen + +private const val StartAngle = 135f +private const val FullSweepAngle = 270f + +/** + * Circular progress gauge drawn as a 270° arc (7 o'clock to 4 o'clock). + * Mirrors legacy [RingDiagram] behavior. + * + * @param progress Progress 0–100, maps to arc sweep + * @param progressColor Color of the progress arc + * @param modifier Modifier + * @param size Total size of the gauge + * @param strokeWidth Stroke width of the arc + * @param backgroundColor Background ring color (default white38) + */ +@Composable +fun RingGauge( + progress: Float, + progressColor: Color, + modifier: Modifier = Modifier, + size: Dp = 77.dp, + strokeWidth: Dp = 5.dp, + backgroundColor: Color = Color.White.copy(alpha = 0.38f) +) { + val animatedProgress by animateFloatAsState( + targetValue = progress.coerceIn(0f, 100f), + animationSpec = tween(durationMillis = 400), + label = "ring_progress" + ) + val sweepDegrees = (FullSweepAngle / 100f) * animatedProgress + + Canvas( + modifier = modifier.size(size) + ) { + val strokePx = strokeWidth.toPx() + val halfStroke = strokePx / 2f + val topLeft = Offset(halfStroke, halfStroke) + val arcSize = androidx.compose.ui.geometry.Size(size.toPx() - strokePx, size.toPx() - strokePx) + + // Background ring + drawArc( + color = backgroundColor, + startAngle = StartAngle, + sweepAngle = FullSweepAngle, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = strokePx, cap = StrokeCap.Round) + ) + // Progress ring + if (sweepDegrees > 0f) { + drawArc( + color = progressColor, + startAngle = StartAngle, + sweepAngle = sweepDegrees, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = strokePx, cap = StrokeCap.Round) + ) + } + } +} + +@Preview(showBackground = true, name = "RingGauge – 40% progress") +@Composable +private fun PreviewRingGauge() { + Box( + modifier = Modifier + .size(100.dp) + .padding(12.dp) + ) { + RingGauge( + progress = 40f, + progressColor = SensorGreen + ) + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreen.kt index 7e14f11..e549b1b 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreen.kt @@ -2,10 +2,16 @@ package org.db3.airmq.features.dashboard import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column 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.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.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -13,6 +19,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.db3.airmq.R import org.db3.airmq.features.common.MockScreenScaffold +import org.db3.airmq.features.common.metric.MetricGaugeRow +import org.db3.airmq.features.common.metric.SensorType import org.db3.airmq.features.common.ScreenAction import org.db3.airmq.features.common.chart.AirMQChart import org.db3.airmq.features.common.chart.ChartConfig @@ -36,30 +44,46 @@ fun DashboardScreen( title = stringResource(id = R.string.title_dashboard), subtitle = stringResource(id = R.string.dashboard_subtitle), content = { - Box( + var selectedSensor by remember { mutableStateOf(SensorType.DUST) } + Column( modifier = Modifier .fillMaxWidth() - .height(144.dp) .background( Brush.verticalGradient( colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd) ) ) ) { - AirMQChart( - data = ChartDataset.Single(generateSineWaveData()), - config = ChartConfig( - lineColor = Color.White, - fillColor = ChartFill, - backgroundColor = ChartBackground, - labelColor = Color.White, - leftTimeLabel = stringResource(R.string.text_yesterday), - rightTimeLabel = stringResource(R.string.text_now), - unit = "°C" - ), - sensorType = "sensor_temperature", - modifier = Modifier.fillMaxSize() - ) + MetricGaugeRow( + selectedSensor = selectedSensor, + values = mapOf( + SensorType.DUST to 6f, + SensorType.RADIOACTIVITY to 0f, + SensorType.TEMPERATURE to 3f + ), + onGaugeSelected = { selectedSensor = it }, + modifier = Modifier.padding(vertical = 8.dp) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(144.dp) + ) { + AirMQChart( + data = ChartDataset.Single(generateSineWaveData()), + config = ChartConfig( + lineColor = Color.White, + fillColor = ChartFill, + backgroundColor = ChartBackground, + labelColor = Color.White, + leftTimeLabel = stringResource(R.string.text_yesterday), + rightTimeLabel = stringResource(R.string.text_now), + unit = "°C" + ), + sensorType = "sensor_temperature", + modifier = Modifier.fillMaxSize() + ) + } } }, actions = listOf( diff --git a/app/src/main/kotlin/org/db3/airmq/ui/theme/Color.kt b/app/src/main/kotlin/org/db3/airmq/ui/theme/Color.kt index 9083f20..ad7ab11 100644 --- a/app/src/main/kotlin/org/db3/airmq/ui/theme/Color.kt +++ b/app/src/main/kotlin/org/db3/airmq/ui/theme/Color.kt @@ -43,4 +43,13 @@ val SensorPressure = Color(0xFF4FC3F7) val SensorDust25 = Color(0xFF4A90D9) val SensorDust10 = Color(0xFF81C784) val SensorDust1 = Color(0xFF90A4AE) -val SensorRadioactivity = Color(0xFFE57373) \ No newline at end of file +val SensorRadioactivity = Color(0xFFE57373) + +// AQI / metric gauge colors (EPA scale for dust) +val SensorGreen = Color(0xFF00FF1E) +val SensorYellow = Color(0xFFFFBF00) +val SensorOrange = Color(0xFFFF6F00) +val SensorRed = Color(0xFFFF0000) +val SensorPink = Color(0xFFFF006A) +val SensorPurple = Color(0xFF6200FF) +val SensorGrey = Color(0xFF858585) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_humidity.xml b/app/src/main/res/drawable/ic_humidity.xml new file mode 100644 index 0000000..f74f645 --- /dev/null +++ b/app/src/main/res/drawable/ic_humidity.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_pressure.xml b/app/src/main/res/drawable/ic_pressure.xml new file mode 100644 index 0000000..39011a5 --- /dev/null +++ b/app/src/main/res/drawable/ic_pressure.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_temperature.xml b/app/src/main/res/drawable/ic_temperature.xml new file mode 100644 index 0000000..3244a26 --- /dev/null +++ b/app/src/main/res/drawable/ic_temperature.xml @@ -0,0 +1,9 @@ + + +