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 {
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)

View File

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

View File

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

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 {
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

View File

@@ -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<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"

View File

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

View File

@@ -17,32 +17,32 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
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<ShareLocationState>
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,

View File

@@ -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(

View File

@@ -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"