diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt index 60e53b4f2b..993e5c9871 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt @@ -10,20 +10,12 @@ package io.element.android.features.location.impl.common import android.Manifest 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 /** * Common configuration values for the map. */ object MapDefaults { - val options = MapOptions( - renderOptions = RenderOptions.Standard, - gestureOptions = GestureOptions.Standard, - ) - val defaultCameraPosition = CameraPosition( target = Position(0.0, 0.0), zoom = 0.0, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt index e30dfd53de..dd380dca83 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt @@ -24,13 +24,13 @@ import org.maplibre.compose.expressions.dsl.feature import org.maplibre.compose.expressions.dsl.image import org.maplibre.compose.expressions.dsl.not import org.maplibre.compose.expressions.value.SymbolAnchor +import org.maplibre.compose.interaction.ClickResult import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.sources.GeoJsonData import org.maplibre.compose.sources.GeoJsonOptions import org.maplibre.compose.sources.GeoJsonSource import org.maplibre.compose.sources.rememberGeoJsonSource -import org.maplibre.compose.util.ClickResult import org.maplibre.spatialk.geojson.Feature import org.maplibre.spatialk.geojson.FeatureCollection import org.maplibre.spatialk.geojson.Point diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt index 983e9fc029..b8a789039d 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -7,6 +7,7 @@ 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.Box 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.navigationBars import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding @@ -41,27 +43,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp 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.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import org.maplibre.compose.camera.CameraState -import org.maplibre.compose.camera.rememberCameraState -import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.interaction.MapInteractions +import org.maplibre.compose.map.MapState +import org.maplibre.compose.map.MapUiOptions import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.map.RenderOptions import org.maplibre.compose.overlay.ExpandingAttributionButton -import org.maplibre.compose.overlay.MapOverlay import org.maplibre.compose.overlay.MaplibreLogo -import org.maplibre.compose.style.BaseStyle -import org.maplibre.compose.util.MaplibreComposable import kotlin.math.roundToInt /** @@ -72,43 +69,43 @@ import kotlin.math.roundToInt * - Updating camera position padding based on sheet height * - Rendering the MaplibreMap with proper ornament positioning * - * @param customMapTilerConfig Optional custom style URL for the map + * @param isReady Used to gate rendering until the map should be displayed * @param modifier Modifier for the root layout * @param scaffoldState State for the bottom sheet scaffold - * @param cameraState The camera state for the map - * @param mapOptions The options to configure the map + * @param mapState The map state (camera, style and layer content) for the map, created with + * [rememberLocationMapState]. `null` in inspection/preview mode, where a placeholder is shown. + * @param renderOptions The options to configure the map rendering * @param sheetPeekHeight The height of the sheet when collapsed * @param sheetDragHandle Optional drag handle for the sheet * @param sheetSwipeEnabled Whether the sheet can be swiped * @param topBar The top app bar content * @param snackbarHost The snackbar host content * @param sheetContent The content to display in the bottom sheet - * @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.) - * @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.) + * @param overlay Content to overlay on top of the map (FAB, pin icons, etc.) */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun MapBottomSheetScaffold( - customMapTilerConfig: AsyncData, + mapState: MapState?, + isReady: Boolean, modifier: Modifier = Modifier, scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberBottomSheetState( initialValue = SheetValue.PartiallyExpanded, ) ), - cameraState: CameraState = rememberCameraState(), - mapOptions: MapOptions = MapDefaults.options, + renderOptions: RenderOptions = RenderOptions.Standard, + interactions: MapInteractions = MapInteractions.Standard, + uiOptions: MapUiOptions = MapUiOptions.Standard, sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight, sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() }, sheetSwipeEnabled: Boolean = true, topBar: (@Composable () -> Unit)? = null, snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }, - sheetContent: @Composable ColumnScope.(PaddingValues) -> Unit = {}, - mapContent: @Composable @MaplibreComposable () -> Unit = {}, - overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {}, + sheetContent: @Composable ColumnScope.() -> Unit = {}, + overlay: @Composable BoxScope.() -> Unit = {}, ) { val density = LocalDensity.current - val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) { val layoutHeightPx by rememberUpdatedState(constraints.maxHeight) @@ -126,7 +123,7 @@ fun MapBottomSheetScaffold( sheetContent = { val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp() Column(modifier = Modifier.heightIn(max = maxContentHeight)) { - sheetContent(sheetPadding) + sheetContent() Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) } }, @@ -136,39 +133,57 @@ fun MapBottomSheetScaffold( snackbarHost = snackbarHost, topBar = topBar, ) { - Box { - when (customMapTilerConfig) { - is AsyncData.Success -> { + Box(modifier = Modifier.fillMaxSize()) { + when { + isReady && mapState != null -> { MaplibreMap( - options = mapOptions, + state = mapState, cameraPadding = sheetPadding, - contentWindowInsets = WindowInsets(bottom = sheetPadding.calculateBottomPadding()), - baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)), - overlay = MapOverlay { - val style = styleState - Row( - Modifier - .align(Alignment.BottomStart) - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + renderOptions = renderOptions, + interactions = interactions, + uiOptions = uiOptions, + overlay = { + Box( + modifier = Modifier + .fillMaxSize() + .padding(sheetPadding) ) { - MaplibreLogo() - ExpandingAttributionButton(cameraState = cameraState, styleState = style) + Row( + Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(all = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + MaplibreLogo() + ExpandingAttributionButton() + } } }, 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 -> { Box(modifier = Modifier.fillMaxSize()) { 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() + } } } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt index 074404a4cc..aecb6f12b8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt @@ -10,24 +10,23 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import org.maplibre.compose.camera.CameraState -import org.maplibre.compose.location.Location +import org.maplibre.compose.location.LocationMeasurement import org.maplibre.compose.location.LocationPuck import org.maplibre.compose.location.LocationPuckColors import org.maplibre.compose.location.LocationPuckSizes +import org.maplibre.spatialk.units.Length @Composable fun UserLocationPuck( - cameraState: CameraState, - location: Location?, + location: LocationMeasurement?, ) { LocationPuck( idPrefix = "user-location", location = location, - cameraState = cameraState, - accuracyThreshold = Float.POSITIVE_INFINITY, - showBearingAccuracy = false, - showBearing = false, + // Hide the accuracy circle. + accuracyThreshold = Length.PositiveInfinity, + // Hide the bearing (and bearing accuracy) indicator. + bearing = null, sizes = LocationPuckSizes( dotRadius = 8.dp, dotStrokeWidth = 2.dp, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt index 083cd9dd45..ded16bc79b 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/DefaultUserLocationStateFactory.kt @@ -39,7 +39,7 @@ class DefaultUserLocationStateFactory : UserLocationState.Factory { .updates(locationRequest) .mapNotNull { event -> when (event) { - is LocationEvent.Fix -> event.location + is LocationEvent.Update -> event.measurement is LocationEvent.Unavailable -> null } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt index 9777e14af5..7b4ed5d914 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/Location.kt @@ -8,19 +8,18 @@ package io.element.android.features.location.impl.common.userlocation 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.units.extensions.meters -import kotlin.time.TimeSource -import org.maplibre.compose.location.Location as MapLibreLocation +import kotlin.time.Clock -fun Location.asMapLibreLocation(): MapLibreLocation { - return MapLibreLocation( - position = PositionWithAccuracy( - value = Position(latitude = lat, longitude = lon), - accuracy = accuracy?.toDouble()?.meters +fun Location.toLocationMeasurement(): LocationMeasurement { + return LocationMeasurement( + position = Position( + latitude = lat, + longitude = lon, ), - // Not relevant as not used - timestamp = TimeSource.Monotonic.markNow(), + horizontalAccuracy = accuracy?.toDouble()?.meters, + measuredAt = Clock.System.now(), ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt index 0251f03238..bd300ea82c 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/LocationProvider.kt @@ -37,7 +37,7 @@ import org.maplibre.compose.location.LocationEvent import org.maplibre.compose.location.LocationProvider import org.maplibre.compose.location.LocationRequest 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 java.util.ServiceLoader @@ -75,7 +75,7 @@ class PlatformLocationProvider( val locationManager = context.getSystemService(LocationManager::class.java) val listener = object : LocationListenerCompat { override fun onLocationChanged(location: Location) { - trySend(LocationEvent.Fix(location.asMapLibreLocation())) + trySend(location.asMapLibreLocationUpdate()) } override fun onProviderDisabled(provider: String) { @@ -100,7 +100,7 @@ class PlatformLocationProvider( return } locationManager.getLastKnownLocation(provider)?.let { location -> - trySend(LocationEvent.Fix(location.asMapLibreLocation())) + trySend(location.asMapLibreLocationUpdate()) } val locationRequestCompat = LocationRequestCompat.Builder(request.minimumInterval.inWholeMilliseconds) .setQuality(request.accuracy.toLocationRequestQuality()) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt index 250ad536b7..938570efd4 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationState.kt @@ -9,10 +9,10 @@ package io.element.android.features.location.impl.common.userlocation import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import org.maplibre.compose.location.Location +import org.maplibre.compose.location.LocationMeasurement @Immutable -data class UserLocationState(val location: Location?) { +data class UserLocationState(val location: LocationMeasurement?) { fun interface Factory { @Composable fun create(hasLocationPermission: Boolean): UserLocationState diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt index ca6c10b7fa..c5760c3a95 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/userlocation/UserLocationTrackingEffect.kt @@ -10,12 +10,15 @@ package io.element.android.features.location.impl.common.userlocation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import io.element.android.features.location.impl.common.MapDefaults import kotlinx.coroutines.flow.distinctUntilChanged -import org.maplibre.compose.camera.CameraState -import org.maplibre.compose.location.Location +import org.maplibre.compose.location.LocationMeasurement +import org.maplibre.compose.map.MapState import kotlin.math.abs /** @@ -27,7 +30,7 @@ internal fun UserLocationTrackingEffect( locationState: UserLocationState, enabled: Boolean = true, precision: Double = 0.00001, - onLocationChange: suspend (Location?) -> Unit, + onLocationChange: suspend (LocationMeasurement?) -> Unit, ) { val latestOnLocationChange by rememberUpdatedState(onLocationChange) val latestLocationState by rememberUpdatedState(locationState) @@ -38,8 +41,8 @@ internal fun UserLocationTrackingEffect( .distinctUntilChanged { oldLocation, newLocation -> if (oldLocation != null && newLocation != null) { when { - abs(oldLocation.position.value.latitude - newLocation.position.value.latitude) >= precision -> false - abs(oldLocation.position.value.longitude - newLocation.position.value.longitude) >= precision -> false + abs(oldLocation.position.latitude - newLocation.position.latitude) >= precision -> false + abs(oldLocation.position.longitude - newLocation.position.longitude) >= precision -> false else -> true } } else { @@ -54,24 +57,30 @@ internal fun UserLocationTrackingEffect( @Composable internal fun UserLocationTrackingEffect( - cameraState: CameraState, + mapState: MapState, locationState: UserLocationState, enabled: Boolean = true, 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( locationState = locationState, enabled = enabled, precision = precision ) { location -> - val target = location?.position?.value ?: cameraState.position.target - cameraState.animateTo( - cameraState.position.copy( - target = target, - // Force pointing to NORTH - bearing = 0.0, - zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM) - ) + val target = location?.position ?: return@UserLocationTrackingEffect + val position = mapState.cameraPosition.copy( + target = target, + // Force pointing to NORTH + bearing = 0.0, + zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM) ) + if (hasPositioned) { + mapState.animateCameraPosition(position) + } else { + mapState.setCameraPosition(position) + hasPositioned = true + } } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt index d8868021dc..ff09cca6e8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/live/service/LiveLocationSharingService.kt @@ -102,11 +102,11 @@ class LiveLocationSharingService : Service() { } .onEach { locationEvent -> when (locationEvent) { - is LocationEvent.Fix -> { + is LocationEvent.Update -> { val location = ApiLocation( - lat = locationEvent.location.position.value.latitude, - lon = locationEvent.location.position.value.longitude, - accuracy = locationEvent.location.position.accuracy?.inMeters?.toFloat(), + lat = locationEvent.measurement.position.latitude, + lon = locationEvent.measurement.position.longitude, + accuracy = locationEvent.measurement.horizontalAccuracy?.inMeters?.toFloat(), ) coordinator.dispatch(location) } @@ -115,7 +115,6 @@ class LiveLocationSharingService : Service() { LocationUnavailableReason.ServicesDisabled, LocationUnavailableReason.TemporarilyUnavailable -> Unit LocationUnavailableReason.Unsupported, - LocationUnavailableReason.Misconfigured, LocationUnavailableReason.PermissionDenied, LocationUnavailableReason.UnexpectedFailure -> coordinator.dispatchUnrecoverableError() } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 4151b5fce3..6baa7bf3d2 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -13,7 +13,6 @@ package io.element.android.features.location.impl.share import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.itemsIndexed @@ -30,6 +29,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter 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.features.location.api.Location 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.common.MapDefaults 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 kotlinx.collections.immutable.ImmutableList import org.maplibre.compose.camera.CameraMoveReason -import org.maplibre.compose.camera.CameraState -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 kotlin.time.Duration -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ShareLocationView( 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( bottomSheetState = rememberBottomSheetState( initialValue = 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( - customMapTilerConfig = state.customMapTilerConfig, - cameraState = cameraState, + isReady = state.customMapTilerConfig.isReady(), + mapState = mapState, modifier = modifier, scaffoldState = scaffoldState, sheetDragHandle = null, @@ -135,38 +197,21 @@ fun ShareLocationView( }, sheetContent = { BottomSheetContent( - cameraState = cameraState, state = state, - navigateUp = navigateUp + navigateUp = navigateUp, + onSharePinLocation = onSharePinLocation, ) }, - mapContent = { - 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) { - PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin)) - } else { - PinVariant.PinnedLocation - } - LocationPin( - variant = variant, - modifier = Modifier.centerBottomEdge(this), - ) + overlay = { + val variant = if (state.trackUserLocation) { + PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin)) + } else { + PinVariant.PinnedLocation } + LocationPin( + variant = variant, + modifier = Modifier.centerBottomEdge(this), + ) LocationFloatingActionButton( isMapCenteredOnUser = state.trackUserLocation, onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserLocation) }, @@ -217,9 +262,9 @@ private fun StartLiveLocationActionView( @Composable private fun BottomSheetContent( - cameraState: CameraState, state: ShareLocationState, navigateUp: () -> Unit, + onSharePinLocation: () -> Unit, ) { Spacer(Modifier.height(20.dp)) val userLocation = state.userLocationState.location @@ -228,8 +273,8 @@ private fun BottomSheetContent( state.eventSink( ShareLocationEvent.ShareStaticLocation( location = Location( - lat = userLocation.position.value.latitude, - lon = userLocation.position.value.longitude + lat = userLocation.position.latitude, + lon = userLocation.position.longitude ), isPinned = false ) @@ -237,18 +282,7 @@ private fun BottomSheetContent( navigateUp() } } else { - SharePinLocationItem( - onClick = { - val positionTarget = cameraState.position.target - state.eventSink( - ShareLocationEvent.ShareStaticLocation( - location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude), - isPinned = true - ) - ) - navigateUp() - } - ) + SharePinLocationItem(onClick = onSharePinLocation) } if (state.canShareLiveLocation) { ShareLiveLocationItem { diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 412e96eb9c..51ed1d6c68 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -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.ui.LocationConstraintsDialogState 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.Presenter import io.element.android.libraries.core.coroutine.mapState @@ -215,7 +215,7 @@ class ShowLocationPresenter( val hideUserLocationPuck = mode is ShowLocationMode.Live && isCurrentlySharing val userLocationState = if (hideUserLocationPuck) { // 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) } else { userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 1e4200c7d7..8e45996d09 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -31,11 +31,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp 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.ui.LocationConstraintsDialog import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton @@ -54,10 +56,11 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import org.maplibre.compose.camera.CameraMoveReason 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 -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ShowLocationView( state: ShowLocationState, @@ -73,7 +76,49 @@ fun ShowLocationView( 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) } LaunchedEffect(state.focusedLocation) { if (state.focusedLocation != null && !hasAnimatedToFocusedLocation) { @@ -82,15 +127,44 @@ fun ShowLocationView( target = Position(latitude = state.focusedLocation.location.lat, longitude = state.focusedLocation.location.lon), zoom = MapDefaults.DEFAULT_ZOOM ) - cameraState.position = position + mapState.setCameraPosition(position) } } - LaunchedEffect(cameraState.isCameraMoving) { - if (cameraState.moveReason == CameraMoveReason.GESTURE) { + LaunchedEffect(mapState.isCameraMoving) { + if (mapState.cameraMoveReason == CameraMoveReason.GESTURE) { 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( bottomSheetState = rememberBottomSheetState( initialValue = SheetValue.Expanded, @@ -103,7 +177,7 @@ fun ShowLocationView( } } MapBottomSheetScaffold( - customMapTilerConfig = state.customMapTilerConfig, + isReady = state.customMapTilerConfig.isReady(), sheetDragHandle = if (state.isSheetDraggable) { { BottomSheetDefaults.DragHandle() } } else { @@ -111,7 +185,7 @@ fun ShowLocationView( }, sheetSwipeEnabled = state.isSheetDraggable, scaffoldState = scaffoldState, - cameraState = cameraState, + mapState = mapState, modifier = modifier, topBar = { TopAppBar( @@ -123,8 +197,7 @@ fun ShowLocationView( }, ) }, - sheetContent = { sheetPaddings -> - val coroutineScope = rememberCoroutineScope() + sheetContent = { if (!state.isSheetDraggable) { // If sheet is draggable the DragHandle has already some padding Spacer(Modifier.height(20.dp)) @@ -152,41 +225,13 @@ fun ShowLocationView( item = locationShare, onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) }, - modifier = Modifier.clickable { - 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) - } - } + modifier = Modifier.clickable { onLocationShareClick(locationShare) } ) } } } }, - mapContent = { - 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 = { + overlay = { LocationFloatingActionButton( isMapCenteredOnUser = state.isTrackMyLocation, onClick = { state.eventSink(ShowLocationEvent.TrackMyLocation(true)) }, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index 1fab0d98af..b2a84d24e1 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -510,8 +510,8 @@ class ShowLocationPresenterTest { val state = awaitItem() assertThat(state.hideUserLocationPuck).isTrue() val location = requireNotNull(state.userLocationState.location) - assertThat(location.position.value.latitude).isEqualTo(ownLocation?.lat) - assertThat(location.position.value.longitude).isEqualTo(ownLocation?.lon) + assertThat(location.position.latitude).isEqualTo(ownLocation?.lat) + assertThat(location.position.longitude).isEqualTo(ownLocation?.lon) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 351852e756..4d7bacc7f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,7 +47,7 @@ sqldelight = "2.3.2" wysiwyg = "2.42.1" telephoto = "0.19.0" haze = "1.7.2" -maplibre-compose = "0.16.0" +maplibre-compose = "0.17.0" # Dependency analysis dependencyAnalysis = "3.19.1"