chore(deps): migrate to maplibre-compose 0.18.0 (#7374)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-26 12:42:17 +00:00
1 parent 96a9a68d2e
commit 3057c9be26
9 files changed
+86 -47

No files matched your search

@@ -16,11 +16,11 @@
*/
package org.meshtastic.feature.map.maplibre
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.unit.dp
import org.maplibre.compose.camera.CameraAnimation
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraUpdate
import org.maplibre.compose.map.MapState
import org.maplibre.compose.util.DpPadding
import org.maplibre.spatialk.geojson.BoundingBox
/**
@@ -35,7 +35,7 @@ import org.maplibre.spatialk.geojson.BoundingBox
internal suspend fun MapState.zoomBy(delta: Double, range: ClosedFloatingPointRange<Float>) {
val target = (cameraPosition.zoom + delta).coerceIn(range.start.toDouble(), range.endInclusive.toDouble())
if (target != cameraPosition.zoom) {
animateCameraPosition(cameraPosition.copy(zoom = target), animation = CameraAnimation.Ease())
animateCamera(CameraUpdate(zoom = target), CameraAnimation.Ease())
}
}
@@ -46,9 +46,9 @@ internal const val ZOOM_STEP = 1.0
* Frames [bounds] without zooming past [FRAME_MAX_ZOOM]: the fit is computed first and capped before the camera moves,
* so nodes standing metres apart open on their surroundings rather than on empty tiles. Eased, not the default flight.
*/
internal suspend fun MapState.frameBounds(bounds: BoundingBox, padding: PaddingValues = PaddingValues(0.dp)) {
val fitted = cameraForBounds(bounds, padding = padding)
animateCameraPosition(fitted.cappedTo(FRAME_MAX_ZOOM), CameraAnimation.Ease())
internal suspend fun MapState.frameBounds(bounds: BoundingBox, padding: DpPadding = DpPadding.Zero) {
val fitted = cameraForBounds(bounds, fitPadding = padding)
animateCamera(fitted.cappedTo(FRAME_MAX_ZOOM).toCameraUpdate(), CameraAnimation.Ease())
}
/** This position, zoomed out to [maxZoom] if it is tighter than that. */
@@ -38,6 +38,7 @@ import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.maplibre.compose.camera.CameraAnimation
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraUpdate
import org.maplibre.compose.location.BearingUpdate
import org.maplibre.compose.location.LocationPermission
import org.maplibre.compose.location.LocationState
@@ -343,12 +344,7 @@ private fun SitePlannerSlot(
nodeNum = nodeNum,
mapCenter = { mapState.cameraPosition.target },
moveTo = { target ->
scope.launch {
mapState.animateCameraPosition(
mapState.cameraPosition.copy(target = target),
animation = CameraAnimation.Ease(),
)
}
scope.launch { mapState.animateCamera(CameraUpdate(target = target), CameraAnimation.Ease()) }
},
onDismiss = onDismiss,
),
@@ -395,12 +391,7 @@ private fun BoxScope.MapToolbar(
if (location.following) {
location.onToggleBearingLock()
} else {
scope.launch {
mapState.animateCameraPosition(
mapState.cameraPosition.copy(bearing = 0.0),
animation = CameraAnimation.Ease(),
)
}
scope.launch { mapState.animateCamera(CameraUpdate(bearing = 0.0), CameraAnimation.Ease()) }
}
},
filterDropdownContent = {
@@ -16,6 +16,7 @@
*/
package org.meshtastic.feature.map.maplibre
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -30,18 +31,19 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import org.maplibre.compose.camera.CameraAnimation
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraUpdate
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.interaction.ClickResult
import org.maplibre.compose.interaction.MapInteractions
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.location.BearingUpdate
import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationState
import org.maplibre.compose.location.LocationTrackingEffect
import org.maplibre.compose.location.updateCamera
@@ -51,7 +53,8 @@ import org.maplibre.compose.map.LocalViewport
import org.maplibre.compose.map.MapState
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.rememberMapState
import org.maplibre.compose.material3.LocationPuckDefaults
import org.maplibre.compose.material3.LocationIndicatorDefaults
import org.maplibre.compose.material3.LocationIndicatorLayer
import org.maplibre.compose.overlay.include
import org.maplibre.compose.util.MaplibreComposable
import org.maplibre.spatialk.geojson.BoundingBox
@@ -81,6 +84,8 @@ import org.meshtastic.feature.map.maplibre.style.MapOverlay
import org.meshtastic.feature.map.maplibre.style.toBaseStyle
import org.meshtastic.feature.map.maplibre.style.zoomRange
import kotlin.math.floor
import kotlin.time.Duration.Companion.seconds
import org.maplibre.compose.layers.LocationIndicatorDefaults as CoreLocationIndicatorDefaults
/**
* Everything the mesh map draws, as one [MapState].
@@ -280,9 +285,9 @@ private fun MeshMapNodeLayers(
val current = mapState.cameraPosition
// A cluster that cannot report an expansion zoom answers with a sentinel (0 on
// Android and desktop, -1 on iOS), so clamp — never zoom out on a tap.
mapState.animateCameraPosition(
current.copy(target = centre, zoom = maxOf(expansionZoom, current.zoom)),
animation = CameraAnimation.Ease(),
mapState.animateCamera(
CameraUpdate(target = centre, zoom = maxOf(expansionZoom, current.zoom)),
CameraAnimation.Ease(),
)
}
},
@@ -342,12 +347,41 @@ private fun FollowUserLocation(
@Composable
@MaplibreComposable
private fun UserLocationPuck(locationState: LocationState?, visible: Boolean) {
if (locationState == null || !visible) return
if (locationState == null) return
// The state overload, which resolves the latest measurement and its most accurate bearing itself.
LocationPuck(idPrefix = "user-location", locationState = locationState, colors = LocationPuckDefaults.colors())
// The indicator has no notion of age, so a fix that stops arriving is greyed and loses its accuracy ring here,
// or an old position reads as a current one.
val mark = locationState.lastLocationMeasurementMark
var stale by remember(mark) { mutableStateOf(mark != null && mark.elapsedNow() > STALE_LOCATION_AFTER) }
LaunchedEffect(mark) {
if (mark == null || stale) return@LaunchedEffect
delay(STALE_LOCATION_AFTER - mark.elapsedNow())
stale = true
}
val colors = MaterialTheme.colorScheme
// The state overload, which resolves the latest measurement and its most accurate bearing itself. Hidden rather
// than unmounted when tracking stops, since layer additions are queued and a quick toggle could lose the re-add.
LocationIndicatorLayer(
id = "user-location",
locationState = locationState,
visible = visible,
accuracyRadiusColor = if (stale) Color.Transparent else colors.primary.copy(alpha = ACCURACY_FILL_ALPHA),
accuracyRadiusBorderColor = if (stale) Color.Transparent else colors.primary,
topImage =
if (stale) {
CoreLocationIndicatorDefaults.topImage(colors.surfaceDim, colors.onPrimary)
} else {
LocationIndicatorDefaults.topImage()
},
)
}
/** How long a location fix counts as current, the threshold the library's own puck used before 0.18. */
private val STALE_LOCATION_AFTER = 30.seconds
private const val ACCURACY_FILL_ALPHA = 0.15f
/**
* The first corner tapped while authoring a geofence box.
*
@@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.interaction.MapInteractions
@@ -34,6 +35,7 @@ import org.maplibre.compose.map.MapState
import org.maplibre.compose.map.MapUiOptions
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.rememberMapState
import org.maplibre.compose.util.DpPadding
import org.maplibre.spatialk.geojson.BoundingBox
import org.meshtastic.feature.map.component.MapEngineUnavailable
import org.meshtastic.feature.map.maplibre.component.BasemapSelection
@@ -153,8 +155,18 @@ internal fun FitBoundsOnceVisible(
// is cancelled by user input as well as by [key]: a fit lost that way is not retried until [key] changes
// again, which for these maps may be never.
val hasViewport = mapState.viewport != null
// MapLibre takes physical edges; resolving start/end here keeps the wide trailing inset on the zoom pair's side
// in RTL too.
val layoutDirection = LocalLayoutDirection.current
val fitPadding =
DpPadding(
left = padding.calculateLeftPadding(layoutDirection),
top = padding.calculateTopPadding(),
right = padding.calculateRightPadding(layoutDirection),
bottom = padding.calculateBottomPadding(),
)
LaunchedEffect(key, hasViewport) {
if (!hasViewport) return@LaunchedEffect
currentBounds()?.let { mapState.frameBounds(it, padding = padding) }
currentBounds()?.let { mapState.frameBounds(it, padding = fitPadding) }
}
}
@@ -42,6 +42,7 @@ import kotlinx.serialization.json.put
import org.jetbrains.compose.resources.stringResource
import org.maplibre.compose.camera.CameraAnimation
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraUpdate
import org.maplibre.compose.expressions.dsl.asBoolean
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.feature
@@ -117,10 +118,7 @@ fun MapLibreInlineMap(
// first composition does not animate to where the camera already is.
LaunchedEffect(target) {
if (mapState.cameraPosition.target != target) {
mapState.animateCameraPosition(
mapState.cameraPosition.copy(target = target),
animation = CameraAnimation.Ease(),
)
mapState.animateCamera(CameraUpdate(target = target), CameraAnimation.Ease())
}
}
@@ -27,7 +27,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import org.maplibre.compose.map.LocalMapState
import org.maplibre.compose.overlay.DisappearingScaleBar
import org.maplibre.compose.overlay.LocalCameraPadding
import org.maplibre.compose.overlay.LocalViewportInsets
import org.maplibre.compose.overlay.include
import org.maplibre.compose.overlay.MapOverlay as MaplibreOverlay
@@ -46,21 +46,21 @@ import org.maplibre.compose.overlay.MapOverlay as MaplibreOverlay
*/
internal val MeshMapOrnaments: MaplibreOverlay = MaplibreOverlay {
val mapState = checkNotNull(LocalMapState.current)
val cameraPadding = LocalCameraPadding.current
val viewportInsets = LocalViewportInsets.current
// A custom overlay fills the map and the library keeps its own inset helper internal, so the scale bar has to
// carry the camera padding, safe-area insets and edge margin that the built-in controls apply for themselves.
// carry the viewport insets, safe-area insets and edge margin that the built-in controls apply for themselves.
Box(
modifier =
Modifier.fillMaxSize()
.padding(cameraPadding)
.consumeWindowInsets(cameraPadding)
.padding(viewportInsets)
.consumeWindowInsets(viewportInsets)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(MaplibreOverlay.Spacing),
) {
DisappearingScaleBar(
metersPerDp = mapState.viewport?.metersPerDpAtTarget ?: 0.0,
zoom = mapState.cameraPosition.zoom,
metersPerDp = { mapState.viewport?.metersPerDpAtTarget ?: 0.0 },
zoom = { mapState.cameraPosition.zoom },
modifier = Modifier.align(Alignment.TopStart),
)
}
@@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import kotlinx.coroutines.launch
import org.maplibre.compose.camera.CameraAnimation
import org.maplibre.compose.camera.CameraUpdate
import org.maplibre.compose.map.MapState
import org.meshtastic.feature.map.component.MapControlsOverlay
@@ -54,12 +55,7 @@ internal fun SecondaryMapControls(
filterDropdownContent = { filterMenu?.invoke(filterMenuExpanded) { filterMenuExpanded = false } },
bearing = mapState.cameraPosition.bearing.toFloat(),
onCompassClick = {
scope.launch {
mapState.animateCameraPosition(
mapState.cameraPosition.copy(bearing = 0.0),
animation = CameraAnimation.Ease(),
)
}
scope.launch { mapState.animateCamera(CameraUpdate(bearing = 0.0), CameraAnimation.Ease()) }
},
mapTypeContent = { BasemapButton(selection = basemaps) },
onToggleLocationTracking = null,
+1 -1
View File
@@ -71,7 +71,7 @@ jetbrains-adaptive = "1.3.0-rc01"
# 2.8.0–2.8.1). KML is now read by the app's own xmlutil-based converter and that parser is unused, so
# the pairing — and the canary test that guarded it — is gone. The floor stays as a plain minimum.
android-maps-utils = "5.2.0"
maplibre-compose = "0.17.0"
maplibre-compose = "0.18.0"
maps-compose = "8.6.0"
# ML Kit
@@ -24,6 +24,7 @@ import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.text.font.createFontFamilyResolver
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
@@ -39,6 +40,7 @@ import org.maplibre.spatialk.geojson.Position
import org.meshtastic.feature.map.maplibre.layers.NodeLayers
import org.meshtastic.feature.map.maplibre.style.Basemaps
import java.nio.file.Files
import java.util.concurrent.Executors
/** One capture: the map area in dp at the screen density, and the zoom the form factor asks for. */
internal data class MapArea(val widthDp: Int, val heightDp: Int, val density: Float, val zoom: Double)
@@ -52,6 +54,12 @@ internal object MapSnapshot {
private const val CAPTURE_TIMEOUT_MS = 180_000L
private const val MAX_SETTLE_RUNTIMES = 4
// The generator is headless and has no Dispatchers.Main, so the runtime owns this thread instead, and every map
// state call runs on it because map state rejects any other thread. Daemon, so it never holds the JVM open.
private val mapThread =
Executors.newSingleThreadExecutor { Thread(it, "maplibre-main").apply { isDaemon = true } }
.asCoroutineDispatcher()
fun capture(areas: List<MapArea>, mesh: SampleMesh): Map<MapArea, ImageBitmap> =
areas.associateWith { area -> captureUntilStable(area, mesh) }
@@ -62,7 +70,7 @@ internal object MapSnapshot {
* shared tile cache: the first fills the cache from the network, the ones after it read the cache in a stable
* order, and two consecutive runtimes agreeing means the run is reproducible. A warm runtime costs about a second.
*/
private fun captureUntilStable(area: MapArea, mesh: SampleMesh): ImageBitmap = runBlocking {
private fun captureUntilStable(area: MapArea, mesh: SampleMesh): ImageBitmap = runBlocking(mapThread) {
val cacheDir = Files.createTempDirectory("marketing-maplibre")
val cacheFile = Path(cacheDir.resolve("cache.db").toString())
try {
@@ -82,7 +90,7 @@ internal object MapSnapshot {
}
private suspend fun captureOnce(cacheFile: Path, area: MapArea, mesh: SampleMesh): ImageBitmap {
val runtime = createMapRuntime(MapRuntimeOptions(cacheFile = cacheFile))
val runtime = createMapRuntime(MapRuntimeOptions(cacheFile = cacheFile, mainDispatcher = mapThread))
return try {
withTimeout(CAPTURE_TIMEOUT_MS) { runtime.capture(area, mesh) }
} finally {