feat: integrate city selection into dashboard, nav and settings

This commit is contained in:
2026-03-16 16:45:33 +01:00
parent 11a515b588
commit 3c41b0d487
10 changed files with 113 additions and 21 deletions

View File

@@ -1,17 +1,26 @@
package org.db3.airmq.features.dashboard package org.db3.airmq.features.dashboard
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.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.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment 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.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -34,8 +44,6 @@ import org.db3.airmq.ui.theme.LegacyNavGradientStart
@Composable @Composable
fun DashboardScreen( fun DashboardScreen(
onOpenMap: () -> Unit,
onOpenManage: () -> Unit,
onOpenCity: () -> Unit, onOpenCity: () -> Unit,
onOpenDevice: () -> Unit, onOpenDevice: () -> Unit,
onOpenNews: () -> Unit, onOpenNews: () -> Unit,
@@ -43,6 +51,17 @@ fun DashboardScreen(
viewModel: DashboardViewModel = hiltViewModel() viewModel: DashboardViewModel = hiltViewModel()
) { ) {
val state by viewModel.uiState.collectAsState() 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( DashboardContent(
state = state, state = state,
onEvent = viewModel::onEvent onEvent = viewModel::onEvent
@@ -69,7 +88,13 @@ internal fun DashboardContent(
) )
.statusBarsPadding() .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( MetricGaugePager(
selectedSensor = state.selectedSensor, selectedSensor = state.selectedSensor,
values = state.gaugeValues, values = state.gaugeValues,
@@ -100,20 +125,60 @@ internal fun DashboardContent(
@Composable @Composable
private fun CitySelector( private fun CitySelector(
city: String, city: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
onClick: () -> Unit = {}
) { ) {
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.height(44.dp), .height(44.dp)
.padding(horizontal = 16.dp)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Row(
text = city, modifier = Modifier
color = Color.White, .height(44.dp)
fontSize = 22.sp, .fillMaxWidth(),
fontWeight = FontWeight.Medium 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()
)
}
} }
} }

View File

@@ -80,6 +80,7 @@ object DashboardScreenContract {
} }
sealed interface Event { sealed interface Event {
data object CitySelectorClicked : Event
data class GaugeSelected(val sensor: SensorType) : Event data class GaugeSelected(val sensor: SensorType) : Event
data class PageChanged(val page: Int) : Event data class PageChanged(val page: Int) : Event
} }

View File

@@ -2,6 +2,7 @@ package org.db3.airmq.features.dashboard
import android.content.Context import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -10,8 +11,12 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import org.db3.airmq.sdk.city.CityService
import org.db3.airmq.R import org.db3.airmq.R
import org.db3.airmq.features.common.chart.ChartConfig import org.db3.airmq.features.common.chart.ChartConfig
import org.db3.airmq.features.common.chart.ChartDataset import org.db3.airmq.features.common.chart.ChartDataset
@@ -23,17 +28,27 @@ import androidx.compose.ui.graphics.Color
@HiltViewModel @HiltViewModel
class DashboardViewModel @Inject constructor( class DashboardViewModel @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context,
private val cityService: CityService
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(initialState()) private val _uiState = MutableStateFlow(initialState())
val uiState: StateFlow<DashboardScreenContract.State> = _uiState.asStateFlow() val uiState: StateFlow<DashboardScreenContract.State> = _uiState.asStateFlow()
init {
viewModelScope.launch {
cityService.observeSelectedCity().collect { city ->
_uiState.update { it.copy(city = city) }
}
}
}
private val _actions = MutableSharedFlow<DashboardScreenContract.Action>(extraBufferCapacity = 1) private val _actions = MutableSharedFlow<DashboardScreenContract.Action>(extraBufferCapacity = 1)
val actions: SharedFlow<DashboardScreenContract.Action> = _actions.asSharedFlow() val actions: SharedFlow<DashboardScreenContract.Action> = _actions.asSharedFlow()
fun onEvent(event: DashboardScreenContract.Event) { fun onEvent(event: DashboardScreenContract.Event) {
when (event) { when (event) {
DashboardScreenContract.Event.CitySelectorClicked -> _actions.tryEmit(DashboardScreenContract.Action.OpenCity)
is DashboardScreenContract.Event.GaugeSelected -> { is DashboardScreenContract.Event.GaugeSelected -> {
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(

View File

@@ -146,8 +146,6 @@ fun AirMQNavGraph(modifier: Modifier = Modifier) {
} }
composable(AirMqRoutes.DASHBOARD) { composable(AirMqRoutes.DASHBOARD) {
DashboardScreen( DashboardScreen(
onOpenMap = { navController.navigate(AirMqRoutes.MAP) },
onOpenManage = { navController.navigate(AirMqRoutes.MANAGE) },
onOpenCity = { navController.navigate(AirMqRoutes.CITY) }, onOpenCity = { navController.navigate(AirMqRoutes.CITY) },
onOpenDevice = { navController.navigate(AirMqRoutes.device()) }, onOpenDevice = { navController.navigate(AirMqRoutes.device()) },
onOpenNews = { navController.navigate(AirMqRoutes.NEWS) }, onOpenNews = { navController.navigate(AirMqRoutes.NEWS) },
@@ -181,13 +179,19 @@ fun AirMQNavGraph(modifier: Modifier = Modifier) {
DeviceSettingsScreen( DeviceSettingsScreen(
deviceId = backStackEntry.arguments?.getString("deviceId") ?: "mock-device-id", deviceId = backStackEntry.arguments?.getString("deviceId") ?: "mock-device-id",
onOpenLocation = { navController.navigate(AirMqRoutes.LOCATION) }, onOpenLocation = { navController.navigate(AirMqRoutes.LOCATION) },
onShowOnMap = { navController.navigate(AirMqRoutes.MAP) }, onShowOnMap = {
navController.navigate(AirMqRoutes.MAP) {
popUpTo(AirMqRoutes.MANAGE) {
inclusive = true
}
}
},
onNavigateBack = { navController.popBackStack() } onNavigateBack = { navController.popBackStack() }
) )
} }
composable(AirMqRoutes.CITY) { composable(AirMqRoutes.CITY) {
CityScreen( CityScreen(
onBackToDashboard = { navController.navigate(AirMqRoutes.DASHBOARD) } onNavigateBack = { navController.popBackStack() }
) )
} }
composable(AirMqRoutes.SETTINGS) { composable(AirMqRoutes.SETTINGS) {

View File

@@ -56,6 +56,7 @@ fun SettingsScreen(
viewModel.actions.collectLatest { action -> viewModel.actions.collectLatest { action ->
when (action) { when (action) {
Action.OpenDebug -> onOpenDebug() Action.OpenDebug -> onOpenDebug()
Action.OpenCity -> onOpenCity()
Action.LogOutToManage -> onLogOutToManage() Action.LogOutToManage -> onLogOutToManage()
is Action.ShowMessage -> Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show() is Action.ShowMessage -> Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show()
} }
@@ -178,7 +179,7 @@ private fun PreferenceRow(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 44.dp) .heightIn(min = 64.dp)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(vertical = 2.dp), .padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -221,7 +222,7 @@ private fun PreferenceCheckRow(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = 44.dp) .heightIn(min = 64.dp)
.clickable { onToggle(!checked) } .clickable { onToggle(!checked) }
.padding(vertical = 2.dp), .padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically

View File

@@ -12,6 +12,7 @@ object SettingsScreenContract {
sealed interface Action { sealed interface Action {
data object OpenDebug : Action data object OpenDebug : Action
data object OpenCity : Action
data object LogOutToManage : Action data object LogOutToManage : Action
data class ShowMessage(val message: String) : Action data class ShowMessage(val message: String) : Action
} }

View File

@@ -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.Event
import org.db3.airmq.features.settings.SettingsScreenContract.State import org.db3.airmq.features.settings.SettingsScreenContract.State
import org.db3.airmq.sdk.auth.AuthService import org.db3.airmq.sdk.auth.AuthService
import org.db3.airmq.sdk.city.CityService
import org.db3.airmq.sdk.settings.SettingsService import org.db3.airmq.sdk.settings.SettingsService
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
@ApplicationContext private val appContext: Context, @ApplicationContext private val appContext: Context,
private val settingsService: SettingsService, private val settingsService: SettingsService,
private val authService: AuthService private val authService: AuthService,
private val cityService: CityService
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(State()) private val _uiState = MutableStateFlow(State())
@@ -40,7 +42,7 @@ class SettingsViewModel @Inject constructor(
fun onEvent(event: Event) { fun onEvent(event: Event) {
when (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.AboutClicked -> _actions.tryEmit(Action.ShowMessage(appContext.getString(R.string.coming_soon)))
Event.DebugClicked -> _actions.tryEmit(Action.OpenDebug) Event.DebugClicked -> _actions.tryEmit(Action.OpenDebug)
Event.LogOutClicked -> logOut() Event.LogOutClicked -> logOut()
@@ -68,7 +70,7 @@ class SettingsViewModel @Inject constructor(
private fun loadSettings() { private fun loadSettings() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val session = authService.getUser() val session = authService.getUser()
val city = settingsService.getCity() ?: appContext.getString(R.string.city_minsk) val city = cityService.getSelectedCity()
val deviceStatus = settingsService.getDeviceStatusNotificationsEnabled() val deviceStatus = settingsService.getDeviceStatusNotificationsEnabled()
val offlineVisible = settingsService.getOfflineDevicesVisible() val offlineVisible = settingsService.getOfflineDevicesVisible()
val advanced = settingsService.getAdvancedEnabled() val advanced = settingsService.getAdvancedEnabled()

View File

@@ -190,6 +190,7 @@
<string name="text_no_city_hint"><u>Няма вашага горада?</u></string> <string name="text_no_city_hint"><u>Няма вашага горада?</u></string>
<string name="text_wrong_city_hint"><u>Няправільны горад?</u></string> <string name="text_wrong_city_hint"><u>Няправільны горад?</u></string>
<string name="text_city_warning">Гэтая опцыя вызначае, якія дадзеныя будуць адлюстроўвацца ў дашбордзе. Калі горад не выбраны, будзе ўсталяваны стандартны варыянт.</string> <string name="text_city_warning">Гэтая опцыя вызначае, якія дадзеныя будуць адлюстроўвацца ў дашбордзе. Калі горад не выбраны, будзе ўсталяваны стандартны варыянт.</string>
<string name="city_list_unavailable">Спіс гарадоў недаступны. Выкарыстоўваецца горад па змаўчанні.</string>
<string name="text_no_city">"Калі вы не бачыце ваш горад, значыць, у ім няма прылад "</string> <string name="text_no_city">"Калі вы не бачыце ваш горад, значыць, у ім няма прылад "</string>
<string name="button_enter_manual">Увесці ўручную</string> <string name="button_enter_manual">Увесці ўручную</string>
<string name="text_welcome">Вітаем вас у AirMQ</string> <string name="text_welcome">Вітаем вас у AirMQ</string>

View File

@@ -190,6 +190,7 @@
<string name="text_no_city_hint"><u>Нет вашего города?</u></string> <string name="text_no_city_hint"><u>Нет вашего города?</u></string>
<string name="text_wrong_city_hint"><u>Неправильный город?</u></string> <string name="text_wrong_city_hint"><u>Неправильный город?</u></string>
<string name="text_city_warning">Эта опция определяет, какие данные будут отображаться в дашборде. Если город не выбран, установится стандартный вариант.</string> <string name="text_city_warning">Эта опция определяет, какие данные будут отображаться в дашборде. Если город не выбран, установится стандартный вариант.</string>
<string name="city_list_unavailable">Список городов недоступен. Используется город по умолчанию.</string>
<string name="text_no_city">"Если вы не видете ваш город, значит в нем нет устройств "</string> <string name="text_no_city">"Если вы не видете ваш город, значит в нем нет устройств "</string>
<string name="button_enter_manual">Ввести вручную</string> <string name="button_enter_manual">Ввести вручную</string>
<string name="text_welcome">Добро пожаловать в AirMQ</string> <string name="text_welcome">Добро пожаловать в AirMQ</string>

View File

@@ -250,6 +250,7 @@
<string name="text_wrong_city_hint"><u>Wrong city?</u></string> <string name="text_wrong_city_hint"><u>Wrong city?</u></string>
<string name="text_no_city">If you dont 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</string> <string name="text_no_city">If you dont 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</string>
<string name="text_city_warning">This setting affects what data is displayed in the dashboard. If no city is selected, default value will be set.</string> <string name="text_city_warning">This setting affects what data is displayed in the dashboard. If no city is selected, default value will be set.</string>
<string name="city_list_unavailable">City list unavailable. Using default city.</string>
<string name="button_enter_manual">Enter manually</string> <string name="button_enter_manual">Enter manually</string>
<string name="text_welcome">Welcome to AirMQ</string> <string name="text_welcome">Welcome to AirMQ</string>
<string name="text_welcome_second">Learn what your city breathes</string> <string name="text_welcome_second">Learn what your city breathes</string>