From 3c41b0d4872b01e5f059112a6320b37ac78fd84b Mon Sep 17 00:00:00 2001 From: beetzung Date: Mon, 16 Mar 2026 16:45:33 +0100 Subject: [PATCH] feat: integrate city selection into dashboard, nav and settings --- .../features/dashboard/DashboardScreen.kt | 87 ++++++++++++++++--- .../dashboard/DashboardScreenContract.kt | 1 + .../features/dashboard/DashboardViewModel.kt | 17 +++- .../features/navigation/AirMQNavGraph.kt | 12 ++- .../airmq/features/settings/SettingsScreen.kt | 5 +- .../settings/SettingsScreenContract.kt | 1 + .../features/settings/SettingsViewModel.kt | 8 +- app/src/main/res/values-be-rBY/strings.xml | 1 + app/src/main/res/values-ru-rRU/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 10 files changed, 113 insertions(+), 21 deletions(-) diff --git a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreen.kt index 16af1cd..5d49bef 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreen.kt @@ -1,17 +1,26 @@ package org.db3.airmq.features.dashboard import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight 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.layout.width +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource 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.ui.Alignment @@ -20,6 +29,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -34,8 +44,6 @@ import org.db3.airmq.ui.theme.LegacyNavGradientStart @Composable fun DashboardScreen( - onOpenMap: () -> Unit, - onOpenManage: () -> Unit, onOpenCity: () -> Unit, onOpenDevice: () -> Unit, onOpenNews: () -> Unit, @@ -43,6 +51,17 @@ fun DashboardScreen( viewModel: DashboardViewModel = hiltViewModel() ) { val state by viewModel.uiState.collectAsState() + + LaunchedEffect(viewModel) { + viewModel.actions.collect { action -> + when (action) { + DashboardScreenContract.Action.OpenCity -> onOpenCity() + DashboardScreenContract.Action.OpenNews -> { /* handled elsewhere */ } + DashboardScreenContract.Action.OpenWidgetConstructor -> { /* handled elsewhere */ } + } + } + } + DashboardContent( state = state, onEvent = viewModel::onEvent @@ -69,7 +88,13 @@ internal fun DashboardContent( ) .statusBarsPadding() ) { - CitySelector(city = state.city, modifier = Modifier.padding(top = 12.dp)) + CitySelector( + city = state.city, + modifier = Modifier + .padding(top = 12.dp) + .padding(horizontal = 16.dp), + onClick = { onEvent(DashboardScreenContract.Event.CitySelectorClicked) } + ) MetricGaugePager( selectedSensor = state.selectedSensor, values = state.gaugeValues, @@ -100,20 +125,60 @@ internal fun DashboardContent( @Composable private fun CitySelector( city: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onClick: () -> Unit = {} ) { Box( modifier = modifier .fillMaxWidth() - .height(44.dp), + .height(44.dp) + .padding(horizontal = 16.dp) + .clickable(onClick = onClick), contentAlignment = Alignment.Center ) { - Text( - text = city, - color = Color.White, - fontSize = 22.sp, - fontWeight = FontWeight.Medium - ) + Row( + modifier = Modifier + .height(44.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.city_left), + contentDescription = null, + modifier = Modifier + .width(22.dp) + .fillMaxHeight() + ) + Box( + modifier = Modifier + .weight(1f) + .height(44.dp) + .padding(horizontal = 4.dp), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(R.drawable.city_middle), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.FillBounds + ) + Text( + text = city, + color = Color.White, + fontSize = 22.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Image( + painter = painterResource(R.drawable.city_right), + contentDescription = null, + modifier = Modifier + .width(22.dp) + .fillMaxHeight() + ) + } } } diff --git a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreenContract.kt index 9e9f29d..d5ab220 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreenContract.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreenContract.kt @@ -80,6 +80,7 @@ object DashboardScreenContract { } sealed interface Event { + data object CitySelectorClicked : Event data class GaugeSelected(val sensor: SensorType) : Event data class PageChanged(val page: Int) : Event } diff --git a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt index 7c8dbbb..2985c7b 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt @@ -2,6 +2,7 @@ package org.db3.airmq.features.dashboard 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.flow.MutableSharedFlow @@ -10,8 +11,12 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject +import org.db3.airmq.sdk.city.CityService import org.db3.airmq.R import org.db3.airmq.features.common.chart.ChartConfig import org.db3.airmq.features.common.chart.ChartDataset @@ -23,17 +28,27 @@ import androidx.compose.ui.graphics.Color @HiltViewModel class DashboardViewModel @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val cityService: CityService ) : ViewModel() { private val _uiState = MutableStateFlow(initialState()) val uiState: StateFlow = _uiState.asStateFlow() + init { + viewModelScope.launch { + cityService.observeSelectedCity().collect { city -> + _uiState.update { it.copy(city = city) } + } + } + } + private val _actions = MutableSharedFlow(extraBufferCapacity = 1) val actions: SharedFlow = _actions.asSharedFlow() fun onEvent(event: DashboardScreenContract.Event) { when (event) { + DashboardScreenContract.Event.CitySelectorClicked -> _actions.tryEmit(DashboardScreenContract.Action.OpenCity) is DashboardScreenContract.Event.GaugeSelected -> { _uiState.update { state -> state.copy( diff --git a/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMQNavGraph.kt b/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMQNavGraph.kt index 8be52d1..115f7c0 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMQNavGraph.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/navigation/AirMQNavGraph.kt @@ -146,8 +146,6 @@ fun AirMQNavGraph(modifier: Modifier = Modifier) { } composable(AirMqRoutes.DASHBOARD) { DashboardScreen( - onOpenMap = { navController.navigate(AirMqRoutes.MAP) }, - onOpenManage = { navController.navigate(AirMqRoutes.MANAGE) }, onOpenCity = { navController.navigate(AirMqRoutes.CITY) }, onOpenDevice = { navController.navigate(AirMqRoutes.device()) }, onOpenNews = { navController.navigate(AirMqRoutes.NEWS) }, @@ -181,13 +179,19 @@ fun AirMQNavGraph(modifier: Modifier = Modifier) { DeviceSettingsScreen( deviceId = backStackEntry.arguments?.getString("deviceId") ?: "mock-device-id", onOpenLocation = { navController.navigate(AirMqRoutes.LOCATION) }, - onShowOnMap = { navController.navigate(AirMqRoutes.MAP) }, + onShowOnMap = { + navController.navigate(AirMqRoutes.MAP) { + popUpTo(AirMqRoutes.MANAGE) { + inclusive = true + } + } + }, onNavigateBack = { navController.popBackStack() } ) } composable(AirMqRoutes.CITY) { CityScreen( - onBackToDashboard = { navController.navigate(AirMqRoutes.DASHBOARD) } + onNavigateBack = { navController.popBackStack() } ) } composable(AirMqRoutes.SETTINGS) { diff --git a/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreen.kt index 2a0d514..5306abb 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreen.kt @@ -56,6 +56,7 @@ fun SettingsScreen( viewModel.actions.collectLatest { action -> when (action) { Action.OpenDebug -> onOpenDebug() + Action.OpenCity -> onOpenCity() Action.LogOutToManage -> onLogOutToManage() is Action.ShowMessage -> Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show() } @@ -178,7 +179,7 @@ private fun PreferenceRow( Row( modifier = Modifier .fillMaxWidth() - .heightIn(min = 44.dp) + .heightIn(min = 64.dp) .clickable(onClick = onClick) .padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, @@ -221,7 +222,7 @@ private fun PreferenceCheckRow( Row( modifier = Modifier .fillMaxWidth() - .heightIn(min = 44.dp) + .heightIn(min = 64.dp) .clickable { onToggle(!checked) } .padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreenContract.kt index 3db2014..acdc01d 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreenContract.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreenContract.kt @@ -12,6 +12,7 @@ object SettingsScreenContract { sealed interface Action { data object OpenDebug : Action + data object OpenCity : Action data object LogOutToManage : Action data class ShowMessage(val message: String) : Action } diff --git a/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsViewModel.kt index 886f1d9..4d9b5bf 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsViewModel.kt @@ -19,13 +19,15 @@ import org.db3.airmq.features.settings.SettingsScreenContract.Action import org.db3.airmq.features.settings.SettingsScreenContract.Event import org.db3.airmq.features.settings.SettingsScreenContract.State import org.db3.airmq.sdk.auth.AuthService +import org.db3.airmq.sdk.city.CityService import org.db3.airmq.sdk.settings.SettingsService @HiltViewModel class SettingsViewModel @Inject constructor( @ApplicationContext private val appContext: Context, private val settingsService: SettingsService, - private val authService: AuthService + private val authService: AuthService, + private val cityService: CityService ) : ViewModel() { private val _uiState = MutableStateFlow(State()) @@ -40,7 +42,7 @@ class SettingsViewModel @Inject constructor( fun onEvent(event: Event) { when (event) { - Event.CityClicked -> _actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.coming_soon))) + Event.CityClicked -> _actions.tryEmit(Action.OpenCity) Event.AboutClicked -> _actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.coming_soon))) Event.DebugClicked -> _actions.tryEmit(Action.OpenDebug) Event.LogOutClicked -> logOut() @@ -68,7 +70,7 @@ class SettingsViewModel @Inject constructor( private fun loadSettings() { viewModelScope.launch(Dispatchers.IO) { val session = authService.getUser() - val city = settingsService.getCity() ?: appContext.getString(R.string.city_minsk) + val city = cityService.getSelectedCity() val deviceStatus = settingsService.getDeviceStatusNotificationsEnabled() val offlineVisible = settingsService.getOfflineDevicesVisible() val advanced = settingsService.getAdvancedEnabled() diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 560bf79..10b9ce4 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -190,6 +190,7 @@ Няма вашага горада? Няправільны горад? Гэтая опцыя вызначае, якія дадзеныя будуць адлюстроўвацца ў дашбордзе. Калі горад не выбраны, будзе ўсталяваны стандартны варыянт. + Спіс гарадоў недаступны. Выкарыстоўваецца горад па змаўчанні. "Калі вы не бачыце ваш горад, значыць, у ім няма прылад " Увесці ўручную Вітаем вас у AirMQ diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 04a86eb..dd6df1e 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -190,6 +190,7 @@ Нет вашего города? Неправильный город? Эта опция определяет, какие данные будут отображаться в дашборде. Если город не выбран, установится стандартный вариант. + Список городов недоступен. Используется город по умолчанию. "Если вы не видете ваш город, значит в нем нет устройств " Ввести вручную Добро пожаловать в AirMQ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 89e49b3..fc749d5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -250,6 +250,7 @@ Wrong city? If you don’t see your city, there are not enough sensors there to provide reliable data just yet. Instead, data from the closest available city will be displayed when automatic detection is enabled This setting affects what data is displayed in the dashboard. If no city is selected, default value will be set. + City list unavailable. Using default city. Enter manually Welcome to AirMQ Learn what your city breathes