diff --git a/app/src/main/kotlin/org/db3/airmq/MainActivity.kt b/app/src/main/kotlin/org/db3/airmq/MainActivity.kt index 5b7712b..75a0607 100644 --- a/app/src/main/kotlin/org/db3/airmq/MainActivity.kt +++ b/app/src/main/kotlin/org/db3/airmq/MainActivity.kt @@ -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()) + } } } } diff --git a/app/src/main/kotlin/org/db3/airmq/features/city/CityScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/city/CityScreen.kt index 40b790a..5c4a3b2 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/city/CityScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/city/CityScreen.kt @@ -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() } + + 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 = {} + ) + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/city/CityScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/city/CityScreenContract.kt new file mode 100644 index 0000000..108db3b --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/city/CityScreenContract.kt @@ -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 = emptyList(), + val selectedCity: String = "", + val isLoading: Boolean = true, + val hasOnlyDefaultCity: Boolean = false, + val localeLanguage: String = "en", + val detectAutomatically: Boolean = false + ) + + fun previewState( + regions: List = 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 + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt new file mode 100644 index 0000000..3a4b5fc --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt @@ -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 = _uiState.asStateFlow() + + private val _actions = MutableSharedFlow(extraBufferCapacity = 1) + val actions: SharedFlow = _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)) + ) + } + } + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/entry/CityInitializer.kt b/app/src/main/kotlin/org/db3/airmq/features/entry/CityInitializer.kt new file mode 100644 index 0000000..1269087 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/entry/CityInitializer.kt @@ -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() +} diff --git a/sdk/src/main/graphql/CityList.graphql b/sdk/src/main/graphql/CityList.graphql new file mode 100644 index 0000000..eabb82a --- /dev/null +++ b/sdk/src/main/graphql/CityList.graphql @@ -0,0 +1,14 @@ +query CityList($langId: String) { + cityList(langId: $langId) { + _id + countryCode + cityName { + be + ru + en + } + latitude + longitude + locationCount + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt new file mode 100644 index 0000000..b0dca2f --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt @@ -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 + + /** + * 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 + + /** + * 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 +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt new file mode 100644 index 0000000..ef8c479 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt @@ -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 = _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 = 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 { + 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 { + 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, + 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, 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, 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" + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityDao.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityDao.kt new file mode 100644 index 0000000..8639ce1 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityDao.kt @@ -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 + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCities(cities: List) + + @Query("DELETE FROM city") + suspend fun deleteAll() +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityDatabase.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityDatabase.kt new file mode 100644 index 0000000..6fb6bc7 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityDatabase.kt @@ -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 +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityEntity.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityEntity.kt new file mode 100644 index 0000000..58b3184 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityEntity.kt @@ -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? +) diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityLocalDataSource.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityLocalDataSource.kt new file mode 100644 index 0000000..2b39704 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/local/CityLocalDataSource.kt @@ -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 = + cityDao.getAllCities().map { it.toDomain() } + + suspend fun insertCities(cities: List) { + 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 + ) +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/remote/CityRemoteDataSource.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/remote/CityRemoteDataSource.kt new file mode 100644 index 0000000..c0d27e1 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/data/remote/CityRemoteDataSource.kt @@ -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> +} + +class CityRemoteDataSourceImpl @Inject constructor( + private val apolloClient: ApolloClient +) : CityRemoteDataSource { + + override suspend fun fetchCities(langId: String?): Result> = 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 + ) +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/di/CityModule.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/di/CityModule.kt new file mode 100644 index 0000000..0aacc8b --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/di/CityModule.kt @@ -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 +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/domain/City.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/domain/City.kt new file mode 100644 index 0000000..994498e --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/domain/City.kt @@ -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 + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/domain/Region.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/domain/Region.kt new file mode 100644 index 0000000..0ba3a12 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/domain/Region.kt @@ -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 +)