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() {
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()

View File

@@ -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<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 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<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(
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<Region> {
@@ -132,6 +143,13 @@ class CityServiceImpl @Inject constructor(
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 {
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<City> {

View File

@@ -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<CityEntity>) {
deleteAll()
insertCities(cities)
}
}

View File

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

View File

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