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 androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import org.db3.airmq.R
|
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.Action
|
||||||
import org.db3.airmq.features.map.MapScreenContract.DevicePanelState
|
import org.db3.airmq.features.map.MapScreenContract.DevicePanelState
|
||||||
import org.db3.airmq.features.map.MapScreenContract.DeviceSensorType
|
import org.db3.airmq.features.map.MapScreenContract.DeviceSensorType
|
||||||
@@ -584,7 +586,12 @@ private fun PreviewMapScreenDevicePanel() {
|
|||||||
status = "Online",
|
status = "Online",
|
||||||
selectedRange = TimeRange.DAY,
|
selectedRange = TimeRange.DAY,
|
||||||
displayedDateRange = "Today",
|
displayedDateRange = "Today",
|
||||||
selectedSensor = DeviceSensorType.DUST
|
selectedSensor = DeviceSensorType.DUST,
|
||||||
|
chartDataset = DashboardChartMapper.chartDataset(
|
||||||
|
DashboardChartMapper.previewStaticRows(),
|
||||||
|
MetricSensorType.DUST
|
||||||
|
),
|
||||||
|
isChartLoading = false
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
onEvent = {},
|
onEvent = {},
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.db3.airmq.features.map
|
package org.db3.airmq.features.map
|
||||||
|
|
||||||
|
import org.db3.airmq.features.common.chart.ChartDataset
|
||||||
|
|
||||||
object MapScreenContract {
|
object MapScreenContract {
|
||||||
|
|
||||||
enum class SensorType {
|
enum class SensorType {
|
||||||
@@ -37,7 +39,12 @@ object MapScreenContract {
|
|||||||
DeviceSensorType.TEMPERATURE,
|
DeviceSensorType.TEMPERATURE,
|
||||||
DeviceSensorType.DUST,
|
DeviceSensorType.DUST,
|
||||||
DeviceSensorType.RADIOACTIVITY
|
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(
|
data class SearchPanelState(
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ 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 java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -13,17 +16,25 @@ 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.collectLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.db3.airmq.R
|
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.Action
|
||||||
import org.db3.airmq.features.map.MapScreenContract.DevicePanelState
|
import org.db3.airmq.features.map.MapScreenContract.DevicePanelState
|
||||||
import org.db3.airmq.features.map.MapScreenContract.DeviceSensorType
|
import org.db3.airmq.features.map.MapScreenContract.DeviceSensorType
|
||||||
import org.db3.airmq.features.map.MapScreenContract.Event
|
import org.db3.airmq.features.map.MapScreenContract.Event
|
||||||
import org.db3.airmq.features.map.MapScreenContract.SearchResult
|
import org.db3.airmq.features.map.MapScreenContract.SearchResult
|
||||||
import org.db3.airmq.features.map.MapScreenContract.SearchPanelState
|
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.State
|
||||||
import org.db3.airmq.features.map.MapScreenContract.TimeRange
|
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.MapService
|
||||||
import org.db3.airmq.sdk.map.domain.MapItem
|
import org.db3.airmq.sdk.map.domain.MapItem
|
||||||
import org.db3.airmq.sdk.settings.SettingsService
|
import org.db3.airmq.sdk.settings.SettingsService
|
||||||
@@ -32,7 +43,9 @@ import org.db3.airmq.sdk.settings.SettingsService
|
|||||||
class MapViewModel @Inject constructor(
|
class MapViewModel @Inject constructor(
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
private val mapService: MapService,
|
private val mapService: MapService,
|
||||||
private val settingsService: SettingsService
|
private val apiTokenStore: ApiTokenStore,
|
||||||
|
private val settingsService: SettingsService,
|
||||||
|
private val deviceTimeSeriesRepository: DeviceTimeSeriesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(State(isLoading = true))
|
private val _uiState = MutableStateFlow(State(isLoading = true))
|
||||||
@@ -43,10 +56,15 @@ class MapViewModel @Inject constructor(
|
|||||||
val actions: SharedFlow<Action> = _actions.asSharedFlow()
|
val actions: SharedFlow<Action> = _actions.asSharedFlow()
|
||||||
|
|
||||||
private var showOfflineDevices = false
|
private var showOfflineDevices = false
|
||||||
|
private var deviceChartRowsCache: List<SensorSampleRow> = emptyList()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
apiTokenStore.observeToken().collectLatest {
|
||||||
refreshMapItems()
|
refreshMapItems()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onEvent(event: Event) {
|
fun onEvent(event: Event) {
|
||||||
when (event) {
|
when (event) {
|
||||||
@@ -55,6 +73,7 @@ class MapViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is Event.SearchButtonClicked -> {
|
is Event.SearchButtonClicked -> {
|
||||||
|
deviceChartRowsCache = emptyList()
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
searchPanelState = SearchPanelState(),
|
searchPanelState = SearchPanelState(),
|
||||||
devicePanelState = null
|
devicePanelState = null
|
||||||
@@ -85,6 +104,7 @@ class MapViewModel @Inject constructor(
|
|||||||
devicePanelState = selectedItem.toDevicePanelState(),
|
devicePanelState = selectedItem.toDevicePanelState(),
|
||||||
selectedMarkerId = event.resultId
|
selectedMarkerId = event.resultId
|
||||||
)
|
)
|
||||||
|
loadDeviceChart(forceFetch = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
is Event.MyLocationClicked -> {
|
is Event.MyLocationClicked -> {
|
||||||
@@ -111,9 +131,11 @@ class MapViewModel @Inject constructor(
|
|||||||
devicePanelState = selectedItem.toDevicePanelState(),
|
devicePanelState = selectedItem.toDevicePanelState(),
|
||||||
selectedMarkerId = event.itemId
|
selectedMarkerId = event.itemId
|
||||||
)
|
)
|
||||||
|
loadDeviceChart(forceFetch = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
is Event.DevicePanelClosed -> {
|
is Event.DevicePanelClosed -> {
|
||||||
|
deviceChartRowsCache = emptyList()
|
||||||
val previousMarkerId = _uiState.value.devicePanelState?.id
|
val previousMarkerId = _uiState.value.devicePanelState?.id
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
devicePanelState = null,
|
devicePanelState = null,
|
||||||
@@ -131,46 +153,149 @@ class MapViewModel @Inject constructor(
|
|||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
devicePanelState = panelData.copy(
|
devicePanelState = panelData.copy(
|
||||||
selectedRange = event.range,
|
selectedRange = event.range,
|
||||||
displayedDateRange = rangeLabel(event.range)
|
displayedDateRange = rangeLabel(event.range),
|
||||||
|
chartWindowOffset = 0
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
loadDeviceChart(forceFetch = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
is Event.DateBackClicked -> {
|
is Event.DateBackClicked -> {
|
||||||
val panelData = _uiState.value.devicePanelState ?: return
|
val panelData = _uiState.value.devicePanelState ?: return
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
devicePanelState = panelData.copy(
|
devicePanelState = panelData.copy(
|
||||||
displayedDateRange = appContext.getString(
|
chartWindowOffset = panelData.chartWindowOffset - 1
|
||||||
R.string.map_previous_period,
|
|
||||||
rangeLabel(panelData.selectedRange)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
loadDeviceChart(forceFetch = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
Event.DateForwardClicked -> {
|
Event.DateForwardClicked -> {
|
||||||
val panelData = _uiState.value.devicePanelState ?: return
|
val panelData = _uiState.value.devicePanelState ?: return
|
||||||
|
if (panelData.chartWindowOffset >= 0) return
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
devicePanelState = panelData.copy(
|
devicePanelState = panelData.copy(
|
||||||
displayedDateRange = appContext.getString(
|
chartWindowOffset = (panelData.chartWindowOffset + 1).coerceAtMost(0)
|
||||||
R.string.map_next_period,
|
|
||||||
rangeLabel(panelData.selectedRange)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
loadDeviceChart(forceFetch = true)
|
||||||
}
|
}
|
||||||
is Event.DeviceSensorSelected -> {
|
is Event.DeviceSensorSelected -> {
|
||||||
val panelData = _uiState.value.devicePanelState ?: return
|
val panelData = _uiState.value.devicePanelState ?: return
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
devicePanelState = panelData.copy(selectedSensor = event.sensor)
|
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() {
|
private fun refreshMapItems() {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
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 {
|
val result = runCatching {
|
||||||
showOfflineDevices = settingsService.getOfflineDevicesVisible()
|
showOfflineDevices = settingsService.getOfflineDevicesVisible()
|
||||||
mapService.fetchMapItems(showOfflineDevices = showOfflineDevices)
|
mapService.fetchMapItems(showOfflineDevices = showOfflineDevices)
|
||||||
@@ -234,6 +359,25 @@ class MapViewModel @Inject constructor(
|
|||||||
TimeRange.MONTH -> appContext.getString(R.string.map_time_this_month)
|
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() {
|
private fun remapMarkers() {
|
||||||
val markers = domainItems.toMarkers(_uiState.value.selectedTopSensor)
|
val markers = domainItems.toMarkers(_uiState.value.selectedTopSensor)
|
||||||
_uiState.value = _uiState.value.copy(
|
_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(
|
MapMarker(
|
||||||
id = "dummy-1",
|
id = "dummy-1",
|
||||||
title = "AirMQ Demo #1",
|
title = "AirMQ Demo #1",
|
||||||
@@ -288,11 +432,11 @@ 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 ->
|
return mapNotNull { item ->
|
||||||
val value = when (sensorType) {
|
val value = when (sensorType) {
|
||||||
SensorType.DUST -> item.dustValue
|
MapSensorType.DUST -> item.dustValue
|
||||||
SensorType.RADIOACTIVITY -> item.radioactivityValue
|
MapSensorType.RADIOACTIVITY -> item.radioactivityValue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return null if device is offline or value is missing
|
// Return null if device is offline or value is missing
|
||||||
@@ -316,8 +460,8 @@ class MapViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun MapMarker.toDevicePanelState(): DevicePanelState {
|
private fun MapMarker.toDevicePanelState(): DevicePanelState {
|
||||||
val defaultSensor = when (sensorType) {
|
val defaultSensor = when (sensorType) {
|
||||||
SensorType.DUST -> DeviceSensorType.DUST
|
MapSensorType.DUST -> DeviceSensorType.DUST
|
||||||
SensorType.RADIOACTIVITY -> DeviceSensorType.RADIOACTIVITY
|
MapSensorType.RADIOACTIVITY -> DeviceSensorType.RADIOACTIVITY
|
||||||
}
|
}
|
||||||
return DevicePanelState(
|
return DevicePanelState(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -329,7 +473,25 @@ class MapViewModel @Inject constructor(
|
|||||||
},
|
},
|
||||||
selectedRange = TimeRange.DAY,
|
selectedRange = TimeRange.DAY,
|
||||||
displayedDateRange = rangeLabel(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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
sdk/src/main/graphql/LocationTimeSeries.graphql
Normal file
18
sdk/src/main/graphql/LocationTimeSeries.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>>
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import org.db3.airmq.sdk.auth.SharedPreferencesApiTokenStore
|
|||||||
import org.db3.airmq.sdk.auth.SharedPreferencesLocalEmailAuthStore
|
import org.db3.airmq.sdk.auth.SharedPreferencesLocalEmailAuthStore
|
||||||
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
|
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
|
||||||
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepositoryImpl
|
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.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
|
||||||
@@ -74,6 +76,12 @@ abstract class SDKBindModule {
|
|||||||
impl: DashboardMetricsRepositoryImpl
|
impl: DashboardMetricsRepositoryImpl
|
||||||
): DashboardMetricsRepository
|
): DashboardMetricsRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindDeviceTimeSeriesRepository(
|
||||||
|
impl: DeviceTimeSeriesRepositoryImpl
|
||||||
|
): DeviceTimeSeriesRepository
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindSettingsService(impl: SettingsServiceImpl): SettingsService
|
abstract fun bindSettingsService(impl: SettingsServiceImpl): SettingsService
|
||||||
|
|||||||
Reference in New Issue
Block a user