From a240f69affbcb2f4ae82774fc6dc66b4a32cc713 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 26 Feb 2026 18:28:46 +0100 Subject: [PATCH] First iteration using maplibre-compose --- features/location/impl/build.gradle.kts | 2 +- .../location/impl/common/MapDefaults.kt | 31 ++- .../common/ui/LocationFloatingActionButton.kt | 8 +- .../impl/common/ui/MapBottomSheetScaffold.kt | 130 +++++++++ .../location/impl/common/ui/UserLocation.kt | 52 ++++ .../location/impl/share/ShareLocationEvent.kt | 18 +- .../impl/share/ShareLocationPresenter.kt | 94 ++----- .../location/impl/share/ShareLocationState.kt | 7 +- .../impl/share/ShareLocationStateProvider.kt | 16 +- .../location/impl/share/ShareLocationView.kt | 253 +++++++++--------- .../location/impl/show/ShowLocationView.kt | 146 +++++----- 11 files changed, 456 insertions(+), 301 deletions(-) create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocation.kt diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 3b805d87ca..558cd86b25 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -27,7 +27,7 @@ setupDependencyInjection() dependencies { api(projects.features.location.api) implementation(projects.features.messages.api) - implementation(projects.libraries.maplibreCompose) + implementation(libs.maplibre.compose) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.di) 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 d01f3e7dd2..6c01c75508 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 @@ -9,21 +9,30 @@ package io.element.android.features.location.impl.common import android.Manifest -import android.view.Gravity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.ui.graphics.Color -import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.maplibre.compose.MapLocationSettings -import io.element.android.libraries.maplibre.compose.MapSymbolManagerSettings -import io.element.android.libraries.maplibre.compose.MapUiSettings -import org.maplibre.android.camera.CameraPosition -import org.maplibre.android.geometry.LatLng +import androidx.compose.ui.Alignment +import org.maplibre.compose.map.GestureOptions +import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.map.OrnamentOptions +import org.maplibre.compose.map.RenderOptions /** * Common configuration values for the map. */ object MapDefaults { + val options = MapOptions( + renderOptions = RenderOptions.Standard, + gestureOptions = GestureOptions.Standard, + ornamentOptions = OrnamentOptions( + isLogoEnabled = true, + logoAlignment = Alignment.BottomStart, + isAttributionEnabled = true, + attributionAlignment = Alignment.BottomEnd, + isCompassEnabled = false, + isScaleBarEnabled = false, + ) + ) + + /* val uiSettings: MapUiSettings @Composable @ReadOnlyComposable @@ -60,6 +69,8 @@ object MapDefaults { .zoom(2.7) .build() + */ + const val DEFAULT_ZOOM = 15.0 val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt index 773544375f..99a4c7470f 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt @@ -9,6 +9,8 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FloatingActionButtonDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -30,13 +32,11 @@ internal fun LocationFloatingActionButton( modifier: Modifier = Modifier, ) { FloatingActionButton( - shape = FloatingActionButtonDefaults.smallShape, + shape = CircleShape, containerColor = ElementTheme.colors.bgCanvasDefault, contentColor = ElementTheme.colors.iconPrimary, onClick = onClick, - modifier = modifier - // Note: design is 40dp, but min is 48 for accessibility. - .size(48.dp), + modifier = modifier.size(48.dp), ) { val iconImage = if (isMapCenteredOnUser) { CompoundIcons.LocationNavigatorCentred() 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 new file mode 100644 index 0000000000..0a1b0cff9a --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.location.impl.common.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +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.location.api.internal.rememberTileStyleUrl +import io.element.android.features.location.impl.common.MapDefaults +import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold +import kotlin.math.roundToInt +import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.map.MaplibreMap +import org.maplibre.compose.style.BaseStyle +import org.maplibre.compose.util.MaplibreComposable + +/** + * A reusable scaffold component for map views with a bottom sheet. + * + * Handles the layout complexity of: + * - Calculating the visible sheet height dynamically + * - Updating camera position padding based on sheet height + * - Rendering the MaplibreMap with proper ornament positioning + * + * @param cameraState The camera state for the map + * @param topBar The top app bar content + * @param sheetContent The content to display in the bottom sheet + * @param modifier Modifier for the root layout + * @param scaffoldState State for the bottom sheet scaffold + * @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 snackbarHost The snackbar host content + * @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.) + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MapBottomSheetScaffold( + modifier: Modifier = Modifier, + scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.PartiallyExpanded) + ), + cameraState: CameraState = rememberCameraState(), + mapOptions: MapOptions = MapDefaults.options, + 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.() -> Unit = {}, + mapContent: @Composable @MaplibreComposable () -> Unit = {}, + overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {}, +) { + val density = LocalDensity.current + + BoxWithConstraints(modifier = modifier.safeDrawingPadding()) { + val layoutHeightPx by rememberUpdatedState(constraints.maxHeight) + val sheetPadding by remember { + derivedStateOf { + val sheetOffset = tryOrNull { scaffoldState.bottomSheetState.requireOffset() } ?: 0f + val sheetVisibleHeightPx = layoutHeightPx - sheetOffset + val bottomPadding = with(density) { max(sheetVisibleHeightPx.roundToInt().toDp(), 0.dp) } + PaddingValues(bottom = bottomPadding) + } + } + // Update camera position when sheet padding changes + LaunchedEffect(sheetPadding) { + cameraState.position = cameraState.position.copy(padding = sheetPadding) + } + + BottomSheetScaffold( + sheetPeekHeight = sheetPeekHeight, + sheetContent = { + sheetContent() + Spacer(modifier = Modifier.navigationBarsPadding()) + }, + scaffoldState = scaffoldState, + sheetDragHandle = sheetDragHandle, + sheetSwipeEnabled = sheetSwipeEnabled, + snackbarHost = snackbarHost, + topBar = topBar, + ) { + val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding) + Box { + MaplibreMap( + options = mapOptions.copy(ornamentOptions = ornamentOptions), + baseStyle = BaseStyle.Uri(rememberTileStyleUrl()), + modifier = Modifier.fillMaxSize(), + cameraState = cameraState, + content = mapContent, + ) + overlayContent(sheetPadding) + } + } + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocation.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocation.kt new file mode 100644 index 0000000000..9a5b6121b0 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocation.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +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.LocationPuck +import org.maplibre.compose.location.LocationPuckColors +import org.maplibre.compose.location.LocationPuckSizes +import org.maplibre.compose.location.LocationTrackingEffect +import org.maplibre.compose.location.UserLocationState + +@Composable +fun UserLocation( + cameraState: CameraState, + locationState: UserLocationState, + trackUserLocation: Boolean, +) { + LocationTrackingEffect( + locationState = locationState, + enabled = trackUserLocation, + ) { + cameraState.updateFromLocation() + } + val location = locationState.location + if (location != null) { + LocationPuck( + idPrefix = "user-location", + locationState = locationState, + cameraState = cameraState, + accuracyThreshold = Float.POSITIVE_INFINITY, + showBearingAccuracy = false, + showBearing = false, + sizes = LocationPuckSizes( + dotRadius = 8.dp, + dotStrokeWidth = 2.dp, + ), + colors = LocationPuckColors( + dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary, + dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary, + dotStrokeColor = ElementTheme.colors.bgCanvasDefault, + ) + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt index 9ac15742d9..eb641df9fb 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt @@ -13,21 +13,15 @@ import kotlin.time.Duration sealed interface ShareLocationEvent { data class ShareStaticLocation( - val cameraPosition: CameraPosition, - val location: Location?, - ) : ShareLocationEvent { - data class CameraPosition( - val lat: Double, - val lon: Double, - val zoom: Double, - ) - } + val location: Location, + val isPinned: Boolean, + ) : ShareLocationEvent - data object SelectLiveLocationDuration : ShareLocationEvent + data object ShowLiveLocationDurationPicker : ShareLocationEvent data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent - data object SwitchToMyLocationMode : ShareLocationEvent - data object SwitchToPinLocationMode : ShareLocationEvent + data object StartTrackingUserPosition : ShareLocationEvent + data object StopTrackingUserPosition : ShareLocationEvent data object DismissDialog : ShareLocationEvent data object RequestPermissions : ShareLocationEvent data object OpenAppSettings : ShareLocationEvent diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index d53b132314..bd82b823f0 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -61,15 +61,7 @@ class ShareLocationPresenter( @Composable override fun present(): ShareLocationState { val permissionsState: PermissionsState = permissionsPresenter.present() - var mode: ShareLocationState.Mode by remember { - mutableStateOf( - if (permissionsState.isAnyGranted) { - ShareLocationState.Mode.SenderLocation - } else { - ShareLocationState.Mode.PinLocation - } - ) - } + var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted) } val isLiveLocationSharingEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing) }.collectAsState(false) @@ -81,7 +73,7 @@ class ShareLocationPresenter( LaunchedEffect(permissionsState.permissions) { if (permissionsState.isAnyGranted) { - mode = ShareLocationState.Mode.SenderLocation + trackUserPosition = true dialogState = ShareLocationState.Dialog.None } } @@ -89,21 +81,21 @@ class ShareLocationPresenter( fun handleEvent(event: ShareLocationEvent) { when (event) { is ShareLocationEvent.ShareStaticLocation -> scope.launch { - shareLocation(event, mode) + shareStaticLocation(event) } - ShareLocationEvent.SwitchToMyLocationMode -> when { - permissionsState.isAnyGranted -> mode = ShareLocationState.Mode.SenderLocation + ShareLocationEvent.StartTrackingUserPosition -> when { + permissionsState.isAnyGranted -> trackUserPosition = true permissionsState.shouldShowRationale -> dialogState = ShareLocationState.Dialog.PermissionRationale else -> dialogState = ShareLocationState.Dialog.PermissionDenied } - ShareLocationEvent.SwitchToPinLocationMode -> mode = ShareLocationState.Mode.PinLocation + ShareLocationEvent.StopTrackingUserPosition -> trackUserPosition = false ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None ShareLocationEvent.OpenAppSettings -> { locationActions.openSettings() dialogState = ShareLocationState.Dialog.None } ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) - ShareLocationEvent.SelectLiveLocationDuration -> dialogState = when { + ShareLocationEvent.ShowLiveLocationDurationPicker -> dialogState = when { permissionsState.isAnyGranted -> ShareLocationState.Dialog.LiveLocationDuration permissionsState.shouldShowRationale -> ShareLocationState.Dialog.PermissionRationale else -> ShareLocationState.Dialog.PermissionDenied @@ -117,7 +109,7 @@ class ShareLocationPresenter( return ShareLocationState( dialogState = dialogState, - mode = mode, + trackUserLocation = trackUserPosition, hasLocationPermission = permissionsState.isAnyGranted, canShareLiveLocation = isLiveLocationSharingEnabled, appName = appName, @@ -125,56 +117,28 @@ class ShareLocationPresenter( ) } - private suspend fun shareLocation( - event: ShareLocationEvent.ShareStaticLocation, - mode: ShareLocationState.Mode, - ) { + private suspend fun shareStaticLocation(event: ShareLocationEvent.ShareStaticLocation) { val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply val inReplyToEventId = replyMode?.eventId - when (mode) { - ShareLocationState.Mode.PinLocation -> { - val geoUri = event.cameraPosition.toGeoUri() - getTimeline().flatMap { - it.sendLocation( - body = generateBody(geoUri), - geoUri = geoUri, - description = null, - zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), - assetType = AssetType.PIN, - inReplyToEventId = inReplyToEventId, - ) - } - analyticsService.capture( - Composer( - inThread = messageComposerContext.composerMode.inThread, - isEditing = messageComposerContext.composerMode.isEditing, - isReply = messageComposerContext.composerMode.isReply, - messageType = Composer.MessageType.LocationPin, - ) - ) - } - ShareLocationState.Mode.SenderLocation -> { - val geoUri = event.toGeoUri() - getTimeline().flatMap { - it.sendLocation( - body = generateBody(geoUri), - geoUri = geoUri, - description = null, - zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), - assetType = AssetType.SENDER, - inReplyToEventId = inReplyToEventId, - ) - } - analyticsService.capture( - Composer( - inThread = messageComposerContext.composerMode.inThread, - isEditing = messageComposerContext.composerMode.isEditing, - isReply = messageComposerContext.composerMode.isReply, - messageType = Composer.MessageType.LocationUser, - ) - ) - } + val geoUri = event.location.toGeoUri() + getTimeline().flatMap { + it.sendLocation( + body = generateBody(geoUri), + geoUri = geoUri, + description = null, + zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), + assetType = if (event.isPinned) AssetType.PIN else AssetType.SENDER, + inReplyToEventId = inReplyToEventId, + ) } + analyticsService.capture( + Composer( + inThread = messageComposerContext.composerMode.inThread, + isEditing = messageComposerContext.composerMode.isEditing, + isReply = messageComposerContext.composerMode.isReply, + messageType = if (event.isPinned) Composer.MessageType.LocationPin else Composer.MessageType.LocationUser + ) + ) } private suspend fun getTimeline(): Result { @@ -185,8 +149,4 @@ class ShareLocationPresenter( } } -private fun ShareLocationEvent.ShareStaticLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri() - -private fun ShareLocationEvent.ShareStaticLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon" - private fun generateBody(uri: String): String = "Location was shared at $uri" diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index 952e728097..947d286c5e 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -10,17 +10,12 @@ package io.element.android.features.location.impl.share data class ShareLocationState( val dialogState: Dialog, - val mode: Mode, + val trackUserLocation: Boolean, val hasLocationPermission: Boolean, val appName: String, val canShareLiveLocation: Boolean, val eventSink: (ShareLocationEvent) -> Unit, ) { - sealed interface Mode { - data object SenderLocation : Mode - data object PinLocation : Mode - } - sealed interface Dialog { data object None : Dialog data object PermissionRationale : Dialog diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index 8832184f18..45a81af1df 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -17,32 +17,32 @@ class ShareLocationStateProvider : PreviewParameterProvider get() = sequenceOf( aShareLocationState( permissionDialog = ShareLocationState.Dialog.None, - mode = ShareLocationState.Mode.PinLocation, + trackUserPosition = false, hasLocationPermission = false, ), aShareLocationState( permissionDialog = ShareLocationState.Dialog.PermissionDenied, - mode = ShareLocationState.Mode.PinLocation, + trackUserPosition = false, hasLocationPermission = false, ), aShareLocationState( permissionDialog = ShareLocationState.Dialog.PermissionRationale, - mode = ShareLocationState.Mode.PinLocation, + trackUserPosition = false, hasLocationPermission = false, ), aShareLocationState( permissionDialog = ShareLocationState.Dialog.None, - mode = ShareLocationState.Mode.PinLocation, + trackUserPosition = false, hasLocationPermission = true, ), aShareLocationState( permissionDialog = ShareLocationState.Dialog.None, - mode = ShareLocationState.Mode.SenderLocation, + trackUserPosition = true, hasLocationPermission = true, ), aShareLocationState( permissionDialog = ShareLocationState.Dialog.LiveLocationDuration, - mode = ShareLocationState.Mode.SenderLocation, + trackUserPosition = true, hasLocationPermission = true, canShareLiveLocation = true, ), @@ -51,13 +51,13 @@ class ShareLocationStateProvider : PreviewParameterProvider private fun aShareLocationState( permissionDialog: ShareLocationState.Dialog, - mode: ShareLocationState.Mode, + trackUserPosition: Boolean, hasLocationPermission: Boolean, canShareLiveLocation: Boolean = false, ): ShareLocationState { return ShareLocationState( dialogState = permissionDialog, - mode = mode, + trackUserLocation = trackUserPosition, hasLocationPermission = hasLocationPermission, canShareLiveLocation = canShareLiveLocation, appName = APP_NAME, 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 62cb38fd9b..84829d4bf9 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 @@ -12,18 +12,14 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,32 +36,36 @@ 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.common.MapDefaults import io.element.android.features.location.impl.common.PermissionDeniedDialog import io.element.android.features.location.impl.common.PermissionRationaleDialog import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton +import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold +import io.element.android.features.location.impl.common.ui.UserLocation import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.ListDialog import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.RadioButtonListItem import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.utils.CommonDrawables -import io.element.android.libraries.maplibre.compose.CameraMode -import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason -import io.element.android.libraries.maplibre.compose.CameraPositionState -import io.element.android.libraries.maplibre.compose.MapLibreMap -import io.element.android.libraries.maplibre.compose.rememberCameraPositionState import io.element.android.libraries.ui.strings.CommonStrings -import org.maplibre.android.camera.CameraPosition +import org.maplibre.compose.camera.CameraMoveReason +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.location.DesiredAccuracy +import org.maplibre.compose.location.UserLocationState +import org.maplibre.compose.location.rememberDefaultLocationProvider +import org.maplibre.compose.location.rememberNullLocationProvider +import org.maplibre.compose.location.rememberUserLocationState import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -99,76 +99,32 @@ fun ShareLocationView( ) } - val cameraPositionState = rememberCameraPositionState { - position = MapDefaults.centerCameraPosition + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded) + ) + val cameraState = rememberCameraState(firstPosition = CameraPosition(zoom = MapDefaults.DEFAULT_ZOOM)) + val locationProvider = if (state.hasLocationPermission) { + rememberDefaultLocationProvider( + updateInterval = 1.minutes, + desiredAccuracy = DesiredAccuracy.Balanced, + minDistanceMeters = 50.0, + ) + } else { + rememberNullLocationProvider() } + val userLocationState = rememberUserLocationState(locationProvider) - LaunchedEffect(state.mode) { - when (state.mode) { - ShareLocationState.Mode.PinLocation -> { - cameraPositionState.cameraMode = CameraMode.NONE - } - ShareLocationState.Mode.SenderLocation -> { - cameraPositionState.position = CameraPosition.Builder() - .zoom(MapDefaults.DEFAULT_ZOOM) - .build() - cameraPositionState.cameraMode = CameraMode.TRACKING - } + LaunchedEffect(cameraState.isCameraMoving) { + if (cameraState.moveReason == CameraMoveReason.GESTURE) { + state.eventSink(ShareLocationEvent.StopTrackingUserPosition) } } - LaunchedEffect(cameraPositionState.isMoving) { - if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { - state.eventSink(ShareLocationEvent.SwitchToPinLocationMode) - } - } - - // BottomSheetScaffold doesn't manage the system insets for sheetContent and the FAB, so we need to do it manually. - val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() - - BottomSheetScaffold( - sheetContent = { - Spacer(Modifier.height(20.dp)) - ListItem( - headlineContent = { - Text( - text = "Sharing options", - style = ElementTheme.typography.fontBodyLgMedium, - ) - } - ) - StaticLocationItem(state.mode, cameraPositionState){ - val positionTarget = cameraPositionState.position.target ?: return@StaticLocationItem - state.eventSink( - ShareLocationEvent.ShareStaticLocation( - cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition( - lat = positionTarget.latitude, - lon = positionTarget.longitude, - zoom = cameraPositionState.position.zoom, - ), - location = cameraPositionState.location?.let { - Location( - lat = it.latitude, - lon = it.longitude, - accuracy = it.accuracy, - ) - } - ) - ) - navigateUp() - } - if(state.canShareLiveLocation){ - LiveLocationItem { - state.eventSink(ShareLocationEvent.SelectLiveLocationDuration) - } - } - Spacer(modifier = Modifier.height(16.dp + navBarPadding)) - }, + MapBottomSheetScaffold( + cameraState = cameraState, modifier = modifier, - scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded), - ), - sheetDragHandle = {}, + scaffoldState = scaffoldState, + sheetDragHandle = null, sheetSwipeEnabled = false, topBar = { TopAppBar( @@ -178,62 +134,102 @@ fun ShareLocationView( }, ) }, - ) { - Box( - modifier = Modifier - .padding(it) - .consumeWindowInsets(it), - contentAlignment = Alignment.Center - ) { - MapLibreMap( - styleUri = rememberTileStyleUrl(), - modifier = Modifier.fillMaxSize(), - cameraPositionState = cameraPositionState, - uiSettings = MapDefaults.uiSettings, - symbolManagerSettings = MapDefaults.symbolManagerSettings, - locationSettings = MapDefaults.locationSettings.copy( - locationEnabled = state.hasLocationPermission, - ), + sheetContent = { + BottomSheetContent( + cameraState = cameraState, + state = state, + userLocationState = userLocationState, + navigateUp = navigateUp ) - Icon( - resourceId = CommonDrawables.pin, - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.centerBottomEdge(this), + }, + mapContent = { + UserLocation( + cameraState = cameraState, + locationState = userLocationState, + trackUserLocation = state.trackUserLocation ) - LocationFloatingActionButton( - isMapCenteredOnUser = state.mode == ShareLocationState.Mode.SenderLocation, - onClick = { state.eventSink(ShareLocationEvent.SwitchToMyLocationMode) }, + }, + overlayContent = { sheetPadding -> + Box( modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 18.dp, bottom = 72.dp + navBarPadding), + .fillMaxSize() + .padding(sheetPadding) + ) { + Icon( + resourceId = CommonDrawables.pin, + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.centerBottomEdge(this), + ) + } + LocationFloatingActionButton( + isMapCenteredOnUser = state.trackUserLocation, + onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserPosition) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(all = 16.dp), ) } + ) +} + +@Composable +private fun BottomSheetContent( + cameraState: CameraState, + state: ShareLocationState, + userLocationState: UserLocationState, + navigateUp: () -> Unit, +) { + Spacer(Modifier.height(20.dp)) + SharePinLocationItem( + onClick = { + val positionTarget = cameraState.position.target + state.eventSink( + ShareLocationEvent.ShareStaticLocation( + location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude), + isPinned = true + ) + ) + navigateUp() + } + ) + ShareCurrentLocationItem( + onClick = { + val userLocation = userLocationState.location + if (state.hasLocationPermission) { + if (userLocation == null) { + // + } else { + state.eventSink( + ShareLocationEvent.ShareStaticLocation( + location = Location( + lat = userLocation.position.latitude, + lon = userLocation.position.longitude + ), + isPinned = false + ) + ) + navigateUp() + } + } + } + ) + if (state.canShareLiveLocation) { + ShareLiveLocationItem { + state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) + } } } @Composable -private fun StaticLocationItem( - mode: ShareLocationState.Mode, - cameraPositionState: CameraPositionState, - onClick: ()->Unit, +private fun ShareCurrentLocationItem( + onClick: () -> Unit, ) { ListItem( headlineContent = { - Text( - stringResource( - when (mode) { - ShareLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action - ShareLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action - } - ) - ) + Text(stringResource(CommonStrings.screen_share_my_location_action)) }, - modifier = Modifier.clickable( - // target is null when the map hasn't loaded (or api key is wrong) so we disable the button - enabled = cameraPositionState.position.target != null, - onClick = onClick - ), + modifier = Modifier.clickable(onClick = onClick), leadingContent = ListItemContent.Icon( iconSource = IconSource.Vector(CompoundIcons.LocationNavigatorCentred()) ) @@ -241,7 +237,22 @@ private fun StaticLocationItem( } @Composable -private fun LiveLocationItem( +private fun SharePinLocationItem( + onClick: () -> Unit, +) { + ListItem( + headlineContent = { + Text(stringResource(CommonStrings.screen_share_this_location_action)) + }, + modifier = Modifier.clickable(onClick = onClick), + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.LocationNavigator()) + ) + ) +} + +@Composable +private fun ShareLiveLocationItem( onClick: () -> Unit, ) { ListItem( 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 6bc9e27bc6..f7d7a15207 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 @@ -8,15 +8,16 @@ package io.element.android.features.location.impl.show -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -24,31 +25,35 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.TypographyTokens -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.PermissionDeniedDialog import io.element.android.features.location.impl.common.PermissionRationaleDialog import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton +import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold +import io.element.android.features.location.impl.common.ui.UserLocation import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton -import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.designsystem.utils.CommonDrawables -import io.element.android.libraries.maplibre.compose.CameraMode -import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason -import io.element.android.libraries.maplibre.compose.IconAnchor -import io.element.android.libraries.maplibre.compose.MapLibreMap -import io.element.android.libraries.maplibre.compose.Symbol -import io.element.android.libraries.maplibre.compose.rememberCameraPositionState -import io.element.android.libraries.maplibre.compose.rememberSymbolState import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.collections.immutable.toImmutableMap -import org.maplibre.android.camera.CameraPosition -import org.maplibre.android.geometry.LatLng +import org.maplibre.compose.camera.CameraMoveReason +import org.maplibre.compose.camera.CameraPosition +import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.compose.expressions.dsl.image +import org.maplibre.compose.layers.SymbolLayer +import org.maplibre.compose.location.DesiredAccuracy +import org.maplibre.compose.location.rememberDefaultLocationProvider +import org.maplibre.compose.location.rememberNullLocationProvider +import org.maplibre.compose.location.rememberUserLocationState +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.spatialk.geojson.Point +import org.maplibre.spatialk.geojson.Position +import kotlin.time.Duration.Companion.minutes @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -71,33 +76,32 @@ fun ShowLocationView( ) } - val cameraPositionState = rememberCameraPositionState { - position = CameraPosition.Builder() - .target(LatLng(state.location.lat, state.location.lon)) - .zoom(MapDefaults.DEFAULT_ZOOM) - .build() + val cameraState = rememberCameraState( + firstPosition = CameraPosition( + target = Position(latitude = state.location.lat, longitude = state.location.lon), + zoom = MapDefaults.DEFAULT_ZOOM + ) + ) + val locationProvider = if (state.hasLocationPermission) { + rememberDefaultLocationProvider( + updateInterval = 1.minutes, + desiredAccuracy = DesiredAccuracy.Balanced, + minDistanceMeters = 50.0, + ) + } else { + rememberNullLocationProvider() } - - LaunchedEffect(state.isTrackMyLocation) { - when (state.isTrackMyLocation) { - false -> cameraPositionState.cameraMode = CameraMode.NONE - true -> { - cameraPositionState.position = CameraPosition.Builder() - .zoom(MapDefaults.DEFAULT_ZOOM) - .build() - cameraPositionState.cameraMode = CameraMode.TRACKING - } - } - } - - LaunchedEffect(cameraPositionState.isMoving) { - if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { + val userLocationState = rememberUserLocationState(locationProvider) + LaunchedEffect(cameraState.isCameraMoving) { + if (cameraState.moveReason == CameraMoveReason.GESTURE) { state.eventSink(ShowLocationEvents.TrackMyLocation(false)) } } - Scaffold( + MapBottomSheetScaffold( + cameraState = cameraState, modifier = modifier, + sheetPeekHeight = 80.dp, topBar = { TopAppBar( titleStr = stringResource(CommonStrings.screen_view_location_title), @@ -118,19 +122,7 @@ fun ShowLocationView( } ) }, - floatingActionButton = { - LocationFloatingActionButton( - isMapCenteredOnUser = state.isTrackMyLocation, - onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) }, - ) - }, - ) { paddingValues -> - Column( - modifier = Modifier - .padding(paddingValues) - .consumeWindowInsets(paddingValues) - .fillMaxSize(), - ) { + sheetContent = { state.description?.let { Text( text = it, @@ -143,28 +135,40 @@ fun ShowLocationView( .padding(8.dp), ) } - - MapLibreMap( - styleUri = rememberTileStyleUrl(), - modifier = Modifier.fillMaxSize(), - images = mapOf(PIN_ID to CommonDrawables.pin).toImmutableMap(), - cameraPositionState = cameraPositionState, - uiSettings = MapDefaults.uiSettings, - symbolManagerSettings = MapDefaults.symbolManagerSettings, - locationSettings = MapDefaults.locationSettings.copy( - locationEnabled = state.hasLocationPermission, - ), - ) { - Symbol( - iconId = PIN_ID, - state = rememberSymbolState( - position = LatLng(state.location.lat, state.location.lon) - ), - iconAnchor = IconAnchor.BOTTOM, + }, + mapContent = { + UserLocation( + cameraState = cameraState, + locationState = userLocationState, + trackUserLocation = state.isTrackMyLocation + ) + val senderLocation = rememberGeoJsonSource( + data = GeoJsonData.Features( + Point( + Position( + latitude = state.location.lat, + longitude = state.location.lon + ) + ) ) - } + ) + val marker = painterResource(R.drawable.pin_small) + SymbolLayer( + id = "sender_location", + source = senderLocation, + iconImage = image(marker) + ) + }, + overlayContent = { + LocationFloatingActionButton( + isMapCenteredOnUser = state.isTrackMyLocation, + onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(all = 16.dp), + ) } - } + ) } @PreviewsDayNight @@ -175,5 +179,3 @@ internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider onBackClick = {}, ) } - -private const val PIN_ID = "pin"