feat(map): load device time series for map panel chart

Add LocationTimeSeries GraphQL query and DeviceTimeSeriesRepository.

Extend DevicePanelState with chart data/loading/offset; MapViewModel fetches via location _id and maps with DashboardChartMapper.

Wire MapScreen preview with sample chart data.

Made-with: Cursor
This commit is contained in:
2026-04-06 22:20:04 +02:00
parent 9cbc521a0d
commit d34b3bf70e
8 changed files with 332 additions and 22 deletions

View File

@@ -38,6 +38,8 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.flow.collectLatest
import org.db3.airmq.R
import org.db3.airmq.features.common.metric.SensorType as MetricSensorType
import org.db3.airmq.features.dashboard.DashboardChartMapper
import org.db3.airmq.features.map.MapScreenContract.Action
import org.db3.airmq.features.map.MapScreenContract.DevicePanelState
import org.db3.airmq.features.map.MapScreenContract.DeviceSensorType
@@ -584,7 +586,12 @@ private fun PreviewMapScreenDevicePanel() {
status = "Online",
selectedRange = TimeRange.DAY,
displayedDateRange = "Today",
selectedSensor = DeviceSensorType.DUST
selectedSensor = DeviceSensorType.DUST,
chartDataset = DashboardChartMapper.chartDataset(
DashboardChartMapper.previewStaticRows(),
MetricSensorType.DUST
),
isChartLoading = false
)
),
onEvent = {},

View File

@@ -1,5 +1,7 @@
package org.db3.airmq.features.map
import org.db3.airmq.features.common.chart.ChartDataset
object MapScreenContract {
enum class SensorType {
@@ -37,7 +39,12 @@ object MapScreenContract {
DeviceSensorType.TEMPERATURE,
DeviceSensorType.DUST,
DeviceSensorType.RADIOACTIVITY
)
),
val chartDataset: ChartDataset = ChartDataset.Single(emptyList()),
val isChartLoading: Boolean = false,
val chartErrorMessage: String? = null,
/** Non-positive: shift the window into the past; `0` ends at now. */
val chartWindowOffset: Int = 0,
)
data class SearchPanelState(

View File

@@ -5,6 +5,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -13,17 +16,25 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.db3.airmq.R
import org.db3.airmq.features.common.chart.ChartDataset
import org.db3.airmq.features.common.metric.SensorType
import org.db3.airmq.features.dashboard.DashboardChartMapper
import org.db3.airmq.features.map.MapScreenContract.Action
import org.db3.airmq.features.map.MapScreenContract.DevicePanelState
import org.db3.airmq.features.map.MapScreenContract.DeviceSensorType
import org.db3.airmq.features.map.MapScreenContract.Event
import org.db3.airmq.features.map.MapScreenContract.SearchResult
import org.db3.airmq.features.map.MapScreenContract.SearchPanelState
import org.db3.airmq.features.map.MapScreenContract.SensorType
import org.db3.airmq.features.map.MapScreenContract.SensorType as MapSensorType
import org.db3.airmq.features.map.MapScreenContract.State
import org.db3.airmq.features.map.MapScreenContract.TimeRange
import org.db3.airmq.sdk.auth.ApiTokenStore
import org.db3.airmq.sdk.dashboard.DeviceTimeSeriesRepository
import org.db3.airmq.sdk.dashboard.SensorSampleRow
import org.db3.airmq.sdk.map.MapService
import org.db3.airmq.sdk.map.domain.MapItem
import org.db3.airmq.sdk.settings.SettingsService
@@ -32,7 +43,9 @@ import org.db3.airmq.sdk.settings.SettingsService
class MapViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val mapService: MapService,
private val settingsService: SettingsService
private val apiTokenStore: ApiTokenStore,
private val settingsService: SettingsService,
private val deviceTimeSeriesRepository: DeviceTimeSeriesRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(State(isLoading = true))
@@ -43,9 +56,14 @@ class MapViewModel @Inject constructor(
val actions: SharedFlow<Action> = _actions.asSharedFlow()
private var showOfflineDevices = false
private var deviceChartRowsCache: List<SensorSampleRow> = emptyList()
init {
refreshMapItems()
viewModelScope.launch {
apiTokenStore.observeToken().collectLatest {
refreshMapItems()
}
}
}
fun onEvent(event: Event) {
@@ -55,6 +73,7 @@ class MapViewModel @Inject constructor(
}
is Event.SearchButtonClicked -> {
deviceChartRowsCache = emptyList()
_uiState.value = _uiState.value.copy(
searchPanelState = SearchPanelState(),
devicePanelState = null
@@ -85,6 +104,7 @@ class MapViewModel @Inject constructor(
devicePanelState = selectedItem.toDevicePanelState(),
selectedMarkerId = event.resultId
)
loadDeviceChart(forceFetch = true)
}
is Event.MyLocationClicked -> {
@@ -111,9 +131,11 @@ class MapViewModel @Inject constructor(
devicePanelState = selectedItem.toDevicePanelState(),
selectedMarkerId = event.itemId
)
loadDeviceChart(forceFetch = true)
}
is Event.DevicePanelClosed -> {
deviceChartRowsCache = emptyList()
val previousMarkerId = _uiState.value.devicePanelState?.id
_uiState.value = _uiState.value.copy(
devicePanelState = null,
@@ -131,46 +153,149 @@ class MapViewModel @Inject constructor(
_uiState.value = _uiState.value.copy(
devicePanelState = panelData.copy(
selectedRange = event.range,
displayedDateRange = rangeLabel(event.range)
displayedDateRange = rangeLabel(event.range),
chartWindowOffset = 0
)
)
loadDeviceChart(forceFetch = true)
}
is Event.DateBackClicked -> {
val panelData = _uiState.value.devicePanelState ?: return
_uiState.value = _uiState.value.copy(
devicePanelState = panelData.copy(
displayedDateRange = appContext.getString(
R.string.map_previous_period,
rangeLabel(panelData.selectedRange)
)
chartWindowOffset = panelData.chartWindowOffset - 1
)
)
loadDeviceChart(forceFetch = true)
}
Event.DateForwardClicked -> {
val panelData = _uiState.value.devicePanelState ?: return
if (panelData.chartWindowOffset >= 0) return
_uiState.value = _uiState.value.copy(
devicePanelState = panelData.copy(
displayedDateRange = appContext.getString(
R.string.map_next_period,
rangeLabel(panelData.selectedRange)
)
chartWindowOffset = (panelData.chartWindowOffset + 1).coerceAtMost(0)
)
)
loadDeviceChart(forceFetch = true)
}
is Event.DeviceSensorSelected -> {
val panelData = _uiState.value.devicePanelState ?: return
_uiState.value = _uiState.value.copy(
devicePanelState = panelData.copy(selectedSensor = event.sensor)
)
loadDeviceChart(forceFetch = false)
}
}
}
private fun loadDeviceChart(forceFetch: Boolean = true) {
viewModelScope.launch {
val panel = _uiState.value.devicePanelState ?: return@launch
val locationId = panel.id
if (!forceFetch && deviceChartRowsCache.isNotEmpty()) {
val current = _uiState.value.devicePanelState ?: return@launch
if (current.id != locationId) return@launch
_uiState.value = _uiState.value.copy(
devicePanelState = current.copy(
chartDataset = DashboardChartMapper.chartDataset(
deviceChartRowsCache,
current.selectedSensor.toMetricSensorType()
),
isChartLoading = false,
chartErrorMessage = null
)
)
return@launch
}
if (locationId.startsWith(DUMMY_PREFIX) || apiTokenStore.getToken().isNullOrBlank()) {
deviceChartRowsCache = emptyList()
val current = _uiState.value.devicePanelState ?: return@launch
_uiState.value = _uiState.value.copy(
devicePanelState = current.copy(
chartDataset = ChartDataset.Single(emptyList()),
isChartLoading = false,
chartErrorMessage = null
)
)
return@launch
}
val loadingPanel = panel.copy(isChartLoading = true, chartErrorMessage = null)
_uiState.value = _uiState.value.copy(devicePanelState = loadingPanel)
val now = System.currentTimeMillis()
val span = spanMillis(panel.selectedRange)
val tTo = now + panel.chartWindowOffset * span
val tFrom = tTo - span
val (intervalH, intervalD, intervalM) = intervalsForRange(panel.selectedRange)
val result = withContext(Dispatchers.IO) {
deviceTimeSeriesRepository.fetchTimeSeries(
locationId = locationId,
tFromEpochMillis = tFrom,
tToEpochMillis = tTo,
intervalHours = intervalH,
intervalDays = intervalD,
intervalMinutes = intervalM,
)
}
val currentPanel = _uiState.value.devicePanelState
if (currentPanel?.id != locationId) return@launch
result.fold(
onSuccess = { rows ->
deviceChartRowsCache = rows
val label = formatWindowRange(tFrom, tTo)
_uiState.value = _uiState.value.copy(
devicePanelState = currentPanel.copy(
chartDataset = DashboardChartMapper.chartDataset(
rows,
currentPanel.selectedSensor.toMetricSensorType()
),
isChartLoading = false,
chartErrorMessage = null,
displayedDateRange = label
)
)
},
onFailure = { throwable ->
deviceChartRowsCache = emptyList()
_actions.tryEmit(
Action.ShowToast(
throwable.message ?: appContext.getString(R.string.map_failed_to_load_items)
)
)
_uiState.value = _uiState.value.copy(
devicePanelState = currentPanel.copy(
chartDataset = ChartDataset.Single(emptyList()),
isChartLoading = false,
chartErrorMessage = throwable.message
)
)
}
)
}
}
private fun refreshMapItems() {
_uiState.value = _uiState.value.copy(isLoading = true)
viewModelScope.launch(Dispatchers.IO) {
if (apiTokenStore.getToken().isNullOrBlank()) {
domainItems = emptyList()
val searchPanelState = _uiState.value.searchPanelState
val selectedSensorType = _uiState.value.selectedTopSensor
_uiState.value = _uiState.value.copy(
isLoading = false,
items = dummyMarkers(selectedSensorType),
searchPanelState = searchPanelState?.copy(results = emptyList())
)
return@launch
}
val result = runCatching {
showOfflineDevices = settingsService.getOfflineDevicesVisible()
mapService.fetchMapItems(showOfflineDevices = showOfflineDevices)
@@ -234,6 +359,25 @@ class MapViewModel @Inject constructor(
TimeRange.MONTH -> appContext.getString(R.string.map_time_this_month)
}
private fun formatWindowRange(tFrom: Long, tTo: Long): String {
val df = SimpleDateFormat("MMM d HH:mm", Locale.getDefault())
return "${df.format(Date(tFrom))} ${df.format(Date(tTo))}"
}
private fun spanMillis(range: TimeRange): Long = when (range) {
TimeRange.HOUR -> HOUR_MS
TimeRange.DAY -> DAY_MS
TimeRange.WEEK -> WEEK_MS
TimeRange.MONTH -> MONTH_MS
}
private fun intervalsForRange(range: TimeRange): Triple<Int, Int, Int> = when (range) {
TimeRange.HOUR -> Triple(0, 0, 15)
TimeRange.DAY -> Triple(1, 0, 0)
TimeRange.WEEK -> Triple(0, 1, 0)
TimeRange.MONTH -> Triple(0, 1, 0)
}
private fun remapMarkers() {
val markers = domainItems.toMarkers(_uiState.value.selectedTopSensor)
_uiState.value = _uiState.value.copy(
@@ -241,7 +385,7 @@ class MapViewModel @Inject constructor(
)
}
private fun dummyMarkers(sensorType: SensorType): List<MapMarker> = listOf(
private fun dummyMarkers(sensorType: MapSensorType): List<MapMarker> = listOf(
MapMarker(
id = "dummy-1",
title = "AirMQ Demo #1",
@@ -288,15 +432,15 @@ class MapViewModel @Inject constructor(
)
)
private fun List<MapItem>.toMarkers(sensorType: SensorType): List<MapMarker> {
private fun List<MapItem>.toMarkers(sensorType: MapSensorType): List<MapMarker> {
return mapNotNull { item ->
val value = when (sensorType) {
SensorType.DUST -> item.dustValue
SensorType.RADIOACTIVITY -> item.radioactivityValue
MapSensorType.DUST -> item.dustValue
MapSensorType.RADIOACTIVITY -> item.radioactivityValue
}
// Return null if device is offline or value is missing
if (!showOfflineDevices && (!item.isOnline || value == null )) {
if (!showOfflineDevices && (!item.isOnline || value == null)) {
return@mapNotNull null
}
@@ -316,8 +460,8 @@ class MapViewModel @Inject constructor(
private fun MapMarker.toDevicePanelState(): DevicePanelState {
val defaultSensor = when (sensorType) {
SensorType.DUST -> DeviceSensorType.DUST
SensorType.RADIOACTIVITY -> DeviceSensorType.RADIOACTIVITY
MapSensorType.DUST -> DeviceSensorType.DUST
MapSensorType.RADIOACTIVITY -> DeviceSensorType.RADIOACTIVITY
}
return DevicePanelState(
id = id,
@@ -329,7 +473,25 @@ class MapViewModel @Inject constructor(
},
selectedRange = TimeRange.DAY,
displayedDateRange = rangeLabel(TimeRange.DAY),
selectedSensor = defaultSensor
selectedSensor = defaultSensor,
chartDataset = ChartDataset.Single(emptyList()),
isChartLoading = true,
chartErrorMessage = null,
chartWindowOffset = 0,
)
}
private fun DeviceSensorType.toMetricSensorType(): SensorType = when (this) {
DeviceSensorType.TEMPERATURE -> SensorType.TEMPERATURE
DeviceSensorType.DUST -> SensorType.DUST
DeviceSensorType.RADIOACTIVITY -> SensorType.RADIOACTIVITY
}
private companion object {
private const val DUMMY_PREFIX = "dummy-"
private const val HOUR_MS = 60L * 60L * 1000L
private const val DAY_MS = 24L * HOUR_MS
private const val WEEK_MS = 7L * DAY_MS
private const val MONTH_MS = 30L * DAY_MS
}
}

View File

@@ -0,0 +1,18 @@
query LocationTimeSeries($filter: LocationFilter!, $span: TimeSpan!) {
location(filter: $filter) {
_id
timeSeries(filter: $span) {
deviceId
time
Temp
Hum
Press
PMS1
PMS25
PMS10
radRg
PPM
IKAV
}
}
}

View File

@@ -0,0 +1,19 @@
package org.db3.airmq.sdk.dashboard
/**
* Fetches per-location sensor time series for map / device charts.
*/
interface DeviceTimeSeriesRepository {
/**
* @param intervalHours/Days/Minutes bucketing for aggregation (server-defined); pass 0 for unused axes.
*/
suspend fun fetchTimeSeries(
locationId: String,
tFromEpochMillis: Long,
tToEpochMillis: Long,
intervalHours: Int,
intervalDays: Int,
intervalMinutes: Int,
): Result<List<SensorSampleRow>>
}

View File

@@ -0,0 +1,70 @@
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 org.db3.airmq.sdk.LocationTimeSeriesQuery
import org.db3.airmq.sdk.type.LocationFilter
import org.db3.airmq.sdk.type.TimeSpan
@Singleton
class DeviceTimeSeriesRepositoryImpl @Inject constructor(
private val apolloClient: ApolloClient,
) : DeviceTimeSeriesRepository {
override suspend fun fetchTimeSeries(
locationId: String,
tFromEpochMillis: Long,
tToEpochMillis: Long,
intervalHours: Int,
intervalDays: Int,
intervalMinutes: Int,
): Result<List<SensorSampleRow>> = runCatching {
val filter = LocationFilter(
_id = Optional.Present(locationId),
)
val span = TimeSpan(
t_from = Optional.Present(formatUtcIso(tFromEpochMillis)),
t_to = Optional.Present(formatUtcIso(tToEpochMillis)),
interval_h = Optional.Present(intervalHours),
interval_d = Optional.Present(intervalDays),
interval_m = Optional.Present(intervalMinutes),
)
Log.d(
TAG,
"LocationTimeSeries locationId=$locationId t_from=${span.t_from} t_to=${span.t_to} " +
"ih=$intervalHours id=$intervalDays im=$intervalMinutes"
)
val response = apolloClient
.query(LocationTimeSeriesQuery(filter = filter, span = span))
.execute()
response.exception?.let { throw it }
response.errors?.firstOrNull()?.let { throw IllegalStateException(it.message) }
val series = response.data?.location?.timeSeries.orEmpty()
val rows = series.mapNotNull { row -> row?.toSensorSampleRow() }
Log.d(TAG, "LocationTimeSeries parsed ${rows.size} rows (raw points=${series.size})")
rows
}
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 companion object {
private const val TAG = "DeviceTimeSeriesApi"
}
}

View File

@@ -0,0 +1,19 @@
package org.db3.airmq.sdk.dashboard
import org.db3.airmq.sdk.LocationTimeSeriesQuery
internal fun LocationTimeSeriesQuery.TimeSeries.toSensorSampleRow(): SensorSampleRow? {
val t = GraphqlDateTimeParser.parseToEpochMillis(time) ?: return null
return SensorSampleRow(
epochMillis = t,
temp = Temp?.toFloat(),
hum = Hum?.toFloat(),
press = Press?.toFloat(),
pms1 = PMS1?.toFloat(),
pms25 = PMS25?.toFloat(),
pms10 = PMS10?.toFloat(),
radRg = radRg?.toFloat(),
ppm = PPM?.toFloat(),
ikav = IKAV?.toFloat(),
)
}

View File

@@ -18,6 +18,8 @@ import org.db3.airmq.sdk.auth.SharedPreferencesApiTokenStore
import org.db3.airmq.sdk.auth.SharedPreferencesLocalEmailAuthStore
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepositoryImpl
import org.db3.airmq.sdk.dashboard.DeviceTimeSeriesRepository
import org.db3.airmq.sdk.dashboard.DeviceTimeSeriesRepositoryImpl
import org.db3.airmq.sdk.map.MapServiceImpl
import org.db3.airmq.sdk.map.MapService
import org.db3.airmq.sdk.settings.SettingsService
@@ -74,6 +76,12 @@ abstract class SDKBindModule {
impl: DashboardMetricsRepositoryImpl
): DashboardMetricsRepository
@Binds
@Singleton
abstract fun bindDeviceTimeSeriesRepository(
impl: DeviceTimeSeriesRepositoryImpl
): DeviceTimeSeriesRepository
@Binds
@Singleton
abstract fun bindSettingsService(impl: SettingsServiceImpl): SettingsService