diff --git a/app/build.gradle.kts b/app/build.gradle.kts index afeb048..0886f96 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -81,6 +81,7 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.foundation) implementation(libs.androidx.navigation.compose) implementation(libs.hilt.android) implementation(libs.androidx.hilt.navigation.compose) diff --git a/app/src/main/kotlin/org/db3/airmq/features/common/chart/AirMQChart.kt b/app/src/main/kotlin/org/db3/airmq/features/common/chart/AirMQChart.kt index 4e319a5..0710bae 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/common/chart/AirMQChart.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/common/chart/AirMQChart.kt @@ -63,7 +63,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -private val ChartPaddingLeftDp = 56.dp +private val ChartPaddingLeftDp = 40.dp private val ChartPaddingRightDp = 40.dp private val ChartPaddingTopDp = 8.dp private val ChartPaddingBottomDp = 52.dp diff --git a/app/src/main/kotlin/org/db3/airmq/features/common/metric/MetricGauge.kt b/app/src/main/kotlin/org/db3/airmq/features/common/metric/MetricGauge.kt index 4d35378..4bc7a35 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/common/metric/MetricGauge.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/common/metric/MetricGauge.kt @@ -13,9 +13,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource @@ -23,8 +25,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import org.db3.airmq.features.common.metric.getColorFromMetrics import org.db3.airmq.features.common.metric.getSensorIconRes import org.db3.airmq.ui.theme.AirMQTheme @@ -126,21 +135,20 @@ fun MetricGauge( } } -/** Default sensor order for dashboard gauge row. */ -private val DashboardGaugeOrder = listOf( +/** Page 1: dust, radioactivity, temperature. */ +private val Page1Sensors = listOf( SensorType.DUST, SensorType.RADIOACTIVITY, SensorType.TEMPERATURE ) -/** - * Horizontal row of metric gauges (dust, radioactivity, temperature). - * - * @param selectedSensor Currently selected sensor; null if none - * @param values Map of sensor type to value - * @param onGaugeSelected Called when a gauge is tapped - * @param modifier Modifier - */ +/** Page 2: humidity, pressure. */ +private val Page2Sensors = listOf( + SensorType.HUMIDITY, + SensorType.PRESSURE +) + +/** @deprecated Use MetricGaugePager. Kept for backward compatibility. */ @Composable fun MetricGaugeRow( selectedSensor: SensorType?, @@ -152,7 +160,7 @@ fun MetricGaugeRow( modifier = modifier, horizontalArrangement = Arrangement.SpaceEvenly ) { - for (sensorType in DashboardGaugeOrder) { + for (sensorType in Page1Sensors) { MetricGauge( value = values[sensorType], sensorType = sensorType, @@ -163,6 +171,90 @@ fun MetricGaugeRow( } } +/** + * Paged metric gauges: 2 pages, 5 gauges total. + * Page 0: dust, radioactivity, temperature. + * Page 1: humidity, pressure. + * + * @param selectedSensor Currently selected sensor + * @param values Map of sensor type to value + * @param currentPage Current page index (0 or 1) + * @param onGaugeSelected Called when a gauge is tapped + * @param onPageChanged Called when page changes (e.g. after swipe) + * @param modifier Modifier + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MetricGaugePager( + selectedSensor: SensorType, + values: Map, + currentPage: Int, + onGaugeSelected: (SensorType) -> Unit, + onPageChanged: (Int) -> Unit, + modifier: Modifier = Modifier +) { + val pagerState = rememberPagerState(pageCount = { 2 }) + LaunchedEffect(currentPage) { + if (pagerState.currentPage != currentPage) { + pagerState.animateScrollToPage(currentPage) + } + } + LaunchedEffect(pagerState.currentPage) { + onPageChanged(pagerState.currentPage) + } + Column(modifier = modifier) { + HorizontalPager( + state = pagerState, + modifier = Modifier.height(132.dp), + userScrollEnabled = true + ) { page -> + val sensors = if (page == 0) Page1Sensors else Page2Sensors + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + for (sensorType in sensors) { + MetricGauge( + value = values[sensorType], + sensorType = sensorType, + selected = selectedSensor == sensorType, + onClick = { onGaugeSelected(sensorType) } + ) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 12.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + PageIndicatorDot(selected = pagerState.currentPage == 0) + Spacer(modifier = Modifier.width(8.dp)) + PageIndicatorDot(selected = pagerState.currentPage == 1) + } + } +} + +@Composable +private fun PageIndicatorDot( + selected: Boolean, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .size(8.dp) + .clip(CircleShape) + .background( + if (selected) Color.White + else Color.White.copy(alpha = 0.38f) + ) + ) +} + @Preview(showBackground = true, name = "Dust – value 6") @Composable private fun PreviewMetricGaugeDust() { @@ -301,6 +393,36 @@ private fun PreviewMetricGaugeSelected() { } } +@Preview(showBackground = true, name = "Gauge pager – page 1") +@Composable +private fun PreviewMetricGaugePager() { + AirMQTheme { + Box( + modifier = Modifier + .background( + androidx.compose.ui.graphics.Brush.verticalGradient( + colors = listOf(LegacyNavGradientStart, LegacyNavGradientEnd) + ) + ) + .padding(16.dp) + ) { + MetricGaugePager( + selectedSensor = SensorType.DUST, + values = mapOf( + SensorType.DUST to 6f, + SensorType.RADIOACTIVITY to 0f, + SensorType.TEMPERATURE to 3f, + SensorType.HUMIDITY to 65f, + SensorType.PRESSURE to 745f + ), + currentPage = 0, + onGaugeSelected = {}, + onPageChanged = {} + ) + } + } +} + @Preview(showBackground = true, name = "Gauge row") @Composable private fun PreviewMetricGaugeRow() { 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 new file mode 100644 index 0000000..9e9f29d --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardScreenContract.kt @@ -0,0 +1,86 @@ +package org.db3.airmq.features.dashboard + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import org.db3.airmq.R +import org.db3.airmq.features.common.chart.ChartConfig +import org.db3.airmq.features.common.chart.ChartDataset +import org.db3.airmq.features.common.chart.generateSineWaveData +import org.db3.airmq.features.common.metric.SensorType +import org.db3.airmq.ui.theme.ChartBackground +import org.db3.airmq.ui.theme.ChartFill +import androidx.compose.ui.graphics.Color + +object DashboardScreenContract { + + @Composable + fun previewState( + city: String = "Minsk", + selectedSensor: SensorType = SensorType.DUST, + currentPage: Int = 0 + ): State { + val chartData = when (selectedSensor) { + SensorType.DUST -> ChartDataset.Single(generateSineWaveData(amplitude = 8f, offset = 10f, periodCount = 1.5f)) + SensorType.RADIOACTIVITY -> ChartDataset.Single(generateSineWaveData(amplitude = 0.15f, offset = 0.2f, periodCount = 1f)) + SensorType.TEMPERATURE -> ChartDataset.Single(generateSineWaveData(amplitude = 5f, offset = 15f, periodCount = 2f)) + SensorType.HUMIDITY -> ChartDataset.Single(generateSineWaveData(amplitude = 15f, offset = 55f, periodCount = 1.2f)) + SensorType.PRESSURE -> ChartDataset.Single(generateSineWaveData(amplitude = 10f, offset = 740f, periodCount = 0.8f)) + else -> ChartDataset.Single(generateSineWaveData()) + } + val chartConfig = ChartConfig( + lineColor = Color.White, + fillColor = ChartFill, + backgroundColor = ChartBackground, + labelColor = Color.White, + leftTimeLabel = "Yesterday", + rightTimeLabel = "Now", + unit = selectedSensor.units(), + centerLabel = LocalContext.current.getString( + when (selectedSensor) { + SensorType.DUST -> R.string.sensor_dust + SensorType.RADIOACTIVITY -> R.string.sensor_radioactivity + SensorType.TEMPERATURE -> R.string.sensor_temperature + SensorType.HUMIDITY -> R.string.sensor_humidity + SensorType.PRESSURE -> R.string.sensor_pressure + else -> R.string.sensor_dust + } + ) + ) + return State( + city = city, + gaugeValues = mapOf( + SensorType.DUST to 6f, + SensorType.RADIOACTIVITY to 0f, + SensorType.TEMPERATURE to 3f, + SensorType.HUMIDITY to 65f, + SensorType.PRESSURE to 745f + ), + selectedSensor = selectedSensor, + currentPage = currentPage, + chartData = chartData, + chartConfig = chartConfig, + chartSensorLabel = chartConfig.centerLabel ?: "" + ) + } + + data class State( + val city: String, + val gaugeValues: Map, + val selectedSensor: SensorType, + val currentPage: Int, + val chartData: ChartDataset?, + val chartConfig: ChartConfig, + val chartSensorLabel: String + ) + + sealed interface Action { + data object OpenCity : Action + data object OpenNews : Action + data object OpenWidgetConstructor : Action + } + + sealed interface 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 new file mode 100644 index 0000000..7c8dbbb --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/dashboard/DashboardViewModel.kt @@ -0,0 +1,114 @@ +package org.db3.airmq.features.dashboard + +import android.content.Context +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +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.flow.update +import javax.inject.Inject +import org.db3.airmq.R +import org.db3.airmq.features.common.chart.ChartConfig +import org.db3.airmq.features.common.chart.ChartDataset +import org.db3.airmq.features.common.chart.generateSineWaveData +import org.db3.airmq.features.common.metric.SensorType +import org.db3.airmq.ui.theme.ChartBackground +import org.db3.airmq.ui.theme.ChartFill +import androidx.compose.ui.graphics.Color + +@HiltViewModel +class DashboardViewModel @Inject constructor( + @ApplicationContext private val context: Context +) : ViewModel() { + + private val _uiState = MutableStateFlow(initialState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actions = MutableSharedFlow(extraBufferCapacity = 1) + val actions: SharedFlow = _actions.asSharedFlow() + + fun onEvent(event: DashboardScreenContract.Event) { + when (event) { + is DashboardScreenContract.Event.GaugeSelected -> { + _uiState.update { state -> + state.copy( + selectedSensor = event.sensor, + chartData = chartDataFor(event.sensor), + chartConfig = chartConfigFor(event.sensor), + chartSensorLabel = chartLabelFor(event.sensor) + ) + } + } + is DashboardScreenContract.Event.PageChanged -> { + _uiState.update { it.copy(currentPage = event.page) } + } + } + } + + private fun initialState(): DashboardScreenContract.State { + val selected = SensorType.DUST + return DashboardScreenContract.State( + city = "Minsk", + gaugeValues = dummyGaugeValues(), + selectedSensor = selected, + currentPage = 0, + chartData = chartDataFor(selected), + chartConfig = chartConfigFor(selected), + chartSensorLabel = chartLabelFor(selected) + ) + } + + private fun dummyGaugeValues(): Map = mapOf( + SensorType.DUST to 6f, + SensorType.RADIOACTIVITY to 0f, + SensorType.TEMPERATURE to 3f, + SensorType.HUMIDITY to 65f, + SensorType.PRESSURE to 745f + ) + + private fun chartDataFor(sensor: SensorType): ChartDataset = when (sensor) { + SensorType.DUST -> ChartDataset.Single( + generateSineWaveData(amplitude = 8f, offset = 10f, periodCount = 1.5f) + ) + SensorType.RADIOACTIVITY -> ChartDataset.Single( + generateSineWaveData(amplitude = 0.15f, offset = 0.2f, periodCount = 1f) + ) + SensorType.TEMPERATURE -> ChartDataset.Single( + generateSineWaveData(amplitude = 5f, offset = 15f, periodCount = 2f) + ) + SensorType.HUMIDITY -> ChartDataset.Single( + generateSineWaveData(amplitude = 15f, offset = 55f, periodCount = 1.2f) + ) + SensorType.PRESSURE -> ChartDataset.Single( + generateSineWaveData(amplitude = 10f, offset = 740f, periodCount = 0.8f) + ) + else -> ChartDataset.Single(generateSineWaveData()) + } + + private fun chartConfigFor(sensor: SensorType): ChartConfig = ChartConfig( + lineColor = Color.White, + fillColor = ChartFill, + backgroundColor = ChartBackground, + labelColor = Color.White, + leftTimeLabel = "Yesterday", + rightTimeLabel = "Now", + unit = sensor.units(), + centerLabel = chartLabelFor(sensor) + ) + + private fun chartLabelFor(sensor: SensorType): String = context.getString( + when (sensor) { + SensorType.DUST -> R.string.sensor_dust + SensorType.RADIOACTIVITY -> R.string.sensor_radioactivity + SensorType.TEMPERATURE -> R.string.sensor_temperature + SensorType.HUMIDITY -> R.string.sensor_humidity + SensorType.PRESSURE -> R.string.sensor_pressure + else -> R.string.sensor_dust + } + ) +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt index 2c9d0e9..0b3c1b6 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -85,7 +86,10 @@ private fun ManageScreenContent( uiState: State, onEvent: (Event) -> Unit ) { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Scaffold( + modifier = Modifier.fillMaxSize(), + contentWindowInsets = WindowInsets(0, 0, 0, 0) + ) { innerPadding -> Column( modifier = Modifier .fillMaxSize() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c8e3259..cde6d5b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }