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 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.Toast
import android.widget.ImageView
import android.widget.TextView
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -16,8 +22,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
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.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
@@ -25,14 +33,23 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import org.db3.airmq.R import org.db3.airmq.R
import org.db3.airmq.features.map.MapScreenContract.Action 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.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.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 import androidx.compose.material3.CircularProgressIndicator
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable
@Composable @Composable
fun MapScreen( 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()) { Box(modifier = Modifier.fillMaxSize()) {
AirMQMap( if (showMap) {
items = uiState.items, AirMQMap(
onMarkerClick = { viewModel.onEvent(Event.MarkerClicked(it)) } items = uiState.items,
) onMarkerClick = { onEvent(Event.MarkerClicked(it)) }
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFE8F1E5))
)
}
MapTopControls( MapTopControls(
selectedSensor = uiState.selectedTopSensor, selectedSensor = uiState.selectedTopSensor,
onSensorSelected = { viewModel.onEvent(Event.TopSensorSelected(it)) }, onSensorSelected = { onEvent(Event.TopSensorSelected(it)) },
onHelpClick = { onHelpClick = {
Toast.makeText( Toast.makeText(
context, context,
@@ -81,8 +121,8 @@ fun MapScreen(
if (uiState.searchPanelState == null && uiState.devicePanelState == null) { if (uiState.searchPanelState == null && uiState.devicePanelState == null) {
MapFloatingActions( MapFloatingActions(
onSearchClick = { viewModel.onEvent(Event.SearchButtonClicked) }, onSearchClick = { onEvent(Event.SearchButtonClicked) },
onMyLocationClick = { viewModel.onEvent(Event.MyLocationClicked) }, onMyLocationClick = { onEvent(Event.MyLocationClicked) },
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(bottom = 92.dp, end = 16.dp) .padding(bottom = 92.dp, end = 16.dp)
@@ -93,9 +133,9 @@ fun MapScreen(
MapSearchOverlay( MapSearchOverlay(
query = searchPanelState.query, query = searchPanelState.query,
results = searchPanelState.results, results = searchPanelState.results,
onQueryChanged = { viewModel.onEvent(Event.SearchQueryChanged(it)) }, onQueryChanged = { onEvent(Event.SearchQueryChanged(it)) },
onClose = { viewModel.onEvent(Event.SearchClosed) }, onClose = { onEvent(Event.SearchClosed) },
onResultClick = { viewModel.onEvent(Event.SearchResultClicked(it.id)) }, onResultClick = { onEvent(Event.SearchResultClicked(it.id)) },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
@@ -103,12 +143,12 @@ fun MapScreen(
uiState.devicePanelState?.let { panelData -> uiState.devicePanelState?.let { panelData ->
MapDevicePanel( MapDevicePanel(
data = panelData, data = panelData,
onClose = { viewModel.onEvent(Event.DevicePanelClosed) }, onClose = { onEvent(Event.DevicePanelClosed) },
onOpenDevice = { viewModel.onEvent(Event.DeviceOpenClicked) }, onOpenDevice = { onEvent(Event.DeviceOpenClicked) },
onRangeSelected = { viewModel.onEvent(Event.TimeRangeSelected(it)) }, onRangeSelected = { onEvent(Event.TimeRangeSelected(it)) },
onDateBack = { viewModel.onEvent(Event.DateBackClicked) }, onDateBack = { onEvent(Event.DateBackClicked) },
onDateForward = { viewModel.onEvent(Event.DateForwardClicked) }, onDateForward = { onEvent(Event.DateForwardClicked) },
onSensorSelected = { viewModel.onEvent(Event.DeviceSensorSelected(it)) }, onSensorSelected = { onEvent(Event.DeviceSensorSelected(it)) },
modifier = Modifier.align(Alignment.BottomCenter) modifier = Modifier.align(Alignment.BottomCenter)
) )
} }
@@ -129,7 +169,7 @@ fun MapScreen(
@Composable @Composable
private fun AirMQMap( private fun AirMQMap(
items: List<MapItem>, items: List<MapMarker>,
onMarkerClick: (String) -> Unit onMarkerClick: (String) -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -171,6 +211,7 @@ 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)
icon = createMarkerIcon(map, item)
setOnMarkerClickListener { _, _ -> setOnMarkerClickListener { _, _ ->
onMarkerClick(item.id) onMarkerClick(item.id)
true 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 package org.db3.airmq.features.map
import org.db3.airmq.sdk.map.domain.MapItem
object MapScreenContract { object MapScreenContract {
enum class SensorType { enum class SensorType {
@@ -44,7 +42,7 @@ object MapScreenContract {
data class State( data class State(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val items: List<MapItem> = emptyList(), val items: List<MapMarker> = emptyList(),
val selectedTopSensor: SensorType = SensorType.DUST, val selectedTopSensor: SensorType = SensorType.DUST,
val searchPanelState: SearchPanelState? = null, val searchPanelState: SearchPanelState? = null,
val devicePanelState: DevicePanelState? = 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.State
import org.db3.airmq.features.map.MapScreenContract.TimeRange import org.db3.airmq.features.map.MapScreenContract.TimeRange
import org.db3.airmq.sdk.map.MapService import org.db3.airmq.sdk.map.MapService
import org.db3.airmq.sdk.map.domain.MapItem
@HiltViewModel @HiltViewModel
class MapViewModel @Inject constructor( class MapViewModel @Inject constructor(
@@ -30,6 +31,7 @@ class MapViewModel @Inject constructor(
private val _uiState = MutableStateFlow(State(isLoading = true)) private val _uiState = MutableStateFlow(State(isLoading = true))
val uiState: StateFlow<State> = _uiState.asStateFlow() val uiState: StateFlow<State> = _uiState.asStateFlow()
private var domainItems: List<MapItem> = emptyList()
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1) private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
val actions: SharedFlow<Action> = _actions.asSharedFlow() val actions: SharedFlow<Action> = _actions.asSharedFlow()
@@ -82,6 +84,7 @@ class MapViewModel @Inject constructor(
is Event.TopSensorSelected -> { is Event.TopSensorSelected -> {
_uiState.value = _uiState.value.copy(selectedTopSensor = event.sensor) _uiState.value = _uiState.value.copy(selectedTopSensor = event.sensor)
remapMarkers()
} }
is Event.MarkerClicked -> { is Event.MarkerClicked -> {
@@ -141,16 +144,19 @@ class MapViewModel @Inject constructor(
val result = runCatching { mapService.fetchMapItems() } val result = runCatching { mapService.fetchMapItems() }
_uiState.value = result.fold( _uiState.value = result.fold(
onSuccess = { items -> onSuccess = { items ->
domainItems = items
val searchPanelState = _uiState.value.searchPanelState val searchPanelState = _uiState.value.searchPanelState
val markers = items.toMarkers(_uiState.value.selectedTopSensor)
_uiState.value.copy( _uiState.value.copy(
isLoading = false, isLoading = false,
items = items, items = markers,
searchPanelState = searchPanelState?.copy( searchPanelState = searchPanelState?.copy(
results = resolveSearchResults(searchPanelState.query) results = resolveSearchResults(searchPanelState.query)
) )
) )
}, },
onFailure = { throwable -> onFailure = { throwable ->
domainItems = emptyList()
_actions.tryEmit(Action.ShowToast(throwable.message ?: "Failed to load map items")) _actions.tryEmit(Action.ShowToast(throwable.message ?: "Failed to load map items"))
val searchPanelState = _uiState.value.searchPanelState val searchPanelState = _uiState.value.searchPanelState
_uiState.value.copy( _uiState.value.copy(
@@ -187,11 +193,35 @@ class MapViewModel @Inject constructor(
TimeRange.MONTH -> "This month" TimeRange.MONTH -> "This month"
} }
private fun org.db3.airmq.sdk.map.domain.MapItem.toDevicePanelState(): DevicePanelState { private fun remapMarkers() {
val defaultSensor = when { _uiState.value = _uiState.value.copy(
title.contains("radiation", ignoreCase = true) -> DeviceSensorType.RADIOACTIVITY items = domainItems.toMarkers(_uiState.value.selectedTopSensor)
title.contains("dust", ignoreCase = true) -> DeviceSensorType.DUST )
else -> DeviceSensorType.TEMPERATURE }
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( return DevicePanelState(
id = id, 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="teal_700">#FF018786</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</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> </resources>

View File

@@ -1,5 +1,5 @@
query MapMarkers($isOnline: Boolean!) { query MapMarkers {
locations(filter: { isOnline: $isOnline }) { locations {
_id _id
name name
city city
@@ -9,6 +9,7 @@ query MapMarkers($isOnline: Boolean!) {
metricList metricList
currentValue { currentValue {
PMS25 PMS25
Count
} }
} }
} }

View File

@@ -13,7 +13,9 @@ class ApolloMapItemMapper @Inject constructor() {
city = location.city?.ifBlank { null }, city = location.city?.ifBlank { null },
latitude = location.latitude, latitude = location.latitude,
longitude = location.longitude, 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> { override suspend fun fetchMapItems(): List<MapItem> {
Log.d(TAG, "Executing MapMarkers Apollo query") Log.d(TAG, "Executing MapMarkers Apollo query")
val response = apolloClient val response = apolloClient
.query(MapMarkersQuery(isOnline = false)) .query(MapMarkersQuery())
.execute() .execute()
response.errors?.firstOrNull()?.let { gqlError -> response.errors?.firstOrNull()?.let { gqlError ->

View File

@@ -6,5 +6,7 @@ data class MapItem(
val city: String?, val city: String?,
val latitude: Double, val latitude: Double,
val longitude: Double, val longitude: Double,
val isOnline: Boolean val isOnline: Boolean,
val dustValue: Double?,
val radioactivityValue: Double?
) )