From e29e6ef498bd8301aa458f5669232fc3e18aeec6 Mon Sep 17 00:00:00 2001 From: beetzung Date: Wed, 4 Mar 2026 22:25:21 +0100 Subject: [PATCH] feat(map): dropdown overlay above chart, sensor-based chart colors, supported sensors Made-with: Cursor --- .../db3/airmq/features/map/MapUiComponents.kt | 340 ++++++++++++++---- 1 file changed, 267 insertions(+), 73 deletions(-) diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapUiComponents.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapUiComponents.kt index 96fd13e..a3c83c6 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapUiComponents.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapUiComponents.kt @@ -21,10 +21,14 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.runtime.getValue import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf @@ -32,6 +36,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -53,9 +58,35 @@ import org.db3.airmq.features.map.MapScreenContract.SearchResult import org.db3.airmq.features.map.MapScreenContract.SensorType import org.db3.airmq.features.map.MapScreenContract.TimeRange import org.db3.airmq.ui.theme.AirMQTheme -import org.db3.airmq.ui.theme.ChartBackgroundWidget -import org.db3.airmq.ui.theme.ChartFillWidget -import org.db3.airmq.ui.theme.ChartLineWidget + +private fun chartConfigForSensor( + sensor: DeviceSensorType, + leftTimeLabel: String = "", + rightTimeLabel: String = "" +): ChartConfig { + val (lineColor, unit) = when (sensor) { + DeviceSensorType.TEMPERATURE -> Color(0xFFFFB357) to "°C" + DeviceSensorType.DUST -> Color(0xFFEAB839) to "µg/m³" + DeviceSensorType.RADIOACTIVITY -> Color(0xFF8F3BB8) to "μSv/h" + } + val fillColor = lineColor.copy(alpha = 0.5f) + return ChartConfig( + lineColor = lineColor, + fillColor = fillColor, + backgroundColor = Color(0x0F000000), + labelColor = Color(0x8A000000), + roundCorners = false, + leftTimeLabel = leftTimeLabel, + rightTimeLabel = rightTimeLabel, + unit = unit + ) +} + +private fun sensorTypeString(sensor: DeviceSensorType): String = when (sensor) { + DeviceSensorType.TEMPERATURE -> "sensor_temperature" + DeviceSensorType.DUST -> "sensor_dust" + DeviceSensorType.RADIOACTIVITY -> "sensor_radioactivity" +} @Composable fun MapTopControls( @@ -339,9 +370,8 @@ fun MapSearchOverlay( } @Composable -fun MapDevicePanel( +fun MapDevicePanelContent( data: DevicePanelState, - onClose: () -> Unit, onOpenDevice: () -> Unit, onRangeSelected: (TimeRange) -> Unit, onDateBack: () -> Unit, @@ -349,25 +379,17 @@ fun MapDevicePanel( onSensorSelected: (DeviceSensorType) -> Unit, modifier: Modifier = Modifier ) { - Card( - modifier = modifier.fillMaxWidth(), - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - colors = CardDefaults.cardColors(containerColor = Color.White) + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(14.dp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - AirMQButton( - text = stringResource(id = R.string.button_close), - onClick = onClose, - style = AirMQButtonStyle.Text - ) Column( modifier = Modifier.weight(1f), horizontalAlignment = Alignment.Start @@ -409,28 +431,58 @@ fun MapDevicePanel( ) } - AirMQChart( - data = ChartDataset.Single(generateSineWaveData()), - config = ChartConfig( - lineColor = ChartLineWidget, - fillColor = ChartFillWidget, - backgroundColor = ChartBackgroundWidget, - labelColor = Color(0x8A000000), - leftTimeLabel = stringResource(R.string.filter_hour), - rightTimeLabel = stringResource(R.string.text_now), - unit = "°C" - ), - sensorType = "sensor_temperature", + Box( modifier = Modifier .fillMaxWidth() - .height(220.dp) - ) + .height(256.dp) + ) { + AirMQChart( + data = ChartDataset.Single(generateSineWaveData()), + config = chartConfigForSensor( + data.selectedSensor, + leftTimeLabel = stringResource(R.string.filter_hour), + rightTimeLabel = stringResource(R.string.text_now) + ), + sensorType = sensorTypeString(data.selectedSensor), + modifier = Modifier + .fillMaxWidth() + .height(220.dp) + .align(Alignment.TopCenter) + ) - DeviceSensorRow( - selectedSensor = data.selectedSensor, - onSelected = onSensorSelected - ) + DeviceSensorDropdown( + selectedSensor = data.selectedSensor, + supportedSensors = data.supportedSensors, + onSelected = onSensorSelected, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } } +} + +@Composable +fun MapDevicePanel( + data: DevicePanelState, + onOpenDevice: () -> Unit, + onRangeSelected: (TimeRange) -> Unit, + onDateBack: () -> Unit, + onDateForward: () -> Unit, + onSensorSelected: (DeviceSensorType) -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + MapDevicePanelContent( + data = data, + onOpenDevice = onOpenDevice, + onRangeSelected = onRangeSelected, + onDateBack = onDateBack, + onDateForward = onDateForward, + onSensorSelected = onSensorSelected + ) } } @@ -441,54 +493,198 @@ private fun TimeRangeRow( ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { - FilterChip( + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TimeRangeChip( + label = stringResource(id = R.string.filter_hour), selected = selected == TimeRange.HOUR, - onClick = { onSelected(TimeRange.HOUR) }, - label = { Text(stringResource(id = R.string.filter_hour)) } + onClick = { onSelected(TimeRange.HOUR) } ) - FilterChip( + TimeRangeChip( + label = stringResource(id = R.string.filter_day), selected = selected == TimeRange.DAY, - onClick = { onSelected(TimeRange.DAY) }, - label = { Text(stringResource(id = R.string.filter_day)) } + onClick = { onSelected(TimeRange.DAY) } ) - FilterChip( + TimeRangeChip( + label = stringResource(id = R.string.filter_week), selected = selected == TimeRange.WEEK, - onClick = { onSelected(TimeRange.WEEK) }, - label = { Text(stringResource(id = R.string.filter_week)) } + onClick = { onSelected(TimeRange.WEEK) } ) - FilterChip( + TimeRangeChip( + label = stringResource(id = R.string.filter_month), selected = selected == TimeRange.MONTH, - onClick = { onSelected(TimeRange.MONTH) }, - label = { Text(stringResource(id = R.string.filter_month)) } + onClick = { onSelected(TimeRange.MONTH) } + ) + } + } +} + +@Composable +private fun TimeRangeChip( + label: String, + selected: Boolean, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .height(32.dp) + .clip(RoundedCornerShape(16.dp)) + .then( + if (selected) { + Modifier.background( + brush = Brush.verticalGradient( + colors = listOf(Color(0xFF53AFA1), Color(0xFF247C84)) + ) + ) + } else { + Modifier.background(Color.Transparent) + } + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = if (selected) Color.White else Color(0x8A000000), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) ) } } @Composable -private fun DeviceSensorRow( +private fun DeviceSensorDropdown( selectedSensor: DeviceSensorType, - onSelected: (DeviceSensorType) -> Unit + supportedSensors: List, + onSelected: (DeviceSensorType) -> Unit, + modifier: Modifier = Modifier ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + var isExpanded by remember { mutableStateOf(false) } + val sensors = supportedSensors.ifEmpty { + listOf(DeviceSensorType.TEMPERATURE, DeviceSensorType.DUST, DeviceSensorType.RADIOACTIVITY) + } + + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center ) { - FilterChip( - selected = selectedSensor == DeviceSensorType.TEMPERATURE, - onClick = { onSelected(DeviceSensorType.TEMPERATURE) }, - label = { Text(stringResource(id = R.string.sensor_temperature)) } + Column( + modifier = Modifier.width(204.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp), + shadowElevation = 10.dp, + color = Color.White + ) { + Column { + sensors.forEachIndexed { index, sensorType -> + if (index > 0) { + HorizontalDivider(color = Color(0x1F000000)) + } + DeviceSensorDropdownOption( + sensorType = sensorType, + selected = selectedSensor == sensorType, + onClick = { + onSelected(sensorType) + isExpanded = false + } + ) + } + } + } + } + + val triggerShape = if (isExpanded) { + RoundedCornerShape(bottomStart = 18.dp, bottomEnd = 18.dp) + } else { + RoundedCornerShape(18.dp) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .height(36.dp) + .clickable { isExpanded = !isExpanded }, + shape = triggerShape, + shadowElevation = 10.dp, + color = Color.White + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = sensorIconRes(selectedSensor)), + contentDescription = null, + tint = Color(0x8A000000), + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(id = sensorLabelRes(selectedSensor)), + style = MaterialTheme.typography.bodyMedium, + color = Color(0x8A000000) + ) + Spacer(modifier = Modifier.weight(1f)) + Icon( + painter = painterResource(id = R.drawable.ic_arrow_right_24), + contentDescription = null, + tint = Color(0x8A000000), + modifier = Modifier.size(24.dp) + ) + } + } + } + } +} + +private fun sensorIconRes(sensorType: DeviceSensorType): Int = when (sensorType) { + DeviceSensorType.TEMPERATURE -> R.drawable.ic_temperature + DeviceSensorType.DUST -> R.drawable.ic_dust + DeviceSensorType.RADIOACTIVITY -> R.drawable.ic_radiation +} + +private fun sensorLabelRes(sensorType: DeviceSensorType): Int = when (sensorType) { + DeviceSensorType.TEMPERATURE -> R.string.sensor_temperature + DeviceSensorType.DUST -> R.string.sensor_dust + DeviceSensorType.RADIOACTIVITY -> R.string.sensor_radioactivity +} + +@Composable +private fun DeviceSensorDropdownOption( + sensorType: DeviceSensorType, + selected: Boolean, + onClick: () -> Unit +) { + val textColor = if (selected) Color(0xFF1C1C1C) else Color(0xFF616161) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = sensorIconRes(sensorType)), + contentDescription = null, + tint = textColor, + modifier = Modifier.size(24.dp) ) - FilterChip( - selected = selectedSensor == DeviceSensorType.DUST, - onClick = { onSelected(DeviceSensorType.DUST) }, - label = { Text(stringResource(id = R.string.sensor_dust)) } - ) - FilterChip( - selected = selectedSensor == DeviceSensorType.RADIOACTIVITY, - onClick = { onSelected(DeviceSensorType.RADIOACTIVITY) }, - label = { Text(stringResource(id = R.string.sensor_radioactivity)) } + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = stringResource(id = sensorLabelRes(sensorType)), + style = MaterialTheme.typography.bodyMedium, + color = textColor ) } } @@ -575,7 +771,6 @@ private fun PreviewMapDevicePanelDust() { displayedDateRange = "Today", selectedSensor = DeviceSensorType.DUST ), - onClose = {}, onOpenDevice = {}, onRangeSelected = {}, onDateBack = {}, @@ -598,7 +793,6 @@ private fun PreviewMapDevicePanelRadioactivity() { displayedDateRange = "Last 7 days", selectedSensor = DeviceSensorType.RADIOACTIVITY ), - onClose = {}, onOpenDevice = {}, onRangeSelected = {}, onDateBack = {},