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

View File

@@ -5,57 +5,66 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
import org.db3.airmq.sdk.city.CityService
import org.db3.airmq.R import org.db3.airmq.R
import org.db3.airmq.features.common.chart.ChartConfig import org.db3.airmq.features.common.chart.ChartConfig
import org.db3.airmq.features.common.chart.ChartDataset 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.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.ChartBackground
import org.db3.airmq.ui.theme.ChartFill 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 import androidx.compose.ui.graphics.Color
@HiltViewModel @HiltViewModel
class DashboardViewModel @Inject constructor( class DashboardViewModel @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
private val cityService: CityService private val cityService: CityService,
private val dashboardMetricsRepository: DashboardMetricsRepository,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(initialState()) private val _uiState = MutableStateFlow(initialState())
val uiState: StateFlow<DashboardScreenContract.State> = _uiState.asStateFlow() 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 { init {
viewModelScope.launch { viewModelScope.launch {
cityService.observeSelectedCity().collect { city -> cityService.observeDashboardCityContext().collectLatest { _ ->
_uiState.update { it.copy(city = city) } 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) { fun onEvent(event: DashboardScreenContract.Event) {
when (event) { when (event) {
DashboardScreenContract.Event.CitySelectorClicked -> _actions.tryEmit(DashboardScreenContract.Action.OpenCity) DashboardScreenContract.Event.CitySelectorClicked -> _actions.tryEmit(DashboardScreenContract.Action.OpenCity)
is DashboardScreenContract.Event.GaugeSelected -> { is DashboardScreenContract.Event.GaugeSelected -> {
val sensor = event.sensor
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
selectedSensor = event.sensor, selectedSensor = sensor,
chartData = chartDataFor(event.sensor), chartData = DashboardChartMapper.chartDataset(cachedAverageRows, sensor),
chartConfig = chartConfigFor(event.sensor), chartConfig = chartConfigFor(sensor),
chartSensorLabel = chartLabelFor(event.sensor) chartSensorLabel = chartLabelFor(sensor),
) )
} }
} }
@@ -68,53 +77,50 @@ class DashboardViewModel @Inject constructor(
private fun initialState(): DashboardScreenContract.State { private fun initialState(): DashboardScreenContract.State {
val selected = SensorType.DUST val selected = SensorType.DUST
return DashboardScreenContract.State( return DashboardScreenContract.State(
city = "Minsk", city = cityService.getDashboardCityDisplayName(),
gaugeValues = dummyGaugeValues(), gaugeValues = SensorType.entries.associateWith { null },
selectedSensor = selected, selectedSensor = selected,
currentPage = 0, currentPage = 0,
chartData = chartDataFor(selected), chartData = ChartDataset.Single(emptyList()),
chartConfig = chartConfigFor(selected), chartConfig = chartConfigFor(selected),
chartSensorLabel = chartLabelFor(selected) chartSensorLabel = chartLabelFor(selected),
) )
} }
private fun dummyGaugeValues(): Map<SensorType, Float?> = mapOf( private suspend fun loadDashboardData(ctx: DashboardCityContext) {
SensorType.DUST to 6f, val result = dashboardMetricsRepository.fetchCityDashboard(ctx)
SensorType.RADIOACTIVITY to 0f, val data = result.getOrNull()
SensorType.TEMPERATURE to 3f, cachedAverageRows = data?.averageRows.orEmpty()
SensorType.HUMIDITY to 65f, val sensor = _uiState.value.selectedSensor
SensorType.PRESSURE to 745f _uiState.update { state ->
) state.copy(
city = ctx.displayName,
private fun chartDataFor(sensor: SensorType): ChartDataset = when (sensor) { gaugeValues = DashboardChartMapper.gaugeValues(data?.lastRow),
SensorType.DUST -> ChartDataset.Single( chartData = DashboardChartMapper.chartDataset(cachedAverageRows, sensor),
generateSineWaveData(amplitude = 8f, offset = 10f, periodCount = 1.5f) chartConfig = chartConfigFor(sensor),
) chartSensorLabel = chartLabelFor(sensor),
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 fun chartConfigFor(sensor: SensorType): ChartConfig = ChartConfig( private fun chartConfigFor(sensor: SensorType): ChartConfig {
lineColor = Color.White, val label = chartLabelFor(sensor)
fillColor = ChartFill, val base = ChartConfig(
backgroundColor = ChartBackground, lineColor = Color.White,
labelColor = Color.White, fillColor = ChartFill,
leftTimeLabel = "Yesterday", backgroundColor = ChartBackground,
rightTimeLabel = "Now", labelColor = Color.White,
unit = sensor.units(), leftTimeLabel = "Yesterday",
centerLabel = chartLabelFor(sensor) 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( private fun chartLabelFor(sensor: SensorType): String = context.getString(
when (sensor) { when (sensor) {
@@ -123,7 +129,8 @@ class DashboardViewModel @Inject constructor(
SensorType.TEMPERATURE -> R.string.sensor_temperature SensorType.TEMPERATURE -> R.string.sensor_temperature
SensorType.HUMIDITY -> R.string.sensor_humidity SensorType.HUMIDITY -> R.string.sensor_humidity
SensorType.PRESSURE -> R.string.sensor_pressure 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.graphics.drawable.BitmapDrawable
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.ViewTreeObserver
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.Toast import android.widget.Toast
import android.widget.ImageView 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 zoomScale = ((18.5 - zoom) / 11.0).coerceIn(0.38, 1.0).toFloat()
val clusterDistancePx = (baseClusterPx * zoomScale).coerceAtLeast(18f * density) val clusterDistancePx = (baseClusterPx * zoomScale).coerceAtLeast(18f * density)
if (map.width <= 0 || map.height <= 0) { if (map.width <= 0 || map.height <= 0) {
map.post { // Compose AndroidView often invokes update before the MapView is measured; one-shot post
if (map.width > 0 && map.height > 0) { // 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( rebuildAirMqMapOverlays(
map, map,
items, items,
@@ -360,6 +379,7 @@ private fun rebuildAirMqMapOverlays(
) )
} }
} }
vto.addOnGlobalLayoutListener(listener)
return return
} }

View File

@@ -1,5 +1,5 @@
mutation AuthGoogleNew($accessToken: String!) { mutation AuthGoogleNew($accessToken: String!) {
authGoogleNew(input: { accessToken: $accessToken }) { authGoogleApp(input: { accessToken: $accessToken }) {
token token
name name
email email

View File

@@ -0,0 +1,18 @@
query CityAverage($filter: MeanFilter!) {
cityAverage(filter: $filter) {
deviceId
time
Temp
Hum
Press
PMS1
PMS25
PMS10
radRg
PPM
IKAV
CO2
VOC
AQI
}
}

View File

@@ -0,0 +1,18 @@
query CityAverageLast($filter: LastFilter!) {
cityAverageLast(filter: $filter) {
deviceId
time
Temp
Hum
Press
PMS1
PMS25
PMS10
radRg
PPM
IKAV
CO2
VOC
AQI
}
}

View File

@@ -49,7 +49,7 @@ class FirebaseAuthService @Inject constructor(
response.errors?.firstOrNull()?.let { gqlError -> response.errors?.firstOrNull()?.let { gqlError ->
throw IllegalStateException(gqlError.message) throw IllegalStateException(gqlError.message)
} }
return response.data?.authGoogleNew?.token return response.data?.authGoogleApp?.token
?: error("Backend auth exchange succeeded without API token.") ?: error("Backend auth exchange succeeded without API token.")
} }
} }

View File

@@ -16,11 +16,32 @@ interface CityService {
*/ */
fun observeSelectedCity(): Flow<String> fun observeSelectedCity(): Flow<String>
/**
* Flow of display name, GraphQL city id, and English name for dashboard data loading.
*/
fun observeDashboardCityContext(): Flow<DashboardCityContext>
/**
* If [DashboardCityContext.cityId] is missing, tries to resolve it from the local city DB
* using the stored English name and updates preferences.
*/
suspend fun refreshDashboardCityIdentity()
/**
* Runs [refreshDashboardCityIdentity] then returns the current [DashboardCityContext] (for API calls).
*/
suspend fun getResolvedDashboardCityContext(): DashboardCityContext
/** /**
* Returns the selected dashboard city display name (localized). * Returns the selected dashboard city display name (localized).
*/ */
suspend fun getSelectedCity(): String suspend fun getSelectedCity(): String
/**
* Same as [getSelectedCity] but synchronous snapshot (for initial UI state).
*/
fun getDashboardCityDisplayName(): String
/** /**
* Called on app launch when city_init is false. * Called on app launch when city_init is false.
* Fetches cities if DB empty, resolves city from location or country, and stores result. * Fetches cities if DB empty, resolves city from location or country, and stores result.

View File

@@ -6,6 +6,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import org.db3.airmq.sdk.city.data.local.CityLocalDataSource import org.db3.airmq.sdk.city.data.local.CityLocalDataSource
import org.db3.airmq.sdk.city.data.remote.CityRemoteDataSource import org.db3.airmq.sdk.city.data.remote.CityRemoteDataSource
import org.db3.airmq.sdk.city.domain.City import org.db3.airmq.sdk.city.domain.City
@@ -29,11 +30,15 @@ class CityServiceImpl @Inject constructor(
) : CityService { ) : CityService {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val _selectedCityFlow = MutableStateFlow(getStoredCityDisplayName()) private val _cityContextFlow = MutableStateFlow(readDashboardContextFromPrefs())
override fun observeSelectedCity(): Flow<String> = _selectedCityFlow override fun observeSelectedCity(): Flow<String> = _cityContextFlow.map { it.displayName }
override suspend fun getSelectedCity(): String = getStoredCityDisplayName() override fun observeDashboardCityContext(): Flow<DashboardCityContext> = _cityContextFlow.asStateFlow()
override suspend fun getSelectedCity(): String = _cityContextFlow.value.displayName
override fun getDashboardCityDisplayName(): String = _cityContextFlow.value.displayName
override suspend fun initialize( override suspend fun initialize(
hasLocationPermission: Boolean, hasLocationPermission: Boolean,
@@ -46,24 +51,33 @@ class CityServiceImpl @Inject constructor(
val resolvedCity = resolveCity(cities, hasLocationPermission, location, countryCode) val resolvedCity = resolveCity(cities, hasLocationPermission, location, countryCode)
val displayName = resolvedCity?.getLocalizedName(Locale.getDefault().language) ?: DEFAULT_CITY_NAME val displayName = resolvedCity?.getLocalizedName(Locale.getDefault().language) ?: DEFAULT_CITY_NAME
prefs.edit() val editor = prefs.edit()
.putString(KEY_DASHBOARD_CITY, displayName) .putString(KEY_DASHBOARD_CITY, displayName)
.putString(KEY_DASHBOARD_CITY_EN, resolvedCity?.nameEn ?: DEFAULT_CITY_NAME) .putString(KEY_DASHBOARD_CITY_EN, resolvedCity?.nameEn ?: DEFAULT_CITY_NAME)
.putBoolean(KEY_CITY_INIT, true) .putBoolean(KEY_CITY_INIT, true)
.apply() if (resolvedCity != null) {
editor.putString(KEY_DASHBOARD_CITY_ID, resolvedCity.id)
_selectedCityFlow.value = displayName } else {
editor.remove(KEY_DASHBOARD_CITY_ID)
}
editor.apply()
pushContextUpdate()
} }
override suspend fun setSelectedCity(cityId: String): Result<Unit> = runCatching { override suspend fun setSelectedCity(cityId: String): Result<Unit> = runCatching {
val cities = cityLocalDataSource.getAllCities() val cities = cityLocalDataSource.getAllCities()
val city = cities.find { it.id == cityId } ?: cities.find { it.nameEn == cityId } val city = cities.find { it.id == cityId } ?: cities.find { it.nameEn == cityId }
val displayName = city?.getLocalizedName(Locale.getDefault().language) ?: cityId val displayName = city?.getLocalizedName(Locale.getDefault().language) ?: cityId
prefs.edit() val editor = prefs.edit()
.putString(KEY_DASHBOARD_CITY, displayName) .putString(KEY_DASHBOARD_CITY, displayName)
.putString(KEY_DASHBOARD_CITY_EN, city?.nameEn ?: cityId) .putString(KEY_DASHBOARD_CITY_EN, city?.nameEn ?: cityId)
.apply() if (city != null) {
_selectedCityFlow.value = displayName editor.putString(KEY_DASHBOARD_CITY_ID, city.id)
} else {
editor.remove(KEY_DASHBOARD_CITY_ID)
}
editor.apply()
pushContextUpdate()
} }
override fun isCityInitComplete(): Boolean = prefs.getBoolean(KEY_CITY_INIT, false) override fun isCityInitComplete(): Boolean = prefs.getBoolean(KEY_CITY_INIT, false)
@@ -84,11 +98,26 @@ class CityServiceImpl @Inject constructor(
prefs.edit() prefs.edit()
.putString(KEY_DASHBOARD_CITY, displayName) .putString(KEY_DASHBOARD_CITY, displayName)
.putString(KEY_DASHBOARD_CITY_EN, resolvedCity.nameEn) .putString(KEY_DASHBOARD_CITY_EN, resolvedCity.nameEn)
.putString(KEY_DASHBOARD_CITY_ID, resolvedCity.id)
.apply() .apply()
_selectedCityFlow.value = displayName pushContextUpdate()
} }
} }
override suspend fun refreshDashboardCityIdentity() {
if (!prefs.getString(KEY_DASHBOARD_CITY_ID, null).isNullOrBlank()) return
val nameEn = prefs.getString(KEY_DASHBOARD_CITY_EN, null) ?: return
val city = cityLocalDataSource.getAllCities()
.find { it.nameEn.equals(nameEn, ignoreCase = true) } ?: return
prefs.edit().putString(KEY_DASHBOARD_CITY_ID, city.id).apply()
pushContextUpdate()
}
override suspend fun getResolvedDashboardCityContext(): DashboardCityContext {
refreshDashboardCityIdentity()
return _cityContextFlow.value
}
override suspend fun getCitiesGroupedByCountry(localeLanguage: String): List<Region> { override suspend fun getCitiesGroupedByCountry(localeLanguage: String): List<Region> {
if (cityLocalDataSource.isEmpty()) return emptyList() if (cityLocalDataSource.isEmpty()) return emptyList()
val cities = cityLocalDataSource.getAllCities() val cities = cityLocalDataSource.getAllCities()
@@ -103,8 +132,16 @@ class CityServiceImpl @Inject constructor(
return grouped return grouped
} }
private fun getStoredCityDisplayName(): String = private fun readDashboardContextFromPrefs(): DashboardCityContext {
prefs.getString(KEY_DASHBOARD_CITY, DEFAULT_CITY_NAME) ?: DEFAULT_CITY_NAME val display = prefs.getString(KEY_DASHBOARD_CITY, DEFAULT_CITY_NAME) ?: DEFAULT_CITY_NAME
val nameEn = prefs.getString(KEY_DASHBOARD_CITY_EN, DEFAULT_CITY_NAME) ?: DEFAULT_CITY_NAME
val id = prefs.getString(KEY_DASHBOARD_CITY_ID, null)?.takeIf { it.isNotBlank() }
return DashboardCityContext(displayName = display, cityId = id, cityNameEn = nameEn)
}
private fun pushContextUpdate() {
_cityContextFlow.value = readDashboardContextFromPrefs()
}
private suspend fun ensureCitiesInDb(): List<City> { private suspend fun ensureCitiesInDb(): List<City> {
if (!cityLocalDataSource.isEmpty()) { if (!cityLocalDataSource.isEmpty()) {
@@ -161,6 +198,7 @@ class CityServiceImpl @Inject constructor(
private const val PREFS_NAME = "airmq_city" private const val PREFS_NAME = "airmq_city"
private const val KEY_DASHBOARD_CITY = "dashboard_city" private const val KEY_DASHBOARD_CITY = "dashboard_city"
private const val KEY_DASHBOARD_CITY_EN = "dashboard_city_en" private const val KEY_DASHBOARD_CITY_EN = "dashboard_city_en"
private const val KEY_DASHBOARD_CITY_ID = "dashboard_city_id"
private const val KEY_CITY_INIT = "city_init" private const val KEY_CITY_INIT = "city_init"
private const val KEY_DASHBOARD_CITY_AUTO = "dashboard_city_auto" private const val KEY_DASHBOARD_CITY_AUTO = "dashboard_city_auto"
} }

View File

@@ -0,0 +1,14 @@
package org.db3.airmq.sdk.city
/**
* Selected dashboard city for UI and GraphQL city average filters.
*
* @param displayName Localized label shown in the dashboard header.
* @param cityId GraphQL / Room city id when known (may be null for legacy prefs).
* @param cityNameEn English city name; used as API fallback when [cityId] is absent.
*/
data class DashboardCityContext(
val displayName: String,
val cityId: String?,
val cityNameEn: String,
)

View File

@@ -0,0 +1,19 @@
package org.db3.airmq.sdk.dashboard
import org.db3.airmq.sdk.city.DashboardCityContext
/**
* Fetches city-aggregated sensor series and latest values for the dashboard.
*/
interface DashboardMetricsRepository {
/**
* Loads mean buckets over the last 24 hours and the latest snapshot for [context].
*/
suspend fun fetchCityDashboard(context: DashboardCityContext): Result<CityDashboardData>
}
data class CityDashboardData(
val averageRows: List<SensorSampleRow>,
val lastRow: SensorSampleRow?,
)

View File

@@ -0,0 +1,136 @@
package org.db3.airmq.sdk.dashboard
import android.util.Log
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.Optional
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.db3.airmq.sdk.CityAverageLastQuery
import org.db3.airmq.sdk.CityAverageQuery
import org.db3.airmq.sdk.city.DashboardCityContext
import org.db3.airmq.sdk.type.LastFilter
import org.db3.airmq.sdk.type.MeanFilter
@Singleton
class DashboardMetricsRepositoryImpl @Inject constructor(
private val apolloClient: ApolloClient,
) : DashboardMetricsRepository {
override suspend fun fetchCityDashboard(context: DashboardCityContext): Result<CityDashboardData> = runCatching {
val now = System.currentTimeMillis()
val from = now - HOURS_24_MS
val tFrom = formatUtcIso(from)
val tTo = formatUtcIso(now)
val meanFilter = MeanFilter(
cityId = context.cityId?.let { Optional.Present(it) } ?: Optional.Absent,
cityName = Optional.Present(context.cityNameEn),
t_from = Optional.Present(tFrom),
t_to = Optional.Present(tTo),
interval_h = Optional.Present(1),
interval_d = Optional.Present(0),
interval_m = Optional.Present(0),
)
val lastFilter = LastFilter(
cityId = context.cityId?.let { Optional.Present(it) } ?: Optional.Absent,
cityName = Optional.Present(context.cityNameEn),
interval_m = Optional.Present(15),
)
Log.d(
TAG,
"fetchCityDashboard request: display=${context.displayName} cityId=${context.cityId} " +
"cityNameEn=${context.cityNameEn} t_from=$tFrom t_to=$tTo " +
"meanFilter=$meanFilter lastFilter=$lastFilter"
)
coroutineScope {
val avgDeferred = async {
apolloClient.query(CityAverageQuery(filter = meanFilter)).execute()
}
val lastDeferred = async {
apolloClient.query(CityAverageLastQuery(filter = lastFilter)).execute()
}
val avgResponse = avgDeferred.await()
val lastResponse = lastDeferred.await()
Log.d(
TAG,
"CityAverage raw: data=${avgResponse.data} errors=${avgResponse.errors} " +
"exception=${avgResponse.exception?.message}"
)
Log.d(
TAG,
"CityAverageLast raw: data=${lastResponse.data} errors=${lastResponse.errors} " +
"exception=${lastResponse.exception?.message}"
)
avgResponse.exception?.let { throw it }
avgResponse.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
lastResponse.exception?.let { throw it }
lastResponse.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
val rows = avgResponse.data?.cityAverage.orEmpty().mapNotNull { row ->
row?.let { mapCityAverageRow(it) }
}
val lastRow = lastResponse.data?.cityAverageLast?.let { mapLastRow(it) }
Log.d(
TAG,
"fetchCityDashboard parsed: ${rows.size} series rows, lastRow=${lastRow != null} " +
"(last epoch=${lastRow?.epochMillis})"
)
CityDashboardData(averageRows = rows, lastRow = lastRow)
}
}
private fun formatUtcIso(epochMillis: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
return sdf.format(Date(epochMillis))
}
private fun mapCityAverageRow(row: CityAverageQuery.CityAverage): SensorSampleRow? {
val t = GraphqlDateTimeParser.parseToEpochMillis(row.time) ?: return null
return SensorSampleRow(
epochMillis = t,
temp = row.Temp?.toFloat(),
hum = row.Hum?.toFloat(),
press = row.Press?.toFloat(),
pms1 = row.PMS1?.toFloat(),
pms25 = row.PMS25?.toFloat(),
pms10 = row.PMS10?.toFloat(),
radRg = row.radRg?.toFloat(),
ppm = row.PPM?.toFloat(),
ikav = row.IKAV?.toFloat(),
)
}
private fun mapLastRow(row: CityAverageLastQuery.CityAverageLast): SensorSampleRow? {
val t = GraphqlDateTimeParser.parseToEpochMillis(row.time) ?: return null
return SensorSampleRow(
epochMillis = t,
temp = row.Temp?.toFloat(),
hum = row.Hum?.toFloat(),
press = row.Press?.toFloat(),
pms1 = row.PMS1?.toFloat(),
pms25 = row.PMS25?.toFloat(),
pms10 = row.PMS10?.toFloat(),
radRg = row.radRg?.toFloat(),
ppm = row.PPM?.toFloat(),
ikav = row.IKAV?.toFloat(),
)
}
private companion object {
private const val TAG = "DashboardApi"
private const val HOURS_24_MS = 24L * 60L * 60L * 1000L
}
}

View File

@@ -0,0 +1,47 @@
package org.db3.airmq.sdk.dashboard
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
/**
* Parses GraphQL [DateTime] values returned as [Any] (typically ISO-8601 string) from Apollo.
*/
internal object GraphqlDateTimeParser {
private val utcPatterns = listOf(
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ssXXX",
)
fun parseToEpochMillis(value: Any?): Long? {
if (value == null) return null
when (value) {
is String -> {
for (pattern in utcPatterns) {
try {
val sdf = SimpleDateFormat(pattern, Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
return sdf.parse(value)?.time
} catch (_: ParseException) {
continue
}
}
try {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US)
return sdf.parse(value)?.time
} catch (_: ParseException) {
return null
}
}
is Number -> {
val v = value.toLong()
return if (v in 1_000_000_000L until 1_000_000_000_000L) v * 1000L else v
}
else -> return null
}
}
}

View File

@@ -0,0 +1,15 @@
package org.db3.airmq.sdk.dashboard
/** Normalized row from city average GraphQL queries for dashboard mapping. */
data class SensorSampleRow(
val epochMillis: Long,
val temp: Float?,
val hum: Float?,
val press: Float?,
val pms1: Float?,
val pms25: Float?,
val pms10: Float?,
val radRg: Float?,
val ppm: Float?,
val ikav: Float?,
)

View File

@@ -14,6 +14,8 @@ import org.db3.airmq.sdk.auth.FirebaseAuthService
import org.db3.airmq.sdk.auth.FirebaseSessionManager import org.db3.airmq.sdk.auth.FirebaseSessionManager
import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl
import org.db3.airmq.sdk.auth.SharedPreferencesApiTokenStore import org.db3.airmq.sdk.auth.SharedPreferencesApiTokenStore
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepositoryImpl
import org.db3.airmq.sdk.map.MapServiceImpl import org.db3.airmq.sdk.map.MapServiceImpl
import org.db3.airmq.sdk.map.MapService import org.db3.airmq.sdk.map.MapService
import org.db3.airmq.sdk.settings.SettingsService import org.db3.airmq.sdk.settings.SettingsService
@@ -60,6 +62,12 @@ abstract class SDKBindModule {
@Singleton @Singleton
abstract fun bindMapService(impl: MapServiceImpl): MapService abstract fun bindMapService(impl: MapServiceImpl): MapService
@Binds
@Singleton
abstract fun bindDashboardMetricsRepository(
impl: DashboardMetricsRepositoryImpl
): DashboardMetricsRepository
@Binds @Binds
@Singleton @Singleton
abstract fun bindSettingsService(impl: SettingsServiceImpl): SettingsService abstract fun bindSettingsService(impl: SettingsServiceImpl): SettingsService

View File

@@ -9,30 +9,59 @@ import com.apollographql.apollo.interceptor.ApolloInterceptorChain
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
/**
* Logs every GraphQL operation name, document, variable payload ([Operation] `toString`),
* and response data or errors to logcat (tag [TAG]).
*/
class ApolloLoggingInterceptor : ApolloInterceptor { class ApolloLoggingInterceptor : ApolloInterceptor {
override fun <D : Operation.Data> intercept( override fun <D : Operation.Data> intercept(
request: ApolloRequest<D>, request: ApolloRequest<D>,
chain: ApolloInterceptorChain chain: ApolloInterceptorChain
): Flow<ApolloResponse<D>> { ): Flow<ApolloResponse<D>> {
val operationName = request.operation.name() val operation = request.operation
val requestData = request.operation.toString() val operationName = operation.name()
Log.d(TAG, "Apollo request -> $operationName, data=$requestData") Log.d(TAG, "---------- GraphQL request: $operationName ----------")
logChunked("$TAG.request.$operationName.doc", operation.document())
Log.d(TAG, "Variables / operation: $operation")
return chain.proceed(request).onEach { response -> return chain.proceed(request).onEach { response ->
val errorsCount = response.errors?.size ?: 0
if (response.exception != null) { if (response.exception != null) {
Log.e( Log.e(
TAG, TAG,
"Apollo response <- $operationName failed: ${response.exception?.message}" "---------- GraphQL response: $operationName FAILED ----------",
response.exception
) )
} else { } else {
Log.d( Log.d(TAG, "---------- GraphQL response: $operationName ----------")
TAG, val errors = response.errors
"Apollo response <- $operationName success, errors=$errorsCount, data=${response.data}" if (!errors.isNullOrEmpty()) {
) errors.forEachIndexed { i, err ->
Log.w(TAG, "errors[$i]: ${err.message} (locations=${err.locations})")
}
} else {
Log.d(TAG, "errors: none")
}
val dataStr = response.data?.toString() ?: "null"
logChunked("$TAG.response.$operationName.data", dataStr)
} }
} }
} }
/** Android single-line log limit is ~4k; split large payloads. */
private fun logChunked(label: String, text: String, chunkSize: Int = 3500) {
if (text.length <= chunkSize) {
Log.d(TAG, "$label: $text")
return
}
var start = 0
var part = 0
while (start < text.length) {
val end = minOf(start + chunkSize, text.length)
Log.d(TAG, "$label [part $part]: ${text.substring(start, end)}")
part++
start = end
}
}
private companion object { private companion object {
private const val TAG = "ApolloNetwork" private const val TAG = "ApolloNetwork"
} }