First iteration using maplibre-compose
This commit is contained in:
parent
29f9640053
commit
a240f69aff
11 changed files with 456 additions and 301 deletions
|
|
@ -27,7 +27,7 @@ setupDependencyInjection()
|
||||||
dependencies {
|
dependencies {
|
||||||
api(projects.features.location.api)
|
api(projects.features.location.api)
|
||||||
implementation(projects.features.messages.api)
|
implementation(projects.features.messages.api)
|
||||||
implementation(projects.libraries.maplibreCompose)
|
implementation(libs.maplibre.compose)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,30 @@
|
||||||
package io.element.android.features.location.impl.common
|
package io.element.android.features.location.impl.common
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.view.Gravity
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.runtime.Composable
|
import org.maplibre.compose.map.GestureOptions
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import org.maplibre.compose.map.MapOptions
|
||||||
import androidx.compose.ui.graphics.Color
|
import org.maplibre.compose.map.OrnamentOptions
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import org.maplibre.compose.map.RenderOptions
|
||||||
import io.element.android.libraries.maplibre.compose.MapLocationSettings
|
|
||||||
import io.element.android.libraries.maplibre.compose.MapSymbolManagerSettings
|
|
||||||
import io.element.android.libraries.maplibre.compose.MapUiSettings
|
|
||||||
import org.maplibre.android.camera.CameraPosition
|
|
||||||
import org.maplibre.android.geometry.LatLng
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,
|
||||||
|
ornamentOptions = OrnamentOptions(
|
||||||
|
isLogoEnabled = true,
|
||||||
|
logoAlignment = Alignment.BottomStart,
|
||||||
|
isAttributionEnabled = true,
|
||||||
|
attributionAlignment = Alignment.BottomEnd,
|
||||||
|
isCompassEnabled = false,
|
||||||
|
isScaleBarEnabled = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
val uiSettings: MapUiSettings
|
val uiSettings: MapUiSettings
|
||||||
@Composable
|
@Composable
|
||||||
@ReadOnlyComposable
|
@ReadOnlyComposable
|
||||||
|
|
@ -60,6 +69,8 @@ object MapDefaults {
|
||||||
.zoom(2.7)
|
.zoom(2.7)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
const val DEFAULT_ZOOM = 15.0
|
const val DEFAULT_ZOOM = 15.0
|
||||||
|
|
||||||
val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
|
val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
package io.element.android.features.location.impl.common.ui
|
package io.element.android.features.location.impl.common.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -30,13 +32,11 @@ internal fun LocationFloatingActionButton(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
shape = FloatingActionButtonDefaults.smallShape,
|
shape = CircleShape,
|
||||||
containerColor = ElementTheme.colors.bgCanvasDefault,
|
containerColor = ElementTheme.colors.bgCanvasDefault,
|
||||||
contentColor = ElementTheme.colors.iconPrimary,
|
contentColor = ElementTheme.colors.iconPrimary,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier
|
modifier = modifier.size(48.dp),
|
||||||
// Note: design is 40dp, but min is 48 for accessibility.
|
|
||||||
.size(48.dp),
|
|
||||||
) {
|
) {
|
||||||
val iconImage = if (isMapCenteredOnUser) {
|
val iconImage = if (isMapCenteredOnUser) {
|
||||||
CompoundIcons.LocationNavigatorCentred()
|
CompoundIcons.LocationNavigatorCentred()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
|
import androidx.compose.material3.BottomSheetDefaults
|
||||||
|
import androidx.compose.material3.BottomSheetScaffoldState
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
|
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.max
|
||||||
|
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
||||||
|
import io.element.android.features.location.impl.common.MapDefaults
|
||||||
|
import io.element.android.libraries.core.data.tryOrNull
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import org.maplibre.compose.camera.CameraState
|
||||||
|
import org.maplibre.compose.camera.rememberCameraState
|
||||||
|
import org.maplibre.compose.map.MapOptions
|
||||||
|
import org.maplibre.compose.map.MaplibreMap
|
||||||
|
import org.maplibre.compose.style.BaseStyle
|
||||||
|
import org.maplibre.compose.util.MaplibreComposable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reusable scaffold component for map views with a bottom sheet.
|
||||||
|
*
|
||||||
|
* Handles the layout complexity of:
|
||||||
|
* - Calculating the visible sheet height dynamically
|
||||||
|
* - Updating camera position padding based on sheet height
|
||||||
|
* - Rendering the MaplibreMap with proper ornament positioning
|
||||||
|
*
|
||||||
|
* @param cameraState The camera state for the map
|
||||||
|
* @param topBar The top app bar content
|
||||||
|
* @param sheetContent The content to display in the bottom sheet
|
||||||
|
* @param modifier Modifier for the root layout
|
||||||
|
* @param scaffoldState State for the bottom sheet scaffold
|
||||||
|
* @param sheetPeekHeight The height of the sheet when collapsed
|
||||||
|
* @param sheetDragHandle Optional drag handle for the sheet
|
||||||
|
* @param sheetSwipeEnabled Whether the sheet can be swiped
|
||||||
|
* @param snackbarHost The snackbar host content
|
||||||
|
* @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.)
|
||||||
|
* @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.)
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MapBottomSheetScaffold(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
|
||||||
|
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
|
||||||
|
),
|
||||||
|
cameraState: CameraState = rememberCameraState(),
|
||||||
|
mapOptions: MapOptions = MapDefaults.options,
|
||||||
|
sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
|
||||||
|
sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
|
||||||
|
sheetSwipeEnabled: Boolean = true,
|
||||||
|
topBar: (@Composable () -> Unit)? = null,
|
||||||
|
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
|
||||||
|
sheetContent: @Composable ColumnScope.() -> Unit = {},
|
||||||
|
mapContent: @Composable @MaplibreComposable () -> Unit = {},
|
||||||
|
overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier = modifier.safeDrawingPadding()) {
|
||||||
|
val layoutHeightPx by rememberUpdatedState(constraints.maxHeight)
|
||||||
|
val sheetPadding by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val sheetOffset = tryOrNull { scaffoldState.bottomSheetState.requireOffset() } ?: 0f
|
||||||
|
val sheetVisibleHeightPx = layoutHeightPx - sheetOffset
|
||||||
|
val bottomPadding = with(density) { max(sheetVisibleHeightPx.roundToInt().toDp(), 0.dp) }
|
||||||
|
PaddingValues(bottom = bottomPadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Update camera position when sheet padding changes
|
||||||
|
LaunchedEffect(sheetPadding) {
|
||||||
|
cameraState.position = cameraState.position.copy(padding = sheetPadding)
|
||||||
|
}
|
||||||
|
|
||||||
|
BottomSheetScaffold(
|
||||||
|
sheetPeekHeight = sheetPeekHeight,
|
||||||
|
sheetContent = {
|
||||||
|
sheetContent()
|
||||||
|
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||||
|
},
|
||||||
|
scaffoldState = scaffoldState,
|
||||||
|
sheetDragHandle = sheetDragHandle,
|
||||||
|
sheetSwipeEnabled = sheetSwipeEnabled,
|
||||||
|
snackbarHost = snackbarHost,
|
||||||
|
topBar = topBar,
|
||||||
|
) {
|
||||||
|
val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding)
|
||||||
|
Box {
|
||||||
|
MaplibreMap(
|
||||||
|
options = mapOptions.copy(ornamentOptions = ornamentOptions),
|
||||||
|
baseStyle = BaseStyle.Uri(rememberTileStyleUrl()),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
cameraState = cameraState,
|
||||||
|
content = mapContent,
|
||||||
|
)
|
||||||
|
overlayContent(sheetPadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2026 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.location.impl.common.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import org.maplibre.compose.camera.CameraState
|
||||||
|
import org.maplibre.compose.location.LocationPuck
|
||||||
|
import org.maplibre.compose.location.LocationPuckColors
|
||||||
|
import org.maplibre.compose.location.LocationPuckSizes
|
||||||
|
import org.maplibre.compose.location.LocationTrackingEffect
|
||||||
|
import org.maplibre.compose.location.UserLocationState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserLocation(
|
||||||
|
cameraState: CameraState,
|
||||||
|
locationState: UserLocationState,
|
||||||
|
trackUserLocation: Boolean,
|
||||||
|
) {
|
||||||
|
LocationTrackingEffect(
|
||||||
|
locationState = locationState,
|
||||||
|
enabled = trackUserLocation,
|
||||||
|
) {
|
||||||
|
cameraState.updateFromLocation()
|
||||||
|
}
|
||||||
|
val location = locationState.location
|
||||||
|
if (location != null) {
|
||||||
|
LocationPuck(
|
||||||
|
idPrefix = "user-location",
|
||||||
|
locationState = locationState,
|
||||||
|
cameraState = cameraState,
|
||||||
|
accuracyThreshold = Float.POSITIVE_INFINITY,
|
||||||
|
showBearingAccuracy = false,
|
||||||
|
showBearing = false,
|
||||||
|
sizes = LocationPuckSizes(
|
||||||
|
dotRadius = 8.dp,
|
||||||
|
dotStrokeWidth = 2.dp,
|
||||||
|
),
|
||||||
|
colors = LocationPuckColors(
|
||||||
|
dotFillColorCurrentLocation = ElementTheme.colors.iconAccentPrimary,
|
||||||
|
dotFillColorOldLocation = ElementTheme.colors.iconAccentTertiary,
|
||||||
|
dotStrokeColor = ElementTheme.colors.bgCanvasDefault,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,21 +13,15 @@ import kotlin.time.Duration
|
||||||
|
|
||||||
sealed interface ShareLocationEvent {
|
sealed interface ShareLocationEvent {
|
||||||
data class ShareStaticLocation(
|
data class ShareStaticLocation(
|
||||||
val cameraPosition: CameraPosition,
|
val location: Location,
|
||||||
val location: Location?,
|
val isPinned: Boolean,
|
||||||
) : ShareLocationEvent {
|
) : ShareLocationEvent
|
||||||
data class CameraPosition(
|
|
||||||
val lat: Double,
|
|
||||||
val lon: Double,
|
|
||||||
val zoom: Double,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data object SelectLiveLocationDuration : ShareLocationEvent
|
data object ShowLiveLocationDurationPicker : ShareLocationEvent
|
||||||
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
|
data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent
|
||||||
|
|
||||||
data object SwitchToMyLocationMode : ShareLocationEvent
|
data object StartTrackingUserPosition : ShareLocationEvent
|
||||||
data object SwitchToPinLocationMode : ShareLocationEvent
|
data object StopTrackingUserPosition : ShareLocationEvent
|
||||||
data object DismissDialog : ShareLocationEvent
|
data object DismissDialog : ShareLocationEvent
|
||||||
data object RequestPermissions : ShareLocationEvent
|
data object RequestPermissions : ShareLocationEvent
|
||||||
data object OpenAppSettings : ShareLocationEvent
|
data object OpenAppSettings : ShareLocationEvent
|
||||||
|
|
|
||||||
|
|
@ -61,15 +61,7 @@ class ShareLocationPresenter(
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): ShareLocationState {
|
override fun present(): ShareLocationState {
|
||||||
val permissionsState: PermissionsState = permissionsPresenter.present()
|
val permissionsState: PermissionsState = permissionsPresenter.present()
|
||||||
var mode: ShareLocationState.Mode by remember {
|
var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted) }
|
||||||
mutableStateOf(
|
|
||||||
if (permissionsState.isAnyGranted) {
|
|
||||||
ShareLocationState.Mode.SenderLocation
|
|
||||||
} else {
|
|
||||||
ShareLocationState.Mode.PinLocation
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val isLiveLocationSharingEnabled by remember {
|
val isLiveLocationSharingEnabled by remember {
|
||||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing)
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing)
|
||||||
}.collectAsState(false)
|
}.collectAsState(false)
|
||||||
|
|
@ -81,7 +73,7 @@ class ShareLocationPresenter(
|
||||||
|
|
||||||
LaunchedEffect(permissionsState.permissions) {
|
LaunchedEffect(permissionsState.permissions) {
|
||||||
if (permissionsState.isAnyGranted) {
|
if (permissionsState.isAnyGranted) {
|
||||||
mode = ShareLocationState.Mode.SenderLocation
|
trackUserPosition = true
|
||||||
dialogState = ShareLocationState.Dialog.None
|
dialogState = ShareLocationState.Dialog.None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -89,21 +81,21 @@ class ShareLocationPresenter(
|
||||||
fun handleEvent(event: ShareLocationEvent) {
|
fun handleEvent(event: ShareLocationEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is ShareLocationEvent.ShareStaticLocation -> scope.launch {
|
is ShareLocationEvent.ShareStaticLocation -> scope.launch {
|
||||||
shareLocation(event, mode)
|
shareStaticLocation(event)
|
||||||
}
|
}
|
||||||
ShareLocationEvent.SwitchToMyLocationMode -> when {
|
ShareLocationEvent.StartTrackingUserPosition -> when {
|
||||||
permissionsState.isAnyGranted -> mode = ShareLocationState.Mode.SenderLocation
|
permissionsState.isAnyGranted -> trackUserPosition = true
|
||||||
permissionsState.shouldShowRationale -> dialogState = ShareLocationState.Dialog.PermissionRationale
|
permissionsState.shouldShowRationale -> dialogState = ShareLocationState.Dialog.PermissionRationale
|
||||||
else -> dialogState = ShareLocationState.Dialog.PermissionDenied
|
else -> dialogState = ShareLocationState.Dialog.PermissionDenied
|
||||||
}
|
}
|
||||||
ShareLocationEvent.SwitchToPinLocationMode -> mode = ShareLocationState.Mode.PinLocation
|
ShareLocationEvent.StopTrackingUserPosition -> trackUserPosition = false
|
||||||
ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None
|
ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None
|
||||||
ShareLocationEvent.OpenAppSettings -> {
|
ShareLocationEvent.OpenAppSettings -> {
|
||||||
locationActions.openSettings()
|
locationActions.openSettings()
|
||||||
dialogState = ShareLocationState.Dialog.None
|
dialogState = ShareLocationState.Dialog.None
|
||||||
}
|
}
|
||||||
ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions)
|
||||||
ShareLocationEvent.SelectLiveLocationDuration -> dialogState = when {
|
ShareLocationEvent.ShowLiveLocationDurationPicker -> dialogState = when {
|
||||||
permissionsState.isAnyGranted -> ShareLocationState.Dialog.LiveLocationDuration
|
permissionsState.isAnyGranted -> ShareLocationState.Dialog.LiveLocationDuration
|
||||||
permissionsState.shouldShowRationale -> ShareLocationState.Dialog.PermissionRationale
|
permissionsState.shouldShowRationale -> ShareLocationState.Dialog.PermissionRationale
|
||||||
else -> ShareLocationState.Dialog.PermissionDenied
|
else -> ShareLocationState.Dialog.PermissionDenied
|
||||||
|
|
@ -117,7 +109,7 @@ class ShareLocationPresenter(
|
||||||
|
|
||||||
return ShareLocationState(
|
return ShareLocationState(
|
||||||
dialogState = dialogState,
|
dialogState = dialogState,
|
||||||
mode = mode,
|
trackUserLocation = trackUserPosition,
|
||||||
hasLocationPermission = permissionsState.isAnyGranted,
|
hasLocationPermission = permissionsState.isAnyGranted,
|
||||||
canShareLiveLocation = isLiveLocationSharingEnabled,
|
canShareLiveLocation = isLiveLocationSharingEnabled,
|
||||||
appName = appName,
|
appName = appName,
|
||||||
|
|
@ -125,56 +117,28 @@ class ShareLocationPresenter(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun shareLocation(
|
private suspend fun shareStaticLocation(event: ShareLocationEvent.ShareStaticLocation) {
|
||||||
event: ShareLocationEvent.ShareStaticLocation,
|
|
||||||
mode: ShareLocationState.Mode,
|
|
||||||
) {
|
|
||||||
val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply
|
val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply
|
||||||
val inReplyToEventId = replyMode?.eventId
|
val inReplyToEventId = replyMode?.eventId
|
||||||
when (mode) {
|
val geoUri = event.location.toGeoUri()
|
||||||
ShareLocationState.Mode.PinLocation -> {
|
getTimeline().flatMap {
|
||||||
val geoUri = event.cameraPosition.toGeoUri()
|
it.sendLocation(
|
||||||
getTimeline().flatMap {
|
body = generateBody(geoUri),
|
||||||
it.sendLocation(
|
geoUri = geoUri,
|
||||||
body = generateBody(geoUri),
|
description = null,
|
||||||
geoUri = geoUri,
|
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
|
||||||
description = null,
|
assetType = if (event.isPinned) AssetType.PIN else AssetType.SENDER,
|
||||||
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
|
inReplyToEventId = inReplyToEventId,
|
||||||
assetType = AssetType.PIN,
|
)
|
||||||
inReplyToEventId = inReplyToEventId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
analyticsService.capture(
|
|
||||||
Composer(
|
|
||||||
inThread = messageComposerContext.composerMode.inThread,
|
|
||||||
isEditing = messageComposerContext.composerMode.isEditing,
|
|
||||||
isReply = messageComposerContext.composerMode.isReply,
|
|
||||||
messageType = Composer.MessageType.LocationPin,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ShareLocationState.Mode.SenderLocation -> {
|
|
||||||
val geoUri = event.toGeoUri()
|
|
||||||
getTimeline().flatMap {
|
|
||||||
it.sendLocation(
|
|
||||||
body = generateBody(geoUri),
|
|
||||||
geoUri = geoUri,
|
|
||||||
description = null,
|
|
||||||
zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(),
|
|
||||||
assetType = AssetType.SENDER,
|
|
||||||
inReplyToEventId = inReplyToEventId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
analyticsService.capture(
|
|
||||||
Composer(
|
|
||||||
inThread = messageComposerContext.composerMode.inThread,
|
|
||||||
isEditing = messageComposerContext.composerMode.isEditing,
|
|
||||||
isReply = messageComposerContext.composerMode.isReply,
|
|
||||||
messageType = Composer.MessageType.LocationUser,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
analyticsService.capture(
|
||||||
|
Composer(
|
||||||
|
inThread = messageComposerContext.composerMode.inThread,
|
||||||
|
isEditing = messageComposerContext.composerMode.isEditing,
|
||||||
|
isReply = messageComposerContext.composerMode.isReply,
|
||||||
|
messageType = if (event.isPinned) Composer.MessageType.LocationPin else Composer.MessageType.LocationUser
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getTimeline(): Result<Timeline> {
|
private suspend fun getTimeline(): Result<Timeline> {
|
||||||
|
|
@ -185,8 +149,4 @@ class ShareLocationPresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ShareLocationEvent.ShareStaticLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri()
|
|
||||||
|
|
||||||
private fun ShareLocationEvent.ShareStaticLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon"
|
|
||||||
|
|
||||||
private fun generateBody(uri: String): String = "Location was shared at $uri"
|
private fun generateBody(uri: String): String = "Location was shared at $uri"
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,12 @@ package io.element.android.features.location.impl.share
|
||||||
|
|
||||||
data class ShareLocationState(
|
data class ShareLocationState(
|
||||||
val dialogState: Dialog,
|
val dialogState: Dialog,
|
||||||
val mode: Mode,
|
val trackUserLocation: Boolean,
|
||||||
val hasLocationPermission: Boolean,
|
val hasLocationPermission: Boolean,
|
||||||
val appName: String,
|
val appName: String,
|
||||||
val canShareLiveLocation: Boolean,
|
val canShareLiveLocation: Boolean,
|
||||||
val eventSink: (ShareLocationEvent) -> Unit,
|
val eventSink: (ShareLocationEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
sealed interface Mode {
|
|
||||||
data object SenderLocation : Mode
|
|
||||||
data object PinLocation : Mode
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed interface Dialog {
|
sealed interface Dialog {
|
||||||
data object None : Dialog
|
data object None : Dialog
|
||||||
data object PermissionRationale : Dialog
|
data object PermissionRationale : Dialog
|
||||||
|
|
|
||||||
|
|
@ -17,32 +17,32 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aShareLocationState(
|
aShareLocationState(
|
||||||
permissionDialog = ShareLocationState.Dialog.None,
|
permissionDialog = ShareLocationState.Dialog.None,
|
||||||
mode = ShareLocationState.Mode.PinLocation,
|
trackUserPosition = false,
|
||||||
hasLocationPermission = false,
|
hasLocationPermission = false,
|
||||||
),
|
),
|
||||||
aShareLocationState(
|
aShareLocationState(
|
||||||
permissionDialog = ShareLocationState.Dialog.PermissionDenied,
|
permissionDialog = ShareLocationState.Dialog.PermissionDenied,
|
||||||
mode = ShareLocationState.Mode.PinLocation,
|
trackUserPosition = false,
|
||||||
hasLocationPermission = false,
|
hasLocationPermission = false,
|
||||||
),
|
),
|
||||||
aShareLocationState(
|
aShareLocationState(
|
||||||
permissionDialog = ShareLocationState.Dialog.PermissionRationale,
|
permissionDialog = ShareLocationState.Dialog.PermissionRationale,
|
||||||
mode = ShareLocationState.Mode.PinLocation,
|
trackUserPosition = false,
|
||||||
hasLocationPermission = false,
|
hasLocationPermission = false,
|
||||||
),
|
),
|
||||||
aShareLocationState(
|
aShareLocationState(
|
||||||
permissionDialog = ShareLocationState.Dialog.None,
|
permissionDialog = ShareLocationState.Dialog.None,
|
||||||
mode = ShareLocationState.Mode.PinLocation,
|
trackUserPosition = false,
|
||||||
hasLocationPermission = true,
|
hasLocationPermission = true,
|
||||||
),
|
),
|
||||||
aShareLocationState(
|
aShareLocationState(
|
||||||
permissionDialog = ShareLocationState.Dialog.None,
|
permissionDialog = ShareLocationState.Dialog.None,
|
||||||
mode = ShareLocationState.Mode.SenderLocation,
|
trackUserPosition = true,
|
||||||
hasLocationPermission = true,
|
hasLocationPermission = true,
|
||||||
),
|
),
|
||||||
aShareLocationState(
|
aShareLocationState(
|
||||||
permissionDialog = ShareLocationState.Dialog.LiveLocationDuration,
|
permissionDialog = ShareLocationState.Dialog.LiveLocationDuration,
|
||||||
mode = ShareLocationState.Mode.SenderLocation,
|
trackUserPosition = true,
|
||||||
hasLocationPermission = true,
|
hasLocationPermission = true,
|
||||||
canShareLiveLocation = true,
|
canShareLiveLocation = true,
|
||||||
),
|
),
|
||||||
|
|
@ -51,13 +51,13 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
|
||||||
|
|
||||||
private fun aShareLocationState(
|
private fun aShareLocationState(
|
||||||
permissionDialog: ShareLocationState.Dialog,
|
permissionDialog: ShareLocationState.Dialog,
|
||||||
mode: ShareLocationState.Mode,
|
trackUserPosition: Boolean,
|
||||||
hasLocationPermission: Boolean,
|
hasLocationPermission: Boolean,
|
||||||
canShareLiveLocation: Boolean = false,
|
canShareLiveLocation: Boolean = false,
|
||||||
): ShareLocationState {
|
): ShareLocationState {
|
||||||
return ShareLocationState(
|
return ShareLocationState(
|
||||||
dialogState = permissionDialog,
|
dialogState = permissionDialog,
|
||||||
mode = mode,
|
trackUserLocation = trackUserPosition,
|
||||||
hasLocationPermission = hasLocationPermission,
|
hasLocationPermission = hasLocationPermission,
|
||||||
canShareLiveLocation = canShareLiveLocation,
|
canShareLiveLocation = canShareLiveLocation,
|
||||||
appName = APP_NAME,
|
appName = APP_NAME,
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,14 @@ import androidx.compose.foundation.clickable
|
||||||
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.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SheetValue
|
import androidx.compose.material3.SheetValue
|
||||||
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
import androidx.compose.material3.rememberBottomSheetScaffoldState
|
||||||
import androidx.compose.material3.rememberStandardBottomSheetState
|
import androidx.compose.material3.rememberStandardBottomSheetState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
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
|
||||||
|
|
@ -40,32 +36,36 @@ 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.common.MapDefaults
|
import io.element.android.features.location.impl.common.MapDefaults
|
||||||
import io.element.android.features.location.impl.common.PermissionDeniedDialog
|
import io.element.android.features.location.impl.common.PermissionDeniedDialog
|
||||||
import io.element.android.features.location.impl.common.PermissionRationaleDialog
|
import io.element.android.features.location.impl.common.PermissionRationaleDialog
|
||||||
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
|
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
|
||||||
|
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
|
||||||
|
import io.element.android.features.location.impl.common.ui.UserLocation
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ListDialog
|
||||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||||
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
|
import io.element.android.libraries.designsystem.components.list.RadioButtonListItem
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
import io.element.android.libraries.maplibre.compose.CameraMode
|
|
||||||
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
|
|
||||||
import io.element.android.libraries.maplibre.compose.CameraPositionState
|
|
||||||
import io.element.android.libraries.maplibre.compose.MapLibreMap
|
|
||||||
import io.element.android.libraries.maplibre.compose.rememberCameraPositionState
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import org.maplibre.android.camera.CameraPosition
|
import org.maplibre.compose.camera.CameraMoveReason
|
||||||
|
import org.maplibre.compose.camera.CameraPosition
|
||||||
|
import org.maplibre.compose.camera.CameraState
|
||||||
|
import org.maplibre.compose.camera.rememberCameraState
|
||||||
|
import org.maplibre.compose.location.DesiredAccuracy
|
||||||
|
import org.maplibre.compose.location.UserLocationState
|
||||||
|
import org.maplibre.compose.location.rememberDefaultLocationProvider
|
||||||
|
import org.maplibre.compose.location.rememberNullLocationProvider
|
||||||
|
import org.maplibre.compose.location.rememberUserLocationState
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -99,76 +99,32 @@ fun ShareLocationView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cameraPositionState = rememberCameraPositionState {
|
val scaffoldState = rememberBottomSheetScaffoldState(
|
||||||
position = MapDefaults.centerCameraPosition
|
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded)
|
||||||
|
)
|
||||||
|
val cameraState = rememberCameraState(firstPosition = CameraPosition(zoom = MapDefaults.DEFAULT_ZOOM))
|
||||||
|
val locationProvider = if (state.hasLocationPermission) {
|
||||||
|
rememberDefaultLocationProvider(
|
||||||
|
updateInterval = 1.minutes,
|
||||||
|
desiredAccuracy = DesiredAccuracy.Balanced,
|
||||||
|
minDistanceMeters = 50.0,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
rememberNullLocationProvider()
|
||||||
}
|
}
|
||||||
|
val userLocationState = rememberUserLocationState(locationProvider)
|
||||||
|
|
||||||
LaunchedEffect(state.mode) {
|
LaunchedEffect(cameraState.isCameraMoving) {
|
||||||
when (state.mode) {
|
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
|
||||||
ShareLocationState.Mode.PinLocation -> {
|
state.eventSink(ShareLocationEvent.StopTrackingUserPosition)
|
||||||
cameraPositionState.cameraMode = CameraMode.NONE
|
|
||||||
}
|
|
||||||
ShareLocationState.Mode.SenderLocation -> {
|
|
||||||
cameraPositionState.position = CameraPosition.Builder()
|
|
||||||
.zoom(MapDefaults.DEFAULT_ZOOM)
|
|
||||||
.build()
|
|
||||||
cameraPositionState.cameraMode = CameraMode.TRACKING
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(cameraPositionState.isMoving) {
|
MapBottomSheetScaffold(
|
||||||
if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
|
cameraState = cameraState,
|
||||||
state.eventSink(ShareLocationEvent.SwitchToPinLocationMode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BottomSheetScaffold doesn't manage the system insets for sheetContent and the FAB, so we need to do it manually.
|
|
||||||
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
|
||||||
|
|
||||||
BottomSheetScaffold(
|
|
||||||
sheetContent = {
|
|
||||||
Spacer(Modifier.height(20.dp))
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
text = "Sharing options",
|
|
||||||
style = ElementTheme.typography.fontBodyLgMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
StaticLocationItem(state.mode, cameraPositionState){
|
|
||||||
val positionTarget = cameraPositionState.position.target ?: return@StaticLocationItem
|
|
||||||
state.eventSink(
|
|
||||||
ShareLocationEvent.ShareStaticLocation(
|
|
||||||
cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition(
|
|
||||||
lat = positionTarget.latitude,
|
|
||||||
lon = positionTarget.longitude,
|
|
||||||
zoom = cameraPositionState.position.zoom,
|
|
||||||
),
|
|
||||||
location = cameraPositionState.location?.let {
|
|
||||||
Location(
|
|
||||||
lat = it.latitude,
|
|
||||||
lon = it.longitude,
|
|
||||||
accuracy = it.accuracy,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
navigateUp()
|
|
||||||
}
|
|
||||||
if(state.canShareLiveLocation){
|
|
||||||
LiveLocationItem {
|
|
||||||
state.eventSink(ShareLocationEvent.SelectLiveLocationDuration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(16.dp + navBarPadding))
|
|
||||||
},
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
scaffoldState = rememberBottomSheetScaffoldState(
|
scaffoldState = scaffoldState,
|
||||||
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded),
|
sheetDragHandle = null,
|
||||||
),
|
|
||||||
sheetDragHandle = {},
|
|
||||||
sheetSwipeEnabled = false,
|
sheetSwipeEnabled = false,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -178,62 +134,102 @@ fun ShareLocationView(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) {
|
sheetContent = {
|
||||||
Box(
|
BottomSheetContent(
|
||||||
modifier = Modifier
|
cameraState = cameraState,
|
||||||
.padding(it)
|
state = state,
|
||||||
.consumeWindowInsets(it),
|
userLocationState = userLocationState,
|
||||||
contentAlignment = Alignment.Center
|
navigateUp = navigateUp
|
||||||
) {
|
|
||||||
MapLibreMap(
|
|
||||||
styleUri = rememberTileStyleUrl(),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
cameraPositionState = cameraPositionState,
|
|
||||||
uiSettings = MapDefaults.uiSettings,
|
|
||||||
symbolManagerSettings = MapDefaults.symbolManagerSettings,
|
|
||||||
locationSettings = MapDefaults.locationSettings.copy(
|
|
||||||
locationEnabled = state.hasLocationPermission,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
Icon(
|
},
|
||||||
resourceId = CommonDrawables.pin,
|
mapContent = {
|
||||||
contentDescription = null,
|
UserLocation(
|
||||||
tint = Color.Unspecified,
|
cameraState = cameraState,
|
||||||
modifier = Modifier.centerBottomEdge(this),
|
locationState = userLocationState,
|
||||||
|
trackUserLocation = state.trackUserLocation
|
||||||
)
|
)
|
||||||
LocationFloatingActionButton(
|
},
|
||||||
isMapCenteredOnUser = state.mode == ShareLocationState.Mode.SenderLocation,
|
overlayContent = { sheetPadding ->
|
||||||
onClick = { state.eventSink(ShareLocationEvent.SwitchToMyLocationMode) },
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.fillMaxSize()
|
||||||
.padding(end = 18.dp, bottom = 72.dp + navBarPadding),
|
.padding(sheetPadding)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
resourceId = CommonDrawables.pin,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Unspecified,
|
||||||
|
modifier = Modifier.centerBottomEdge(this),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LocationFloatingActionButton(
|
||||||
|
isMapCenteredOnUser = state.trackUserLocation,
|
||||||
|
onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserPosition) },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(all = 16.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BottomSheetContent(
|
||||||
|
cameraState: CameraState,
|
||||||
|
state: ShareLocationState,
|
||||||
|
userLocationState: UserLocationState,
|
||||||
|
navigateUp: () -> Unit,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
SharePinLocationItem(
|
||||||
|
onClick = {
|
||||||
|
val positionTarget = cameraState.position.target
|
||||||
|
state.eventSink(
|
||||||
|
ShareLocationEvent.ShareStaticLocation(
|
||||||
|
location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude),
|
||||||
|
isPinned = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ShareCurrentLocationItem(
|
||||||
|
onClick = {
|
||||||
|
val userLocation = userLocationState.location
|
||||||
|
if (state.hasLocationPermission) {
|
||||||
|
if (userLocation == null) {
|
||||||
|
//
|
||||||
|
} else {
|
||||||
|
state.eventSink(
|
||||||
|
ShareLocationEvent.ShareStaticLocation(
|
||||||
|
location = Location(
|
||||||
|
lat = userLocation.position.latitude,
|
||||||
|
lon = userLocation.position.longitude
|
||||||
|
),
|
||||||
|
isPinned = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
navigateUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (state.canShareLiveLocation) {
|
||||||
|
ShareLiveLocationItem {
|
||||||
|
state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun StaticLocationItem(
|
private fun ShareCurrentLocationItem(
|
||||||
mode: ShareLocationState.Mode,
|
onClick: () -> Unit,
|
||||||
cameraPositionState: CameraPositionState,
|
|
||||||
onClick: ()->Unit,
|
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(
|
Text(stringResource(CommonStrings.screen_share_my_location_action))
|
||||||
stringResource(
|
|
||||||
when (mode) {
|
|
||||||
ShareLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action
|
|
||||||
ShareLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.clickable(
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
// target is null when the map hasn't loaded (or api key is wrong) so we disable the button
|
|
||||||
enabled = cameraPositionState.position.target != null,
|
|
||||||
onClick = onClick
|
|
||||||
),
|
|
||||||
leadingContent = ListItemContent.Icon(
|
leadingContent = ListItemContent.Icon(
|
||||||
iconSource = IconSource.Vector(CompoundIcons.LocationNavigatorCentred())
|
iconSource = IconSource.Vector(CompoundIcons.LocationNavigatorCentred())
|
||||||
)
|
)
|
||||||
|
|
@ -241,7 +237,22 @@ private fun StaticLocationItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LiveLocationItem(
|
private fun SharePinLocationItem(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(stringResource(CommonStrings.screen_share_this_location_action))
|
||||||
|
},
|
||||||
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
|
leadingContent = ListItemContent.Icon(
|
||||||
|
iconSource = IconSource.Vector(CompoundIcons.LocationNavigator())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ShareLiveLocationItem(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
ListItem(
|
ListItem(
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,16 @@
|
||||||
|
|
||||||
package io.element.android.features.location.impl.show
|
package io.element.android.features.location.impl.show
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
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.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
@ -24,31 +25,35 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.compound.tokens.generated.TypographyTokens
|
import io.element.android.compound.tokens.generated.TypographyTokens
|
||||||
import io.element.android.features.location.api.internal.rememberTileStyleUrl
|
import io.element.android.features.location.impl.R
|
||||||
import io.element.android.features.location.impl.common.MapDefaults
|
import io.element.android.features.location.impl.common.MapDefaults
|
||||||
import io.element.android.features.location.impl.common.PermissionDeniedDialog
|
import io.element.android.features.location.impl.common.PermissionDeniedDialog
|
||||||
import io.element.android.features.location.impl.common.PermissionRationaleDialog
|
import io.element.android.features.location.impl.common.PermissionRationaleDialog
|
||||||
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
|
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
|
||||||
|
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
|
||||||
|
import io.element.android.features.location.impl.common.ui.UserLocation
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
|
||||||
import io.element.android.libraries.maplibre.compose.CameraMode
|
|
||||||
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
|
|
||||||
import io.element.android.libraries.maplibre.compose.IconAnchor
|
|
||||||
import io.element.android.libraries.maplibre.compose.MapLibreMap
|
|
||||||
import io.element.android.libraries.maplibre.compose.Symbol
|
|
||||||
import io.element.android.libraries.maplibre.compose.rememberCameraPositionState
|
|
||||||
import io.element.android.libraries.maplibre.compose.rememberSymbolState
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.toImmutableMap
|
import org.maplibre.compose.camera.CameraMoveReason
|
||||||
import org.maplibre.android.camera.CameraPosition
|
import org.maplibre.compose.camera.CameraPosition
|
||||||
import org.maplibre.android.geometry.LatLng
|
import org.maplibre.compose.camera.rememberCameraState
|
||||||
|
import org.maplibre.compose.expressions.dsl.image
|
||||||
|
import org.maplibre.compose.layers.SymbolLayer
|
||||||
|
import org.maplibre.compose.location.DesiredAccuracy
|
||||||
|
import org.maplibre.compose.location.rememberDefaultLocationProvider
|
||||||
|
import org.maplibre.compose.location.rememberNullLocationProvider
|
||||||
|
import org.maplibre.compose.location.rememberUserLocationState
|
||||||
|
import org.maplibre.compose.sources.GeoJsonData
|
||||||
|
import org.maplibre.compose.sources.rememberGeoJsonSource
|
||||||
|
import org.maplibre.spatialk.geojson.Point
|
||||||
|
import org.maplibre.spatialk.geojson.Position
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -71,33 +76,32 @@ fun ShowLocationView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cameraPositionState = rememberCameraPositionState {
|
val cameraState = rememberCameraState(
|
||||||
position = CameraPosition.Builder()
|
firstPosition = CameraPosition(
|
||||||
.target(LatLng(state.location.lat, state.location.lon))
|
target = Position(latitude = state.location.lat, longitude = state.location.lon),
|
||||||
.zoom(MapDefaults.DEFAULT_ZOOM)
|
zoom = MapDefaults.DEFAULT_ZOOM
|
||||||
.build()
|
)
|
||||||
|
)
|
||||||
|
val locationProvider = if (state.hasLocationPermission) {
|
||||||
|
rememberDefaultLocationProvider(
|
||||||
|
updateInterval = 1.minutes,
|
||||||
|
desiredAccuracy = DesiredAccuracy.Balanced,
|
||||||
|
minDistanceMeters = 50.0,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
rememberNullLocationProvider()
|
||||||
}
|
}
|
||||||
|
val userLocationState = rememberUserLocationState(locationProvider)
|
||||||
LaunchedEffect(state.isTrackMyLocation) {
|
LaunchedEffect(cameraState.isCameraMoving) {
|
||||||
when (state.isTrackMyLocation) {
|
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
|
||||||
false -> cameraPositionState.cameraMode = CameraMode.NONE
|
|
||||||
true -> {
|
|
||||||
cameraPositionState.position = CameraPosition.Builder()
|
|
||||||
.zoom(MapDefaults.DEFAULT_ZOOM)
|
|
||||||
.build()
|
|
||||||
cameraPositionState.cameraMode = CameraMode.TRACKING
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(cameraPositionState.isMoving) {
|
|
||||||
if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) {
|
|
||||||
state.eventSink(ShowLocationEvents.TrackMyLocation(false))
|
state.eventSink(ShowLocationEvents.TrackMyLocation(false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
MapBottomSheetScaffold(
|
||||||
|
cameraState = cameraState,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
sheetPeekHeight = 80.dp,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
titleStr = stringResource(CommonStrings.screen_view_location_title),
|
titleStr = stringResource(CommonStrings.screen_view_location_title),
|
||||||
|
|
@ -118,19 +122,7 @@ fun ShowLocationView(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
sheetContent = {
|
||||||
LocationFloatingActionButton(
|
|
||||||
isMapCenteredOnUser = state.isTrackMyLocation,
|
|
||||||
onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { paddingValues ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(paddingValues)
|
|
||||||
.consumeWindowInsets(paddingValues)
|
|
||||||
.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
state.description?.let {
|
state.description?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
|
|
@ -143,28 +135,40 @@ fun ShowLocationView(
|
||||||
.padding(8.dp),
|
.padding(8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
MapLibreMap(
|
mapContent = {
|
||||||
styleUri = rememberTileStyleUrl(),
|
UserLocation(
|
||||||
modifier = Modifier.fillMaxSize(),
|
cameraState = cameraState,
|
||||||
images = mapOf(PIN_ID to CommonDrawables.pin).toImmutableMap(),
|
locationState = userLocationState,
|
||||||
cameraPositionState = cameraPositionState,
|
trackUserLocation = state.isTrackMyLocation
|
||||||
uiSettings = MapDefaults.uiSettings,
|
)
|
||||||
symbolManagerSettings = MapDefaults.symbolManagerSettings,
|
val senderLocation = rememberGeoJsonSource(
|
||||||
locationSettings = MapDefaults.locationSettings.copy(
|
data = GeoJsonData.Features(
|
||||||
locationEnabled = state.hasLocationPermission,
|
Point(
|
||||||
),
|
Position(
|
||||||
) {
|
latitude = state.location.lat,
|
||||||
Symbol(
|
longitude = state.location.lon
|
||||||
iconId = PIN_ID,
|
)
|
||||||
state = rememberSymbolState(
|
)
|
||||||
position = LatLng(state.location.lat, state.location.lon)
|
|
||||||
),
|
|
||||||
iconAnchor = IconAnchor.BOTTOM,
|
|
||||||
)
|
)
|
||||||
}
|
)
|
||||||
|
val marker = painterResource(R.drawable.pin_small)
|
||||||
|
SymbolLayer(
|
||||||
|
id = "sender_location",
|
||||||
|
source = senderLocation,
|
||||||
|
iconImage = image(marker)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
overlayContent = {
|
||||||
|
LocationFloatingActionButton(
|
||||||
|
isMapCenteredOnUser = state.isTrackMyLocation,
|
||||||
|
onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(all = 16.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
|
|
@ -175,5 +179,3 @@ internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider
|
||||||
onBackClick = {},
|
onBackClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val PIN_ID = "pin"
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue