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 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 = {},

View File

@@ -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(

View File

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

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