feat(dashboard): city average GraphQL, metrics repository, and wiring

Adds CityAverage operations and DashboardMetricsRepository, extends CityService, updates dashboard contract/VM and chart mapping, refreshes schema, tweaks map screen and Apollo logging, and adds debug manifest.

Made-with: Cursor
This commit is contained in:
2026-04-06 17:02:19 +02:00
parent 35d23110d7
commit df4e6f9c56
19 changed files with 1169 additions and 614 deletions

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:usesCleartextTraffic="true"
tools:replace="android:usesCleartextTraffic" />
</manifest>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
package org.db3.airmq.features.dashboard
import org.db3.airmq.features.common.chart.ChartDataPoint
import org.db3.airmq.features.common.chart.ChartDataset
import org.db3.airmq.features.common.chart.ChartLine
import org.db3.airmq.features.common.metric.SensorType
import org.db3.airmq.sdk.dashboard.SensorSampleRow
/**
* Maps city-average [SensorSampleRow] lists to [ChartDataset] and gauge values for the dashboard.
*/
internal object DashboardChartMapper {
/** Synthetic rows for Compose previews (no network). */
fun previewStaticRows(): List<SensorSampleRow> {
val now = System.currentTimeMillis()
return (0 until 12).map { i ->
val t = now - (12 - i) * 3_600_000L
SensorSampleRow(
epochMillis = t,
temp = 15f + i % 5,
hum = 50f + i,
press = 745f - i * 0.5f,
pms1 = 4f + i * 0.2f,
pms25 = 8f + i * 0.5f,
pms10 = 7f + i * 0.3f,
radRg = 0.12f,
ppm = null,
ikav = null,
)
}
}
fun chartDataset(rows: List<SensorSampleRow>, sensor: SensorType): ChartDataset {
val sorted = rows.sortedBy { it.epochMillis }
return when (sensor) {
SensorType.DUST -> {
val pm10 = sorted.mapNotNull { r ->
r.pms10?.let { ChartDataPoint(r.epochMillis, it) }
}
val pm25 = sorted.mapNotNull { r ->
r.pms25?.let { ChartDataPoint(r.epochMillis, it) }
}
val pm1 = sorted.mapNotNull { r ->
r.pms1?.let { ChartDataPoint(r.epochMillis, it) }
}
ChartDataset.Multi(
listOf(
ChartLine("PM10", pm10),
ChartLine("PM2.5", pm25),
ChartLine("PM1", pm1),
)
)
}
SensorType.TEMPERATURE -> ChartDataset.Single(
sorted.mapNotNull { r -> r.temp?.let { ChartDataPoint(r.epochMillis, it) } }
)
SensorType.HUMIDITY -> ChartDataset.Single(
sorted.mapNotNull { r -> r.hum?.let { ChartDataPoint(r.epochMillis, it) } }
)
SensorType.PRESSURE -> ChartDataset.Single(
sorted.mapNotNull { r -> r.press?.let { ChartDataPoint(r.epochMillis, it) } }
)
SensorType.RADIOACTIVITY -> ChartDataset.Single(
sorted.mapNotNull { r ->
(r.radRg ?: r.ppm ?: r.ikav)?.let { ChartDataPoint(r.epochMillis, it) }
}
)
SensorType.CO2, SensorType.VOC -> ChartDataset.Single(emptyList())
}
}
fun gaugeValues(last: SensorSampleRow?): Map<SensorType, Float?> {
if (last == null) return SensorType.entries.associateWith { null }
return mapOf(
SensorType.DUST to last.pms25,
SensorType.RADIOACTIVITY to (last.radRg ?: last.ppm ?: last.ikav),
SensorType.TEMPERATURE to last.temp,
SensorType.HUMIDITY to last.hum,
SensorType.PRESSURE to last.press,
SensorType.CO2 to null,
SensorType.VOC to null,
)
}
}

View File

@@ -5,10 +5,12 @@ import androidx.compose.ui.platform.LocalContext
import org.db3.airmq.R
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.common.metric.SensorType
import org.db3.airmq.ui.theme.ChartBackground
import org.db3.airmq.ui.theme.ChartFill
import org.db3.airmq.ui.theme.SensorDust1
import org.db3.airmq.ui.theme.SensorDust10
import org.db3.airmq.ui.theme.SensorDust25
import androidx.compose.ui.graphics.Color
object DashboardScreenContract {
@@ -19,14 +21,19 @@ object DashboardScreenContract {
selectedSensor: SensorType = SensorType.DUST,
currentPage: Int = 0
): State {
val chartData = when (selectedSensor) {
SensorType.DUST -> ChartDataset.Single(generateSineWaveData(amplitude = 8f, offset = 10f, periodCount = 1.5f))
SensorType.RADIOACTIVITY -> ChartDataset.Single(generateSineWaveData(amplitude = 0.15f, offset = 0.2f, periodCount = 1f))
SensorType.TEMPERATURE -> ChartDataset.Single(generateSineWaveData(amplitude = 5f, offset = 15f, periodCount = 2f))
SensorType.HUMIDITY -> ChartDataset.Single(generateSineWaveData(amplitude = 15f, offset = 55f, periodCount = 1.2f))
SensorType.PRESSURE -> ChartDataset.Single(generateSineWaveData(amplitude = 10f, offset = 740f, periodCount = 0.8f))
else -> ChartDataset.Single(generateSineWaveData())
}
val previewRows = DashboardChartMapper.previewStaticRows()
val chartData = DashboardChartMapper.chartDataset(previewRows, selectedSensor)
val center = LocalContext.current.getString(
when (selectedSensor) {
SensorType.DUST -> R.string.sensor_dust
SensorType.RADIOACTIVITY -> R.string.sensor_radioactivity
SensorType.TEMPERATURE -> R.string.sensor_temperature
SensorType.HUMIDITY -> R.string.sensor_humidity
SensorType.PRESSURE -> R.string.sensor_pressure
SensorType.CO2 -> R.string.sensor_co2
SensorType.VOC -> R.string.sensor_voc
}
)
val chartConfig = ChartConfig(
lineColor = Color.White,
fillColor = ChartFill,
@@ -35,16 +42,12 @@ object DashboardScreenContract {
leftTimeLabel = "Yesterday",
rightTimeLabel = "Now",
unit = selectedSensor.units(),
centerLabel = LocalContext.current.getString(
when (selectedSensor) {
SensorType.DUST -> R.string.sensor_dust
SensorType.RADIOACTIVITY -> R.string.sensor_radioactivity
SensorType.TEMPERATURE -> R.string.sensor_temperature
SensorType.HUMIDITY -> R.string.sensor_humidity
SensorType.PRESSURE -> R.string.sensor_pressure
else -> R.string.sensor_dust
}
)
centerLabel = center,
multiLineColors = if (selectedSensor == SensorType.DUST) {
listOf(SensorDust10, SensorDust25, SensorDust1)
} else {
null
}
)
return State(
city = city,

View File

@@ -5,57 +5,66 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
import org.db3.airmq.sdk.city.CityService
import org.db3.airmq.R
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.common.metric.SensorType
import org.db3.airmq.sdk.city.CityService
import org.db3.airmq.sdk.city.DashboardCityContext
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
import org.db3.airmq.sdk.dashboard.SensorSampleRow
import org.db3.airmq.ui.theme.ChartBackground
import org.db3.airmq.ui.theme.ChartFill
import org.db3.airmq.ui.theme.SensorDust1
import org.db3.airmq.ui.theme.SensorDust10
import org.db3.airmq.ui.theme.SensorDust25
import androidx.compose.ui.graphics.Color
@HiltViewModel
class DashboardViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val cityService: CityService
private val cityService: CityService,
private val dashboardMetricsRepository: DashboardMetricsRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(initialState())
val uiState: StateFlow<DashboardScreenContract.State> = _uiState.asStateFlow()
private val _actions = MutableSharedFlow<DashboardScreenContract.Action>(extraBufferCapacity = 1)
val actions: SharedFlow<DashboardScreenContract.Action> = _actions.asSharedFlow()
private var cachedAverageRows: List<SensorSampleRow> = emptyList()
init {
viewModelScope.launch {
cityService.observeSelectedCity().collect { city ->
_uiState.update { it.copy(city = city) }
cityService.observeDashboardCityContext().collectLatest { _ ->
val ctx = cityService.getResolvedDashboardCityContext()
loadDashboardData(ctx)
}
}
}
private val _actions = MutableSharedFlow<DashboardScreenContract.Action>(extraBufferCapacity = 1)
val actions: SharedFlow<DashboardScreenContract.Action> = _actions.asSharedFlow()
fun onEvent(event: DashboardScreenContract.Event) {
when (event) {
DashboardScreenContract.Event.CitySelectorClicked -> _actions.tryEmit(DashboardScreenContract.Action.OpenCity)
is DashboardScreenContract.Event.GaugeSelected -> {
val sensor = event.sensor
_uiState.update { state ->
state.copy(
selectedSensor = event.sensor,
chartData = chartDataFor(event.sensor),
chartConfig = chartConfigFor(event.sensor),
chartSensorLabel = chartLabelFor(event.sensor)
selectedSensor = sensor,
chartData = DashboardChartMapper.chartDataset(cachedAverageRows, sensor),
chartConfig = chartConfigFor(sensor),
chartSensorLabel = chartLabelFor(sensor),
)
}
}
@@ -68,53 +77,50 @@ class DashboardViewModel @Inject constructor(
private fun initialState(): DashboardScreenContract.State {
val selected = SensorType.DUST
return DashboardScreenContract.State(
city = "Minsk",
gaugeValues = dummyGaugeValues(),
city = cityService.getDashboardCityDisplayName(),
gaugeValues = SensorType.entries.associateWith { null },
selectedSensor = selected,
currentPage = 0,
chartData = chartDataFor(selected),
chartData = ChartDataset.Single(emptyList()),
chartConfig = chartConfigFor(selected),
chartSensorLabel = chartLabelFor(selected)
chartSensorLabel = chartLabelFor(selected),
)
}
private fun dummyGaugeValues(): Map<SensorType, Float?> = mapOf(
SensorType.DUST to 6f,
SensorType.RADIOACTIVITY to 0f,
SensorType.TEMPERATURE to 3f,
SensorType.HUMIDITY to 65f,
SensorType.PRESSURE to 745f
)
private fun chartDataFor(sensor: SensorType): ChartDataset = when (sensor) {
SensorType.DUST -> ChartDataset.Single(
generateSineWaveData(amplitude = 8f, offset = 10f, periodCount = 1.5f)
)
SensorType.RADIOACTIVITY -> ChartDataset.Single(
generateSineWaveData(amplitude = 0.15f, offset = 0.2f, periodCount = 1f)
)
SensorType.TEMPERATURE -> ChartDataset.Single(
generateSineWaveData(amplitude = 5f, offset = 15f, periodCount = 2f)
)
SensorType.HUMIDITY -> ChartDataset.Single(
generateSineWaveData(amplitude = 15f, offset = 55f, periodCount = 1.2f)
)
SensorType.PRESSURE -> ChartDataset.Single(
generateSineWaveData(amplitude = 10f, offset = 740f, periodCount = 0.8f)
)
else -> ChartDataset.Single(generateSineWaveData())
private suspend fun loadDashboardData(ctx: DashboardCityContext) {
val result = dashboardMetricsRepository.fetchCityDashboard(ctx)
val data = result.getOrNull()
cachedAverageRows = data?.averageRows.orEmpty()
val sensor = _uiState.value.selectedSensor
_uiState.update { state ->
state.copy(
city = ctx.displayName,
gaugeValues = DashboardChartMapper.gaugeValues(data?.lastRow),
chartData = DashboardChartMapper.chartDataset(cachedAverageRows, sensor),
chartConfig = chartConfigFor(sensor),
chartSensorLabel = chartLabelFor(sensor),
)
}
}
private fun chartConfigFor(sensor: SensorType): ChartConfig = ChartConfig(
lineColor = Color.White,
fillColor = ChartFill,
backgroundColor = ChartBackground,
labelColor = Color.White,
leftTimeLabel = "Yesterday",
rightTimeLabel = "Now",
unit = sensor.units(),
centerLabel = chartLabelFor(sensor)
)
private fun chartConfigFor(sensor: SensorType): ChartConfig {
val label = chartLabelFor(sensor)
val base = ChartConfig(
lineColor = Color.White,
fillColor = ChartFill,
backgroundColor = ChartBackground,
labelColor = Color.White,
leftTimeLabel = "Yesterday",
rightTimeLabel = "Now",
unit = sensor.units(),
centerLabel = label,
)
return if (sensor == SensorType.DUST) {
base.copy(multiLineColors = listOf(SensorDust10, SensorDust25, SensorDust1))
} else {
base
}
}
private fun chartLabelFor(sensor: SensorType): String = context.getString(
when (sensor) {
@@ -123,7 +129,8 @@ class DashboardViewModel @Inject constructor(
SensorType.TEMPERATURE -> R.string.sensor_temperature
SensorType.HUMIDITY -> R.string.sensor_humidity
SensorType.PRESSURE -> R.string.sensor_pressure
else -> R.string.sensor_dust
SensorType.CO2 -> R.string.sensor_co2
SensorType.VOC -> R.string.sensor_voc
}
)
}

View File

@@ -5,6 +5,7 @@ import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.os.Handler
import android.os.Looper
import android.view.ViewTreeObserver
import android.view.LayoutInflater
import android.widget.Toast
import android.widget.ImageView
@@ -348,8 +349,26 @@ private fun rebuildAirMqMapOverlays(
val zoomScale = ((18.5 - zoom) / 11.0).coerceIn(0.38, 1.0).toFloat()
val clusterDistancePx = (baseClusterPx * zoomScale).coerceAtLeast(18f * density)
if (map.width <= 0 || map.height <= 0) {
map.post {
if (map.width > 0 && map.height > 0) {
// Compose AndroidView often invokes update before the MapView is measured; one-shot post
// can still see 0×0 and then nothing retriggers rebuild until the user pans the map.
val vto = map.viewTreeObserver
if (!vto.isAlive) {
map.post {
rebuildAirMqMapOverlays(
map,
items,
onMarkerClick,
clusterEnabled,
centerOnMarker,
initialCameraDone
)
}
return
}
val listener = object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (map.width <= 0 || map.height <= 0) return
map.viewTreeObserver.removeOnGlobalLayoutListener(this)
rebuildAirMqMapOverlays(
map,
items,
@@ -360,6 +379,7 @@ private fun rebuildAirMqMapOverlays(
)
}
}
vto.addOnGlobalLayoutListener(listener)
return
}