feat: implement city selection flow with auto-detect
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
109
app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt
Normal file
109
app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt
Normal 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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
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