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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
mutation AuthGoogleNew($accessToken: String!) {
|
||||
authGoogleNew(input: { accessToken: $accessToken }) {
|
||||
authGoogleApp(input: { accessToken: $accessToken }) {
|
||||
token
|
||||
name
|
||||
email
|
||||
|
||||
18
sdk/src/main/graphql/CityAverage.graphql
Normal file
18
sdk/src/main/graphql/CityAverage.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
18
sdk/src/main/graphql/CityAverageLast.graphql
Normal file
18
sdk/src/main/graphql/CityAverageLast.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user