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:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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>)
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user