feat: implement city selection flow with auto-detect
This commit is contained in:
14
sdk/src/main/graphql/CityList.graphql
Normal file
14
sdk/src/main/graphql/CityList.graphql
Normal file
@@ -0,0 +1,14 @@
|
||||
query CityList($langId: String) {
|
||||
cityList(langId: $langId) {
|
||||
_id
|
||||
countryCode
|
||||
cityName {
|
||||
be
|
||||
ru
|
||||
en
|
||||
}
|
||||
latitude
|
||||
longitude
|
||||
locationCount
|
||||
}
|
||||
}
|
||||
75
sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt
Normal file
75
sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt
Normal 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>
|
||||
}
|
||||
167
sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt
Normal file
167
sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
48
sdk/src/main/kotlin/org/db3/airmq/sdk/city/di/CityModule.kt
Normal file
48
sdk/src/main/kotlin/org/db3/airmq/sdk/city/di/CityModule.kt
Normal 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
|
||||
}
|
||||
34
sdk/src/main/kotlin/org/db3/airmq/sdk/city/domain/City.kt
Normal file
34
sdk/src/main/kotlin/org/db3/airmq/sdk/city/domain/City.kt
Normal 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
|
||||
}
|
||||
}
|
||||
14
sdk/src/main/kotlin/org/db3/airmq/sdk/city/domain/Region.kt
Normal file
14
sdk/src/main/kotlin/org/db3/airmq/sdk/city/domain/Region.kt
Normal 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>
|
||||
)
|
||||
Reference in New Issue
Block a user