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

@@ -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"
}