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
}

View File

@@ -1,5 +1,5 @@
mutation AuthGoogleNew($accessToken: String!) {
authGoogleNew(input: { accessToken: $accessToken }) {
authGoogleApp(input: { accessToken: $accessToken }) {
token
name
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 ->
throw IllegalStateException(gqlError.message)
}
return response.data?.authGoogleNew?.token
return response.data?.authGoogleApp?.token
?: error("Backend auth exchange succeeded without API token.")
}
}

View File

@@ -16,11 +16,32 @@ interface CityService {
*/
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).
*/
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.
* 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.MutableStateFlow
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.remote.CityRemoteDataSource
import org.db3.airmq.sdk.city.domain.City
@@ -29,11 +30,15 @@ class CityServiceImpl @Inject constructor(
) : CityService {
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(
hasLocationPermission: Boolean,
@@ -46,24 +51,33 @@ class CityServiceImpl @Inject constructor(
val resolvedCity = resolveCity(cities, hasLocationPermission, location, countryCode)
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_EN, resolvedCity?.nameEn ?: DEFAULT_CITY_NAME)
.putBoolean(KEY_CITY_INIT, true)
.apply()
_selectedCityFlow.value = displayName
if (resolvedCity != null) {
editor.putString(KEY_DASHBOARD_CITY_ID, resolvedCity.id)
} else {
editor.remove(KEY_DASHBOARD_CITY_ID)
}
editor.apply()
pushContextUpdate()
}
override suspend fun setSelectedCity(cityId: String): Result<Unit> = runCatching {
val cities = cityLocalDataSource.getAllCities()
val city = cities.find { it.id == cityId } ?: cities.find { it.nameEn == cityId }
val displayName = city?.getLocalizedName(Locale.getDefault().language) ?: cityId
prefs.edit()
val editor = prefs.edit()
.putString(KEY_DASHBOARD_CITY, displayName)
.putString(KEY_DASHBOARD_CITY_EN, city?.nameEn ?: cityId)
.apply()
_selectedCityFlow.value = displayName
if (city != null) {
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)
@@ -84,11 +98,26 @@ class CityServiceImpl @Inject constructor(
prefs.edit()
.putString(KEY_DASHBOARD_CITY, displayName)
.putString(KEY_DASHBOARD_CITY_EN, resolvedCity.nameEn)
.putString(KEY_DASHBOARD_CITY_ID, resolvedCity.id)
.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> {
if (cityLocalDataSource.isEmpty()) return emptyList()
val cities = cityLocalDataSource.getAllCities()
@@ -103,8 +132,16 @@ class CityServiceImpl @Inject constructor(
return grouped
}
private fun getStoredCityDisplayName(): String =
prefs.getString(KEY_DASHBOARD_CITY, DEFAULT_CITY_NAME) ?: DEFAULT_CITY_NAME
private fun readDashboardContextFromPrefs(): DashboardCityContext {
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> {
if (!cityLocalDataSource.isEmpty()) {
@@ -161,6 +198,7 @@ class CityServiceImpl @Inject constructor(
private const val PREFS_NAME = "airmq_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_ID = "dashboard_city_id"
private const val KEY_CITY_INIT = "city_init"
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.FirebaseSessionManagerImpl
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.MapService
import org.db3.airmq.sdk.settings.SettingsService
@@ -60,6 +62,12 @@ abstract class SDKBindModule {
@Singleton
abstract fun bindMapService(impl: MapServiceImpl): MapService
@Binds
@Singleton
abstract fun bindDashboardMetricsRepository(
impl: DashboardMetricsRepositoryImpl
): DashboardMetricsRepository
@Binds
@Singleton
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.onEach
/**
* Logs every GraphQL operation name, document, variable payload ([Operation] `toString`),
* and response data or errors to logcat (tag [TAG]).
*/
class ApolloLoggingInterceptor : ApolloInterceptor {
override fun <D : Operation.Data> intercept(
request: ApolloRequest<D>,
chain: ApolloInterceptorChain
): Flow<ApolloResponse<D>> {
val operationName = request.operation.name()
val requestData = request.operation.toString()
Log.d(TAG, "Apollo request -> $operationName, data=$requestData")
val operation = request.operation
val operationName = operation.name()
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 ->
val errorsCount = response.errors?.size ?: 0
if (response.exception != null) {
Log.e(
TAG,
"Apollo response <- $operationName failed: ${response.exception?.message}"
"---------- GraphQL response: $operationName FAILED ----------",
response.exception
)
} else {
Log.d(
TAG,
"Apollo response <- $operationName success, errors=$errorsCount, data=${response.data}"
)
Log.d(TAG, "---------- GraphQL response: $operationName ----------")
val errors = response.errors
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 const val TAG = "ApolloNetwork"
}