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
This commit is contained in:
2026-04-06 22:19:42 +02:00
parent 3057d9c2d4
commit 9cbc521a0d
6 changed files with 55 additions and 16 deletions

View File

@@ -51,6 +51,7 @@ class CityViewModel @Inject constructor(
private fun loadCities() { private fun loadCities() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val localeLanguage = Locale.getDefault().language val localeLanguage = Locale.getDefault().language
cityService.syncCitiesFromRemote(localeLanguage)
val regions = cityService.getCitiesGroupedByCountry(localeLanguage) val regions = cityService.getCitiesGroupedByCountry(localeLanguage)
val selectedCity = cityService.getSelectedCity() val selectedCity = cityService.getSelectedCity()
val hasOnlyDefaultCity = regions.isEmpty() val hasOnlyDefaultCity = regions.isEmpty()

View File

@@ -93,4 +93,10 @@ interface CityService {
* @param localeLanguage Device locale language for country display names (e.g. "en", "ru") * @param localeLanguage Device locale language for country display names (e.g. "en", "ru")
*/ */
suspend fun getCitiesGroupedByCountry(localeLanguage: String): List<Region> suspend fun getCitiesGroupedByCountry(localeLanguage: String): List<Region>
/**
* 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<Unit>
} }

View File

@@ -4,8 +4,7 @@ import android.content.Context
import android.location.Location import android.location.Location
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.db3.airmq.sdk.city.data.local.CityLocalDataSource import org.db3.airmq.sdk.city.data.local.CityLocalDataSource
import org.db3.airmq.sdk.city.data.remote.CityRemoteDataSource import org.db3.airmq.sdk.city.data.remote.CityRemoteDataSource
@@ -30,15 +29,27 @@ class CityServiceImpl @Inject constructor(
) : CityService { ) : CityService {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val _cityContextFlow = MutableStateFlow(readDashboardContextFromPrefs())
override fun observeSelectedCity(): Flow<String> = _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<Unit>(replay = 1, extraBufferCapacity = 64)
override fun observeDashboardCityContext(): Flow<DashboardCityContext> = _cityContextFlow.asStateFlow() init {
_cityContextUpdates.tryEmit(Unit)
}
override suspend fun getSelectedCity(): String = _cityContextFlow.value.displayName override fun observeSelectedCity(): Flow<String> =
_cityContextUpdates.map { readDashboardContextFromPrefs().displayName }
override fun getDashboardCityDisplayName(): String = _cityContextFlow.value.displayName override fun observeDashboardCityContext(): Flow<DashboardCityContext> =
_cityContextUpdates.map { readDashboardContextFromPrefs() }
override suspend fun getSelectedCity(): String = readDashboardContextFromPrefs().displayName
override fun getDashboardCityDisplayName(): String = readDashboardContextFromPrefs().displayName
override suspend fun initialize( override suspend fun initialize(
hasLocationPermission: Boolean, hasLocationPermission: Boolean,
@@ -115,7 +126,7 @@ class CityServiceImpl @Inject constructor(
override suspend fun getResolvedDashboardCityContext(): DashboardCityContext { override suspend fun getResolvedDashboardCityContext(): DashboardCityContext {
refreshDashboardCityIdentity() refreshDashboardCityIdentity()
return _cityContextFlow.value return readDashboardContextFromPrefs()
} }
override suspend fun getCitiesGroupedByCountry(localeLanguage: String): List<Region> { override suspend fun getCitiesGroupedByCountry(localeLanguage: String): List<Region> {
@@ -132,6 +143,13 @@ class CityServiceImpl @Inject constructor(
return grouped return grouped
} }
override suspend fun syncCitiesFromRemote(langId: String?): Result<Unit> = runCatching {
val cities = cityRemoteDataSource.fetchCities(langId).getOrThrow()
if (cities.isNotEmpty()) {
cityLocalDataSource.replaceAllCities(cities)
}
}
private fun readDashboardContextFromPrefs(): DashboardCityContext { private fun readDashboardContextFromPrefs(): DashboardCityContext {
val display = prefs.getString(KEY_DASHBOARD_CITY, DEFAULT_CITY_NAME) ?: DEFAULT_CITY_NAME 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 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() { private fun pushContextUpdate() {
_cityContextFlow.value = readDashboardContextFromPrefs() _cityContextUpdates.tryEmit(Unit)
} }
private suspend fun ensureCitiesInDb(): List<City> { private suspend fun ensureCitiesInDb(): List<City> {

View File

@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
@Dao @Dao
interface CityDao { interface CityDao {
@@ -19,4 +20,10 @@ interface CityDao {
@Query("DELETE FROM city") @Query("DELETE FROM city")
suspend fun deleteAll() suspend fun deleteAll()
@Transaction
suspend fun replaceAllCities(cities: List<CityEntity>) {
deleteAll()
insertCities(cities)
}
} }

View File

@@ -21,6 +21,11 @@ class CityLocalDataSource @Inject constructor(
cityDao.insertCities(entities) cityDao.insertCities(entities)
} }
suspend fun replaceAllCities(cities: List<City>) {
val entities = cities.map { it.toEntity() }
cityDao.replaceAllCities(entities)
}
private fun CityEntity.toDomain(): City = City( private fun CityEntity.toDomain(): City = City(
id = id, id = id,
countryCode = countryCode, countryCode = countryCode,

View File

@@ -43,17 +43,19 @@ class CityRemoteDataSourceImpl @Inject constructor(
private fun CityListQuery.CityList.toDomain(): City? { private fun CityListQuery.CityList.toDomain(): City? {
val id = _id ?: return null val id = _id ?: return null
val lat = latitude ?: return null val nameEn = cityName?.primaryName() ?: return null
val lon = longitude ?: return null
val nameEn = cityName?.en ?: return null
return City( return City(
id = id, id = id,
countryCode = countryCode, countryCode = countryCode,
nameEn = nameEn, nameEn = nameEn,
nameBe = cityName?.be ?: nameEn, nameBe = cityName?.be?.takeIf { it.isNotBlank() } ?: nameEn,
nameRu = cityName?.ru ?: nameEn, nameRu = cityName?.ru?.takeIf { it.isNotBlank() } ?: nameEn,
latitude = lat.toDouble(), latitude = latitude,
longitude = lon.toDouble(), longitude = longitude,
locationCount = locationCount 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()