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:
@@ -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 = {},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user