From 00ad737e7e3d4dbf4d0a3a9b89deb2e78be58cbb Mon Sep 17 00:00:00 2001 From: beetzung Date: Wed, 4 Mar 2026 19:18:03 +0100 Subject: [PATCH] feat(chart): implement AirMQChart component with legacy design - Add AirMQChart Composable with 4-segment background, rounded corners, fake piece - Support single-line and multiline datasets with cubic Bezier curves - Add value labels (max, mid, min) on left, last value on right - Add bottom row with time labels and center label (e.g. sensor name) - Include touch marker, empty state (threshold 3 points), multiline preview - Add ChartConfig, ChartData, ChartDataGenerator, ChartUtils - Add chart/sensor colors to Color.kt - Integrate test chart in DashboardScreen, use in MapUiComponents device panel Made-with: Cursor --- .../airmq/features/common/chart/AirMQChart.kt | 1008 +++++++++++++++++ .../features/common/chart/ChartConfig.kt | 20 + .../airmq/features/common/chart/ChartData.kt | 12 + .../common/chart/ChartDataGenerator.kt | 54 + .../airmq/features/common/chart/ChartUtils.kt | 46 + .../features/dashboard/DashboardScreen.kt | 44 + .../db3/airmq/features/map/MapUiComponents.kt | 30 +- .../kotlin/org/db3/airmq/ui/theme/Color.kt | 18 +- 8 files changed, 1221 insertions(+), 11 deletions(-) create mode 100644 app/src/main/kotlin/org/db3/airmq/features/common/chart/AirMQChart.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartConfig.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartData.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartDataGenerator.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartUtils.kt diff --git a/app/src/main/kotlin/org/db3/airmq/features/common/chart/AirMQChart.kt b/app/src/main/kotlin/org/db3/airmq/features/common/chart/AirMQChart.kt new file mode 100644 index 0000000..4e319a5 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/common/chart/AirMQChart.kt @@ -0,0 +1,1008 @@ +package org.db3.airmq.features.common.chart + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +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.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.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.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.material3.Text +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.db3.airmq.ui.theme.AirMQTheme +import org.db3.airmq.ui.theme.ChartBackground +import org.db3.airmq.ui.theme.ChartFill +import org.db3.airmq.ui.theme.ChartFillWidget +import org.db3.airmq.ui.theme.ChartBackgroundWidget +import org.db3.airmq.ui.theme.ChartLineWidget +import org.db3.airmq.ui.theme.LegacyNavGradientEnd +import org.db3.airmq.ui.theme.LegacyNavGradientStart +import org.db3.airmq.ui.theme.SensorDust1 +import org.db3.airmq.ui.theme.SensorDust10 +import org.db3.airmq.ui.theme.SensorDust25 +import org.db3.airmq.ui.theme.SensorHumidity +import org.db3.airmq.ui.theme.SensorRadioactivity +import org.db3.airmq.ui.theme.SensorTemperature +import org.db3.airmq.R +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val ChartPaddingLeftDp = 56.dp +private val ChartPaddingRightDp = 40.dp +private val ChartPaddingTopDp = 8.dp +private val ChartPaddingBottomDp = 52.dp +private val ChartBottomRowHeightDp = 36.dp +private val LabelTextSizeSp = 12f +private val MarkerRadiusPx = 8f + +@OptIn(ExperimentalTextApi::class) +@Composable +fun AirMQChart( + data: ChartDataset?, + config: ChartConfig, + modifier: Modifier = Modifier, + sensorType: String = "sensor_temperature", + noDataText: String = stringResource(R.string.text_no_data) +) { + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val unit = config.unit.ifEmpty { getUnitsForSensor(sensorType) } + + val (points, multiLines) = when (data) { + is ChartDataset.Single -> data.points to null + is ChartDataset.Multi -> { + val allPoints = data.lines.flatMap { it.points } + allPoints to data.lines + } + else -> emptyList() to null + } + val hasData = when { + multiLines != null -> multiLines.any { it.points.size >= 3 } + else -> points.size >= 3 + } + + var markerPoint: ChartDataPoint? by remember { mutableStateOf(null) } + var showMarker by remember { mutableStateOf(false) } + + val sortedPoints = remember(points) { + points.sortedBy { it.timestamp } + } + val (xMin, xMax, yMin, yMax, rangeX, rangeY, lastY) = remember(sortedPoints, multiLines) { + val pts = multiLines?.flatMap { it.points.sortedBy { p -> p.timestamp } } ?: sortedPoints + if (pts.size < 3) { + Quad(0L, 0L, 0f, 0f, 1L, 1f, 0f) + } else { + val ts = pts.map { it.timestamp } + val vals = pts.map { it.value } + val xMin = ts.min() + val xMax = ts.max() + val yMin = vals.min() + val yMax = vals.max() + val rangeX = (xMax - xMin).toFloat().coerceAtLeast(1f) + val rangeY = (yMax - yMin).coerceAtLeast(0.01f) + val lastY = if (multiLines != null && multiLines.isNotEmpty()) { + val primaryLine = multiLines.getOrNull(1) ?: multiLines.first() + primaryLine.points.maxByOrNull { it.timestamp }?.value ?: vals.last() + } else { + vals.last() + } + Quad(xMin, xMax, yMin, yMax, rangeX.toLong(), rangeY, lastY) + } + } + + var chartSize by remember { mutableStateOf(IntSize.Zero) } + + Column(modifier = modifier) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .onSizeChanged { chartSize = it } + .pointerInput(hasData, chartSize, sortedPoints, xMin, rangeX) { + if (!hasData || chartSize.width == 0) return@pointerInput + detectTapGestures { offset -> + val nearest = findNearestPoint( + offset = offset, + points = sortedPoints, + chartSize = chartSize, + xMin = xMin, + rangeX = rangeX, + density = density + ) + if (nearest != null) { + markerPoint = nearest + showMarker = true + } + } + } + ) { + if (!hasData) { + Canvas(modifier = Modifier.fillMaxSize()) { + val w = size.width + val h = size.height + val left = with(density) { ChartPaddingLeftDp.toPx() } + val right = w - with(density) { ChartPaddingRightDp.toPx() } + val rad = with(density) { 8.dp.toPx() } + drawRoundRect( + color = config.backgroundColor, + topLeft = Offset(0f, 0f), + size = Size(w, h), + cornerRadius = CornerRadius(rad, rad) + ) + } + CenteredNoDataLabel( + text = noDataText, + textMeasurer = textMeasurer, + modifier = Modifier.fillMaxSize(), + labelColor = config.effectiveLabelColor + ) + } else { + Canvas(modifier = Modifier.fillMaxSize()) { + val w = size.width + val h = size.height + val innerLeft = with(density) { ChartPaddingLeftDp.toPx() } + val innerRight = w - with(density) { ChartPaddingRightDp.toPx() } + val innerTop = with(density) { ChartPaddingTopDp.toPx() } + val bottomMarginPx = with(density) { 16.dp.toPx() } + val fakePieceTop = h - bottomMarginPx - 1f + val innerBottomLegacy = h - with(density) { ChartPaddingBottomDp.toPx() } + val fillBottom = if (config.roundCorners) fakePieceTop else innerBottomLegacy + bottomMarginPx + val chartWidth = innerRight - innerLeft + val chartHeight = fillBottom - innerTop + + fun toX(ts: Long) = innerLeft + ((ts - xMin) / rangeX.toFloat()) * chartWidth + fun toY(v: Float) = fillBottom - ((v - yMin) / rangeY) * chartHeight + + drawChartBackground( + left = innerLeft, + top = 0f, + right = innerRight, + bottom = h, + config = config, + density = density + ) + + if (multiLines != null) { + val defaultMultiColors = listOf(SensorDust10, SensorDust25, SensorDust1) + val lineColors = config.multiLineColors ?: defaultMultiColors + multiLines.forEachIndexed { index, line -> + val linePoints = line.points.sortedBy { it.timestamp } + if (linePoints.size >= 2) { + val screenPoints = linePoints.map { ChartScreenPoint(toX(it.timestamp), toY(it.value), it) } + val lineColor = lineColors.getOrElse(index) { config.lineColor } + val fillColor = Color( + alpha = lineColor.alpha * 0.5f, + red = lineColor.red, + green = lineColor.green, + blue = lineColor.blue + ) + drawChartData( + screenPoints = screenPoints, + innerLeft = innerLeft, + innerRight = innerRight, + fillBottom = fillBottom, + lineColor = lineColor, + fillColor = fillColor, + roundCorners = config.roundCorners + ) + } + } + } else { + val screenPoints = sortedPoints.map { ChartScreenPoint(toX(it.timestamp), toY(it.value), it) } + drawChartData( + screenPoints = screenPoints, + innerLeft = innerLeft, + innerRight = innerRight, + fillBottom = fillBottom, + lineColor = config.lineColor, + fillColor = config.fillColor, + roundCorners = config.roundCorners + ) + } + + drawLabels( + minY = yMin, + maxY = yMax, + lastY = lastY, + rangeY = rangeY, + innerLeft = innerLeft, + innerRight = innerRight, + innerTop = innerTop, + fillBottom = fillBottom, + config = config, + unit = unit, + textMeasurer = textMeasurer + ) + + if (showMarker && markerPoint != null) { + val mp = markerPoint!! + val mx = toX(mp.timestamp) + val my = toY(mp.value) + drawMarker( + x = mx, + y = my, + point = mp, + rangeY = rangeY, + unit = unit, + config = config, + innerLeft = innerLeft, + innerRight = innerRight, + innerTop = innerTop, + fillBottom = fillBottom, + textMeasurer = textMeasurer + ) + } + } + } + } + ChartBottomRow( + config = config, + modifier = Modifier + .height(ChartBottomRowHeightDp) + .fillMaxWidth() + .padding(start = ChartPaddingLeftDp, end = ChartPaddingRightDp) + ) + } +} + +private data class Quad( + val xMin: Long, + val xMax: Long, + val yMin: Float, + val yMax: Float, + val rangeX: Long, + val rangeY: Float, + val lastY: Float +) + +private data class ChartScreenPoint(val x: Float, val y: Float, val point: ChartDataPoint) + +@OptIn(ExperimentalTextApi::class) +@Composable +private fun CenteredNoDataLabel( + text: String, + textMeasurer: androidx.compose.ui.text.TextMeasurer, + modifier: Modifier, + labelColor: Color +) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Canvas(modifier = Modifier.fillMaxSize()) { + val layoutResult = textMeasurer.measure(text, TextStyle(fontSize = LabelTextSizeSp.sp)) + val textWidth = layoutResult.size.width + val textHeight = layoutResult.size.height + val baseline = center.y + textHeight / 4 + drawContext.canvas.nativeCanvas.apply { + val paint = android.graphics.Paint().apply { + color = android.graphics.Color.argb( + (labelColor.alpha * 255).toInt(), + (labelColor.red * 255).toInt(), + (labelColor.green * 255).toInt(), + (labelColor.blue * 255).toInt() + ) + textSize = with(density) { LabelTextSizeSp.sp.toPx() } + isAntiAlias = true + } + drawText(text, center.x - textWidth / 2, baseline, paint) + } + } + } +} + +@Composable +private fun ChartBottomRow( + config: ChartConfig, + modifier: Modifier = Modifier +) { + val bottomColor = config.effectiveBottomLabelColor + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Text( + text = config.leftTimeLabel, + color = bottomColor, + fontSize = LabelTextSizeSp.sp, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(top = 2.dp) + ) + config.centerLabel?.let { center -> + Text( + text = center, + color = bottomColor, + fontSize = 16.sp, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 24.dp, vertical = 2.dp) + ) + } + Text( + text = config.rightTimeLabel, + color = bottomColor, + fontSize = LabelTextSizeSp.sp, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(top = 2.dp) + ) + } +} + +private fun findNearestPoint( + offset: Offset, + points: List, + chartSize: IntSize, + xMin: Long, + rangeX: Long, + density: androidx.compose.ui.unit.Density +): ChartDataPoint? { + if (points.isEmpty() || chartSize.width == 0) return null + val innerLeft = with(density) { ChartPaddingLeftDp.toPx() } + val innerRight = chartSize.width - with(density) { ChartPaddingRightDp.toPx() } + val chartWidth = innerRight - innerLeft + if (chartWidth <= 0) return null + val tapX = offset.x + if (tapX < innerLeft || tapX > innerRight) return null + val normalizedX = (tapX - innerLeft) / chartWidth + val timestamp = xMin + (normalizedX * rangeX).toLong() + var nearest = points.first() + var minDist = Long.MAX_VALUE + for (p in points) { + val d = kotlin.math.abs(p.timestamp - timestamp) + if (d < minDist) { + minDist = d + nearest = p + } + } + return nearest +} + +private fun DrawScope.drawChartBackground( + left: Float, + top: Float, + right: Float, + bottom: Float, + config: ChartConfig, + density: androidx.compose.ui.unit.Density +) { + val rad = with(density) { 8.dp.toPx() } + val height = bottom - top + val space = with(density) { 1.dp.toPx() } + val fragmentHeight = (height - space * 3) / 4f + val bottomMargin = with(density) { 16.dp.toPx() } + + if (height <= 0) return + + val bgColor = config.backgroundColor + if (config.roundCorners) { + // Top segment (rounded top corners only) + val topPath = Path().apply { + addRoundRect( + RoundRect( + rect = Rect(left, top, right, top + fragmentHeight), + topLeft = CornerRadius(rad, rad), + topRight = CornerRadius(rad, rad), + bottomLeft = CornerRadius.Zero, + bottomRight = CornerRadius.Zero + ) + ) + } + drawPath(topPath, bgColor) + // Second segment (transparent gap below) + drawRect( + color = bgColor, + topLeft = Offset(left, top + fragmentHeight + space), + size = Size(right - left, fragmentHeight) + ) + // Third segment + drawRect( + color = bgColor, + topLeft = Offset(left, top + 2 * (fragmentHeight + space)), + size = Size(right - left, fragmentHeight) + ) + // Fourth segment + drawRect( + color = bgColor, + topLeft = Offset(left, top + 3 * (fragmentHeight + space)), + size = Size(right - left, fragmentHeight - bottomMargin) + ) + // Bottom fill / fake chart piece (rounded bottom corners only) - same color as segments + val bottomPath = Path().apply { + addRoundRect( + RoundRect( + rect = Rect(left, bottom - bottomMargin - 1, right, bottom + 1), + topLeft = CornerRadius.Zero, + topRight = CornerRadius.Zero, + bottomLeft = CornerRadius(rad, rad), + bottomRight = CornerRadius(rad, rad) + ) + ) + } + drawPath(bottomPath, config.fillColor) + } else { + drawRect(color = config.backgroundColor, topLeft = Offset(left, top), size = Size(right - left, height)) + } +} + +private fun DrawScope.drawChartData( + screenPoints: List, + innerLeft: Float, + innerRight: Float, + fillBottom: Float, + lineColor: Color, + fillColor: Color, + roundCorners: Boolean +) { + if (screenPoints.size < 2) return + val bottomY = fillBottom + + val fillPath = Path().apply { + fillType = PathFillType.EvenOdd + val p0 = screenPoints[0] + moveTo(innerLeft, p0.y) + lineTo(p0.x, p0.y) + for (i in 0 until screenPoints.size - 1) { + val curr = screenPoints[i] + val next = screenPoints[i + 1] + cubicTo( + (curr.x + next.x) / 2f, curr.y, + (curr.x + next.x) / 2f, next.y, + next.x, next.y + ) + } + lineTo(innerRight, bottomY) + lineTo(innerLeft, bottomY) + lineTo(innerLeft, screenPoints[0].y) + close() + } + drawPath(fillPath, fillColor) + + val linePath = Path().apply { + val p0 = screenPoints[0] + moveTo(innerLeft, p0.y) + lineTo(p0.x, p0.y) + for (i in 0 until screenPoints.size - 1) { + val curr = screenPoints[i] + val next = screenPoints[i + 1] + moveTo(curr.x, curr.y) + cubicTo( + (curr.x + next.x) / 2f, curr.y, + (curr.x + next.x) / 2f, next.y, + next.x, next.y + ) + } + } + val strokeWidth = with(density) { 2.dp.toPx() } + drawPath(linePath, lineColor, style = Stroke(width = strokeWidth)) +} + +@OptIn(ExperimentalTextApi::class) +private fun DrawScope.drawLabels( + minY: Float, + maxY: Float, + lastY: Float, + rangeY: Float, + innerLeft: Float, + innerRight: Float, + innerTop: Float, + fillBottom: Float, + config: ChartConfig, + unit: String, + textMeasurer: androidx.compose.ui.text.TextMeasurer +) { + val maxText = formatRounded(maxY, rangeY) + val minText = formatRounded(minY, rangeY) + val lastText = formatRounded(lastY, rangeY) + (if (unit.isNotEmpty()) " $unit" else "") + + val labelColor = config.effectiveLabelColor + val pad = with(density) { 8.dp.toPx() } + val textLayout = textMeasurer.measure("0", TextStyle(fontSize = LabelTextSizeSp.sp)) + val baselineOffset = textLayout.size.height * 0.75f + val chartHeight = fillBottom - innerTop + val midY = (maxY + minY) / 2f + val midText = formatRounded(midY, rangeY) + val midYpos = innerTop + chartHeight / 2f + baselineOffset + val bottomBaseline = fillBottom + baselineOffset + drawTextLabelRightAligned(maxText, innerLeft - pad, innerTop + baselineOffset, labelColor, textMeasurer) + drawTextLabelRightAligned(midText, innerLeft - pad, midYpos, labelColor, textMeasurer) + drawTextLabelRightAligned(minText, innerLeft - pad, bottomBaseline, labelColor, textMeasurer) + val lastYpos = fillBottom - ((lastY - minY) / rangeY) * chartHeight + val lastTextLayout = textMeasurer.measure(lastText, TextStyle(fontSize = LabelTextSizeSp.sp)) + val lastBaseline = lastYpos - lastTextLayout.size.height / 3f + drawTextLabel(lastText, innerRight + pad, lastBaseline, labelColor, textMeasurer) +} + +private fun DrawScope.drawTextLabel( + text: String, + x: Float, + y: Float, + color: Color, + textMeasurer: androidx.compose.ui.text.TextMeasurer +) { + val textSizePx = with(density) { LabelTextSizeSp.sp.toPx() } + drawContext.canvas.nativeCanvas.apply { + val paint = android.graphics.Paint().apply { + this.color = android.graphics.Color.argb( + (color.alpha * 255).toInt(), + (color.red * 255).toInt(), + (color.green * 255).toInt(), + (color.blue * 255).toInt() + ) + textSize = textSizePx + isAntiAlias = true + } + drawText(text, x, y, paint) + } +} + +private fun DrawScope.drawTextLabelRightAligned( + text: String, + rightX: Float, + y: Float, + color: Color, + textMeasurer: androidx.compose.ui.text.TextMeasurer +) { + val layout = textMeasurer.measure(text, TextStyle(fontSize = LabelTextSizeSp.sp)) + drawTextLabel(text, rightX - layout.size.width, y, color, textMeasurer) +} + +@OptIn(ExperimentalTextApi::class) +private fun DrawScope.drawMarker( + x: Float, + y: Float, + point: ChartDataPoint, + rangeY: Float, + unit: String, + config: ChartConfig, + innerLeft: Float, + innerRight: Float, + innerTop: Float, + fillBottom: Float, + textMeasurer: androidx.compose.ui.text.TextMeasurer +) { + drawLine(config.lineColor, Offset(innerLeft, y), Offset(innerRight, y), strokeWidth = 1f) + drawLine(config.lineColor, Offset(x, innerTop - 16), Offset(x, fillBottom + 16), strokeWidth = 1f) + drawCircle(config.lineColor, radius = MarkerRadiusPx, center = Offset(x, y)) + + val valueStr = "${formatRounded(point.value, rangeY)} $unit".trim() + val dateStr = SimpleDateFormat("MMM dd yyyy HH:mm", Locale.getDefault()).format(Date(point.timestamp)) + + val layout1 = textMeasurer.measure(valueStr, TextStyle(fontSize = LabelTextSizeSp.sp)) + val layout2 = textMeasurer.measure(dateStr, TextStyle(fontSize = LabelTextSizeSp.sp)) + val w = maxOf(layout1.size.width, layout2.size.width) + 16f + val h = layout1.size.height + layout2.size.height + 16f + var left = x + 4 + var top = y - h - 4 + if (left + w >= innerRight) left = x - w - 8 + if (top <= innerTop) top = y + 8 + drawRoundRect( + color = config.backgroundColor, + topLeft = Offset(left - 4, top - 4), + size = Size(w + 8, h + 8), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(8f, 8f) + ) + drawContext.canvas.nativeCanvas.apply { + val paint = android.graphics.Paint().apply { + color = android.graphics.Color.argb( + (config.lineColor.alpha * 255).toInt(), + (config.lineColor.red * 255).toInt(), + (config.lineColor.green * 255).toInt(), + (config.lineColor.blue * 255).toInt() + ) + textSize = with(density) { LabelTextSizeSp.sp.toPx() } + isAntiAlias = true + } + drawText(valueStr, left, top + layout1.size.height, paint) + drawText(dateStr, left, top + layout1.size.height + layout2.size.height, paint) + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewAirMQChartWithData() { + AirMQTheme { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .background( + androidx.compose.ui.graphics.Brush.verticalGradient( + colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd) + ) + ) + ) { + AirMQChart( + data = ChartDataset.Single(generateSineWaveData()), + config = ChartConfig( + lineColor = Color.White, + fillColor = ChartFill, + backgroundColor = ChartBackground, + labelColor = Color.White, + centerLabel = "Temperature", + leftTimeLabel = "Yesterday", + rightTimeLabel = "Now", + unit = "°C" + ), + sensorType = "sensor_temperature", + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewAirMQChartEmpty() { + AirMQTheme { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .background(Color(0xFFF5F5F5)) + ) { + AirMQChart( + data = null, + config = ChartConfig( + lineColor = ChartLineWidget, + fillColor = ChartFillWidget, + backgroundColor = ChartBackgroundWidget, + labelColor = Color(0x8A000000), + leftTimeLabel = "Yesterday", + rightTimeLabel = "Now" + ), + sensorType = "sensor_temperature", + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Preview(showBackground = true, name = "Multiline dust") +@Composable +private fun PreviewAirMQChartMultiLine() { + AirMQTheme { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .background(Color(0xFFF5F5F5)) + ) { + AirMQChart( + data = ChartDataset.Multi(generateMultiLineDustData()), + config = ChartConfig( + lineColor = ChartLineWidget, + fillColor = ChartFillWidget, + backgroundColor = ChartBackgroundWidget, + labelColor = Color(0x8A000000), + centerLabel = "Solid particles", + multiLineColors = listOf(SensorDust10, SensorDust25, SensorDust1), + leftTimeLabel = "Yesterday", + rightTimeLabel = "Now", + unit = "µg/m³" + ), + sensorType = "sensor_dust", + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Preview(showBackground = true, name = "Widget style with data") +@Composable +private fun PreviewAirMQChartWidgetStyle() { + AirMQTheme { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .background(Color(0xFFF5F5F5)) + ) { + AirMQChart( + data = ChartDataset.Single(generateSineWaveData()), + config = ChartConfig( + lineColor = ChartLineWidget, + fillColor = ChartFillWidget, + backgroundColor = ChartBackgroundWidget, + labelColor = Color(0x8A000000), + centerLabel = "Temperature", + leftTimeLabel = "Hour ago", + rightTimeLabel = "Now", + unit = "°C" + ), + sensorType = "sensor_temperature", + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Preview(showBackground = true, name = "No round corners") +@Composable +private fun PreviewAirMQChartNoRoundCorners() { + AirMQTheme { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .background(Brush.verticalGradient(colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd))) + ) { + AirMQChart( + data = ChartDataset.Single(generateSineWaveData()), + config = ChartConfig( + lineColor = Color.White, + fillColor = ChartFill, + backgroundColor = ChartBackground, + labelColor = Color.White, + roundCorners = false, + leftTimeLabel = "Yesterday", + rightTimeLabel = "Now", + unit = "°C" + ), + sensorType = "sensor_temperature", + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Preview(showBackground = true, name = "Hour range") +@Composable +private fun PreviewAirMQChartHourRange() { + AirMQTheme { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .background(Brush.verticalGradient(colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd))) + ) { + AirMQChart( + data = ChartDataset.Single(generateSineWaveData(count = 12)), + config = ChartConfig( + lineColor = Color.White, + fillColor = ChartFill, + backgroundColor = ChartBackground, + labelColor = Color.White, + leftTimeLabel = "Hour ago", + rightTimeLabel = "Now", + unit = "°C" + ), + sensorType = "sensor_temperature", + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Preview(showBackground = true, name = "Temperature sensor (orange)") +@Composable +private fun PreviewAirMQChartTemperature() { + AirMQTheme { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .background(Color(0xFFF5F5F5)) + ) { + AirMQChart( + data = ChartDataset.Single(generateSineWaveData()), + config = ChartConfig( + lineColor = SensorTemperature, + fillColor = Color(0x80FFB357), + backgroundColor = ChartBackgroundWidget, + labelColor = Color(0x8A000000), + centerLabel = "Temperature", + leftTimeLabel = "Yesterday", + rightTimeLabel = "Now", + unit = "°C" + ), + sensorType = "sensor_temperature", + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Preview(showBackground = true, name = "Humidity sensor") +@Composable +private fun PreviewAirMQChartHumidity() { + AirMQTheme { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .background(Color(0xFFF5F5F5)) + ) { + AirMQChart( + data = ChartDataset.Single(generateSineWaveData(amplitude = 30f, offset = 50f)), + config = ChartConfig( + lineColor = SensorHumidity, + fillColor = Color(0x8096D98D), + backgroundColor = ChartBackgroundWidget, + labelColor = Color(0x8A000000), + centerLabel = "Humidity", + leftTimeLabel = "Yesterday", + rightTimeLabel = "Now", + unit = "%" + ), + sensorType = "sensor_humidity", + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Preview(showBackground = true, name = "Dust sensor") +@Composable +private fun PreviewAirMQChartDust() { + AirMQTheme { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .background(Color(0xFFF5F5F5)) + ) { + AirMQChart( + data = ChartDataset.Single(generateSineWaveData(amplitude = 15f, offset = 10f)), + config = ChartConfig( + lineColor = SensorDust25, + fillColor = Color(0x804A90D9), + backgroundColor = ChartBackgroundWidget, + labelColor = Color(0x8A000000), + centerLabel = "Solid particles", + leftTimeLabel = "Day", + rightTimeLabel = "Now", + unit = "µg/m³" + ), + sensorType = "sensor_dust", + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Preview(showBackground = true, name = "Radioactivity sensor") +@Composable +private fun PreviewAirMQChartRadioactivity() { + AirMQTheme { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .background(Color(0xFFF5F5F5)) + ) { + AirMQChart( + data = ChartDataset.Single(generateSineWaveData(amplitude = 0.2f, offset = 0.2f)), + config = ChartConfig( + lineColor = SensorRadioactivity, + fillColor = Color(0x80E57373), + backgroundColor = ChartBackgroundWidget, + labelColor = Color(0x8A000000), + centerLabel = "Radioactivity", + leftTimeLabel = "Yesterday", + rightTimeLabel = "Now", + unit = "μSv/h" + ), + sensorType = "sensor_radioactivity", + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Preview(showBackground = true, name = "Compact (top bar)") +@Composable +private fun PreviewAirMQChartCompact() { + AirMQTheme { + Box( + modifier = Modifier + .height(80.dp) + .fillMaxWidth() + .background(Brush.verticalGradient(colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd))) + ) { + AirMQChart( + data = ChartDataset.Single(generateSineWaveData(count = 12)), + config = ChartConfig( + lineColor = Color.White, + fillColor = ChartFill, + backgroundColor = ChartBackground, + labelColor = Color.White, + leftTimeLabel = "Hour ago", + rightTimeLabel = "Now", + unit = "°C" + ), + sensorType = "sensor_temperature", + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Preview(showBackground = true, name = "2 points shows No data") +@Composable +private fun PreviewAirMQChartTwoPoints() { + AirMQTheme { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .background(Color(0xFFF5F5F5)) + ) { + AirMQChart( + data = ChartDataset.Single(generateTwoPointsData()), + config = ChartConfig( + lineColor = ChartLineWidget, + fillColor = ChartFillWidget, + backgroundColor = ChartBackgroundWidget, + labelColor = Color(0x8A000000), + leftTimeLabel = "Yesterday", + rightTimeLabel = "Now", + unit = "°C" + ), + sensorType = "sensor_temperature", + modifier = Modifier.fillMaxSize() + ) + } + } +} + +@Preview(showBackground = true, name = "Empty widget style") +@Composable +private fun PreviewAirMQChartEmptyWidget() { + AirMQTheme { + Box( + modifier = Modifier + .height(200.dp) + .fillMaxWidth() + .background(Color.White) + ) { + AirMQChart( + data = null, + config = ChartConfig( + lineColor = ChartLineWidget, + fillColor = ChartFillWidget, + backgroundColor = ChartBackgroundWidget, + labelColor = Color(0x61000000), + leftTimeLabel = "Day", + rightTimeLabel = "Now" + ), + sensorType = "sensor_dust", + modifier = Modifier.fillMaxSize() + ) + } + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartConfig.kt b/app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartConfig.kt new file mode 100644 index 0000000..0a0a72b --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartConfig.kt @@ -0,0 +1,20 @@ +package org.db3.airmq.features.common.chart + +import androidx.compose.ui.graphics.Color + +data class ChartConfig( + val lineColor: Color, + val fillColor: Color, + val backgroundColor: Color, + val labelColor: Color? = null, + val bottomLabelColor: Color? = null, + val centerLabel: String? = null, + val multiLineColors: List? = null, + val roundCorners: Boolean = true, + val leftTimeLabel: String = "Yesterday", + val rightTimeLabel: String = "Now", + val unit: String = "" +) { + val effectiveLabelColor: Color get() = labelColor ?: lineColor + val effectiveBottomLabelColor: Color get() = bottomLabelColor ?: labelColor ?: lineColor +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartData.kt b/app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartData.kt new file mode 100644 index 0000000..5f15cde --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartData.kt @@ -0,0 +1,12 @@ +package org.db3.airmq.features.common.chart + +/** Single data point for chart */ +data class ChartDataPoint(val timestamp: Long, val value: Float) + +/** Single-line or multi-line dataset */ +sealed class ChartDataset { + data class Single(val points: List) : ChartDataset() + data class Multi(val lines: List) : ChartDataset() +} + +data class ChartLine(val label: String, val points: List) diff --git a/app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartDataGenerator.kt b/app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartDataGenerator.kt new file mode 100644 index 0000000..0137a4b --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartDataGenerator.kt @@ -0,0 +1,54 @@ +package org.db3.airmq.features.common.chart + +import kotlin.math.PI +import kotlin.math.sin + +/** + * Generates sine wave data for chart previews and testing. + * + * @param count Number of data points to generate + * @param amplitude Amplitude of the sine wave + * @param offset Base value (vertical offset) + * @param periodCount Number of full periods across the dataset + * @return List of ChartDataPoint with timestamps spread over last 24 hours + */ +fun generateSineWaveData( + count: Int = 24, + amplitude: Float = 5f, + offset: Float = 20f, + periodCount: Float = 2f +): List { + val now = System.currentTimeMillis() + val msPerDay = 24 * 60 * 60 * 1000L + val step = msPerDay / count.toLong() + return (0 until count).map { i -> + val timestamp = now - msPerDay + (i * step) + val value = offset + amplitude * sin(2 * PI * periodCount * i / count).toFloat() + ChartDataPoint(timestamp = timestamp, value = value) + } +} + +/** + * Generates multiline dust data (PM1, PM2.5, PM10) for chart previews. + * Uses distinct amplitudes/offsets so each line is clearly visible. + */ +fun generateMultiLineDustData(count: Int = 24): List { + val basePoints = generateSineWaveData(count = count, amplitude = 8f, offset = 15f, periodCount = 1.5f) + return listOf( + ChartLine("PM 10", basePoints.map { ChartDataPoint(it.timestamp, it.value + 10f) }), + ChartLine("PM 2.5", basePoints.map { ChartDataPoint(it.timestamp, it.value - 2f) }), + ChartLine("PM 1", basePoints.map { ChartDataPoint(it.timestamp, it.value - 12f) }) + ) +} + +/** + * Generates exactly 2 points - used to verify empty state threshold (no data when < 3). + */ +fun generateTwoPointsData(): List { + val now = System.currentTimeMillis() + val msPerDay = 24 * 60 * 60 * 1000L + return listOf( + ChartDataPoint(now - msPerDay, 18f), + ChartDataPoint(now, 22f) + ) +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartUtils.kt b/app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartUtils.kt new file mode 100644 index 0000000..b3ad8d4 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/common/chart/ChartUtils.kt @@ -0,0 +1,46 @@ +package org.db3.airmq.features.common.chart + +/** + * Returns display unit for sensor type (legacy Chart.getUnits mapping). + */ +fun getUnitsForSensor(sensorType: String): String = when (sensorType) { + "sensor_temperature" -> "°C" + "sensor_humidity" -> "%" + "sensor_pressure" -> "mmHg" + "sensor_dust" -> "µg/m³" + "sensor_radioactivity" -> "μSv/h" + else -> "" +} + +internal fun getPlaces(value: Float, rangeY: Float): Int = when { + value >= 1000 -> 0 + value >= 100 -> if (rangeY > 5) 0 else 1 + value >= 10 -> when { + rangeY >= 5 -> 0 + rangeY >= 1 -> 1 + else -> 2 + } + else -> when { + rangeY >= 5 -> 0 + rangeY >= 1 -> 2 + else -> 3 + } +} + +internal fun formatRounded(f: Float, range: Float): String { + val places = getPlaces(f, range) + val rounded = roundToPlaces(f.toDouble(), places) + val str = rounded.toString() + return if (places == 0) str.replace(".0", "") else str +} + +private fun roundToPlaces(value: Double, places: Int): Float { + if (places == 0) { + val c = (value + 0.5).toInt() + val n = value + 0.5 + return if ((n - c).toLong() % 2 == 0L) value.toInt().toFloat() else c.toFloat() + } + var factor = 1.0 + repeat(places) { factor *= 10 } + return (kotlin.math.ceil(value * factor) / factor).toFloat() +} 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 f0c4414..7e14f11 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 @@ -1,10 +1,27 @@ package org.db3.airmq.features.dashboard +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color 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.ScreenAction +import org.db3.airmq.features.common.chart.AirMQChart +import org.db3.airmq.features.common.chart.ChartConfig +import org.db3.airmq.features.common.chart.ChartDataset +import org.db3.airmq.features.common.chart.generateSineWaveData +import org.db3.airmq.ui.theme.ChartBackground +import org.db3.airmq.ui.theme.ChartFill +import org.db3.airmq.ui.theme.LegacyNavGradientEnd +import org.db3.airmq.ui.theme.LegacyNavGradientStart @Composable fun DashboardScreen( @@ -18,6 +35,33 @@ fun DashboardScreen( MockScreenScaffold( title = stringResource(id = R.string.title_dashboard), subtitle = stringResource(id = R.string.dashboard_subtitle), + content = { + Box( + 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() + ) + } + }, actions = listOf( ScreenAction(stringResource(id = R.string.dashboard_open_news), onOpenNews), ScreenAction(stringResource(id = R.string.manage_open_widget_constructor), onOpenWidgetConstructor) diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapUiComponents.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapUiComponents.kt index 727d272..96fd13e 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapUiComponents.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapUiComponents.kt @@ -43,12 +43,19 @@ import androidx.compose.ui.unit.dp import org.db3.airmq.R import org.db3.airmq.features.common.AirMQButton import org.db3.airmq.features.common.AirMQButtonStyle +import org.db3.airmq.features.common.chart.AirMQChart +import org.db3.airmq.features.common.chart.ChartConfig +import org.db3.airmq.features.common.chart.ChartDataset +import org.db3.airmq.features.common.chart.generateSineWaveData import org.db3.airmq.features.map.MapScreenContract.DevicePanelState import org.db3.airmq.features.map.MapScreenContract.DeviceSensorType import org.db3.airmq.features.map.MapScreenContract.SearchResult import org.db3.airmq.features.map.MapScreenContract.SensorType import org.db3.airmq.features.map.MapScreenContract.TimeRange import org.db3.airmq.ui.theme.AirMQTheme +import org.db3.airmq.ui.theme.ChartBackgroundWidget +import org.db3.airmq.ui.theme.ChartFillWidget +import org.db3.airmq.ui.theme.ChartLineWidget @Composable fun MapTopControls( @@ -402,19 +409,22 @@ fun MapDevicePanel( ) } - Box( + AirMQChart( + data = ChartDataset.Single(generateSineWaveData()), + config = ChartConfig( + lineColor = ChartLineWidget, + fillColor = ChartFillWidget, + backgroundColor = ChartBackgroundWidget, + labelColor = Color(0x8A000000), + leftTimeLabel = stringResource(R.string.filter_hour), + rightTimeLabel = stringResource(R.string.text_now), + unit = "°C" + ), + sensorType = "sensor_temperature", modifier = Modifier .fillMaxWidth() .height(220.dp) - .background(Color(0x14000000), RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(id = R.string.map_chart_placeholder), - style = MaterialTheme.typography.bodyMedium, - color = Color.Gray - ) - } + ) DeviceSensorRow( selectedSensor = data.selectedSensor, 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 0ca0ab4..9083f20 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 @@ -27,4 +27,20 @@ val LegacyButtonOnContained = Color(0xFFFFFFFF) val LegacyButtonOnOutlined = Color(0xFF135CA5) val LegacyButtonOnText = Color(0xFF295989) val LegacyButtonGradientStart = Color(0xFF03B6EC) -val LegacyButtonGradientEnd = Color(0xFF01DEA7) \ No newline at end of file +val LegacyButtonGradientEnd = Color(0xFF01DEA7) + +// Chart colors +val ChartFill = Color(0xFFBBD3E9) +val ChartBackground = Color(0x66FFFFFF) // More opaque for visibility on light/dark +val ChartFillWidget = Color(0xFF84B0B3) +val ChartBackgroundWidget = Color(0x33FFFFFF) // More visible on light +val ChartLineWidget = Color(0xFF2C7575) + +// Sensor colors +val SensorTemperature = Color(0xFFFFB357) +val SensorHumidity = Color(0xFF96D98D) +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