Merge pull request #7718 from element-hq/renovate/maplibre.compose

Update maplibre.compose to v0.16.0
This commit is contained in:
ganfra
2026-09-29 10:50:24 +02:00
committed by GitHub
36 changed files with 332 additions and 238 deletions
@@ -10,20 +10,12 @@ package io.element.android.features.location.impl.common
import android.Manifest import android.Manifest
import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.map.GestureOptions
import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.RenderOptions
import org.maplibre.spatialk.geojson.Position import org.maplibre.spatialk.geojson.Position
/** /**
* Common configuration values for the map. * Common configuration values for the map.
*/ */
object MapDefaults { object MapDefaults {
val options = MapOptions(
renderOptions = RenderOptions.Standard,
gestureOptions = GestureOptions.Standard,
)
val defaultCameraPosition = CameraPosition( val defaultCameraPosition = CameraPosition(
target = Position(0.0, 0.0), target = Position(0.0, 0.0),
zoom = 0.0, zoom = 0.0,
@@ -24,13 +24,13 @@ import org.maplibre.compose.expressions.dsl.feature
import org.maplibre.compose.expressions.dsl.image import org.maplibre.compose.expressions.dsl.image
import org.maplibre.compose.expressions.dsl.not import org.maplibre.compose.expressions.dsl.not
import org.maplibre.compose.expressions.value.SymbolAnchor import org.maplibre.compose.expressions.value.SymbolAnchor
import org.maplibre.compose.interaction.ClickResult
import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.layers.SymbolLayer
import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.GeoJsonOptions import org.maplibre.compose.sources.GeoJsonOptions
import org.maplibre.compose.sources.GeoJsonSource import org.maplibre.compose.sources.GeoJsonSource
import org.maplibre.compose.sources.rememberGeoJsonSource import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.util.ClickResult
import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Point import org.maplibre.spatialk.geojson.Point
@@ -7,6 +7,7 @@
package io.element.android.features.location.impl.common.ui package io.element.android.features.location.impl.common.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
@@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
@@ -41,27 +43,22 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment 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.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max import androidx.compose.ui.unit.max
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import org.maplibre.compose.camera.CameraState import org.maplibre.compose.interaction.MapInteractions
import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.map.MapState
import org.maplibre.compose.map.MapOptions import org.maplibre.compose.map.MapUiOptions
import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.RenderOptions
import org.maplibre.compose.overlay.ExpandingAttributionButton import org.maplibre.compose.overlay.ExpandingAttributionButton
import org.maplibre.compose.overlay.MapOverlay
import org.maplibre.compose.overlay.MaplibreLogo import org.maplibre.compose.overlay.MaplibreLogo
import org.maplibre.compose.style.BaseStyle
import org.maplibre.compose.util.MaplibreComposable
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -72,43 +69,45 @@ import kotlin.math.roundToInt
* - Updating camera position padding based on sheet height * - Updating camera position padding based on sheet height
* - Rendering the MaplibreMap with proper ornament positioning * - Rendering the MaplibreMap with proper ornament positioning
* *
* @param customMapTilerConfig Optional custom style URL for the map * @param mapState The map state (camera, style and layer content) for the map. `null` in
* inspection/preview mode, where the native map cannot initialize and a placeholder is shown.
* @param isReady Used to gate rendering until the map should be displayed
* @param modifier Modifier for the root layout * @param modifier Modifier for the root layout
* @param scaffoldState State for the bottom sheet scaffold * @param scaffoldState State for the bottom sheet scaffold
* @param cameraState The camera state for the map * @param renderOptions The options to configure the map rendering
* @param mapOptions The options to configure the map * @param interactions The gestures and camera interactions allowed on the map
* @param uiOptions The map UI options (gestures, scrolling, keys, first-frame behaviour)
* @param sheetPeekHeight The height of the sheet when collapsed * @param sheetPeekHeight The height of the sheet when collapsed
* @param sheetDragHandle Optional drag handle for the sheet * @param sheetDragHandle Optional drag handle for the sheet
* @param sheetSwipeEnabled Whether the sheet can be swiped * @param sheetSwipeEnabled Whether the sheet can be swiped
* @param topBar The top app bar content * @param topBar The top app bar content
* @param snackbarHost The snackbar host content * @param snackbarHost The snackbar host content
* @param sheetContent The content to display in the bottom sheet * @param sheetContent The content to display in the bottom sheet
* @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.) * @param overlay Content to overlay on top of the map (FAB, pin icons, etc.)
* @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.)
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MapBottomSheetScaffold( fun MapBottomSheetScaffold(
customMapTilerConfig: AsyncData<MapTilerConfig?>, mapState: MapState?,
isReady: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState( scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState( bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.PartiallyExpanded, initialValue = SheetValue.PartiallyExpanded,
) )
), ),
cameraState: CameraState = rememberCameraState(), renderOptions: RenderOptions = RenderOptions.Standard,
mapOptions: MapOptions = MapDefaults.options, interactions: MapInteractions = MapInteractions.Standard,
uiOptions: MapUiOptions = MapUiOptions.Standard,
sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight, sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
sheetSwipeEnabled: Boolean = true, sheetSwipeEnabled: Boolean = true,
topBar: (@Composable () -> Unit)? = null, topBar: (@Composable () -> Unit)? = null,
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
sheetContent: @Composable ColumnScope.(PaddingValues) -> Unit = {}, sheetContent: @Composable ColumnScope.() -> Unit = {},
mapContent: @Composable @MaplibreComposable () -> Unit = {}, overlay: @Composable BoxScope.() -> Unit = {},
overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {},
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) { BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) {
val layoutHeightPx by rememberUpdatedState(constraints.maxHeight) val layoutHeightPx by rememberUpdatedState(constraints.maxHeight)
@@ -126,7 +125,7 @@ fun MapBottomSheetScaffold(
sheetContent = { sheetContent = {
val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp() val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp()
Column(modifier = Modifier.heightIn(max = maxContentHeight)) { Column(modifier = Modifier.heightIn(max = maxContentHeight)) {
sheetContent(sheetPadding) sheetContent()
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
} }
}, },
@@ -136,39 +135,57 @@ fun MapBottomSheetScaffold(
snackbarHost = snackbarHost, snackbarHost = snackbarHost,
topBar = topBar, topBar = topBar,
) { ) {
Box { Box(modifier = Modifier.fillMaxSize()) {
when (customMapTilerConfig) { when {
is AsyncData.Success -> { isReady && mapState != null -> {
MaplibreMap( MaplibreMap(
options = mapOptions, state = mapState,
cameraPadding = sheetPadding, cameraPadding = sheetPadding,
contentWindowInsets = WindowInsets(bottom = sheetPadding.calculateBottomPadding()), renderOptions = renderOptions,
baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)), interactions = interactions,
overlay = MapOverlay { uiOptions = uiOptions,
val style = styleState overlay = {
Box(
modifier = Modifier
.fillMaxSize()
.padding(sheetPadding)
) {
Row( Row(
Modifier Modifier
.align(Alignment.BottomStart) .align(Alignment.BottomStart)
.fillMaxWidth(), .fillMaxWidth()
.padding(all = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
MaplibreLogo() MaplibreLogo()
ExpandingAttributionButton(cameraState = cameraState, styleState = style) ExpandingAttributionButton()
}
} }
}, },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
cameraState = cameraState,
content = mapContent,
) )
} }
mapState == null -> {
// Inspection/preview mode: the map cannot initialize, render a placeholder
// matching MaplibreMap's own inspection rendering.
Box(modifier = Modifier.fillMaxSize().background(Color.Gray))
}
else -> { else -> {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} }
} }
} }
overlayContent(sheetPadding) // App overlay (FABs, pins, live-location indicators). Layered above the map so it is
// present even in inspection/preview mode, where the map is a placeholder.
Box(
modifier = Modifier
.fillMaxSize()
.padding(sheetPadding)
) {
overlay()
}
} }
} }
} }
@@ -10,24 +10,23 @@ package io.element.android.features.location.impl.common.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import org.maplibre.compose.camera.CameraState import org.maplibre.compose.location.LocationMeasurement
import org.maplibre.compose.location.Location
import org.maplibre.compose.location.LocationPuck import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationPuckColors import org.maplibre.compose.location.LocationPuckColors
import org.maplibre.compose.location.LocationPuckSizes import org.maplibre.compose.location.LocationPuckSizes
import org.maplibre.spatialk.units.Length
@Composable @Composable
fun UserLocationPuck( fun UserLocationPuck(
cameraState: CameraState, location: LocationMeasurement?,
location: Location?,
) { ) {
LocationPuck( LocationPuck(
idPrefix = "user-location", idPrefix = "user-location",
location = location, location = location,
cameraState = cameraState, // Hide the accuracy circle.
accuracyThreshold = Float.POSITIVE_INFINITY, accuracyThreshold = Length.PositiveInfinity,
showBearingAccuracy = false, // Hide the bearing (and bearing accuracy) indicator.
showBearing = false, bearing = null,
sizes = LocationPuckSizes( sizes = LocationPuckSizes(
dotRadius = 8.dp, dotRadius = 8.dp,
dotStrokeWidth = 2.dp, dotStrokeWidth = 2.dp,
@@ -39,7 +39,7 @@ class DefaultUserLocationStateFactory : UserLocationState.Factory {
.updates(locationRequest) .updates(locationRequest)
.mapNotNull { event -> .mapNotNull { event ->
when (event) { when (event) {
is LocationEvent.Fix -> event.location is LocationEvent.Update -> event.measurement
is LocationEvent.Unavailable -> null is LocationEvent.Unavailable -> null
} }
} }
@@ -8,19 +8,18 @@
package io.element.android.features.location.impl.common.userlocation package io.element.android.features.location.impl.common.userlocation
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import org.maplibre.compose.location.PositionWithAccuracy import org.maplibre.compose.location.LocationMeasurement
import org.maplibre.spatialk.geojson.Position import org.maplibre.spatialk.geojson.Position
import org.maplibre.spatialk.units.extensions.meters import org.maplibre.spatialk.units.extensions.meters
import kotlin.time.TimeSource import kotlin.time.Clock
import org.maplibre.compose.location.Location as MapLibreLocation
fun Location.asMapLibreLocation(): MapLibreLocation { fun Location.toLocationMeasurement(): LocationMeasurement {
return MapLibreLocation( return LocationMeasurement(
position = PositionWithAccuracy( position = Position(
value = Position(latitude = lat, longitude = lon), latitude = lat,
accuracy = accuracy?.toDouble()?.meters longitude = lon,
), ),
// Not relevant as not used horizontalAccuracy = accuracy?.toDouble()?.meters,
timestamp = TimeSource.Monotonic.markNow(), measuredAt = Clock.System.now(),
) )
} }
@@ -37,7 +37,7 @@ import org.maplibre.compose.location.LocationEvent
import org.maplibre.compose.location.LocationProvider import org.maplibre.compose.location.LocationProvider
import org.maplibre.compose.location.LocationRequest import org.maplibre.compose.location.LocationRequest
import org.maplibre.compose.location.LocationUnavailableReason import org.maplibre.compose.location.LocationUnavailableReason
import org.maplibre.compose.location.asMapLibreLocation import org.maplibre.compose.location.asMapLibreLocationUpdate
import org.maplibre.spatialk.units.extensions.inMeters import org.maplibre.spatialk.units.extensions.inMeters
import java.util.ServiceLoader import java.util.ServiceLoader
@@ -75,7 +75,7 @@ class PlatformLocationProvider(
val locationManager = context.getSystemService(LocationManager::class.java) val locationManager = context.getSystemService(LocationManager::class.java)
val listener = object : LocationListenerCompat { val listener = object : LocationListenerCompat {
override fun onLocationChanged(location: Location) { override fun onLocationChanged(location: Location) {
trySend(LocationEvent.Fix(location.asMapLibreLocation())) trySend(location.asMapLibreLocationUpdate())
} }
override fun onProviderDisabled(provider: String) { override fun onProviderDisabled(provider: String) {
@@ -100,7 +100,7 @@ class PlatformLocationProvider(
return return
} }
locationManager.getLastKnownLocation(provider)?.let { location -> locationManager.getLastKnownLocation(provider)?.let { location ->
trySend(LocationEvent.Fix(location.asMapLibreLocation())) trySend(location.asMapLibreLocationUpdate())
} }
val locationRequestCompat = LocationRequestCompat.Builder(request.minimumInterval.inWholeMilliseconds) val locationRequestCompat = LocationRequestCompat.Builder(request.minimumInterval.inWholeMilliseconds)
.setQuality(request.accuracy.toLocationRequestQuality()) .setQuality(request.accuracy.toLocationRequestQuality())
@@ -9,10 +9,10 @@ package io.element.android.features.location.impl.common.userlocation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import org.maplibre.compose.location.Location import org.maplibre.compose.location.LocationMeasurement
@Immutable @Immutable
data class UserLocationState(val location: Location?) { data class UserLocationState(val location: LocationMeasurement?) {
fun interface Factory { fun interface Factory {
@Composable @Composable
fun create(hasLocationPermission: Boolean): UserLocationState fun create(hasLocationPermission: Boolean): UserLocationState
@@ -10,12 +10,15 @@ package io.element.android.features.location.impl.common.userlocation
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.MapDefaults
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import org.maplibre.compose.camera.CameraState import org.maplibre.compose.location.LocationMeasurement
import org.maplibre.compose.location.Location import org.maplibre.compose.map.MapState
import kotlin.math.abs import kotlin.math.abs
/** /**
@@ -27,7 +30,7 @@ internal fun UserLocationTrackingEffect(
locationState: UserLocationState, locationState: UserLocationState,
enabled: Boolean = true, enabled: Boolean = true,
precision: Double = 0.00001, precision: Double = 0.00001,
onLocationChange: suspend (Location?) -> Unit, onLocationChange: suspend (LocationMeasurement?) -> Unit,
) { ) {
val latestOnLocationChange by rememberUpdatedState(onLocationChange) val latestOnLocationChange by rememberUpdatedState(onLocationChange)
val latestLocationState by rememberUpdatedState(locationState) val latestLocationState by rememberUpdatedState(locationState)
@@ -38,8 +41,8 @@ internal fun UserLocationTrackingEffect(
.distinctUntilChanged { oldLocation, newLocation -> .distinctUntilChanged { oldLocation, newLocation ->
if (oldLocation != null && newLocation != null) { if (oldLocation != null && newLocation != null) {
when { when {
abs(oldLocation.position.value.latitude - newLocation.position.value.latitude) >= precision -> false abs(oldLocation.position.latitude - newLocation.position.latitude) >= precision -> false
abs(oldLocation.position.value.longitude - newLocation.position.value.longitude) >= precision -> false abs(oldLocation.position.longitude - newLocation.position.longitude) >= precision -> false
else -> true else -> true
} }
} else { } else {
@@ -54,24 +57,30 @@ internal fun UserLocationTrackingEffect(
@Composable @Composable
internal fun UserLocationTrackingEffect( internal fun UserLocationTrackingEffect(
cameraState: CameraState, mapState: MapState,
locationState: UserLocationState, locationState: UserLocationState,
enabled: Boolean = true, enabled: Boolean = true,
precision: Double = 0.00001, precision: Double = 0.00001,
) { ) {
// Tracks whether the camera has already been placed on the user location once.
var hasPositioned by remember(mapState) { mutableStateOf(false) }
UserLocationTrackingEffect( UserLocationTrackingEffect(
locationState = locationState, locationState = locationState,
enabled = enabled, enabled = enabled,
precision = precision precision = precision
) { location -> ) { location ->
val target = location?.position?.value ?: cameraState.position.target val target = location?.position ?: return@UserLocationTrackingEffect
cameraState.animateTo( val position = mapState.cameraPosition.copy(
cameraState.position.copy(
target = target, target = target,
// Force pointing to NORTH // Force pointing to NORTH
bearing = 0.0, bearing = 0.0,
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM) zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM)
)
) )
if (hasPositioned) {
mapState.animateCameraPosition(position)
} else {
mapState.setCameraPosition(position)
hasPositioned = true
}
} }
} }
@@ -102,11 +102,11 @@ class LiveLocationSharingService : Service() {
} }
.onEach { locationEvent -> .onEach { locationEvent ->
when (locationEvent) { when (locationEvent) {
is LocationEvent.Fix -> { is LocationEvent.Update -> {
val location = ApiLocation( val location = ApiLocation(
lat = locationEvent.location.position.value.latitude, lat = locationEvent.measurement.position.latitude,
lon = locationEvent.location.position.value.longitude, lon = locationEvent.measurement.position.longitude,
accuracy = locationEvent.location.position.accuracy?.inMeters?.toFloat(), accuracy = locationEvent.measurement.horizontalAccuracy?.inMeters?.toFloat(),
) )
coordinator.dispatch(location) coordinator.dispatch(location)
} }
@@ -115,7 +115,6 @@ class LiveLocationSharingService : Service() {
LocationUnavailableReason.ServicesDisabled, LocationUnavailableReason.ServicesDisabled,
LocationUnavailableReason.TemporarilyUnavailable -> Unit LocationUnavailableReason.TemporarilyUnavailable -> Unit
LocationUnavailableReason.Unsupported, LocationUnavailableReason.Unsupported,
LocationUnavailableReason.Misconfigured,
LocationUnavailableReason.PermissionDenied, LocationUnavailableReason.PermissionDenied,
LocationUnavailableReason.UnexpectedFailure -> coordinator.dispatchUnrecoverableError() LocationUnavailableReason.UnexpectedFailure -> coordinator.dispatchUnrecoverableError()
} }
@@ -13,7 +13,6 @@ package io.element.android.features.location.impl.share
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@@ -30,6 +29,7 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -37,6 +37,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.centerBottomEdge import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.R import io.element.android.features.location.impl.R
import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
@@ -67,11 +68,11 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraState import org.maplibre.compose.map.MapState
import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.map.rememberMapState
import org.maplibre.compose.style.BaseStyle
import kotlin.time.Duration import kotlin.time.Duration
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ShareLocationView( fun ShareLocationView(
state: ShareLocationState, state: ShareLocationState,
@@ -104,23 +105,84 @@ fun ShareLocationView(
) )
} }
if (LocalInspectionMode.current) {
// Inspection/preview mode: the native map runtime is unavailable (previews, screenshot
// tests, Robolectric), so no MapState is created and the scaffold renders a placeholder map.
ShareLocationScaffold(
state = state,
mapState = null,
navigateUp = navigateUp,
onSharePinLocation = {},
modifier = modifier,
)
} else {
ShareLocationViewContent(
state = state,
navigateUp = navigateUp,
modifier = modifier,
)
}
}
@Composable
private fun ShareLocationViewContent(
state: ShareLocationState,
navigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {
val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull())
val mapState = rememberMapState(
baseStyle = BaseStyle.Uri(tileStyleUrl),
initialCameraPosition = MapDefaults.defaultCameraPosition,
) {
UserLocationPuck(location = state.userLocationState.location)
}
UserLocationTrackingEffect(
mapState = mapState,
locationState = state.userLocationState,
enabled = state.trackUserLocation,
)
LaunchedEffect(mapState.isCameraMoving) {
if (mapState.cameraMoveReason == CameraMoveReason.GESTURE) {
state.eventSink(ShareLocationEvent.StopTrackingUserLocation)
}
}
ShareLocationScaffold(
state = state,
mapState = mapState,
navigateUp = navigateUp,
onSharePinLocation = {
val positionTarget = mapState.cameraPosition.target
state.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude),
isPinned = true
)
)
navigateUp()
},
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShareLocationScaffold(
mapState: MapState?,
state: ShareLocationState,
navigateUp: () -> Unit,
onSharePinLocation: () -> Unit,
modifier: Modifier = Modifier,
) {
val scaffoldState = rememberBottomSheetScaffoldState( val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState( bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.Expanded, initialValue = SheetValue.Expanded,
enabledValues = setOf(SheetValue.PartiallyExpanded, SheetValue.Expanded), enabledValues = setOf(SheetValue.PartiallyExpanded, SheetValue.Expanded),
) )
) )
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
LaunchedEffect(cameraState.isCameraMoving) {
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
state.eventSink(ShareLocationEvent.StopTrackingUserLocation)
}
}
MapBottomSheetScaffold( MapBottomSheetScaffold(
customMapTilerConfig = state.customMapTilerConfig, isReady = state.customMapTilerConfig.isReady(),
cameraState = cameraState, mapState = mapState,
modifier = modifier, modifier = modifier,
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
sheetDragHandle = null, sheetDragHandle = null,
@@ -135,28 +197,12 @@ fun ShareLocationView(
}, },
sheetContent = { sheetContent = {
BottomSheetContent( BottomSheetContent(
cameraState = cameraState,
state = state, state = state,
navigateUp = navigateUp navigateUp = navigateUp,
onSharePinLocation = onSharePinLocation,
) )
}, },
mapContent = { overlay = {
UserLocationTrackingEffect(
cameraState = cameraState,
locationState = state.userLocationState,
enabled = state.trackUserLocation,
)
UserLocationPuck(
cameraState = cameraState,
location = state.userLocationState.location,
)
},
overlayContent = { sheetPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(sheetPadding)
) {
val variant = if (state.trackUserLocation) { val variant = if (state.trackUserLocation) {
PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin)) PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin))
} else { } else {
@@ -166,7 +212,6 @@ fun ShareLocationView(
variant = variant, variant = variant,
modifier = Modifier.centerBottomEdge(this), modifier = Modifier.centerBottomEdge(this),
) )
}
LocationFloatingActionButton( LocationFloatingActionButton(
isMapCenteredOnUser = state.trackUserLocation, isMapCenteredOnUser = state.trackUserLocation,
onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserLocation) }, onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserLocation) },
@@ -217,9 +262,9 @@ private fun StartLiveLocationActionView(
@Composable @Composable
private fun BottomSheetContent( private fun BottomSheetContent(
cameraState: CameraState,
state: ShareLocationState, state: ShareLocationState,
navigateUp: () -> Unit, navigateUp: () -> Unit,
onSharePinLocation: () -> Unit,
) { ) {
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
val userLocation = state.userLocationState.location val userLocation = state.userLocationState.location
@@ -228,8 +273,8 @@ private fun BottomSheetContent(
state.eventSink( state.eventSink(
ShareLocationEvent.ShareStaticLocation( ShareLocationEvent.ShareStaticLocation(
location = Location( location = Location(
lat = userLocation.position.value.latitude, lat = userLocation.position.latitude,
lon = userLocation.position.value.longitude lon = userLocation.position.longitude
), ),
isPinned = false isPinned = false
) )
@@ -237,18 +282,7 @@ private fun BottomSheetContent(
navigateUp() navigateUp()
} }
} else { } else {
SharePinLocationItem( SharePinLocationItem(onClick = onSharePinLocation)
onClick = {
val positionTarget = cameraState.position.target
state.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude),
isPinned = true
)
)
navigateUp()
}
)
} }
if (state.canShareLiveLocation) { if (state.canShareLiveLocation) {
ShareLiveLocationItem { ShareLiveLocationItem {
@@ -38,7 +38,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.location.impl.common.toDialogState import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.features.location.impl.common.userlocation.asMapLibreLocation import io.element.android.features.location.impl.common.userlocation.toLocationMeasurement
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.coroutine.mapState
@@ -215,7 +215,7 @@ class ShowLocationPresenter(
val hideUserLocationPuck = mode is ShowLocationMode.Live && isCurrentlySharing val hideUserLocationPuck = mode is ShowLocationMode.Live && isCurrentlySharing
val userLocationState = if (hideUserLocationPuck) { val userLocationState = if (hideUserLocationPuck) {
// When sharing with this device, use the user LocationShareItem as source of data instead of the device. // When sharing with this device, use the user LocationShareItem as source of data instead of the device.
val ownLocationShare by remember { derivedStateOf { updatedLocationShares.find { it.isOwnUser }?.location?.asMapLibreLocation() } } val ownLocationShare by remember { derivedStateOf { updatedLocationShares.find { it.isOwnUser }?.location?.toLocationMeasurement() } }
UserLocationState(ownLocationShare) UserLocationState(ownLocationShare)
} else { } else {
userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted) userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted)
@@ -31,11 +31,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
@@ -54,10 +56,11 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState import org.maplibre.compose.map.MapState
import org.maplibre.compose.map.rememberMapState
import org.maplibre.compose.style.BaseStyle
import org.maplibre.spatialk.geojson.Position import org.maplibre.spatialk.geojson.Position
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ShowLocationView( fun ShowLocationView(
state: ShowLocationState, state: ShowLocationState,
@@ -73,7 +76,49 @@ fun ShowLocationView(
onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) }, onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) },
) )
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition) if (LocalInspectionMode.current) {
// Inspection/preview mode: the native map runtime is unavailable (previews, screenshot
// tests, Robolectric), so no MapState is created and the scaffold renders a placeholder map.
ShowLocationScaffold(
state = state,
mapState = null,
onBackClick = onBackClick,
onLocationShareClick = {},
modifier = modifier,
)
} else {
ShowLocationViewContent(
state = state,
onBackClick = onBackClick,
modifier = modifier,
)
}
}
@Composable
private fun ShowLocationViewContent(
state: ShowLocationState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull())
val mapState = rememberMapState(
baseStyle = BaseStyle.Uri(tileStyleUrl),
initialCameraPosition = MapDefaults.defaultCameraPosition,
) {
if (!state.hideUserLocationPuck) {
UserLocationPuck(location = state.userLocationState.location)
}
val markers = remember(state.locationShares) {
state.locationShares.map { it.toMarkerData() }.toImmutableList()
}
LocationPinMarkers(markers)
}
UserLocationTrackingEffect(
mapState = mapState,
locationState = state.userLocationState,
enabled = state.isTrackMyLocation,
)
var hasAnimatedToFocusedLocation by remember { mutableStateOf(false) } var hasAnimatedToFocusedLocation by remember { mutableStateOf(false) }
LaunchedEffect(state.focusedLocation) { LaunchedEffect(state.focusedLocation) {
if (state.focusedLocation != null && !hasAnimatedToFocusedLocation) { if (state.focusedLocation != null && !hasAnimatedToFocusedLocation) {
@@ -82,15 +127,44 @@ fun ShowLocationView(
target = Position(latitude = state.focusedLocation.location.lat, longitude = state.focusedLocation.location.lon), target = Position(latitude = state.focusedLocation.location.lat, longitude = state.focusedLocation.location.lon),
zoom = MapDefaults.DEFAULT_ZOOM zoom = MapDefaults.DEFAULT_ZOOM
) )
cameraState.position = position mapState.setCameraPosition(position)
} }
} }
LaunchedEffect(cameraState.isCameraMoving) { LaunchedEffect(mapState.isCameraMoving) {
if (cameraState.moveReason == CameraMoveReason.GESTURE) { if (mapState.cameraMoveReason == CameraMoveReason.GESTURE) {
state.eventSink(ShowLocationEvent.TrackMyLocation(false)) state.eventSink(ShowLocationEvent.TrackMyLocation(false))
} }
} }
val coroutineScope = rememberCoroutineScope()
ShowLocationScaffold(
state = state,
mapState = mapState,
onBackClick = onBackClick,
onLocationShareClick = { locationShare ->
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
val position = CameraPosition(
target = Position(locationShare.location.lon, locationShare.location.lat),
// Force pointing to NORTH
bearing = 0.0,
zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM),
)
coroutineScope.launch {
mapState.animateCameraPosition(position = position)
}
},
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShowLocationScaffold(
state: ShowLocationState,
mapState: MapState?,
onBackClick: () -> Unit,
onLocationShareClick: (LocationShareItem) -> Unit,
modifier: Modifier = Modifier,
) {
val scaffoldState = rememberBottomSheetScaffoldState( val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState( bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.Expanded, initialValue = SheetValue.Expanded,
@@ -103,7 +177,7 @@ fun ShowLocationView(
} }
} }
MapBottomSheetScaffold( MapBottomSheetScaffold(
customMapTilerConfig = state.customMapTilerConfig, isReady = state.customMapTilerConfig.isReady(),
sheetDragHandle = if (state.isSheetDraggable) { sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() } { BottomSheetDefaults.DragHandle() }
} else { } else {
@@ -111,7 +185,7 @@ fun ShowLocationView(
}, },
sheetSwipeEnabled = state.isSheetDraggable, sheetSwipeEnabled = state.isSheetDraggable,
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
cameraState = cameraState, mapState = mapState,
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -123,8 +197,7 @@ fun ShowLocationView(
}, },
) )
}, },
sheetContent = { sheetPaddings -> sheetContent = {
val coroutineScope = rememberCoroutineScope()
if (!state.isSheetDraggable) { if (!state.isSheetDraggable) {
// If sheet is draggable the DragHandle has already some padding // If sheet is draggable the DragHandle has already some padding
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
@@ -152,41 +225,13 @@ fun ShowLocationView(
item = locationShare, item = locationShare,
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) }, onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) },
modifier = Modifier.clickable { modifier = Modifier.clickable { onLocationShareClick(locationShare) }
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
val position = CameraPosition(
target = Position(locationShare.location.lon, locationShare.location.lat),
// Force pointing to NORTH
bearing = 0.0,
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM),
)
coroutineScope.launch {
cameraState.animateTo(finalPosition = position)
}
}
) )
} }
} }
} }
}, },
mapContent = { overlay = {
UserLocationTrackingEffect(
cameraState = cameraState,
locationState = state.userLocationState,
enabled = state.isTrackMyLocation,
)
if (!state.hideUserLocationPuck) {
UserLocationPuck(
cameraState = cameraState,
location = state.userLocationState.location,
)
}
val markers = remember(state.locationShares) {
state.locationShares.map { it.toMarkerData() }.toImmutableList()
}
LocationPinMarkers(markers)
},
overlayContent = {
LocationFloatingActionButton( LocationFloatingActionButton(
isMapCenteredOnUser = state.isTrackMyLocation, isMapCenteredOnUser = state.isTrackMyLocation,
onClick = { state.eventSink(ShowLocationEvent.TrackMyLocation(true)) }, onClick = { state.eventSink(ShowLocationEvent.TrackMyLocation(true)) },
@@ -510,8 +510,8 @@ class ShowLocationPresenterTest {
val state = awaitItem() val state = awaitItem()
assertThat(state.hideUserLocationPuck).isTrue() assertThat(state.hideUserLocationPuck).isTrue()
val location = requireNotNull(state.userLocationState.location) val location = requireNotNull(state.userLocationState.location)
assertThat(location.position.value.latitude).isEqualTo(ownLocation?.lat) assertThat(location.position.latitude).isEqualTo(ownLocation?.lat)
assertThat(location.position.value.longitude).isEqualTo(ownLocation?.lon) assertThat(location.position.longitude).isEqualTo(ownLocation?.lon)
} }
} }
} }
+1 -1
View File
@@ -45,7 +45,7 @@ appyx = "1.7.1"
sqldelight = "2.3.2" sqldelight = "2.3.2"
wysiwyg = "2.42.1" wysiwyg = "2.42.1"
telephoto = "0.19.0" telephoto = "0.19.0"
maplibre-compose = "0.15.0" maplibre-compose = "0.17.0"
# Dependency analysis # Dependency analysis
dependencyAnalysis = "3.19.2" dependencyAnalysis = "3.19.2"