Start implementing location shares sheet content

This commit is contained in:
ganfra
2026-03-06 18:37:02 +01:00
parent 91626bd217
commit cbec9dbe2c
9 changed files with 271 additions and 31 deletions

View File

@@ -41,9 +41,11 @@ dependencies {
implementation(libs.accompanist.permission)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.dateformatter.api)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
implementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.testtags)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)

View File

@@ -10,10 +10,12 @@ package io.element.android.features.location.impl.common
import android.Manifest
import androidx.compose.ui.Alignment
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.map.GestureOptions
import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.OrnamentOptions
import org.maplibre.compose.map.RenderOptions
import org.maplibre.spatialk.geojson.Position
/**
* Common configuration values for the map.
@@ -64,13 +66,12 @@ object MapDefaults {
pulseColor = Color.Black,
)
val centerCameraPosition = CameraPosition.Builder()
.target(LatLng(49.843, 9.902056))
.zoom(2.7)
.build()
*/
val centerCameraPosition = CameraPosition(
target = Position(49.843, 9.902056),
zoom = 2.7,
)
const val DEFAULT_ZOOM = 15.0
val permissions = listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)

View File

@@ -0,0 +1,143 @@
/*
* 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.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.show.LocationShareItem
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.preview.ElementPreview
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.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LocationShareRow(
item: LocationShareItem,
onShareClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Avatar(
avatarData = item.avatarData,
avatarType = AvatarType.User,
)
Spacer(modifier = Modifier.width(16.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = item.displayName,
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (item.isLive) {
Icon(
imageVector = CompoundIcons.LocationPinSolid(),
contentDescription = null,
tint = ElementTheme.colors.iconAccentPrimary,
modifier = Modifier.size(16.dp),
)
}else {
val icon = if(item.assetType == AssetType.PIN) CompoundIcons.LocationNavigator() else CompoundIcons.LocationNavigatorCentred()
Icon(
imageVector = icon,
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
modifier = Modifier.size(16.dp),
)
}
Text(
text = item.formattedTimestamp,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
IconButton(onClick = onShareClick) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(CommonStrings.action_share),
tint = ElementTheme.colors.iconPrimary,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun LocationShareRowPreview() = ElementPreview {
Column {
LocationShareRow(
item = LocationShareItem(
userId = UserId("@alice:matrix.org"),
displayName = "Alice",
avatarData = AvatarData(
id = "@alice:matrix.org",
name = "Alice",
url = null,
size = AvatarSize.UserListItem,
),
formattedTimestamp = "Shared 1 min ago",
assetType = AssetType.SENDER,
isLive = true,
location = Location(0.0,0.0)
),
onShareClick = {},
)
LocationShareRow(
item = LocationShareItem(
userId = UserId("@bob:matrix.org"),
displayName = "Bob",
avatarData = AvatarData(
id = "@bob:matrix.org",
name = "Bob",
url = null,
size = AvatarSize.UserListItem,
),
assetType = AssetType.PIN,
formattedTimestamp = "Shared 5 hours ago",
isLive = false,
location = Location(0.0,0.0)
),
onShareClick = {},
)
}
}

View File

@@ -88,7 +88,7 @@ fun MapBottomSheetScaffold(
sheetSwipeEnabled: Boolean = true,
topBar: (@Composable () -> Unit)? = null,
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
sheetContent: @Composable ColumnScope.() -> Unit = {},
sheetContent: @Composable ColumnScope.(PaddingValues) -> Unit = {},
mapContent: @Composable @MaplibreComposable () -> Unit = {},
overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {},
) {
@@ -113,7 +113,7 @@ fun MapBottomSheetScaffold(
modifier = Modifier,
sheetPeekHeight = sheetPeekHeight,
sheetContent = {
sheetContent()
sheetContent(sheetPadding)
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
},
scaffoldState = scaffoldState,

View File

@@ -8,8 +8,10 @@
package io.element.android.features.location.impl.show
import io.element.android.features.location.api.Location
sealed interface ShowLocationEvents {
data object Share : ShowLocationEvents
data class Share(val location: Location) : ShowLocationEvents
data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents
data object DismissDialog : ShowLocationEvents
data object RequestPermissions : ShowLocationEvents

View File

@@ -27,6 +27,8 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.location.impl.common.ui.LocationMarkerData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -38,6 +40,7 @@ class ShowLocationPresenter(
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val locationActions: LocationActions,
private val buildMeta: BuildMeta,
private val dateFormatter: DateFormatter,
) : Presenter<ShowLocationState> {
@AssistedFactory
fun interface Factory {
@@ -63,15 +66,8 @@ class ShowLocationPresenter(
fun handleEvent(event: ShowLocationEvents) {
when (event) {
ShowLocationEvents.Share -> {
when (mode) {
is ShowLocationMode.Static -> {
locationActions.share(mode.location, null)
}
ShowLocationMode.Live -> {
// TODO: Handle sharing for live locations
}
}
is ShowLocationEvents.Share -> {
locationActions.share(event.location, null)
}
is ShowLocationEvents.TrackMyLocation -> {
if (event.enabled) {
@@ -121,10 +117,37 @@ class ShowLocationPresenter(
}
}
val locationShares = remember(mode) {
when (mode) {
is ShowLocationMode.Static -> {
val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true)
val formattedTimestamp = "Shared $relativeTime"
listOf(
LocationShareItem(
userId = mode.senderId,
displayName = mode.senderName,
avatarData = AvatarData(
id = mode.senderId.value,
name = mode.senderName,
url = mode.senderAvatarUrl,
size = AvatarSize.UserListItem,
),
formattedTimestamp = formattedTimestamp,
isLive = false,
assetType = mode.assetType,
location = mode.location,
)
)
}
ShowLocationMode.Live -> emptyList()
}
}
return ShowLocationState(
permissionDialog = permissionDialog,
mode = mode,
markers = markers,
locationShares = locationShares,
hasLocationPermission = permissionsState.isAnyGranted,
isTrackMyLocation = isTrackMyLocation,
appName = appName,

View File

@@ -8,21 +8,39 @@
package io.element.android.features.location.impl.show
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.common.ui.LocationMarkerData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
data class ShowLocationState(
val permissionDialog: Dialog,
val mode: ShowLocationMode,
val markers: List<LocationMarkerData>,
val locationShares: List<LocationShareItem>,
val hasLocationPermission: Boolean,
val isTrackMyLocation: Boolean,
val appName: String,
val eventSink: (ShowLocationEvents) -> Unit,
) {
val isSheetDraggable = locationShares.any { item -> item.isLive }
sealed interface Dialog {
data object None : Dialog
data object PermissionRationale : Dialog
data object PermissionDenied : Dialog
}
}
data class LocationShareItem(
val userId: UserId,
val displayName: String,
val avatarData: AvatarData,
val formattedTimestamp: String,
val location: Location,
val isLive: Boolean,
val assetType: AssetType?,
)

View File

@@ -55,6 +55,7 @@ fun aShowLocationState(
permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None,
mode: ShowLocationMode = aStaticLocationMode(),
markers: List<LocationMarkerData>? = null,
locationSharers: List<LocationShareItem>? = null,
hasLocationPermission: Boolean = false,
isTrackMyLocation: Boolean = false,
appName: String = APP_NAME,
@@ -82,10 +83,30 @@ fun aShowLocationState(
)
ShowLocationMode.Live -> emptyList()
}
val effectiveLocationSharers = locationSharers ?: when (mode) {
is ShowLocationMode.Static -> listOf(
LocationShareItem(
userId = mode.senderId,
displayName = mode.senderName,
avatarData = AvatarData(
id = mode.senderId.value,
name = mode.senderName,
url = mode.senderAvatarUrl,
size = AvatarSize.UserListItem,
),
formattedTimestamp = "Shared 1 min ago",
isLive = false,
assetType = mode.assetType,
location = mode.location,
)
)
ShowLocationMode.Live -> emptyList()
}
return ShowLocationState(
permissionDialog = permissionDialog,
mode = mode,
markers = effectiveMarkers,
locationShares = effectiveLocationSharers,
hasLocationPermission = hasLocationPermission,
isTrackMyLocation = isTrackMyLocation,
appName = appName,

View File

@@ -8,13 +8,16 @@
package io.element.android.features.location.impl.show
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -25,10 +28,13 @@ import io.element.android.features.location.api.ShowLocationMode
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.PermissionRationaleDialog
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
import io.element.android.features.location.impl.common.ui.LocationPinMarkers
import io.element.android.features.location.impl.common.ui.LocationShareRow
import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold
import io.element.android.features.location.impl.common.ui.UserLocationPuck
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -36,6 +42,7 @@ 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.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState
@@ -72,9 +79,7 @@ fun ShowLocationView(
target = Position(latitude = mode.location.lat, longitude = mode.location.lon),
zoom = MapDefaults.DEFAULT_ZOOM
)
ShowLocationMode.Live -> CameraPosition(
zoom = MapDefaults.DEFAULT_ZOOM
)
ShowLocationMode.Live -> MapDefaults.centerCameraPosition
}
val cameraState = rememberCameraState(firstPosition = initialPosition)
val locationProvider = if (state.hasLocationPermission) {
@@ -94,9 +99,22 @@ fun ShowLocationView(
}
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.PartiallyExpanded)
bottomSheetState = rememberStandardBottomSheetState(initialValue =
if(state.isSheetDraggable) {
SheetValue.PartiallyExpanded
}else {
SheetValue.Expanded
}
)
)
MapBottomSheetScaffold(
//sheetPeekHeight = 180.dp,
sheetDragHandle = if(state.isSheetDraggable) {
{BottomSheetDefaults.DragHandle()}
} else {
null
},
sheetSwipeEnabled = state.isSheetDraggable,
scaffoldState = scaffoldState,
cameraState = cameraState,
modifier = modifier,
@@ -108,18 +126,30 @@ fun ShowLocationView(
onClick = onBackClick,
)
},
actions = {
IconButton(
onClick = { state.eventSink(ShowLocationEvents.Share) }
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(CommonStrings.action_share),
)
}
}
)
},
sheetContent = { sheetPaddings ->
val coroutineScope = rememberCoroutineScope()
Text(
text = "On the map",
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
state.locationShares.forEach { locationShare ->
LocationShareRow(
item = locationShare,
onShareClick = { state.eventSink(ShowLocationEvents.Share(locationShare.location)) },
modifier = Modifier.clickable {
state.eventSink(ShowLocationEvents.TrackMyLocation(false))
val position = CameraPosition(padding = sheetPaddings, target = Position(locationShare.location.lon, locationShare.location.lat), zoom = MapDefaults.DEFAULT_ZOOM)
coroutineScope.launch {
cameraState.animateTo(finalPosition = position)
}
}
)
}
},
mapContent = {
UserLocationPuck(
cameraState = cameraState,