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:
8
app/src/debug/AndroidManifest.xml
Normal file
8
app/src/debug/AndroidManifest.xml
Normal 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
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user