diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt index 4268747..74476c9 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt @@ -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 = {}, diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreenContract.kt index b3f5864..c9e6735 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreenContract.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreenContract.kt @@ -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( diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt index 133e727..93bd56d 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt @@ -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 = _actions.asSharedFlow() private var showOfflineDevices = false + private var deviceChartRowsCache: List = 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 = 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 = listOf( + private fun dummyMarkers(sensorType: MapSensorType): List = listOf( MapMarker( id = "dummy-1", title = "AirMQ Demo #1", @@ -288,15 +432,15 @@ class MapViewModel @Inject constructor( ) ) - private fun List.toMarkers(sensorType: SensorType): List { + private fun List.toMarkers(sensorType: MapSensorType): List { 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 + } } diff --git a/sdk/src/main/graphql/LocationTimeSeries.graphql b/sdk/src/main/graphql/LocationTimeSeries.graphql new file mode 100644 index 0000000..bf221c0 --- /dev/null +++ b/sdk/src/main/graphql/LocationTimeSeries.graphql @@ -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 + } + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DeviceTimeSeriesRepository.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DeviceTimeSeriesRepository.kt new file mode 100644 index 0000000..80526f7 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DeviceTimeSeriesRepository.kt @@ -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> +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DeviceTimeSeriesRepositoryImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DeviceTimeSeriesRepositoryImpl.kt new file mode 100644 index 0000000..59f05f9 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DeviceTimeSeriesRepositoryImpl.kt @@ -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> = 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" + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/LocationTimeSeriesMapper.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/LocationTimeSeriesMapper.kt new file mode 100644 index 0000000..0bcc719 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/LocationTimeSeriesMapper.kt @@ -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(), + ) +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt index 25eecf9..fae3563 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt @@ -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