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