feat(dashboard): add DashboardViewModel, metric gauge pager, chart integration

- Add DashboardScreenContract and DashboardViewModel with dummy data
- Extend MetricGauge with pager, page indicators, sensor config
- Integrate AirMQChart and city selector
- ManageScreen: scaffold contentWindowInsets for edge-to-edge header
- Add compose-foundation dependency for HorizontalPager

Made-with: Cursor
This commit is contained in:
2026-03-04 20:28:53 +01:00
parent c9c7cedd55
commit 8c54921661
7 changed files with 341 additions and 13 deletions

View File

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

View File

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

View File

@@ -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<SensorType, Float?>,
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() {

View File

@@ -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<SensorType, Float?>,
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
}
}

View File

@@ -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<DashboardScreenContract.State> = _uiState.asStateFlow()
private val _actions = MutableSharedFlow<DashboardScreenContract.Action>(extraBufferCapacity = 1)
val actions: SharedFlow<DashboardScreenContract.Action> = _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<SensorType, Float?> = 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
}
)
}

View File

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

View File

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