feat(ui): switch from google maps to maplibre

Wayy more performant and flexible
This commit is contained in:
Cilly Leang 2026-02-27 22:47:19 +11:00
parent 2ad6e5d9e6
commit b9fa8f77c7
Signed by: cilly
GPG key ID: 6500251E087653C9
3 changed files with 154 additions and 108 deletions

View file

@ -3,6 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidApplication) alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
@ -39,9 +40,6 @@ kotlin {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.play.services.location) implementation(libs.play.services.location)
implementation(libs.play.services.maps)
implementation(libs.maps.compose)
implementation(libs.maps.compose.utils)
} }
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.components.resources) implementation(libs.compose.components.resources)
@ -62,6 +60,7 @@ kotlin {
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.contentnegotiation) implementation(libs.ktor.client.contentnegotiation)
implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.maplibre.compose)
implementation(libs.moko.geo) implementation(libs.moko.geo)
implementation(libs.moko.geo.compose) implementation(libs.moko.geo.compose)
implementation(projects.shared) implementation(projects.shared)

View file

@ -1,25 +1,20 @@
@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
package moe.lava.banksia.ui.platform.maps package moe.lava.banksia.ui.platform.maps
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -27,28 +22,43 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.android.gms.location.LocationServices import com.google.android.gms.location.LocationServices
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.android.gms.maps.model.MapStyleOptions
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.DefaultMapProperties
import com.google.maps.android.compose.DefaultMapUiSettings
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import moe.lava.banksia.R import kotlinx.serialization.Serializable
import moe.lava.banksia.ui.components.RouteIcon import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import moe.lava.banksia.model.RouteType
import moe.lava.banksia.ui.components.getUIProperties
import moe.lava.banksia.ui.platform.BanksiaTheme import moe.lava.banksia.ui.platform.BanksiaTheme
import moe.lava.banksia.ui.screens.MELBOURNE
import moe.lava.banksia.ui.screens.MapScreenEvent import moe.lava.banksia.ui.screens.MapScreenEvent
import moe.lava.banksia.ui.state.MapState import moe.lava.banksia.ui.state.MapState
import moe.lava.banksia.util.BoxedValue import moe.lava.banksia.util.BoxedValue
import moe.lava.banksia.util.Point import moe.lava.banksia.util.Point
import moe.lava.banksia.util.log
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.expressions.dsl.case
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.convertToString
import org.maplibre.compose.expressions.dsl.feature
import org.maplibre.compose.expressions.dsl.switch
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.OrnamentOptions
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.style.BaseStyle
import org.maplibre.compose.util.ClickResult
import org.maplibre.spatialk.geojson.BoundingBox
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Position
import org.maplibre.spatialk.geojson.dsl.addFeature
import org.maplibre.spatialk.geojson.dsl.buildFeatureCollection
fun Point.toLatLng(): LatLng = LatLng(this.lat, this.lng) import org.maplibre.compose.camera.CameraPosition as MLCameraPosition
import org.maplibre.spatialk.geojson.Point as MLPoint
fun Point.toPos(): Position = Position(this.lng, this.lat)
@Composable @Composable
private fun checkLocationPermission() = private fun checkLocationPermission() =
@ -62,6 +72,40 @@ actual fun getScreenHeight(): Int {
} }
} }
@Serializable
data class MarkerProps(
val type: RouteType,
)
private fun buildMarkers(markers: List<Marker>): FeatureCollection<MLPoint, MarkerProps> {
return buildFeatureCollection {
markers.forEach { marker ->
val type = when (marker) {
is Marker.Stop -> marker.type
is Marker.Vehicle -> marker.type
}
val id = when (marker) {
is Marker.Stop -> marker.id
is Marker.Vehicle -> marker.ref
}
addFeature(
geometry = MLPoint(marker.point.toPos()),
properties = MarkerProps(type),
) {
setId(id)
}
}
}
}
private val colorTypeExpression @Composable get() = switch(
input = feature["type"].convertToString(),
cases = RouteType.entries.map {
case(label = it.name, output = const(it.getUIProperties().colour))
}.toTypedArray(),
fallback = const(BanksiaTheme.colors.surface),
)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
actual fun Maps( actual fun Maps(
@ -72,104 +116,110 @@ actual fun Maps(
setLastKnownLocation: (Point) -> Unit, setLastKnownLocation: (Point) -> Unit,
extInsets: WindowInsets, extInsets: WindowInsets,
) { ) {
val camPos = rememberCameraPositionState() val camPos = rememberCameraState(
MLCameraPosition(
zoom = 16.0,
target = MELBOURNE.toPos()
)
)
val newCameraPos by cameraPositionFlow.collectAsStateWithLifecycle(null) val newCameraPos by cameraPositionFlow.collectAsStateWithLifecycle(null)
LaunchedEffect(newCameraPos) { LaunchedEffect(newCameraPos) {
log("maps", "newPos ${newCameraPos?.value}")
val pos = newCameraPos?.value ?: return@LaunchedEffect val pos = newCameraPos?.value ?: return@LaunchedEffect
val update = if (pos.bounds != null) { if (pos.bounds != null) {
val (northeast, southwest) = pos.bounds val (northeast, southwest) = pos.bounds
val bounds = LatLngBounds( camPos.animateTo(
southwest.toLatLng(), boundingBox = BoundingBox(
northeast.toLatLng() southwest.toPos(),
northeast.toPos()
)
) )
CameraUpdateFactory.newLatLngBounds(bounds, 150) } else {
} else camPos.animateTo(MLCameraPosition(
CameraUpdateFactory.newLatLngZoom(pos.centre.toLatLng(), 16.0f) target = pos.centre.toPos(),
zoom = 16.0,
camPos.animate(update, 1000) ))
}
} }
val ctx = LocalContext.current val ctx = LocalContext.current
val fusedLocation = remember { LocationServices.getFusedLocationProviderClient(ctx) } val fusedLocation = remember { LocationServices.getFusedLocationProviderClient(ctx) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@SuppressLint("MissingPermission")
fusedLocation.lastLocation.addOnSuccessListener { fusedLocation.lastLocation.addOnSuccessListener {
if (it != null) { if (it != null) {
camPos.position = com.google.android.gms.maps.model.CameraPosition( camPos.position = MLCameraPosition(
LatLng( zoom = 16.0,
it.latitude, target = Position(it.longitude, it.latitude)
it.longitude
), 16.0f, 0.0f, 0.0f
) )
setLastKnownLocation(Point(it.latitude, it.longitude)) setLastKnownLocation(Point(it.latitude, it.longitude))
} }
} }
} }
GoogleMap( MaplibreMap(
modifier = Modifier.fillMaxSize(), modifier = modifier,
cameraPositionState = camPos, baseStyle = BaseStyle.Uri("https://tiles.openfreemap.org/styles/positron"),
mapColorScheme = if (isSystemInDarkTheme()) { cameraState = camPos,
ComposeMapColorScheme.DARK options = MapOptions(
} else { ornamentOptions = OrnamentOptions(
ComposeMapColorScheme.LIGHT padding = WindowInsets.safeDrawing.add(extInsets).asPaddingValues(),
}, isScaleBarEnabled = false,
properties = DefaultMapProperties.copy( isAttributionEnabled = false,
mapStyleOptions = MapStyleOptions.loadRawResourceStyle( )
LocalContext.current, )
R.raw.def_mapstyle
),
isMyLocationEnabled = checkLocationPermission(),
),
uiSettings = DefaultMapUiSettings.copy(
zoomControlsEnabled = false,
myLocationButtonEnabled = false,
mapToolbarEnabled = false,
),
contentPadding = WindowInsets.safeDrawing.add(extInsets).asPaddingValues()
) { ) {
// [TODO]: Slight lag when routes with many stops such as the 901 bus is set if (state.stops.isNotEmpty()) {
for (marker in state.stops) { val stopsSource = rememberGeoJsonSource(
val state = rememberUpdatedMarkerState(marker.point.toLatLng()) GeoJsonData.Features(buildMarkers(state.stops))
MarkerComposable( )
keys = arrayOf(marker), CircleLayer(
zIndex = 0f, id = "maps-stops0",
state = state, source = stopsSource,
onClick = { color = const(BanksiaTheme.colors.surface),
onEvent(MapScreenEvent.SelectStop(marker.type to marker.id)) radius = const(3.dp),
false strokeWidth = const(2.dp),
strokeColor = colorTypeExpression,
onClick = { features ->
val feature = features[0]
val marker = Json.decodeFromJsonElement<MarkerProps>(feature.properties!!)
onEvent(MapScreenEvent.SelectStop(marker.type to feature.id!!.content))
ClickResult.Consume
} }
) {
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(BanksiaTheme.colors.surface)
.border(2.dp, marker.colour, CircleShape)
)
}
}
for (marker in state.vehicles) {
val state = rememberUpdatedMarkerState(marker.point.toLatLng())
MarkerComposable(
keys = arrayOf(marker),
zIndex = 1f,
state = state,
onClick = {
onEvent(MapScreenEvent.SelectRun(marker.ref))
false
}
) {
RouteIcon(
size = 30.dp,
routeType = marker.type,
)
}
}
for (polyline in state.polylines) {
Polyline(
points = polyline.points.map { it.toLatLng() },
color = polyline.colour
) )
} }
// TODO
// if (state.vehicles.isNotEmpty()) {
// val stopsSource = rememberGeoJsonSource(
// GeoJsonData.Features(buildMarkers(state.vehicles))
// )
// SymbolLayer
// CircleLayer(
// id = "maps-vehicles0",
// source = stopsSource,
// color = const(BanksiaTheme.colors.surface),
// radius = const(3.dp),
// strokeWidth = const(2.dp),
// strokeColor = colorTypeExpression,
// onClick = { features ->
// val feature = features[0]
// val marker = Json.decodeFromJsonElement<MarkerProps>(feature.properties!!)
// onEvent(MapScreenEvent.SelectStop(marker.type to feature.id!!.content))
// ClickResult.Consume
// }
// )
// }
//
// if (state.polylines.isNotEmpty()) {
// val polySource = rememberGeoJsonSource(
//
// )
// LineLayer(
// id = "maps-routeline",
// source = polySource,
// color = colorTypeExpression,
// )
// }
} }
} }

View file

@ -24,12 +24,11 @@ kotlinxSerialization = "1.10.0"
ksp = "2.3.4" ksp = "2.3.4"
ktor = "3.4.0" ktor = "3.4.0"
logback = "1.5.32" logback = "1.5.32"
mapsCompose = "6.12.2" maplibre = "0.12.1"
material = "1.7.3" material = "1.7.3"
material3 = "1.11.0-alpha02" material3 = "1.11.0-alpha02"
okio = "3.16.4" okio = "3.16.4"
playServicesLocation = "21.3.0" playServicesLocation = "21.3.0"
playServicesMaps = "19.2.0"
sqlite = "2.6.2" sqlite = "2.6.2"
room = "2.8.4" room = "2.8.4"
secretsGradlePlugin = "2.0.1" secretsGradlePlugin = "2.0.1"
@ -73,11 +72,9 @@ ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "kto
ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" } ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" }
ktor-server-tests = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" } ktor-server-tests = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" }
logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } logback = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = "mapsCompose" } maplibre-compose = { module = "org.maplibre.compose:maplibre-compose", version.ref = "maplibre" }
maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "mapsCompose" }
okio = { module = "com.squareup.okio:okio", version.ref = "okio" } okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" }
play-services-maps = { module = "com.google.android.gms:play-services-maps", version.ref = "playServicesMaps" }
room-common = { group = "androidx.room", name = "room-common", version.ref = "room" } room-common = { group = "androidx.room", name = "room-common", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }