feat: implement city selection flow with auto-detect

This commit is contained in:
2026-03-16 16:45:13 +01:00
parent 88ebc14d24
commit 11a515b588
16 changed files with 1200 additions and 8 deletions

View File

@@ -0,0 +1,14 @@
query CityList($langId: String) {
cityList(langId: $langId) {
_id
countryCode
cityName {
be
ru
en
}
latitude
longitude
locationCount
}
}

View File

@@ -0,0 +1,75 @@
package org.db3.airmq.sdk.city
import android.location.Location
import kotlinx.coroutines.flow.Flow
import org.db3.airmq.sdk.city.domain.Region
/**
* Service for city selection and resolution.
* Fetches cities from API, persists locally, and resolves the dashboard city
* based on location (when permission granted) or country (when denied).
*/
interface CityService {
/**
* Flow of the selected dashboard city display name (localized).
*/
fun observeSelectedCity(): Flow<String>
/**
* Returns the selected dashboard city display name (localized).
*/
suspend fun getSelectedCity(): String
/**
* Called on app launch when city_init is false.
* Fetches cities if DB empty, resolves city from location or country, and stores result.
* Sets city_init = true only on success.
*
* @param hasLocationPermission Whether location permission was granted
* @param location User's last known location (null if permission denied or unavailable)
* @param countryCode User's country code when permission denied (e.g. from TelephonyManager)
*/
suspend fun initialize(
hasLocationPermission: Boolean,
location: Location?,
countryCode: String?
)
/**
* Manual override of selected city (for future CityScreen).
*/
suspend fun setSelectedCity(cityId: String): Result<Unit>
/**
* Returns true if the run-once city resolution flow has completed successfully.
* When true, skip permission request and initialize on app launch.
*/
fun isCityInitComplete(): Boolean
/**
* Returns whether "detect automatically" (location-based city) is enabled.
* Stored as dashboard_city_auto in SharedPreferences. Default: false.
*/
fun getDetectAutomatically(): Boolean
/**
* Sets the "detect automatically" preference.
*/
suspend fun setDetectAutomatically(enabled: Boolean)
/**
* Refreshes the selected city from location. Used when detect automatically is on.
* Resolves closest city from the cities DB and updates stored city.
* No-op if location is null or no matching city found.
*/
suspend fun refreshCityFromLocation(location: Location?)
/**
* Returns cities grouped by country for the city selection screen.
* Returns empty list when cities DB is empty (API failed, only default city available).
*
* @param localeLanguage Device locale language for country display names (e.g. "en", "ru")
*/
suspend fun getCitiesGroupedByCountry(localeLanguage: String): List<Region>
}

View File

@@ -0,0 +1,167 @@
package org.db3.airmq.sdk.city
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 org.db3.airmq.sdk.city.data.local.CityLocalDataSource
import org.db3.airmq.sdk.city.data.remote.CityRemoteDataSource
import org.db3.airmq.sdk.city.domain.City
import org.db3.airmq.sdk.city.domain.Region
import java.util.Locale
import javax.inject.Inject
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Default hardcoded city when API fails or no match is found.
*/
const val DEFAULT_CITY_NAME = "Minsk"
class CityServiceImpl @Inject constructor(
@ApplicationContext private val context: Context,
private val cityLocalDataSource: CityLocalDataSource,
private val cityRemoteDataSource: CityRemoteDataSource
) : CityService {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val _selectedCityFlow = MutableStateFlow(getStoredCityDisplayName())
override fun observeSelectedCity(): Flow<String> = _selectedCityFlow
override suspend fun getSelectedCity(): String = getStoredCityDisplayName()
override suspend fun initialize(
hasLocationPermission: Boolean,
location: Location?,
countryCode: String?
) {
if (isCityInitComplete()) return
val cities = ensureCitiesInDb()
val resolvedCity = resolveCity(cities, hasLocationPermission, location, countryCode)
val displayName = resolvedCity?.getLocalizedName(Locale.getDefault().language) ?: DEFAULT_CITY_NAME
prefs.edit()
.putString(KEY_DASHBOARD_CITY, displayName)
.putString(KEY_DASHBOARD_CITY_EN, resolvedCity?.nameEn ?: DEFAULT_CITY_NAME)
.putBoolean(KEY_CITY_INIT, true)
.apply()
_selectedCityFlow.value = displayName
}
override suspend fun setSelectedCity(cityId: String): Result<Unit> = runCatching {
val cities = cityLocalDataSource.getAllCities()
val city = cities.find { it.id == cityId } ?: cities.find { it.nameEn == cityId }
val displayName = city?.getLocalizedName(Locale.getDefault().language) ?: cityId
prefs.edit()
.putString(KEY_DASHBOARD_CITY, displayName)
.putString(KEY_DASHBOARD_CITY_EN, city?.nameEn ?: cityId)
.apply()
_selectedCityFlow.value = displayName
}
override fun isCityInitComplete(): Boolean = prefs.getBoolean(KEY_CITY_INIT, false)
override fun getDetectAutomatically(): Boolean =
prefs.getBoolean(KEY_DASHBOARD_CITY_AUTO, false)
override suspend fun setDetectAutomatically(enabled: Boolean) {
prefs.edit().putBoolean(KEY_DASHBOARD_CITY_AUTO, enabled).apply()
}
override suspend fun refreshCityFromLocation(location: Location?) {
if (location == null) return
val cities = ensureCitiesInDb()
val resolvedCity = findClosestCity(cities, location.latitude, location.longitude)
if (resolvedCity != null) {
val displayName = resolvedCity.getLocalizedName(Locale.getDefault().language)
prefs.edit()
.putString(KEY_DASHBOARD_CITY, displayName)
.putString(KEY_DASHBOARD_CITY_EN, resolvedCity.nameEn)
.apply()
_selectedCityFlow.value = displayName
}
}
override suspend fun getCitiesGroupedByCountry(localeLanguage: String): List<Region> {
if (cityLocalDataSource.isEmpty()) return emptyList()
val cities = cityLocalDataSource.getAllCities()
val locale = Locale(localeLanguage)
val grouped = cities.groupBy { it.countryCode?.uppercase()?.take(2) ?: "" }
.filterKeys { it.isNotBlank() }
.map { (code, cityList) ->
val countryName = Locale("", code).getDisplayCountry(locale).ifBlank { code }
Region(countryName = countryName, countryCode = code, cities = cityList)
}
.sortedBy { it.countryName }
return grouped
}
private fun getStoredCityDisplayName(): String =
prefs.getString(KEY_DASHBOARD_CITY, DEFAULT_CITY_NAME) ?: DEFAULT_CITY_NAME
private suspend fun ensureCitiesInDb(): List<City> {
if (!cityLocalDataSource.isEmpty()) {
return cityLocalDataSource.getAllCities()
}
val result = cityRemoteDataSource.fetchCities(Locale.getDefault().language)
result.getOrNull()?.let { cities ->
if (cities.isNotEmpty()) {
cityLocalDataSource.insertCities(cities)
return cities
}
}
return emptyList()
}
private fun resolveCity(
cities: List<City>,
hasLocationPermission: Boolean,
location: Location?,
countryCode: String?
): City? {
if (cities.isEmpty()) return null
return when {
hasLocationPermission && location != null -> findClosestCity(cities, location.latitude, location.longitude)
countryCode != null -> findCityByCountry(cities, countryCode)
else -> null
}
}
private fun findClosestCity(cities: List<City>, userLat: Double, userLon: Double): City? {
val withCoords = cities.filter { it.latitude != null && it.longitude != null }
if (withCoords.isEmpty()) return null
return withCoords.minByOrNull { haversineDistance(userLat, userLon, it.latitude!!, it.longitude!!) }
}
private fun findCityByCountry(cities: List<City>, countryCode: String): City? {
val normalized = countryCode.uppercase().take(2)
return cities.filter { (it.countryCode?.uppercase()?.take(2) ?: "") == normalized }.firstOrNull()
}
private fun haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val r = 6371.0 // Earth radius in km
val dLat = Math.toRadians(lat2 - lat1)
val dLon = Math.toRadians(lon2 - lon1)
val a = sin(dLat / 2) * sin(dLat / 2) +
cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
sin(dLon / 2) * sin(dLon / 2)
val c = 2 * atan2(sqrt(a), sqrt(1 - a))
return r * c
}
private companion object {
private const val PREFS_NAME = "airmq_city"
private const val KEY_DASHBOARD_CITY = "dashboard_city"
private const val KEY_DASHBOARD_CITY_EN = "dashboard_city_en"
private const val KEY_CITY_INIT = "city_init"
private const val KEY_DASHBOARD_CITY_AUTO = "dashboard_city_auto"
}
}

View File

@@ -0,0 +1,22 @@
package org.db3.airmq.sdk.city.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@Dao
interface CityDao {
@Query("SELECT COUNT(*) FROM city")
suspend fun getCount(): Int
@Query("SELECT * FROM city")
suspend fun getAllCities(): List<CityEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertCities(cities: List<CityEntity>)
@Query("DELETE FROM city")
suspend fun deleteAll()
}

View File

@@ -0,0 +1,13 @@
package org.db3.airmq.sdk.city.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [CityEntity::class],
version = 1,
exportSchema = false
)
abstract class CityDatabase : RoomDatabase() {
abstract fun cityDao(): CityDao
}

View File

@@ -0,0 +1,25 @@
package org.db3.airmq.sdk.city.data.local
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Room entity for city storage.
* Fetched from GraphQL cityList and persisted for offline use.
*/
@Entity(
tableName = "city",
indices = [Index(value = ["countryCode"])]
)
data class CityEntity(
@PrimaryKey
val id: String,
val countryCode: String?,
val nameEn: String?,
val nameBe: String?,
val nameRu: String?,
val latitude: Double?,
val longitude: Double?,
val locationCount: Int?
)

View File

@@ -0,0 +1,45 @@
package org.db3.airmq.sdk.city.data.local
import org.db3.airmq.sdk.city.domain.City
import javax.inject.Inject
/**
* Local data source for cities.
* Maps between Room entities and domain models.
*/
class CityLocalDataSource @Inject constructor(
private val cityDao: CityDao
) {
suspend fun isEmpty(): Boolean = cityDao.getCount() == 0
suspend fun getAllCities(): List<City> =
cityDao.getAllCities().map { it.toDomain() }
suspend fun insertCities(cities: List<City>) {
val entities = cities.map { it.toEntity() }
cityDao.insertCities(entities)
}
private fun CityEntity.toDomain(): City = City(
id = id,
countryCode = countryCode,
nameEn = nameEn ?: "",
nameBe = nameBe,
nameRu = nameRu,
latitude = latitude,
longitude = longitude,
locationCount = locationCount
)
private fun City.toEntity(): CityEntity = CityEntity(
id = id,
countryCode = countryCode,
nameEn = nameEn,
nameBe = nameBe,
nameRu = nameRu,
latitude = latitude,
longitude = longitude,
locationCount = locationCount
)
}

View File

@@ -0,0 +1,59 @@
package org.db3.airmq.sdk.city.data.remote
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.Optional
import org.db3.airmq.sdk.CityListQuery
import org.db3.airmq.sdk.city.domain.City
import javax.inject.Inject
/**
* Remote data source for cities.
* Fetches city list from GraphQL cityList query.
*/
interface CityRemoteDataSource {
/**
* Fetches the list of cities from the API.
* @return List of cities, or null on failure
*/
suspend fun fetchCities(langId: String? = null): Result<List<City>>
}
class CityRemoteDataSourceImpl @Inject constructor(
private val apolloClient: ApolloClient
) : CityRemoteDataSource {
override suspend fun fetchCities(langId: String?): Result<List<City>> = runCatching {
val response = apolloClient
.query(CityListQuery(langId = langId?.let { Optional.Present(it) } ?: Optional.Absent))
.execute()
response.errors?.firstOrNull()?.let { gqlError ->
throw IllegalStateException(gqlError.message)
}
val cities = response.data?.cityList
.orEmpty()
.filterNotNull()
.mapNotNull { it.toDomain() }
cities
}
}
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
return City(
id = id,
countryCode = countryCode,
nameEn = nameEn,
nameBe = cityName?.be ?: nameEn,
nameRu = cityName?.ru ?: nameEn,
latitude = lat.toDouble(),
longitude = lon.toDouble(),
locationCount = locationCount
)
}

View File

@@ -0,0 +1,48 @@
package org.db3.airmq.sdk.city.di
import android.content.Context
import androidx.room.Room
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.db3.airmq.sdk.city.CityService
import org.db3.airmq.sdk.city.CityServiceImpl
import org.db3.airmq.sdk.city.data.local.CityDao
import org.db3.airmq.sdk.city.data.local.CityDatabase
import org.db3.airmq.sdk.city.data.remote.CityRemoteDataSource
import org.db3.airmq.sdk.city.data.remote.CityRemoteDataSourceImpl
@Module
@InstallIn(SingletonComponent::class)
object CityDatabaseModule {
@Provides
@Singleton
fun provideCityDatabase(@ApplicationContext context: Context): CityDatabase =
Room.databaseBuilder(
context,
CityDatabase::class.java,
"airmq_city_db"
).build()
@Provides
@Singleton
fun provideCityDao(database: CityDatabase): CityDao = database.cityDao()
}
@Module
@InstallIn(SingletonComponent::class)
abstract class CityBindModule {
@Binds
@Singleton
abstract fun bindCityService(impl: CityServiceImpl): CityService
@Binds
@Singleton
abstract fun bindCityRemoteDataSource(impl: CityRemoteDataSourceImpl): CityRemoteDataSource
}

View File

@@ -0,0 +1,34 @@
package org.db3.airmq.sdk.city.domain
/**
* Domain model for a city from the city list API.
*
* @param id Unique city identifier
* @param countryCode ISO country code (e.g. "BY", "RU")
* @param nameEn English name
* @param nameBe Belarusian name
* @param nameRu Russian name
* @param latitude Latitude for distance calculation
* @param longitude Longitude for distance calculation
* @param locationCount Number of locations in the city
*/
data class City(
val id: String,
val countryCode: String?,
val nameEn: String,
val nameBe: String?,
val nameRu: String?,
val latitude: Double?,
val longitude: Double?,
val locationCount: Int? = null
) {
/**
* Returns the localized display name based on device locale.
* Falls back to English if locale-specific name is null.
*/
fun getLocalizedName(localeLanguage: String): String = when (localeLanguage) {
"be" -> nameBe ?: nameEn
"ru" -> nameRu ?: nameEn
else -> nameEn
}
}

View File

@@ -0,0 +1,14 @@
package org.db3.airmq.sdk.city.domain
/**
* A region (country) with its cities for the city selection screen.
*
* @param countryName Display name of the country (e.g. "Belarus", "Russia")
* @param countryCode ISO country code (e.g. "BY", "RU")
* @param cities List of cities in this region
*/
data class Region(
val countryName: String,
val countryCode: String,
val cities: List<City>
)