Merge branch 'develop' into bma/upgradePaparazzi

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