diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 94f1ea07dd..c7b19fbf59 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -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) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt index 6c01c75508..c515ff6c72 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/MapDefaults.kt @@ -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) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt new file mode 100644 index 0000000000..35817a91e8 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt @@ -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 = {}, + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt index 78364a6f4e..97ed6baa62 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -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, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt index 12f368fa11..672cab9beb 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt @@ -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 diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 9bbfe35b83..b131d0d137 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -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 { @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, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt index ec29cd7c54..118d7e9f61 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -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, + val locationShares: List, 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?, +) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt index 944645fa1f..a11b317170 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStateProvider.kt @@ -55,6 +55,7 @@ fun aShowLocationState( permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None, mode: ShowLocationMode = aStaticLocationMode(), markers: List? = null, + locationSharers: List? = 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, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 8839dca52f..0193e52827 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -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,