From c4626ca40c9cbd6356138c7298ab8a78df8229a1 Mon Sep 17 00:00:00 2001 From: beetzung Date: Sat, 28 Feb 2026 22:24:44 +0100 Subject: [PATCH] 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 --- .cursor/rules/screen-ui-contract.mdc | 41 +++ .../org/db3/airmq/features/map/MapScreen.kt | 110 ++++-- .../airmq/features/map/MapScreenContract.kt | 74 ++++ .../db3/airmq/features/map/MapUiComponents.kt | 342 ++++++++++++++++++ .../db3/airmq/features/map/MapViewModel.kt | 175 ++++++++- 5 files changed, 696 insertions(+), 46 deletions(-) create mode 100644 .cursor/rules/screen-ui-contract.mdc create mode 100644 app/src/main/kotlin/org/db3/airmq/features/map/MapScreenContract.kt create mode 100644 app/src/main/kotlin/org/db3/airmq/features/map/MapUiComponents.kt diff --git a/.cursor/rules/screen-ui-contract.mdc b/.cursor/rules/screen-ui-contract.mdc new file mode 100644 index 0000000..d561583 --- /dev/null +++ b/.cursor/rules/screen-ui-contract.mdc @@ -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 +} +``` diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt index 8d8fc00..c22ec64 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt @@ -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 + items: List, + 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() - ) - } -} diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreenContract.kt new file mode 100644 index 0000000..053e175 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreenContract.kt @@ -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 = emptyList() + ) + + data class State( + val isLoading: Boolean = false, + val items: List = 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 + } +} 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 new file mode 100644 index 0000000..f9d7aa2 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapUiComponents.kt @@ -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, + 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)) } + ) + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt index 3f337b2..6cfda8f 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt @@ -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 = _uiState.asStateFlow() + private val _uiState = MutableStateFlow(State(isLoading = true)) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _actions = MutableSharedFlow(extraBufferCapacity = 1) + val actions: SharedFlow = _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 { + 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 + ) + } }