feat: implement city selection flow with auto-detect

This commit is contained in:
2026-03-16 16:45:13 +01:00
parent 88ebc14d24
commit 11a515b588
16 changed files with 1200 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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