Implement legacy-style map markers with UI model mapping.

Replace default OSM pins with round value-based icons, add DTO-to-domain-to-UI marker mapping, and normalize no-value/offline styling while keeping ownership icon behavior stubbed for future auth integration.

Made-with: Cursor
This commit is contained in:
2026-03-01 00:19:40 +01:00
parent 02c33e5ad5
commit 920a832424
14 changed files with 362 additions and 32 deletions

View File

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

View File

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

View File

@@ -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<MapItem>,
items: List<MapMarker>,
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<android.view.View>(R.id.marker_border)
val markerCenter = iconView.findViewById<android.view.View>(R.id.marker_center)
val markerText = iconView.findViewById<TextView>(R.id.marker_text)
val markerShade = iconView.findViewById<android.view.View>(R.id.marker_image_shade)
val markerImage = iconView.findViewById<ImageView>(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
)
}
}

View File

@@ -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<MapItem> = emptyList(),
val items: List<MapMarker> = emptyList(),
val selectedTopSensor: SensorType = SensorType.DUST,
val searchPanelState: SearchPanelState? = null,
val devicePanelState: DevicePanelState? = null

View File

@@ -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<State> = _uiState.asStateFlow()
private var domainItems: List<MapItem> = emptyList()
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
val actions: SharedFlow<Action> = _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<MapItem>.toMarkers(sensorType: SensorType): List<MapMarker> {
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,

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="@color/white" />
</shape>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<solid android:color="#66000000" />
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z" />
</vector>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="50dp"
android:layout_height="50dp"
android:padding="4dp">
<View
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_gravity="center"
android:background="@drawable/circle_marker" />
<View
android:id="@+id/marker_border"
android:layout_width="42dp"
android:layout_height="42dp"
android:layout_gravity="center"
android:background="@drawable/circle_marker" />
<View
android:id="@+id/marker_center"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center"
android:background="@drawable/circle_marker" />
<ImageView
android:id="@+id/marker_image"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center"
android:visibility="gone" />
<View
android:id="@+id/marker_image_shade"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_gravity="center"
android:background="@drawable/circle_marker_shade"
android:visibility="gone" />
<TextView
android:id="@+id/marker_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textSize="16sp"
android:textStyle="bold" />
</FrameLayout>

View File

@@ -7,4 +7,11 @@
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="colorGrey">#FF9E9E9E</color>
<color name="sensorGreen">#FF00C853</color>
<color name="sensorYellow">#FFFFD54F</color>
<color name="sensorOrange">#FFFF9800</color>
<color name="sensorRed">#FFF44336</color>
<color name="sensorPink">#FFEC407A</color>
<color name="sensorPurple">#FF8E24AA</color>
</resources>

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ class MapServiceImpl @Inject constructor(
override suspend fun fetchMapItems(): List<MapItem> {
Log.d(TAG, "Executing MapMarkers Apollo query")
val response = apolloClient
.query(MapMarkersQuery(isOnline = false))
.query(MapMarkersQuery())
.execute()
response.errors?.firstOrNull()?.let { gqlError ->

View File

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