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 android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import org.db3.airmq.features.common.AirMqContainedButton
|
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.db3.airmq.sdk.map.domain.MapItem
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
import org.osmdroid.views.MapView
|
import org.osmdroid.views.MapView
|
||||||
import org.osmdroid.views.overlay.Marker
|
import org.osmdroid.views.overlay.Marker
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MapScreen(
|
fun MapScreen(
|
||||||
viewModel: MapViewModel = hiltViewModel()
|
viewModel: MapViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(uiState) {
|
LaunchedEffect(viewModel) {
|
||||||
Toast.makeText(context, uiState.items.count().toString(), Toast.LENGTH_LONG).show()
|
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()) {
|
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) {
|
if (uiState.isLoading) {
|
||||||
Box(
|
Box(
|
||||||
@@ -60,18 +116,13 @@ fun MapScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.errorMessage != null) {
|
|
||||||
ErrorOverlay(
|
|
||||||
message = uiState.errorMessage ?: "Unknown error",
|
|
||||||
onRetry = viewModel::refresh
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AirMQMap(
|
private fun AirMQMap(
|
||||||
items: List<MapItem>
|
items: List<MapItem>,
|
||||||
|
onMarkerClick: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
@@ -112,6 +163,10 @@ private fun AirMQMap(
|
|||||||
title = listOfNotNull(item.title, item.city).joinToString(" - ")
|
title = listOfNotNull(item.title, item.city).joinToString(" - ")
|
||||||
subDescription = if (item.isOnline) "Online" else "Offline"
|
subDescription = if (item.isOnline) "Online" else "Offline"
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
|
setOnMarkerClickListener { _, _ ->
|
||||||
|
onMarkerClick(item.id)
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
map.overlays.add(marker)
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
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
|
import org.db3.airmq.sdk.map.MapService
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -16,33 +28,178 @@ class MapViewModel @Inject constructor(
|
|||||||
private val mapService: MapService
|
private val mapService: MapService
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(MapUiState(isLoading = true))
|
private val _uiState = MutableStateFlow(State(isLoading = true))
|
||||||
val uiState: StateFlow<MapUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<State> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
|
||||||
|
val actions: SharedFlow<Action> = _actions.asSharedFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refreshMapItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun onEvent(event: Event) {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
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) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val result = runCatching { mapService.fetchMapItems() }
|
val result = runCatching { mapService.fetchMapItems() }
|
||||||
_uiState.value = result.fold(
|
_uiState.value = result.fold(
|
||||||
onSuccess = { items ->
|
onSuccess = { items ->
|
||||||
MapUiState(
|
val searchPanelState = _uiState.value.searchPanelState
|
||||||
|
_uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
items = items,
|
items = items,
|
||||||
errorMessage = null
|
searchPanelState = searchPanelState?.copy(
|
||||||
|
results = resolveSearchResults(searchPanelState.query)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onFailure = { throwable ->
|
onFailure = { throwable ->
|
||||||
MapUiState(
|
_actions.tryEmit(Action.ShowToast(throwable.message ?: "Failed to load map items"))
|
||||||
|
val searchPanelState = _uiState.value.searchPanelState
|
||||||
|
_uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
items = emptyList(),
|
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