mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +00:00
Merge branch 'develop' into bma/upgradePaparazzi
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
|
||||
|
||||
+59
-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,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
LFS
BIN
Binary file not shown.
Reference in New Issue
Block a user