From 9cbc521a0db7f1e0ef327d181df13a0b9c51c634 Mon Sep 17 00:00:00 2001 From: beetzung Date: Mon, 6 Apr 2026 22:19:42 +0200 Subject: [PATCH] fix(city): persist full city list and re-emit dashboard context on re-selection - Map cityList rows without coordinates; fall back en/ru/be for display names - Replace local city cache on sync when opening city screen - Use SharedFlow + prefs reads so re-selecting the same city (e.g. Minsk) notifies dashboard Made-with: Cursor --- .../db3/airmq/features/city/CityViewModel.kt | 1 + .../org/db3/airmq/sdk/city/CityService.kt | 6 ++++ .../org/db3/airmq/sdk/city/CityServiceImpl.kt | 36 ++++++++++++++----- .../db3/airmq/sdk/city/data/local/CityDao.kt | 7 ++++ .../city/data/local/CityLocalDataSource.kt | 5 +++ .../city/data/remote/CityRemoteDataSource.kt | 16 +++++---- 6 files changed, 55 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt index 3a4b5fc..8d8cc88 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt @@ -51,6 +51,7 @@ class CityViewModel @Inject constructor( private fun loadCities() { viewModelScope.launch(Dispatchers.IO) { val localeLanguage = Locale.getDefault().language + cityService.syncCitiesFromRemote(localeLanguage) val regions = cityService.getCitiesGroupedByCountry(localeLanguage) val selectedCity = cityService.getSelectedCity() val hasOnlyDefaultCity = regions.isEmpty() diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt index fd62396..a9f5cda 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt @@ -93,4 +93,10 @@ interface CityService { * @param localeLanguage Device locale language for country display names (e.g. "en", "ru") */ suspend fun getCitiesGroupedByCountry(localeLanguage: String): List + + /** + * Fetches the full city list from the API and replaces the local cache when non-empty. + * Safe to call when opening the city picker; on failure, existing cached cities remain. + */ + suspend fun syncCitiesFromRemote(langId: String?): Result } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt index f9923db..7f70ea5 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt @@ -4,8 +4,7 @@ import android.content.Context import android.location.Location import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.map import org.db3.airmq.sdk.city.data.local.CityLocalDataSource import org.db3.airmq.sdk.city.data.remote.CityRemoteDataSource @@ -30,15 +29,27 @@ class CityServiceImpl @Inject constructor( ) : CityService { private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - private val _cityContextFlow = MutableStateFlow(readDashboardContextFromPrefs()) - override fun observeSelectedCity(): Flow = _cityContextFlow.map { it.displayName } + /** + * Emits after every prefs write. Used instead of [MutableStateFlow] so we still notify + * observers when the user re-selects the same city (e.g. Minsk) — equal [DashboardCityContext] + * values would otherwise suppress [MutableStateFlow] emissions and leave the dashboard stale. + */ + private val _cityContextUpdates = MutableSharedFlow(replay = 1, extraBufferCapacity = 64) - override fun observeDashboardCityContext(): Flow = _cityContextFlow.asStateFlow() + init { + _cityContextUpdates.tryEmit(Unit) + } - override suspend fun getSelectedCity(): String = _cityContextFlow.value.displayName + override fun observeSelectedCity(): Flow = + _cityContextUpdates.map { readDashboardContextFromPrefs().displayName } - override fun getDashboardCityDisplayName(): String = _cityContextFlow.value.displayName + override fun observeDashboardCityContext(): Flow = + _cityContextUpdates.map { readDashboardContextFromPrefs() } + + override suspend fun getSelectedCity(): String = readDashboardContextFromPrefs().displayName + + override fun getDashboardCityDisplayName(): String = readDashboardContextFromPrefs().displayName override suspend fun initialize( hasLocationPermission: Boolean, @@ -115,7 +126,7 @@ class CityServiceImpl @Inject constructor( override suspend fun getResolvedDashboardCityContext(): DashboardCityContext { refreshDashboardCityIdentity() - return _cityContextFlow.value + return readDashboardContextFromPrefs() } override suspend fun getCitiesGroupedByCountry(localeLanguage: String): List { @@ -132,6 +143,13 @@ class CityServiceImpl @Inject constructor( return grouped } + override suspend fun syncCitiesFromRemote(langId: String?): Result = runCatching { + val cities = cityRemoteDataSource.fetchCities(langId).getOrThrow() + if (cities.isNotEmpty()) { + cityLocalDataSource.replaceAllCities(cities) + } + } + private fun readDashboardContextFromPrefs(): DashboardCityContext { val display = prefs.getString(KEY_DASHBOARD_CITY, DEFAULT_CITY_NAME) ?: DEFAULT_CITY_NAME val nameEn = prefs.getString(KEY_DASHBOARD_CITY_EN, DEFAULT_CITY_NAME) ?: DEFAULT_CITY_NAME @@ -140,7 +158,7 @@ class CityServiceImpl @Inject constructor( } private fun pushContextUpdate() { - _cityContextFlow.value = readDashboardContextFromPrefs() + _cityContextUpdates.tryEmit(Unit) } private suspend fun ensureCitiesInDb(): List { diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityDao.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityDao.kt index 8639ce1..00d1442 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityDao.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction @Dao interface CityDao { @@ -19,4 +20,10 @@ interface CityDao { @Query("DELETE FROM city") suspend fun deleteAll() + + @Transaction + suspend fun replaceAllCities(cities: List) { + deleteAll() + insertCities(cities) + } } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityLocalDataSource.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityLocalDataSource.kt index 2b39704..cb606b2 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityLocalDataSource.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityLocalDataSource.kt @@ -21,6 +21,11 @@ class CityLocalDataSource @Inject constructor( cityDao.insertCities(entities) } + suspend fun replaceAllCities(cities: List) { + val entities = cities.map { it.toEntity() } + cityDao.replaceAllCities(entities) + } + private fun CityEntity.toDomain(): City = City( id = id, countryCode = countryCode, diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/remote/CityRemoteDataSource.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/remote/CityRemoteDataSource.kt index c0d27e1..f63f59d 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/remote/CityRemoteDataSource.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/remote/CityRemoteDataSource.kt @@ -43,17 +43,19 @@ class CityRemoteDataSourceImpl @Inject constructor( private fun CityListQuery.CityList.toDomain(): City? { val id = _id ?: return null - val lat = latitude ?: return null - val lon = longitude ?: return null - val nameEn = cityName?.en ?: return null + val nameEn = cityName?.primaryName() ?: return null return City( id = id, countryCode = countryCode, nameEn = nameEn, - nameBe = cityName?.be ?: nameEn, - nameRu = cityName?.ru ?: nameEn, - latitude = lat.toDouble(), - longitude = lon.toDouble(), + nameBe = cityName?.be?.takeIf { it.isNotBlank() } ?: nameEn, + nameRu = cityName?.ru?.takeIf { it.isNotBlank() } ?: nameEn, + latitude = latitude, + longitude = longitude, locationCount = locationCount ) } + +/** First non-blank of en → ru → be so we keep cities that omit English in the payload. */ +private fun CityListQuery.CityName.primaryName(): String? = + listOfNotNull(en, ru, be).firstOrNull { it.isNotBlank() }?.trim()