Refactor map screen panel state to nullable contracts.
This replaces visibility booleans and error state fields with nullable `searchPanelState`/`devicePanelState` and action-based error toasts for one-shot UI effects. Made-with: Cursor
This commit is contained in:
41
.cursor/rules/screen-ui-contract.mdc
Normal file
41
.cursor/rules/screen-ui-contract.mdc
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
description: Screen UI contract structure for State, Action, and Event
|
||||
alwaysApply: false
|
||||
---
|
||||
# Screen UI Contract Rule
|
||||
|
||||
Every screen must define its contract in a `*ScreenContract.kt` file using `State`, `Action`, and `Event`.
|
||||
|
||||
## Required structure
|
||||
|
||||
1. Define `State` with all static UI data required to render the screen.
|
||||
2. Define `Action` (enum or sealed interface) for what the ViewModel does.
|
||||
3. Define `Event` (enum or sealed interface) for user interactions.
|
||||
|
||||
## State guidelines
|
||||
|
||||
- `State` must include stable screen data (for example `deviceName`, `deviceId`, `isSharingEnabled`).
|
||||
- Do not put user interaction triggers in `State`; those belong to `Event`.
|
||||
|
||||
## Action guidelines
|
||||
|
||||
- Use `Action` for ViewModel-driven outcomes such as navigation or side effects.
|
||||
- Example: `Action.NavigateTo*Screen`, `Action.Show*Toast`
|
||||
|
||||
## Event guidelines
|
||||
|
||||
- Use `Event` for user-originated input.
|
||||
- Examples: `Event.*ButtonClicked`, `Event.*InputChanged(value)`.
|
||||
|
||||
## Example
|
||||
|
||||
```kotlin
|
||||
data class State(val deviceName: String, val deviceId: String, val isSharingEnabled: Boolean)
|
||||
|
||||
sealed interface Action { data object NavigateToScreenX : Action }
|
||||
|
||||
sealed interface Event {
|
||||
data object XButtonClicked : Event
|
||||
data class InputChanged(val value: String) : Event
|
||||
}
|
||||
```
|
||||
@@ -2,15 +2,10 @@ package org.db3.airmq.features.map
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -23,31 +18,92 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import org.db3.airmq.features.common.AirMqContainedButton
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import org.db3.airmq.features.map.MapScreenContract.Action
|
||||
import org.db3.airmq.features.map.MapScreenContract.Event
|
||||
import org.db3.airmq.sdk.map.domain.MapItem
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
import org.osmdroid.views.MapView
|
||||
import org.osmdroid.views.overlay.Marker
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
|
||||
@Composable
|
||||
fun MapScreen(
|
||||
viewModel: MapViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(uiState) {
|
||||
Toast.makeText(context, uiState.items.count().toString(), Toast.LENGTH_LONG).show()
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.actions.collectLatest { action ->
|
||||
when (action) {
|
||||
is Action.ShowToast -> {
|
||||
Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
is Action.OpenDeviceRequested -> {
|
||||
// Stub for future navigation integration.
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Open device ${action.deviceId} is not wired yet",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AirMQMap(uiState.items)
|
||||
AirMQMap(
|
||||
items = uiState.items,
|
||||
onMarkerClick = { viewModel.onEvent(Event.MarkerClicked(it)) }
|
||||
)
|
||||
|
||||
MapTopControls(
|
||||
selectedSensor = uiState.selectedTopSensor,
|
||||
onSensorSelected = { viewModel.onEvent(Event.TopSensorSelected(it)) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 20.dp, end = 16.dp)
|
||||
)
|
||||
|
||||
if (uiState.searchPanelState == null && uiState.devicePanelState == null) {
|
||||
MapFloatingActions(
|
||||
onSearchClick = { viewModel.onEvent(Event.SearchButtonClicked) },
|
||||
onMyLocationClick = { viewModel.onEvent(Event.MyLocationClicked) },
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(bottom = 92.dp, end = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
uiState.searchPanelState?.let { searchPanelState ->
|
||||
MapSearchOverlay(
|
||||
query = searchPanelState.query,
|
||||
results = searchPanelState.results,
|
||||
onQueryChanged = { viewModel.onEvent(Event.SearchQueryChanged(it)) },
|
||||
onClose = { viewModel.onEvent(Event.SearchClosed) },
|
||||
onResultClick = { viewModel.onEvent(Event.SearchResultClicked(it.id)) },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
uiState.devicePanelState?.let { panelData ->
|
||||
MapDevicePanel(
|
||||
data = panelData,
|
||||
onClose = { viewModel.onEvent(Event.DevicePanelClosed) },
|
||||
onOpenDevice = { viewModel.onEvent(Event.DeviceOpenClicked) },
|
||||
onRangeSelected = { viewModel.onEvent(Event.TimeRangeSelected(it)) },
|
||||
onDateBack = { viewModel.onEvent(Event.DateBackClicked) },
|
||||
onDateForward = { viewModel.onEvent(Event.DateForwardClicked) },
|
||||
onSensorSelected = { viewModel.onEvent(Event.DeviceSensorSelected(it)) },
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
)
|
||||
}
|
||||
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
@@ -60,18 +116,13 @@ fun MapScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.errorMessage != null) {
|
||||
ErrorOverlay(
|
||||
message = uiState.errorMessage ?: "Unknown error",
|
||||
onRetry = viewModel::refresh
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AirMQMap(
|
||||
items: List<MapItem>
|
||||
items: List<MapItem>,
|
||||
onMarkerClick: (String) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
@@ -112,6 +163,10 @@ private fun AirMQMap(
|
||||
title = listOfNotNull(item.title, item.city).joinToString(" - ")
|
||||
subDescription = if (item.isOnline) "Online" else "Offline"
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
setOnMarkerClickListener { _, _ ->
|
||||
onMarkerClick(item.id)
|
||||
true
|
||||
}
|
||||
}
|
||||
map.overlays.add(marker)
|
||||
}
|
||||
@@ -124,22 +179,3 @@ private fun AirMQMap(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorOverlay(
|
||||
message: String,
|
||||
onRetry: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(PaddingValues(16.dp)),
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
AirMqContainedButton(
|
||||
text = "Retry: $message",
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.db3.airmq.features.map
|
||||
|
||||
import org.db3.airmq.sdk.map.domain.MapItem
|
||||
|
||||
object MapScreenContract {
|
||||
|
||||
enum class SensorType {
|
||||
DUST,
|
||||
RADIOACTIVITY
|
||||
}
|
||||
|
||||
enum class DeviceSensorType {
|
||||
TEMPERATURE,
|
||||
DUST,
|
||||
RADIOACTIVITY
|
||||
}
|
||||
|
||||
enum class TimeRange {
|
||||
HOUR,
|
||||
DAY,
|
||||
WEEK,
|
||||
MONTH
|
||||
}
|
||||
|
||||
data class SearchResult(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String
|
||||
)
|
||||
|
||||
data class DevicePanelState(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: String,
|
||||
val selectedRange: TimeRange = TimeRange.DAY,
|
||||
val displayedDateRange: String = "Today",
|
||||
val selectedSensor: DeviceSensorType = DeviceSensorType.TEMPERATURE
|
||||
)
|
||||
|
||||
data class SearchPanelState(
|
||||
val query: String = "",
|
||||
val results: List<SearchResult> = emptyList()
|
||||
)
|
||||
|
||||
data class State(
|
||||
val isLoading: Boolean = false,
|
||||
val items: List<MapItem> = emptyList(),
|
||||
val selectedTopSensor: SensorType = SensorType.DUST,
|
||||
val searchPanelState: SearchPanelState? = null,
|
||||
val devicePanelState: DevicePanelState? = null
|
||||
)
|
||||
|
||||
sealed interface Action {
|
||||
data class ShowToast(val message: String) : Action
|
||||
data class OpenDeviceRequested(val deviceId: String) : Action
|
||||
}
|
||||
|
||||
sealed interface Event {
|
||||
data object RetryClicked : Event
|
||||
data object SearchButtonClicked : Event
|
||||
data object SearchClosed : Event
|
||||
data class SearchQueryChanged(val value: String) : Event
|
||||
data class SearchResultClicked(val resultId: String) : Event
|
||||
data object MyLocationClicked : Event
|
||||
data class TopSensorSelected(val sensor: SensorType) : Event
|
||||
data class MarkerClicked(val itemId: String) : Event
|
||||
data object DevicePanelClosed : Event
|
||||
data object DeviceOpenClicked : Event
|
||||
data class TimeRangeSelected(val range: TimeRange) : Event
|
||||
data object DateBackClicked : Event
|
||||
data object DateForwardClicked : Event
|
||||
data class DeviceSensorSelected(val sensor: DeviceSensorType) : Event
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package org.db3.airmq.features.map
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.db3.airmq.R
|
||||
import org.db3.airmq.features.common.AirMqButton
|
||||
import org.db3.airmq.features.common.AirMqButtonStyle
|
||||
import org.db3.airmq.features.map.MapScreenContract.DevicePanelState
|
||||
import org.db3.airmq.features.map.MapScreenContract.DeviceSensorType
|
||||
import org.db3.airmq.features.map.MapScreenContract.SearchResult
|
||||
import org.db3.airmq.features.map.MapScreenContract.SensorType
|
||||
import org.db3.airmq.features.map.MapScreenContract.TimeRange
|
||||
|
||||
@Composable
|
||||
fun MapTopControls(
|
||||
selectedSensor: SensorType,
|
||||
onSensorSelected: (SensorType) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(22.dp),
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 6.dp,
|
||||
color = Color.White
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selectedSensor == SensorType.DUST,
|
||||
onClick = { onSensorSelected(SensorType.DUST) },
|
||||
label = { Text(stringResource(id = R.string.map_sensor_dust)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedSensor == SensorType.RADIOACTIVITY,
|
||||
onClick = { onSensorSelected(SensorType.RADIOACTIVITY) },
|
||||
label = { Text(stringResource(id = R.string.map_sensor_radioactivity)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapFloatingActions(
|
||||
onSearchClick: () -> Unit,
|
||||
onMyLocationClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
RoundIconButton(
|
||||
iconRes = R.drawable.ic_map_search,
|
||||
contentDescription = stringResource(id = R.string.map_search_action_content_desc),
|
||||
onClick = onSearchClick
|
||||
)
|
||||
RoundIconButton(
|
||||
iconRes = R.drawable.ic_map_my_location,
|
||||
contentDescription = stringResource(id = R.string.map_my_location_content_desc),
|
||||
onClick = onMyLocationClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoundIconButton(
|
||||
iconRes: Int,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clickable(onClick = onClick),
|
||||
shape = CircleShape,
|
||||
shadowElevation = 8.dp,
|
||||
color = Color.White
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painter = androidx.compose.ui.res.painterResource(id = iconRes),
|
||||
contentDescription = contentDescription,
|
||||
tint = Color(0xFF1C1C1C)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapSearchOverlay(
|
||||
query: String,
|
||||
results: List<SearchResult>,
|
||||
onQueryChanged: (String) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onResultClick: (SearchResult) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White)
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AirMqButton(
|
||||
text = stringResource(id = R.string.map_back),
|
||||
onClick = onClose,
|
||||
style = AirMqButtonStyle.Outlined
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChanged,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text(stringResource(id = R.string.map_search_hint)) }
|
||||
)
|
||||
}
|
||||
|
||||
if (results.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.map_search_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
results.forEach { result ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onResultClick(result) },
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F7F7))
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
Text(
|
||||
text = result.title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = result.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MapDevicePanel(
|
||||
data: DevicePanelState,
|
||||
onClose: () -> Unit,
|
||||
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)
|
||||
) {
|
||||
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
|
||||
) {
|
||||
AirMqButton(
|
||||
text = stringResource(id = R.string.map_close),
|
||||
onClick = onClose,
|
||||
style = AirMqButtonStyle.Text
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Text(text = data.name, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = data.status,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
AirMqButton(
|
||||
text = stringResource(id = R.string.map_open_device),
|
||||
onClick = onOpenDevice,
|
||||
style = AirMqButtonStyle.Outlined
|
||||
)
|
||||
}
|
||||
|
||||
TimeRangeRow(selected = data.selectedRange, onSelected = onRangeSelected)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AirMqButton(
|
||||
text = stringResource(id = R.string.map_arrow_left),
|
||||
onClick = onDateBack,
|
||||
style = AirMqButtonStyle.Text
|
||||
)
|
||||
Text(
|
||||
text = data.displayedDateRange,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
AirMqButton(
|
||||
text = stringResource(id = R.string.map_arrow_right),
|
||||
onClick = onDateForward,
|
||||
style = AirMqButtonStyle.Text
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp)
|
||||
.background(Color(0x14000000), RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.map_chart_placeholder),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
|
||||
DeviceSensorRow(
|
||||
selectedSensor = data.selectedSensor,
|
||||
onSelected = onSensorSelected
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeRangeRow(
|
||||
selected: TimeRange,
|
||||
onSelected: (TimeRange) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selected == TimeRange.HOUR,
|
||||
onClick = { onSelected(TimeRange.HOUR) },
|
||||
label = { Text(stringResource(id = R.string.map_filter_hour)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected == TimeRange.DAY,
|
||||
onClick = { onSelected(TimeRange.DAY) },
|
||||
label = { Text(stringResource(id = R.string.map_filter_day)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected == TimeRange.WEEK,
|
||||
onClick = { onSelected(TimeRange.WEEK) },
|
||||
label = { Text(stringResource(id = R.string.map_filter_week)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = selected == TimeRange.MONTH,
|
||||
onClick = { onSelected(TimeRange.MONTH) },
|
||||
label = { Text(stringResource(id = R.string.map_filter_month)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceSensorRow(
|
||||
selectedSensor: DeviceSensorType,
|
||||
onSelected: (DeviceSensorType) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selectedSensor == DeviceSensorType.TEMPERATURE,
|
||||
onClick = { onSelected(DeviceSensorType.TEMPERATURE) },
|
||||
label = { Text(stringResource(id = R.string.map_device_sensor_temperature)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedSensor == DeviceSensorType.DUST,
|
||||
onClick = { onSelected(DeviceSensorType.DUST) },
|
||||
label = { Text(stringResource(id = R.string.map_device_sensor_dust)) }
|
||||
)
|
||||
FilterChip(
|
||||
selected = selectedSensor == DeviceSensorType.RADIOACTIVITY,
|
||||
onClick = { onSelected(DeviceSensorType.RADIOACTIVITY) },
|
||||
label = { Text(stringResource(id = R.string.map_device_sensor_radioactivity)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,22 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.launch
|
||||
import org.db3.airmq.features.map.MapScreenContract.Action
|
||||
import org.db3.airmq.features.map.MapScreenContract.DevicePanelState
|
||||
import org.db3.airmq.features.map.MapScreenContract.DeviceSensorType
|
||||
import org.db3.airmq.features.map.MapScreenContract.Event
|
||||
import org.db3.airmq.features.map.MapScreenContract.SearchResult
|
||||
import org.db3.airmq.features.map.MapScreenContract.SearchPanelState
|
||||
import org.db3.airmq.features.map.MapScreenContract.SensorType
|
||||
import org.db3.airmq.features.map.MapScreenContract.State
|
||||
import org.db3.airmq.features.map.MapScreenContract.TimeRange
|
||||
import org.db3.airmq.sdk.map.MapService
|
||||
|
||||
@HiltViewModel
|
||||
@@ -16,33 +28,178 @@ class MapViewModel @Inject constructor(
|
||||
private val mapService: MapService
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(MapUiState(isLoading = true))
|
||||
val uiState: StateFlow<MapUiState> = _uiState.asStateFlow()
|
||||
private val _uiState = MutableStateFlow(State(isLoading = true))
|
||||
val uiState: StateFlow<State> = _uiState.asStateFlow()
|
||||
|
||||
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
|
||||
val actions: SharedFlow<Action> = _actions.asSharedFlow()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
refreshMapItems()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
fun onEvent(event: Event) {
|
||||
when (event) {
|
||||
is Event.RetryClicked -> {
|
||||
refreshMapItems()
|
||||
}
|
||||
|
||||
is Event.SearchButtonClicked -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
searchPanelState = SearchPanelState(),
|
||||
devicePanelState = null
|
||||
)
|
||||
}
|
||||
|
||||
is Event.SearchClosed -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
searchPanelState = null
|
||||
)
|
||||
}
|
||||
|
||||
is Event.SearchQueryChanged -> {
|
||||
val searchPanelState = _uiState.value.searchPanelState ?: return
|
||||
val results = resolveSearchResults(event.value)
|
||||
_uiState.value = _uiState.value.copy(
|
||||
searchPanelState = searchPanelState.copy(
|
||||
query = event.value,
|
||||
results = results
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is Event.SearchResultClicked -> {
|
||||
val selectedItem = _uiState.value.items.firstOrNull { it.id == event.resultId } ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
searchPanelState = null,
|
||||
devicePanelState = selectedItem.toDevicePanelState()
|
||||
)
|
||||
}
|
||||
|
||||
is Event.MyLocationClicked -> {
|
||||
_actions.tryEmit(Action.ShowToast("My location logic will be added later"))
|
||||
}
|
||||
|
||||
is Event.TopSensorSelected -> {
|
||||
_uiState.value = _uiState.value.copy(selectedTopSensor = event.sensor)
|
||||
}
|
||||
|
||||
is Event.MarkerClicked -> {
|
||||
val selectedItem = _uiState.value.items.firstOrNull { it.id == event.itemId } ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
searchPanelState = null,
|
||||
devicePanelState = selectedItem.toDevicePanelState()
|
||||
)
|
||||
}
|
||||
|
||||
is Event.DevicePanelClosed -> {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
devicePanelState = null
|
||||
)
|
||||
}
|
||||
|
||||
is Event.DeviceOpenClicked -> {
|
||||
val deviceId = _uiState.value.devicePanelState?.id ?: return
|
||||
_actions.tryEmit(Action.OpenDeviceRequested(deviceId))
|
||||
}
|
||||
|
||||
is Event.TimeRangeSelected -> {
|
||||
val panelData = _uiState.value.devicePanelState ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
devicePanelState = panelData.copy(
|
||||
selectedRange = event.range,
|
||||
displayedDateRange = rangeLabel(event.range)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is Event.DateBackClicked -> {
|
||||
val panelData = _uiState.value.devicePanelState ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
devicePanelState = panelData.copy(displayedDateRange = "Previous ${rangeLabel(panelData.selectedRange)}")
|
||||
)
|
||||
}
|
||||
|
||||
Event.DateForwardClicked -> {
|
||||
val panelData = _uiState.value.devicePanelState ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
devicePanelState = panelData.copy(displayedDateRange = "Next ${rangeLabel(panelData.selectedRange)}")
|
||||
)
|
||||
}
|
||||
is Event.DeviceSensorSelected -> {
|
||||
val panelData = _uiState.value.devicePanelState ?: return
|
||||
_uiState.value = _uiState.value.copy(
|
||||
devicePanelState = panelData.copy(selectedSensor = event.sensor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMapItems() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val result = runCatching { mapService.fetchMapItems() }
|
||||
_uiState.value = result.fold(
|
||||
onSuccess = { items ->
|
||||
MapUiState(
|
||||
val searchPanelState = _uiState.value.searchPanelState
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
items = items,
|
||||
errorMessage = null
|
||||
searchPanelState = searchPanelState?.copy(
|
||||
results = resolveSearchResults(searchPanelState.query)
|
||||
)
|
||||
)
|
||||
},
|
||||
onFailure = { throwable ->
|
||||
MapUiState(
|
||||
_actions.tryEmit(Action.ShowToast(throwable.message ?: "Failed to load map items"))
|
||||
val searchPanelState = _uiState.value.searchPanelState
|
||||
_uiState.value.copy(
|
||||
isLoading = false,
|
||||
items = emptyList(),
|
||||
errorMessage = throwable.message ?: "Failed to load map items"
|
||||
searchPanelState = searchPanelState?.copy(results = emptyList())
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveSearchResults(query: String): List<SearchResult> {
|
||||
if (query.isBlank()) return emptyList()
|
||||
return _uiState.value.items
|
||||
.filter { item ->
|
||||
item.title.contains(query, ignoreCase = true) ||
|
||||
(item.city?.contains(query, ignoreCase = true) == true)
|
||||
}
|
||||
.take(20)
|
||||
.map { item ->
|
||||
SearchResult(
|
||||
id = item.id,
|
||||
title = item.title,
|
||||
subtitle = item.city ?: "No city"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rangeLabel(range: TimeRange): String = when (range) {
|
||||
TimeRange.HOUR -> "Last hour"
|
||||
TimeRange.DAY -> "Today"
|
||||
TimeRange.WEEK -> "This week"
|
||||
TimeRange.MONTH -> "This month"
|
||||
}
|
||||
|
||||
private fun org.db3.airmq.sdk.map.domain.MapItem.toDevicePanelState(): DevicePanelState {
|
||||
val defaultSensor = when {
|
||||
title.contains("radiation", ignoreCase = true) -> DeviceSensorType.RADIOACTIVITY
|
||||
title.contains("dust", ignoreCase = true) -> DeviceSensorType.DUST
|
||||
else -> DeviceSensorType.TEMPERATURE
|
||||
}
|
||||
return DevicePanelState(
|
||||
id = id,
|
||||
name = title,
|
||||
status = if (isOnline) "online" else "offline",
|
||||
selectedRange = TimeRange.DAY,
|
||||
displayedDateRange = rangeLabel(TimeRange.DAY),
|
||||
selectedSensor = defaultSensor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user