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
This commit is contained in:
2026-03-04 19:18:03 +01:00
parent b607d0198b
commit 00ad737e7e
8 changed files with 1221 additions and 11 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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<Color>? = 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
}

View File

@@ -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<ChartDataPoint>) : ChartDataset()
data class Multi(val lines: List<ChartLine>) : ChartDataset()
}
data class ChartLine(val label: String, val points: List<ChartDataPoint>)

View File

@@ -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<ChartDataPoint> {
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<ChartLine> {
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<ChartDataPoint> {
val now = System.currentTimeMillis()
val msPerDay = 24 * 60 * 60 * 1000L
return listOf(
ChartDataPoint(now - msPerDay, 18f),
ChartDataPoint(now, 22f)
)
}

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -27,4 +27,20 @@ val LegacyButtonOnContained = Color(0xFFFFFFFF)
val LegacyButtonOnOutlined = Color(0xFF135CA5)
val LegacyButtonOnText = Color(0xFF295989)
val LegacyButtonGradientStart = Color(0xFF03B6EC)
val LegacyButtonGradientEnd = Color(0xFF01DEA7)
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)