First iteration using maplibre-compose

This commit is contained in:
ganfra 2026-02-26 18:28:46 +01:00
parent 29f9640053
commit a240f69aff
11 changed files with 456 additions and 301 deletions

View file

@ -27,7 +27,7 @@ setupDependencyInjection()
dependencies { dependencies {
api(projects.features.location.api) api(projects.features.location.api)
implementation(projects.features.messages.api) implementation(projects.features.messages.api)
implementation(projects.libraries.maplibreCompose) implementation(libs.maplibre.compose)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.di) implementation(projects.libraries.di)

View file

@ -9,21 +9,30 @@
package io.element.android.features.location.impl.common package io.element.android.features.location.impl.common
import android.Manifest import android.Manifest
import android.view.Gravity import androidx.compose.ui.Alignment
import androidx.compose.runtime.Composable import org.maplibre.compose.map.GestureOptions
import androidx.compose.runtime.ReadOnlyComposable import org.maplibre.compose.map.MapOptions
import androidx.compose.ui.graphics.Color import org.maplibre.compose.map.OrnamentOptions
import io.element.android.compound.theme.ElementTheme import org.maplibre.compose.map.RenderOptions
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
/** /**
* 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,
ornamentOptions = OrnamentOptions(
isLogoEnabled = true,
logoAlignment = Alignment.BottomStart,
isAttributionEnabled = true,
attributionAlignment = Alignment.BottomEnd,
isCompassEnabled = false,
isScaleBarEnabled = false,
)
)
/*
val uiSettings: MapUiSettings val uiSettings: MapUiSettings
@Composable @Composable
@ReadOnlyComposable @ReadOnlyComposable
@ -60,6 +69,8 @@ object MapDefaults {
.zoom(2.7) .zoom(2.7)
.build() .build()
*/
const val DEFAULT_ZOOM = 15.0 const val DEFAULT_ZOOM = 15.0
val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)

View file

@ -9,6 +9,8 @@
package io.element.android.features.location.impl.common.ui package io.element.android.features.location.impl.common.ui
import androidx.compose.foundation.layout.size 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.material3.FloatingActionButtonDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -30,13 +32,11 @@ internal fun LocationFloatingActionButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
FloatingActionButton( FloatingActionButton(
shape = FloatingActionButtonDefaults.smallShape, shape = CircleShape,
containerColor = ElementTheme.colors.bgCanvasDefault, containerColor = ElementTheme.colors.bgCanvasDefault,
contentColor = ElementTheme.colors.iconPrimary, contentColor = ElementTheme.colors.iconPrimary,
onClick = onClick, onClick = onClick,
modifier = modifier modifier = modifier.size(48.dp),
// Note: design is 40dp, but min is 48 for accessibility.
.size(48.dp),
) { ) {
val iconImage = if (isMapCenteredOnUser) { val iconImage = if (isMapCenteredOnUser) {
CompoundIcons.LocationNavigatorCentred() CompoundIcons.LocationNavigatorCentred()

View file

@ -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)
}
}
}
}

View file

@ -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,
)
)
}
}

View file

@ -13,21 +13,15 @@ import kotlin.time.Duration
sealed interface ShareLocationEvent { sealed interface ShareLocationEvent {
data class ShareStaticLocation( data class ShareStaticLocation(
val cameraPosition: CameraPosition, val location: Location,
val location: Location?, val isPinned: Boolean,
) : ShareLocationEvent { ) : ShareLocationEvent
data class CameraPosition(
val lat: Double,
val lon: Double,
val zoom: Double,
)
}
data object SelectLiveLocationDuration : ShareLocationEvent data object ShowLiveLocationDurationPicker : ShareLocationEvent
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
data object SwitchToMyLocationMode : ShareLocationEvent data object StartTrackingUserPosition : ShareLocationEvent
data object SwitchToPinLocationMode : ShareLocationEvent data object StopTrackingUserPosition : ShareLocationEvent
data object DismissDialog : ShareLocationEvent data object DismissDialog : ShareLocationEvent
data object RequestPermissions : ShareLocationEvent data object RequestPermissions : ShareLocationEvent
data object OpenAppSettings : ShareLocationEvent data object OpenAppSettings : ShareLocationEvent

View file

@ -61,15 +61,7 @@ class ShareLocationPresenter(
@Composable @Composable
override fun present(): ShareLocationState { override fun present(): ShareLocationState {
val permissionsState: PermissionsState = permissionsPresenter.present() val permissionsState: PermissionsState = permissionsPresenter.present()
var mode: ShareLocationState.Mode by remember { var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted) }
mutableStateOf(
if (permissionsState.isAnyGranted) {
ShareLocationState.Mode.SenderLocation
} else {
ShareLocationState.Mode.PinLocation
}
)
}
val isLiveLocationSharingEnabled by remember { val isLiveLocationSharingEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing) featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing)
}.collectAsState(false) }.collectAsState(false)
@ -81,7 +73,7 @@ class ShareLocationPresenter(
LaunchedEffect(permissionsState.permissions) { LaunchedEffect(permissionsState.permissions) {
if (permissionsState.isAnyGranted) { if (permissionsState.isAnyGranted) {
mode = ShareLocationState.Mode.SenderLocation trackUserPosition = true
dialogState = ShareLocationState.Dialog.None dialogState = ShareLocationState.Dialog.None
} }
} }
@ -89,21 +81,21 @@ class ShareLocationPresenter(
fun handleEvent(event: ShareLocationEvent) { fun handleEvent(event: ShareLocationEvent) {
when (event) { when (event) {
is ShareLocationEvent.ShareStaticLocation -> scope.launch { is ShareLocationEvent.ShareStaticLocation -> scope.launch {
shareLocation(event, mode) shareStaticLocation(event)
} }
ShareLocationEvent.SwitchToMyLocationMode -> when { ShareLocationEvent.StartTrackingUserPosition -> when {
permissionsState.isAnyGranted -> mode = ShareLocationState.Mode.SenderLocation permissionsState.isAnyGranted -> trackUserPosition = true
permissionsState.shouldShowRationale -> dialogState = ShareLocationState.Dialog.PermissionRationale permissionsState.shouldShowRationale -> dialogState = ShareLocationState.Dialog.PermissionRationale
else -> dialogState = ShareLocationState.Dialog.PermissionDenied else -> dialogState = ShareLocationState.Dialog.PermissionDenied
} }
ShareLocationEvent.SwitchToPinLocationMode -> mode = ShareLocationState.Mode.PinLocation ShareLocationEvent.StopTrackingUserPosition -> trackUserPosition = false
ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None
ShareLocationEvent.OpenAppSettings -> { ShareLocationEvent.OpenAppSettings -> {
locationActions.openSettings() locationActions.openSettings()
dialogState = ShareLocationState.Dialog.None dialogState = ShareLocationState.Dialog.None
} }
ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
ShareLocationEvent.SelectLiveLocationDuration -> dialogState = when { ShareLocationEvent.ShowLiveLocationDurationPicker -> dialogState = when {
permissionsState.isAnyGranted -> ShareLocationState.Dialog.LiveLocationDuration permissionsState.isAnyGranted -> ShareLocationState.Dialog.LiveLocationDuration
permissionsState.shouldShowRationale -> ShareLocationState.Dialog.PermissionRationale permissionsState.shouldShowRationale -> ShareLocationState.Dialog.PermissionRationale
else -> ShareLocationState.Dialog.PermissionDenied else -> ShareLocationState.Dialog.PermissionDenied
@ -117,7 +109,7 @@ class ShareLocationPresenter(
return ShareLocationState( return ShareLocationState(
dialogState = dialogState, dialogState = dialogState,
mode = mode, trackUserLocation = trackUserPosition,
hasLocationPermission = permissionsState.isAnyGranted, hasLocationPermission = permissionsState.isAnyGranted,
canShareLiveLocation = isLiveLocationSharingEnabled, canShareLiveLocation = isLiveLocationSharingEnabled,
appName = appName, appName = appName,
@ -125,56 +117,28 @@ class ShareLocationPresenter(
) )
} }
private suspend fun shareLocation( private suspend fun shareStaticLocation(event: ShareLocationEvent.ShareStaticLocation) {
event: ShareLocationEvent.ShareStaticLocation,
mode: ShareLocationState.Mode,
) {
val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply
val inReplyToEventId = replyMode?.eventId val inReplyToEventId = replyMode?.eventId
when (mode) { val geoUri = event.location.toGeoUri()
ShareLocationState.Mode.PinLocation -> { getTimeline().flatMap {
val geoUri = event.cameraPosition.toGeoUri() it.sendLocation(
getTimeline().flatMap { body = generateBody(geoUri),
it.sendLocation( geoUri = geoUri,
body = generateBody(geoUri), description = null,
geoUri = geoUri, zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
description = null, assetType = if (event.isPinned) AssetType.PIN else AssetType.SENDER,
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), inReplyToEventId = inReplyToEventId,
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,
)
)
}
} }
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<Timeline> { private suspend fun getTimeline(): Result<Timeline> {
@ -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" private fun generateBody(uri: String): String = "Location was shared at $uri"

View file

@ -10,17 +10,12 @@ package io.element.android.features.location.impl.share
data class ShareLocationState( data class ShareLocationState(
val dialogState: Dialog, val dialogState: Dialog,
val mode: Mode, val trackUserLocation: Boolean,
val hasLocationPermission: Boolean, val hasLocationPermission: Boolean,
val appName: String, val appName: String,
val canShareLiveLocation: Boolean, val canShareLiveLocation: Boolean,
val eventSink: (ShareLocationEvent) -> Unit, val eventSink: (ShareLocationEvent) -> Unit,
) { ) {
sealed interface Mode {
data object SenderLocation : Mode
data object PinLocation : Mode
}
sealed interface Dialog { sealed interface Dialog {
data object None : Dialog data object None : Dialog
data object PermissionRationale : Dialog data object PermissionRationale : Dialog

View file

@ -17,32 +17,32 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
get() = sequenceOf( get() = sequenceOf(
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.None, permissionDialog = ShareLocationState.Dialog.None,
mode = ShareLocationState.Mode.PinLocation, trackUserPosition = false,
hasLocationPermission = false, hasLocationPermission = false,
), ),
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.PermissionDenied, permissionDialog = ShareLocationState.Dialog.PermissionDenied,
mode = ShareLocationState.Mode.PinLocation, trackUserPosition = false,
hasLocationPermission = false, hasLocationPermission = false,
), ),
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.PermissionRationale, permissionDialog = ShareLocationState.Dialog.PermissionRationale,
mode = ShareLocationState.Mode.PinLocation, trackUserPosition = false,
hasLocationPermission = false, hasLocationPermission = false,
), ),
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.None, permissionDialog = ShareLocationState.Dialog.None,
mode = ShareLocationState.Mode.PinLocation, trackUserPosition = false,
hasLocationPermission = true, hasLocationPermission = true,
), ),
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.None, permissionDialog = ShareLocationState.Dialog.None,
mode = ShareLocationState.Mode.SenderLocation, trackUserPosition = true,
hasLocationPermission = true, hasLocationPermission = true,
), ),
aShareLocationState( aShareLocationState(
permissionDialog = ShareLocationState.Dialog.LiveLocationDuration, permissionDialog = ShareLocationState.Dialog.LiveLocationDuration,
mode = ShareLocationState.Mode.SenderLocation, trackUserPosition = true,
hasLocationPermission = true, hasLocationPermission = true,
canShareLiveLocation = true, canShareLiveLocation = true,
), ),
@ -51,13 +51,13 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
private fun aShareLocationState( private fun aShareLocationState(
permissionDialog: ShareLocationState.Dialog, permissionDialog: ShareLocationState.Dialog,
mode: ShareLocationState.Mode, trackUserPosition: Boolean,
hasLocationPermission: Boolean, hasLocationPermission: Boolean,
canShareLiveLocation: Boolean = false, canShareLiveLocation: Boolean = false,
): ShareLocationState { ): ShareLocationState {
return ShareLocationState( return ShareLocationState(
dialogState = permissionDialog, dialogState = permissionDialog,
mode = mode, trackUserLocation = trackUserPosition,
hasLocationPermission = hasLocationPermission, hasLocationPermission = hasLocationPermission,
canShareLiveLocation = canShareLiveLocation, canShareLiveLocation = canShareLiveLocation,
appName = APP_NAME, appName = APP_NAME,

View file

@ -12,18 +12,14 @@ import androidx.compose.foundation.clickable
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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.foundation.lazy.itemsIndexed
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
@ -40,32 +36,36 @@ 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.common.MapDefaults 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.PermissionDeniedDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog 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.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.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ListDialog 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.ListItemContent
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource 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.ListItem
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables 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 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
import kotlin.time.Duration.Companion.minutes
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -99,76 +99,32 @@ fun ShareLocationView(
) )
} }
val cameraPositionState = rememberCameraPositionState { val scaffoldState = rememberBottomSheetScaffoldState(
position = MapDefaults.centerCameraPosition 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) { LaunchedEffect(cameraState.isCameraMoving) {
when (state.mode) { if (cameraState.moveReason == CameraMoveReason.GESTURE) {
ShareLocationState.Mode.PinLocation -> { state.eventSink(ShareLocationEvent.StopTrackingUserPosition)
cameraPositionState.cameraMode = CameraMode.NONE
}
ShareLocationState.Mode.SenderLocation -> {
cameraPositionState.position = CameraPosition.Builder()
.zoom(MapDefaults.DEFAULT_ZOOM)
.build()
cameraPositionState.cameraMode = CameraMode.TRACKING
}
} }
} }
LaunchedEffect(cameraPositionState.isMoving) { MapBottomSheetScaffold(
if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { cameraState = cameraState,
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))
},
modifier = modifier, modifier = modifier,
scaffoldState = rememberBottomSheetScaffoldState( scaffoldState = scaffoldState,
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded), sheetDragHandle = null,
),
sheetDragHandle = {},
sheetSwipeEnabled = false, sheetSwipeEnabled = false,
topBar = { topBar = {
TopAppBar( TopAppBar(
@ -178,62 +134,102 @@ fun ShareLocationView(
}, },
) )
}, },
) { sheetContent = {
Box( BottomSheetContent(
modifier = Modifier cameraState = cameraState,
.padding(it) state = state,
.consumeWindowInsets(it), userLocationState = userLocationState,
contentAlignment = Alignment.Center navigateUp = navigateUp
) {
MapLibreMap(
styleUri = rememberTileStyleUrl(),
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
uiSettings = MapDefaults.uiSettings,
symbolManagerSettings = MapDefaults.symbolManagerSettings,
locationSettings = MapDefaults.locationSettings.copy(
locationEnabled = state.hasLocationPermission,
),
) )
Icon( },
resourceId = CommonDrawables.pin, mapContent = {
contentDescription = null, UserLocation(
tint = Color.Unspecified, cameraState = cameraState,
modifier = Modifier.centerBottomEdge(this), locationState = userLocationState,
trackUserLocation = state.trackUserLocation
) )
LocationFloatingActionButton( },
isMapCenteredOnUser = state.mode == ShareLocationState.Mode.SenderLocation, overlayContent = { sheetPadding ->
onClick = { state.eventSink(ShareLocationEvent.SwitchToMyLocationMode) }, Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .fillMaxSize()
.padding(end = 18.dp, bottom = 72.dp + navBarPadding), .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 @Composable
private fun StaticLocationItem( private fun ShareCurrentLocationItem(
mode: ShareLocationState.Mode, onClick: () -> Unit,
cameraPositionState: CameraPositionState,
onClick: ()->Unit,
) { ) {
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(stringResource(CommonStrings.screen_share_my_location_action))
stringResource(
when (mode) {
ShareLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action
ShareLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action
}
)
)
}, },
modifier = Modifier.clickable( modifier = Modifier.clickable(onClick = onClick),
// 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
),
leadingContent = ListItemContent.Icon( leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(CompoundIcons.LocationNavigatorCentred()) iconSource = IconSource.Vector(CompoundIcons.LocationNavigatorCentred())
) )
@ -241,7 +237,22 @@ private fun StaticLocationItem(
} }
@Composable @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, onClick: () -> Unit,
) { ) {
ListItem( ListItem(

View file

@ -8,15 +8,16 @@
package io.element.android.features.location.impl.show package io.element.android.features.location.impl.show
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
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.text.style.TextOverflow 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 androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.compound.tokens.generated.TypographyTokens 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.MapDefaults
import io.element.android.features.location.impl.common.PermissionDeniedDialog 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.PermissionRationaleDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton 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.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight 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.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton 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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar 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 io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableMap import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.android.camera.CameraPosition import org.maplibre.compose.camera.CameraPosition
import org.maplibre.android.geometry.LatLng 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -71,33 +76,32 @@ fun ShowLocationView(
) )
} }
val cameraPositionState = rememberCameraPositionState { val cameraState = rememberCameraState(
position = CameraPosition.Builder() firstPosition = CameraPosition(
.target(LatLng(state.location.lat, state.location.lon)) target = Position(latitude = state.location.lat, longitude = state.location.lon),
.zoom(MapDefaults.DEFAULT_ZOOM) zoom = MapDefaults.DEFAULT_ZOOM
.build() )
)
val locationProvider = if (state.hasLocationPermission) {
rememberDefaultLocationProvider(
updateInterval = 1.minutes,
desiredAccuracy = DesiredAccuracy.Balanced,
minDistanceMeters = 50.0,
)
} else {
rememberNullLocationProvider()
} }
val userLocationState = rememberUserLocationState(locationProvider)
LaunchedEffect(state.isTrackMyLocation) { LaunchedEffect(cameraState.isCameraMoving) {
when (state.isTrackMyLocation) { if (cameraState.moveReason == CameraMoveReason.GESTURE) {
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) {
state.eventSink(ShowLocationEvents.TrackMyLocation(false)) state.eventSink(ShowLocationEvents.TrackMyLocation(false))
} }
} }
Scaffold( MapBottomSheetScaffold(
cameraState = cameraState,
modifier = modifier, modifier = modifier,
sheetPeekHeight = 80.dp,
topBar = { topBar = {
TopAppBar( TopAppBar(
titleStr = stringResource(CommonStrings.screen_view_location_title), titleStr = stringResource(CommonStrings.screen_view_location_title),
@ -118,19 +122,7 @@ fun ShowLocationView(
} }
) )
}, },
floatingActionButton = { sheetContent = {
LocationFloatingActionButton(
isMapCenteredOnUser = state.isTrackMyLocation,
onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) },
)
},
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize(),
) {
state.description?.let { state.description?.let {
Text( Text(
text = it, text = it,
@ -143,28 +135,40 @@ fun ShowLocationView(
.padding(8.dp), .padding(8.dp),
) )
} }
},
MapLibreMap( mapContent = {
styleUri = rememberTileStyleUrl(), UserLocation(
modifier = Modifier.fillMaxSize(), cameraState = cameraState,
images = mapOf(PIN_ID to CommonDrawables.pin).toImmutableMap(), locationState = userLocationState,
cameraPositionState = cameraPositionState, trackUserLocation = state.isTrackMyLocation
uiSettings = MapDefaults.uiSettings, )
symbolManagerSettings = MapDefaults.symbolManagerSettings, val senderLocation = rememberGeoJsonSource(
locationSettings = MapDefaults.locationSettings.copy( data = GeoJsonData.Features(
locationEnabled = state.hasLocationPermission, Point(
), Position(
) { latitude = state.location.lat,
Symbol( longitude = state.location.lon
iconId = PIN_ID, )
state = rememberSymbolState( )
position = LatLng(state.location.lat, state.location.lon)
),
iconAnchor = IconAnchor.BOTTOM,
) )
} )
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 @PreviewsDayNight
@ -175,5 +179,3 @@ internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider
onBackClick = {}, onBackClick = {},
) )
} }
private const val PIN_ID = "pin"