diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapMarker.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapMarker.kt new file mode 100644 index 0000000..43cc74a --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapMarker.kt @@ -0,0 +1,15 @@ +package org.db3.airmq.features.map + +import org.db3.airmq.features.map.MapScreenContract.SensorType + +data class MapMarker( + val id: String, + val title: String, + val city: String?, + val latitude: Double, + val longitude: Double, + val isOnline: Boolean, + val sensorType: SensorType, + val value: Double?, + val isOwned: Boolean +) diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerStyle.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerStyle.kt new file mode 100644 index 0000000..6faf33e --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapMarkerStyle.kt @@ -0,0 +1,47 @@ +package org.db3.airmq.features.map + +import androidx.annotation.ColorRes +import org.db3.airmq.R +import org.db3.airmq.features.map.MapScreenContract.SensorType +import java.util.Locale +import kotlin.math.roundToInt + +object MapMarkerStyle { + + fun formatValue(value: Double?, sensorType: SensorType): String { + if (value == null) return "--" + return when (sensorType) { + SensorType.DUST -> value.roundToInt().toString() + SensorType.RADIOACTIVITY -> formatAdaptive(value) + } + } + + @ColorRes + fun valueColorRes(value: Double?, sensorType: SensorType): Int { + if (value == null) return R.color.colorGrey + return when (sensorType) { + SensorType.DUST -> when { + value <= 12.0 -> R.color.sensorGreen + value <= 35.4 -> R.color.sensorYellow + value <= 55.4 -> R.color.sensorOrange + value <= 150.4 -> R.color.sensorRed + value <= 250.4 -> R.color.sensorPink + else -> R.color.sensorPurple + } + SensorType.RADIOACTIVITY -> R.color.sensorGreen + } + } + + private fun formatAdaptive(value: Double): String { + val decimals = when { + value > 10.0 -> 0 + value > 1.0 -> 1 + else -> 2 + } + return if (decimals == 0) { + value.roundToInt().toString() + } else { + String.format(Locale.US, "%.${decimals}f", value) + } + } +} 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 de01919..f7bb873 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 @@ -1,6 +1,12 @@ package org.db3.airmq.features.map +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.view.LayoutInflater import android.widget.Toast +import android.widget.ImageView +import android.widget.TextView import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -16,8 +22,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -25,14 +33,23 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import kotlinx.coroutines.flow.collectLatest import org.db3.airmq.R 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.sdk.map.domain.MapItem +import org.db3.airmq.features.map.MapScreenContract.SearchPanelState +import org.db3.airmq.features.map.MapScreenContract.SearchResult +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.ui.theme.AirMQTheme 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 +import androidx.core.graphics.createBitmap +import androidx.core.graphics.drawable.toDrawable @Composable fun MapScreen( @@ -58,15 +75,38 @@ fun MapScreen( } } + MapScreenContent( + uiState = uiState, + onEvent = viewModel::onEvent, + showMap = true + ) +} + +@Composable +private fun MapScreenContent( + uiState: State, + onEvent: (Event) -> Unit, + showMap: Boolean +) { + val context = LocalContext.current + Box(modifier = Modifier.fillMaxSize()) { - AirMQMap( - items = uiState.items, - onMarkerClick = { viewModel.onEvent(Event.MarkerClicked(it)) } - ) + if (showMap) { + AirMQMap( + items = uiState.items, + onMarkerClick = { onEvent(Event.MarkerClicked(it)) } + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFFE8F1E5)) + ) + } MapTopControls( selectedSensor = uiState.selectedTopSensor, - onSensorSelected = { viewModel.onEvent(Event.TopSensorSelected(it)) }, + onSensorSelected = { onEvent(Event.TopSensorSelected(it)) }, onHelpClick = { Toast.makeText( context, @@ -81,8 +121,8 @@ fun MapScreen( if (uiState.searchPanelState == null && uiState.devicePanelState == null) { MapFloatingActions( - onSearchClick = { viewModel.onEvent(Event.SearchButtonClicked) }, - onMyLocationClick = { viewModel.onEvent(Event.MyLocationClicked) }, + onSearchClick = { onEvent(Event.SearchButtonClicked) }, + onMyLocationClick = { onEvent(Event.MyLocationClicked) }, modifier = Modifier .align(Alignment.BottomEnd) .padding(bottom = 92.dp, end = 16.dp) @@ -93,9 +133,9 @@ fun MapScreen( 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)) }, + onQueryChanged = { onEvent(Event.SearchQueryChanged(it)) }, + onClose = { onEvent(Event.SearchClosed) }, + onResultClick = { onEvent(Event.SearchResultClicked(it.id)) }, modifier = Modifier.fillMaxSize() ) } @@ -103,12 +143,12 @@ fun MapScreen( 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)) }, + onClose = { onEvent(Event.DevicePanelClosed) }, + onOpenDevice = { onEvent(Event.DeviceOpenClicked) }, + onRangeSelected = { onEvent(Event.TimeRangeSelected(it)) }, + onDateBack = { onEvent(Event.DateBackClicked) }, + onDateForward = { onEvent(Event.DateForwardClicked) }, + onSensorSelected = { onEvent(Event.DeviceSensorSelected(it)) }, modifier = Modifier.align(Alignment.BottomCenter) ) } @@ -129,7 +169,7 @@ fun MapScreen( @Composable private fun AirMQMap( - items: List, + items: List, onMarkerClick: (String) -> Unit ) { val context = LocalContext.current @@ -171,6 +211,7 @@ private fun AirMQMap( title = listOfNotNull(item.title, item.city).joinToString(" - ") subDescription = if (item.isOnline) "Online" else "Offline" setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) + icon = createMarkerIcon(map, item) setOnMarkerClickListener { _, _ -> onMarkerClick(item.id) true @@ -187,3 +228,122 @@ private fun AirMQMap( ) } +private fun createMarkerIcon(mapView: MapView, item: MapMarker): BitmapDrawable { + val context = mapView.context + val iconView = LayoutInflater.from(context).inflate(R.layout.view_map_marker, null, false) + val markerBorder = iconView.findViewById(R.id.marker_border) + val markerCenter = iconView.findViewById(R.id.marker_center) + val markerText = iconView.findViewById(R.id.marker_text) + val markerShade = iconView.findViewById(R.id.marker_image_shade) + val markerImage = iconView.findViewById(R.id.marker_image) + + val hasValue = item.value != null + val isNoValueStyled = !hasValue + val colorRes = if (isNoValueStyled) { + R.color.colorGrey + } else { + MapMarkerStyle.valueColorRes(item.value, item.sensorType) + } + val markerColor = ContextCompat.getColor(context, colorRes) + val centerColor = if (isNoValueStyled) R.color.colorGrey else R.color.white + markerBorder.backgroundTintList = android.content.res.ColorStateList.valueOf(markerColor) + markerCenter.backgroundTintList = + android.content.res.ColorStateList.valueOf(ContextCompat.getColor(context, centerColor)) + + markerText.text = MapMarkerStyle.formatValue(item.value, item.sensorType) + if (item.isOwned && !isNoValueStyled) { + markerImage.setImageResource(R.drawable.ic_marker_user) + markerImage.visibility = android.view.View.VISIBLE + markerShade.visibility = android.view.View.VISIBLE + markerText.setTextColor(ContextCompat.getColor(context, R.color.white)) + } else { + markerImage.visibility = android.view.View.GONE + markerShade.visibility = android.view.View.GONE + markerText.setTextColor( + ContextCompat.getColor( + context, + if (isNoValueStyled) R.color.white else colorRes + ) + ) + } + + iconView.measure( + android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED), + android.view.View.MeasureSpec.makeMeasureSpec(0, android.view.View.MeasureSpec.UNSPECIFIED) + ) + iconView.layout(0, 0, iconView.measuredWidth, iconView.measuredHeight) + val bitmap = createBitmap(iconView.measuredWidth, iconView.measuredHeight) + iconView.draw(Canvas(bitmap)) + return bitmap.toDrawable(context.resources) +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun PreviewMapScreenDefault() { + AirMQTheme { + MapScreenContent( + uiState = State( + selectedTopSensor = SensorType.DUST, + items = listOf( + MapMarker( + id = "1", + title = "AirMQ #1", + city = "Minsk", + latitude = 53.9, + longitude = 27.56, + isOnline = true, + sensorType = SensorType.DUST, + value = 12.0, + isOwned = false + ) + ) + ), + onEvent = {}, + showMap = false + ) + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun PreviewMapScreenSearch() { + AirMQTheme { + MapScreenContent( + uiState = State( + selectedTopSensor = SensorType.RADIOACTIVITY, + searchPanelState = SearchPanelState( + query = "Minsk", + results = listOf( + SearchResult(id = "1", title = "AirMQ #42", subtitle = "Minsk"), + SearchResult(id = "2", title = "AirMQ #91", subtitle = "Salihorsk") + ) + ) + ), + onEvent = {}, + showMap = false + ) + } +} + +@Preview(showBackground = true, showSystemUi = true) +@Composable +private fun PreviewMapScreenDevicePanel() { + AirMQTheme { + MapScreenContent( + uiState = State( + selectedTopSensor = SensorType.DUST, + devicePanelState = DevicePanelState( + id = "42", + name = "AirMQ #42", + status = "Online", + selectedRange = TimeRange.DAY, + displayedDateRange = "Today", + selectedSensor = DeviceSensorType.DUST + ) + ), + onEvent = {}, + showMap = false + ) + } +} + 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 index 053e175..6d1e6b5 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreenContract.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreenContract.kt @@ -1,7 +1,5 @@ package org.db3.airmq.features.map -import org.db3.airmq.sdk.map.domain.MapItem - object MapScreenContract { enum class SensorType { @@ -44,7 +42,7 @@ object MapScreenContract { data class State( val isLoading: Boolean = false, - val items: List = emptyList(), + val items: List = emptyList(), val selectedTopSensor: SensorType = SensorType.DUST, val searchPanelState: SearchPanelState? = null, val devicePanelState: DevicePanelState? = null 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 6cfda8f..4741c16 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 @@ -22,6 +22,7 @@ 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.domain.MapItem @HiltViewModel class MapViewModel @Inject constructor( @@ -30,6 +31,7 @@ class MapViewModel @Inject constructor( private val _uiState = MutableStateFlow(State(isLoading = true)) val uiState: StateFlow = _uiState.asStateFlow() + private var domainItems: List = emptyList() private val _actions = MutableSharedFlow(extraBufferCapacity = 1) val actions: SharedFlow = _actions.asSharedFlow() @@ -82,6 +84,7 @@ class MapViewModel @Inject constructor( is Event.TopSensorSelected -> { _uiState.value = _uiState.value.copy(selectedTopSensor = event.sensor) + remapMarkers() } is Event.MarkerClicked -> { @@ -141,16 +144,19 @@ class MapViewModel @Inject constructor( val result = runCatching { mapService.fetchMapItems() } _uiState.value = result.fold( onSuccess = { items -> + domainItems = items val searchPanelState = _uiState.value.searchPanelState + val markers = items.toMarkers(_uiState.value.selectedTopSensor) _uiState.value.copy( isLoading = false, - items = items, + items = markers, searchPanelState = searchPanelState?.copy( results = resolveSearchResults(searchPanelState.query) ) ) }, onFailure = { throwable -> + domainItems = emptyList() _actions.tryEmit(Action.ShowToast(throwable.message ?: "Failed to load map items")) val searchPanelState = _uiState.value.searchPanelState _uiState.value.copy( @@ -187,11 +193,35 @@ class MapViewModel @Inject constructor( 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 + private fun remapMarkers() { + _uiState.value = _uiState.value.copy( + items = domainItems.toMarkers(_uiState.value.selectedTopSensor) + ) + } + + private fun List.toMarkers(sensorType: SensorType): List { + return map { item -> + MapMarker( + id = item.id, + title = item.title, + city = item.city, + latitude = item.latitude, + longitude = item.longitude, + isOnline = item.isOnline, + sensorType = sensorType, + value = when (sensorType) { + SensorType.DUST -> item.dustValue + SensorType.RADIOACTIVITY -> item.radioactivityValue + }, + isOwned = false // TODO: derive from authenticated user when ownership/auth is implemented. + ) + } + } + + private fun MapMarker.toDevicePanelState(): DevicePanelState { + val defaultSensor = when (sensorType) { + SensorType.DUST -> DeviceSensorType.DUST + SensorType.RADIOACTIVITY -> DeviceSensorType.RADIOACTIVITY } return DevicePanelState( id = id, diff --git a/app/src/main/res/drawable/circle_marker.xml b/app/src/main/res/drawable/circle_marker.xml new file mode 100644 index 0000000..b4ebec7 --- /dev/null +++ b/app/src/main/res/drawable/circle_marker.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/circle_marker_shade.xml b/app/src/main/res/drawable/circle_marker_shade.xml new file mode 100644 index 0000000..b817fc5 --- /dev/null +++ b/app/src/main/res/drawable/circle_marker_shade.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/drawable/ic_marker_user.xml b/app/src/main/res/drawable/ic_marker_user.xml new file mode 100644 index 0000000..42cd831 --- /dev/null +++ b/app/src/main/res/drawable/ic_marker_user.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/view_map_marker.xml b/app/src/main/res/layout/view_map_marker.xml new file mode 100644 index 0000000..e0f9524 --- /dev/null +++ b/app/src/main/res/layout/view_map_marker.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..5210eac 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,11 @@ #FF018786 #FF000000 #FFFFFFFF + #FF9E9E9E + #FF00C853 + #FFFFD54F + #FFFF9800 + #FFF44336 + #FFEC407A + #FF8E24AA \ No newline at end of file diff --git a/sdk/src/main/graphql/MapLocations.graphql b/sdk/src/main/graphql/MapLocations.graphql index 1e52753..461c207 100644 --- a/sdk/src/main/graphql/MapLocations.graphql +++ b/sdk/src/main/graphql/MapLocations.graphql @@ -1,5 +1,5 @@ -query MapMarkers($isOnline: Boolean!) { - locations(filter: { isOnline: $isOnline }) { +query MapMarkers { + locations { _id name city @@ -9,6 +9,7 @@ query MapMarkers($isOnline: Boolean!) { metricList currentValue { PMS25 + Count } } } \ No newline at end of file diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapItemMapper.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapItemMapper.kt index 4471a96..72259a2 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapItemMapper.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapItemMapper.kt @@ -13,7 +13,9 @@ class ApolloMapItemMapper @Inject constructor() { city = location.city?.ifBlank { null }, latitude = location.latitude, longitude = location.longitude, - isOnline = location.isOnline ?: false + isOnline = location.isOnline ?: false, + dustValue = location.currentValue?.PMS25?.toDouble(), + radioactivityValue = location.currentValue?.Count?.toDouble() ) } } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/map/MapServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/MapServiceImpl.kt index 2652cf7..3f57bc7 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/map/MapServiceImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/MapServiceImpl.kt @@ -13,7 +13,7 @@ class MapServiceImpl @Inject constructor( override suspend fun fetchMapItems(): List { Log.d(TAG, "Executing MapMarkers Apollo query") val response = apolloClient - .query(MapMarkersQuery(isOnline = false)) + .query(MapMarkersQuery()) .execute() response.errors?.firstOrNull()?.let { gqlError -> diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/map/domain/MapItem.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/domain/MapItem.kt index 848ec8d..d61472c 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/map/domain/MapItem.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/domain/MapItem.kt @@ -6,5 +6,7 @@ data class MapItem( val city: String?, val latitude: Double, val longitude: Double, - val isOnline: Boolean + val isOnline: Boolean, + val dustValue: Double?, + val radioactivityValue: Double? )