feat: implement city selection flow with auto-detect
This commit is contained in:
@@ -7,18 +7,27 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.db3.airmq.features.entry.CityInitializer
|
||||||
import org.db3.airmq.features.navigation.AirMQNavGraph
|
import org.db3.airmq.features.navigation.AirMQNavGraph
|
||||||
|
import org.db3.airmq.sdk.city.CityService
|
||||||
import org.db3.airmq.ui.theme.AirMQTheme
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var cityService: CityService
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
AirMQTheme {
|
AirMQTheme {
|
||||||
|
CityInitializer(cityService = cityService) {
|
||||||
AirMQNavGraph(modifier = Modifier.fillMaxSize())
|
AirMQNavGraph(modifier = Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,409 @@
|
|||||||
package org.db3.airmq.features.city
|
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.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.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.R
|
||||||
import org.db3.airmq.features.common.MockScreenScaffold
|
import org.db3.airmq.features.city.CityScreenContract.Action
|
||||||
import org.db3.airmq.features.common.ScreenAction
|
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
|
@Composable
|
||||||
fun CityScreen(onBackToDashboard: () -> Unit) {
|
fun CityScreen(
|
||||||
MockScreenScaffold(
|
onNavigateBack: () -> Unit,
|
||||||
title = stringResource(id = R.string.title_city),
|
viewModel: CityViewModel = hiltViewModel()
|
||||||
subtitle = stringResource(id = R.string.coming_soon),
|
) {
|
||||||
actions = listOf(ScreenAction(stringResource(id = R.string.back_to_dashboard), onBackToDashboard))
|
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