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

@@ -7,17 +7,26 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import dagger.hilt.android.AndroidEntryPoint
import org.db3.airmq.features.entry.CityInitializer
import org.db3.airmq.features.navigation.AirMQNavGraph
import org.db3.airmq.sdk.city.CityService
import org.db3.airmq.ui.theme.AirMQTheme
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var cityService: CityService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AirMQTheme {
AirMQNavGraph(modifier = Modifier.fillMaxSize())
CityInitializer(cityService = cityService) {
AirMQNavGraph(modifier = Modifier.fillMaxSize())
}
}
}
}

View File

@@ -1,16 +1,409 @@
package org.db3.airmq.features.city
import android.Manifest
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.rememberCoroutineScope
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import org.db3.airmq.R
import org.db3.airmq.features.common.MockScreenScaffold
import org.db3.airmq.features.common.ScreenAction
import org.db3.airmq.features.city.CityScreenContract.Action
import org.db3.airmq.features.city.CityScreenContract.Event
import org.db3.airmq.features.city.CityScreenContract.State
import org.db3.airmq.sdk.city.domain.City
import org.db3.airmq.ui.theme.AirMQTheme
@Composable
fun CityScreen(onBackToDashboard: () -> Unit) {
MockScreenScaffold(
title = stringResource(id = R.string.title_city),
subtitle = stringResource(id = R.string.coming_soon),
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_dashboard), onBackToDashboard))
fun CityScreen(
onNavigateBack: () -> Unit,
viewModel: CityViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
val activity = context as? android.app.Activity
val scope = rememberCoroutineScope()
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val granted = result.values.any { it }
scope.launch {
val location = if (granted && activity != null) {
withContext(Dispatchers.IO) {
runCatching {
LocationServices.getFusedLocationProviderClient(activity).getLastLocation().await()
}.getOrNull()
}
} else null
viewModel.onEvent(Event.EnableDetectAutomaticallyResult(location))
}
}
val onDetectAutomaticallyChange: (Boolean) -> Unit = { enabled ->
if (enabled) {
val permissions = arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
val allGranted = permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
if (allGranted && activity != null) {
scope.launch {
val location = withContext(Dispatchers.IO) {
runCatching {
LocationServices.getFusedLocationProviderClient(activity).getLastLocation().await()
}.getOrNull()
}
viewModel.onEvent(Event.EnableDetectAutomaticallyResult(location))
}
} else {
permissionLauncher.launch(permissions)
}
} else {
viewModel.onEvent(Event.DetectAutomaticallyChanged(false))
}
}
LaunchedEffect(viewModel) {
viewModel.actions.collect { action ->
when (action) {
Action.NavigateBack -> onNavigateBack()
is Action.ShowToast -> Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show()
}
}
}
CityScreenScaffold(
state = uiState,
onEvent = viewModel::onEvent,
onDetectAutomaticallyChange = onDetectAutomaticallyChange
)
}
@Composable
internal fun CityScreenScaffold(
state: State,
onEvent: (Event) -> Unit,
onDetectAutomaticallyChange: (Boolean) -> Unit
) {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
TopBar(
title = stringResource(R.string.title_city),
onBackClick = { onEvent(Event.BackClicked) }
)
if (state.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
CityScreenContent(
uiState = state,
onEvent = onEvent,
onDetectAutomaticallyChange = onDetectAutomaticallyChange
)
}
}
}
}
@Composable
private fun TopBar(
title: String,
onBackClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBackClick) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back),
contentDescription = stringResource(R.string.content_back)
)
}
Text(
text = title,
modifier = Modifier
.weight(1f)
.padding(end = 48.dp),
fontSize = 24.sp
)
}
}
@Composable
private fun CityScreenContent(
uiState: State,
onEvent: (Event) -> Unit,
onDetectAutomaticallyChange: (Boolean) -> Unit
) {
val expandedRegions = remember { mutableStateMapOf<Int, Boolean>() }
Column(modifier = Modifier.fillMaxSize()) {
WarningRow()
DetectAutomaticallyRow(
enabled = uiState.detectAutomatically,
onCheckedChange = onDetectAutomaticallyChange
)
if (uiState.hasOnlyDefaultCity) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(36.dp),
contentAlignment = Alignment.Center
) {
Text(
text = uiState.selectedCity,
fontSize = 16.sp,
color = Color(0xFF6B6B6B)
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
uiState.regions.forEachIndexed { index, region ->
val isExpanded = expandedRegions.getOrDefault(index, false)
item(key = "region_$index") {
RegionRow(
countryName = region.countryName,
isExpanded = isExpanded,
onClick = {
expandedRegions[index] = !isExpanded
}
)
}
if (isExpanded) {
region.cities.forEach { city ->
item(key = "city_${city.id}") {
CityRow(
city = city,
localeLanguage = uiState.localeLanguage,
onClick = { onEvent(Event.CitySelected(city)) }
)
}
}
}
}
}
}
}
}
@Composable
private fun WarningRow() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_warning),
contentDescription = null,
modifier = Modifier.size(36.dp),
tint = Color(0x99333333)
)
Text(
text = stringResource(R.string.text_city_warning),
modifier = Modifier.padding(start = 16.dp),
fontSize = 14.sp,
color = Color(0x99333333)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color(0x1F000000))
)
}
@Composable
private fun DetectAutomaticallyRow(
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.text_detect_automatically),
modifier = Modifier.weight(1f),
fontSize = 14.sp,
color = Color(0xFF222222)
)
Switch(
checked = enabled,
onCheckedChange = onCheckedChange
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color(0x1F000000))
)
}
@Composable
private fun RegionRow(
countryName: String,
isExpanded: Boolean,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_arrow_down_dark),
contentDescription = null,
modifier = Modifier
.size(24.dp)
.then(if (isExpanded) Modifier.rotate(180f) else Modifier),
tint = Color(0xFF1F5DA5)
)
Text(
text = countryName,
modifier = Modifier.padding(start = 16.dp),
fontSize = 14.sp,
color = Color(0xFF1F5DA5)
)
}
}
@Composable
private fun CityRow(
city: City,
localeLanguage: String,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = city.getLocalizedName(localeLanguage),
modifier = Modifier.weight(1f),
fontSize = 16.sp,
color = Color(0xFF222222)
)
Text(
text = "${city.locationCount ?: "—"} x ",
fontSize = 12.sp,
color = Color(0x99333333)
)
Icon(
painter = painterResource(R.drawable.ic_device_basic_active_10),
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = Color(0x99333333)
)
}
}
@Preview(showBackground = true, name = "City list")
@Composable
private fun PreviewCityList() {
AirMQTheme {
CityScreenScaffold(
state = CityScreenContract.previewState(),
onEvent = {},
onDetectAutomaticallyChange = {}
)
}
}
@Preview(showBackground = true, name = "City loading")
@Composable
private fun PreviewCityLoading() {
AirMQTheme {
CityScreenScaffold(
state = CityScreenContract.previewState(isLoading = true),
onEvent = {},
onDetectAutomaticallyChange = {}
)
}
}
@Preview(showBackground = true, name = "City default only")
@Composable
private fun PreviewCityDefaultOnly() {
AirMQTheme {
CityScreenScaffold(
state = CityScreenContract.previewState(
regions = emptyList(),
hasOnlyDefaultCity = true,
selectedCity = "Minsk"
),
onEvent = {},
onDetectAutomaticallyChange = {}
)
}
}

View File

@@ -0,0 +1,62 @@
package org.db3.airmq.features.city
import android.location.Location
import org.db3.airmq.sdk.city.domain.City
import org.db3.airmq.sdk.city.domain.Region
object CityScreenContract {
data class State(
val regions: List<Region> = emptyList(),
val selectedCity: String = "",
val isLoading: Boolean = true,
val hasOnlyDefaultCity: Boolean = false,
val localeLanguage: String = "en",
val detectAutomatically: Boolean = false
)
fun previewState(
regions: List<Region> = listOf(
Region(
countryName = "Belarus",
countryCode = "BY",
cities = listOf(
City("minsk", "BY", "Minsk", "Мінск", "Минск", 53.9, 27.5, 12),
City("gomel", "BY", "Gomel", "Гомель", "Гомель", 52.4, 31.0, 5)
)
),
Region(
countryName = "Russia",
countryCode = "RU",
cities = listOf(
City("moscow", "RU", "Moscow", null, "Москва", 55.7, 37.6, 45)
)
)
),
selectedCity: String = "Minsk",
isLoading: Boolean = false,
hasOnlyDefaultCity: Boolean = false,
localeLanguage: String = "en",
detectAutomatically: Boolean = false
): State = State(
regions = regions,
selectedCity = selectedCity,
isLoading = isLoading,
hasOnlyDefaultCity = hasOnlyDefaultCity,
localeLanguage = localeLanguage,
detectAutomatically = detectAutomatically
)
sealed interface Action {
data object NavigateBack : Action
data class ShowToast(val message: String) : Action
}
sealed interface Event {
data object BackClicked : Event
data class CitySelected(val city: City) : Event
data class DetectAutomaticallyChanged(val enabled: Boolean) : Event
/** Called when permission flow completes for enabling auto-detect. Location is null if denied or unavailable. */
data class EnableDetectAutomaticallyResult(val location: Location?) : Event
}
}

View File

@@ -0,0 +1,109 @@
package org.db3.airmq.features.city
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import android.location.Location
import org.db3.airmq.R
import org.db3.airmq.sdk.city.CityService
import org.db3.airmq.sdk.city.domain.Region
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
class CityViewModel @Inject constructor(
@ApplicationContext private val appContext: Context,
private val cityService: CityService
) : ViewModel() {
private val _uiState = MutableStateFlow(CityScreenContract.State())
val uiState: StateFlow<CityScreenContract.State> = _uiState.asStateFlow()
private val _actions = MutableSharedFlow<CityScreenContract.Action>(extraBufferCapacity = 1)
val actions: SharedFlow<CityScreenContract.Action> = _actions.asSharedFlow()
init {
loadCities()
}
fun onEvent(event: CityScreenContract.Event) {
when (event) {
CityScreenContract.Event.BackClicked -> _actions.tryEmit(CityScreenContract.Action.NavigateBack)
is CityScreenContract.Event.CitySelected -> selectCity(event.city)
is CityScreenContract.Event.DetectAutomaticallyChanged -> {
if (event.enabled) return // Handled via EnableDetectAutomaticallyResult after permission flow
setDetectAutomatically(false)
}
is CityScreenContract.Event.EnableDetectAutomaticallyResult -> enableDetectAutomaticallyWithLocation(event.location)
}
}
private fun loadCities() {
viewModelScope.launch(Dispatchers.IO) {
val localeLanguage = Locale.getDefault().language
val regions = cityService.getCitiesGroupedByCountry(localeLanguage)
val selectedCity = cityService.getSelectedCity()
val hasOnlyDefaultCity = regions.isEmpty()
val detectAutomatically = cityService.getDetectAutomatically()
_uiState.value = _uiState.value.copy(
regions = regions,
selectedCity = selectedCity,
isLoading = false,
hasOnlyDefaultCity = hasOnlyDefaultCity,
detectAutomatically = detectAutomatically,
localeLanguage = localeLanguage
)
if (hasOnlyDefaultCity) {
_actions.tryEmit(
CityScreenContract.Action.ShowToast(
appContext.getString(R.string.city_list_unavailable)
)
)
}
}
}
private fun selectCity(city: org.db3.airmq.sdk.city.domain.City) {
viewModelScope.launch(Dispatchers.IO) {
cityService.setSelectedCity(city.id)
_actions.tryEmit(CityScreenContract.Action.NavigateBack)
}
}
private fun setDetectAutomatically(enabled: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
cityService.setDetectAutomatically(enabled)
_uiState.value = _uiState.value.copy(detectAutomatically = enabled)
}
}
private fun enableDetectAutomaticallyWithLocation(location: Location?) {
viewModelScope.launch(Dispatchers.IO) {
if (location != null) {
cityService.refreshCityFromLocation(location)
cityService.setDetectAutomatically(true)
val selectedCity = cityService.getSelectedCity()
_uiState.value = _uiState.value.copy(
detectAutomatically = true,
selectedCity = selectedCity
)
} else {
_actions.tryEmit(
CityScreenContract.Action.ShowToast(appContext.getString(R.string.toast_error))
)
}
}
}
}

View File

@@ -0,0 +1,103 @@
package org.db3.airmq.features.entry
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.TelephonyManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import org.db3.airmq.sdk.city.CityService
/**
* Composable that runs the run-once city resolution flow when city_init is false.
* Requests location permission, gets location or country, and calls CityService.initialize().
*/
@Composable
fun CityInitializer(
cityService: CityService,
content: @Composable () -> Unit
) {
val context = LocalContext.current
val activity = context as? android.app.Activity
val scope = rememberCoroutineScope()
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val granted = result.values.any { it }
scope.launch {
val location = if (granted && activity != null) {
withContext(Dispatchers.IO) {
runCatching {
LocationServices.getFusedLocationProviderClient(activity).getLastLocation().await()
}.getOrNull()
}
} else null
val country = if (!granted) {
withContext(Dispatchers.IO) {
(context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager)
?.networkCountryIso
?.takeIf { !it.isNullOrBlank() }
}
} else null
cityService.initialize(granted, location, country)
}
}
LaunchedEffect(cityService) {
if (!cityService.isCityInitComplete()) {
val permissions = arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
val allGranted = permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
if (allGranted) {
val location = withContext(Dispatchers.IO) {
runCatching {
LocationServices.getFusedLocationProviderClient(context).getLastLocation().await()
}.getOrNull()
}
val country = withContext(Dispatchers.IO) {
(context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager)
?.networkCountryIso
?.takeIf { !it.isNullOrBlank() }
}
cityService.initialize(true, location, country)
} else {
permissionLauncher.launch(permissions)
}
} else if (cityService.getDetectAutomatically()) {
val permissions = arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
val allGranted = permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
if (allGranted && activity != null) {
val location = withContext(Dispatchers.IO) {
runCatching {
LocationServices.getFusedLocationProviderClient(activity).getLastLocation().await()
}.getOrNull()
}
cityService.refreshCityFromLocation(location)
}
}
}
content()
}

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