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