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
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()
)
}
}
}

View File

@@ -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
}

View File

@@ -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<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)
val actions: SharedFlow<DashboardScreenContract.Action> = _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(

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
}

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.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()

View File

@@ -190,6 +190,7 @@
<string name="text_no_city_hint"><u>Няма вашага горада?</u></string>
<string name="text_wrong_city_hint"><u>Няправільны горад?</u></string>
<string name="text_city_warning">Гэтая опцыя вызначае, якія дадзеныя будуць адлюстроўвацца ў дашбордзе. Калі горад не выбраны, будзе ўсталяваны стандартны варыянт.</string>
<string name="city_list_unavailable">Спіс гарадоў недаступны. Выкарыстоўваецца горад па змаўчанні.</string>
<string name="text_no_city">"Калі вы не бачыце ваш горад, значыць, у ім няма прылад "</string>
<string name="button_enter_manual">Увесці ўручную</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_wrong_city_hint"><u>Неправильный город?</u></string>
<string name="text_city_warning">Эта опция определяет, какие данные будут отображаться в дашборде. Если город не выбран, установится стандартный вариант.</string>
<string name="city_list_unavailable">Список городов недоступен. Используется город по умолчанию.</string>
<string name="text_no_city">"Если вы не видете ваш город, значит в нем нет устройств "</string>
<string name="button_enter_manual">Ввести вручную</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_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="city_list_unavailable">City list unavailable. Using default city.</string>
<string name="button_enter_manual">Enter manually</string>
<string name="text_welcome">Welcome to AirMQ</string>
<string name="text_welcome_second">Learn what your city breathes</string>