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.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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 ->
|
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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user