mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +00:00
Adapt location feature to maplibre-compose 0.17.0 API
This commit is contained in:
-8
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+57
-42
@@ -7,6 +7,7 @@
|
||||
|
||||
package io.element.android.features.location.impl.common.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
@@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
@@ -41,27 +43,22 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.max
|
||||
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
||||
import io.element.android.features.location.impl.common.MapDefaults
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||
import org.maplibre.compose.camera.CameraState
|
||||
import org.maplibre.compose.camera.rememberCameraState
|
||||
import org.maplibre.compose.map.MapOptions
|
||||
import org.maplibre.compose.interaction.MapInteractions
|
||||
import org.maplibre.compose.map.MapState
|
||||
import org.maplibre.compose.map.MapUiOptions
|
||||
import org.maplibre.compose.map.MaplibreMap
|
||||
import org.maplibre.compose.map.RenderOptions
|
||||
import org.maplibre.compose.overlay.ExpandingAttributionButton
|
||||
import org.maplibre.compose.overlay.MapOverlay
|
||||
import org.maplibre.compose.overlay.MaplibreLogo
|
||||
import org.maplibre.compose.style.BaseStyle
|
||||
import org.maplibre.compose.util.MaplibreComposable
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -72,43 +69,43 @@ import kotlin.math.roundToInt
|
||||
* - Updating camera position padding based on sheet height
|
||||
* - Rendering the MaplibreMap with proper ornament positioning
|
||||
*
|
||||
* @param customMapTilerConfig Optional custom style URL for the map
|
||||
* @param isReady Used to gate rendering until the map should be displayed
|
||||
* @param modifier Modifier for the root layout
|
||||
* @param scaffoldState State for the bottom sheet scaffold
|
||||
* @param cameraState The camera state for the map
|
||||
* @param mapOptions The options to configure the map
|
||||
* @param mapState The map state (camera, style and layer content) for the map, created with
|
||||
* [rememberLocationMapState]. `null` in inspection/preview mode, where a placeholder is shown.
|
||||
* @param renderOptions The options to configure the map rendering
|
||||
* @param sheetPeekHeight The height of the sheet when collapsed
|
||||
* @param sheetDragHandle Optional drag handle for the sheet
|
||||
* @param sheetSwipeEnabled Whether the sheet can be swiped
|
||||
* @param topBar The top app bar content
|
||||
* @param snackbarHost The snackbar host content
|
||||
* @param sheetContent The content to display in the bottom sheet
|
||||
* @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.)
|
||||
* @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.)
|
||||
* @param overlay Content to overlay on top of the map (FAB, pin icons, etc.)
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MapBottomSheetScaffold(
|
||||
customMapTilerConfig: AsyncData<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 +123,7 @@ fun MapBottomSheetScaffold(
|
||||
sheetContent = {
|
||||
val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp()
|
||||
Column(modifier = Modifier.heightIn(max = maxContentHeight)) {
|
||||
sheetContent(sheetPadding)
|
||||
sheetContent()
|
||||
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
}
|
||||
},
|
||||
@@ -136,39 +133,57 @@ fun MapBottomSheetScaffold(
|
||||
snackbarHost = snackbarHost,
|
||||
topBar = topBar,
|
||||
) {
|
||||
Box {
|
||||
when (customMapTilerConfig) {
|
||||
is AsyncData.Success -> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
isReady && mapState != null -> {
|
||||
MaplibreMap(
|
||||
options = mapOptions,
|
||||
state = mapState,
|
||||
cameraPadding = sheetPadding,
|
||||
contentWindowInsets = WindowInsets(bottom = sheetPadding.calculateBottomPadding()),
|
||||
baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)),
|
||||
overlay = MapOverlay {
|
||||
val style = styleState
|
||||
Row(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
renderOptions = renderOptions,
|
||||
interactions = interactions,
|
||||
uiOptions = uiOptions,
|
||||
overlay = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(sheetPadding)
|
||||
) {
|
||||
MaplibreLogo()
|
||||
ExpandingAttributionButton(cameraState = cameraState, styleState = style)
|
||||
Row(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.padding(all = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MaplibreLogo()
|
||||
ExpandingAttributionButton()
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
cameraState = cameraState,
|
||||
content = mapContent,
|
||||
)
|
||||
}
|
||||
mapState == null -> {
|
||||
// Inspection/preview mode: the map cannot initialize, render a placeholder
|
||||
// matching MaplibreMap's own inspection rendering.
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Gray))
|
||||
}
|
||||
else -> {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
}
|
||||
}
|
||||
overlayContent(sheetPadding)
|
||||
// App overlay (FABs, pins, live-location indicators). Layered above the map so it is
|
||||
// present even in inspection/preview mode, where the map is a placeholder.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(sheetPadding)
|
||||
) {
|
||||
overlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-8
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+9
-10
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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())
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+23
-14
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-5
@@ -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()
|
||||
}
|
||||
|
||||
+91
-57
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+85
-40
@@ -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)) },
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ sqldelight = "2.3.2"
|
||||
wysiwyg = "2.42.1"
|
||||
telephoto = "0.19.0"
|
||||
haze = "1.7.2"
|
||||
maplibre-compose = "0.16.0"
|
||||
maplibre-compose = "0.17.0"
|
||||
|
||||
# Dependency analysis
|
||||
dependencyAnalysis = "3.19.1"
|
||||
|
||||
Reference in New Issue
Block a user