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:
2026-02-28 22:24:44 +01:00
parent 117caa9122
commit c4626ca40c
5 changed files with 696 additions and 46 deletions

View File

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

View File

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

View File

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

View File

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