From 5b6866435048817dc108d11bb1e5f26e6b715f49 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 18 Feb 2026 14:59:49 +0100 Subject: [PATCH 01/52] Expose liveLocationSharing methods from sdk --- .../libraries/matrix/api/room/JoinedRoom.kt | 27 ++++++++++++++++ .../api/room/location/LiveLocationShare.kt | 24 ++++++++++++++ .../matrix/impl/room/JoinedRustRoom.kt | 31 +++++++++++++++++++ .../room/location/LiveLocationShareMapper.kt | 21 +++++++++++++ .../matrix/test/room/FakeJoinedRoom.kt | 21 +++++++++++++ 5 files changed, 124 insertions(+) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt create mode 100644 libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt index b804bdf46d..808f37c7c9 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt @@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility @@ -182,4 +183,30 @@ interface JoinedRoom : BaseRoom { * Subscribe to a [Flow] of [SendQueueUpdate] related to this room. */ fun subscribeToSendQueueUpdates(): Flow + + /** + * Subscribe to live location shares in this room. + * @return Flow of list of active live location shares. + */ + fun subscribeToLiveLocationShares(): Flow> + + /** + * Start sharing live location in this room. + * @param durationMillis How long to share location (in milliseconds). + * @return Result indicating success or failure. + */ + suspend fun startLiveLocationShare(durationMillis: Long): Result + + /** + * Stop sharing live location in this room. + * @return Result indicating success or failure. + */ + suspend fun stopLiveLocationShare(): Result + + /** + * Send a live location update while a live location share is active. + * @param geoUri The geo URI (e.g., "geo:51.5074,-0.1278"). + * @return Result indicating success or failure. + */ + suspend fun sendLiveLocation(geoUri: String): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt new file mode 100644 index 0000000000..7e841639bd --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationShare.kt @@ -0,0 +1,24 @@ +/* + * 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.libraries.matrix.api.room.location + +import io.element.android.libraries.matrix.api.core.UserId + +/** + * Represents a live location share from a user in a room. + */ +data class LiveLocationShare( + /** The user who is sharing their location. */ + val userId: UserId, + /** The last known geo URI (e.g., "geo:51.5074,-0.1278"). */ + val lastGeoUri: String, + /** The timestamp of the last location update. */ + val lastTimestamp: Long, + /** Whether the live location share is still active. */ + val isLive: Boolean, +) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index dded213d4c..4f9568df7c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.SendQueueUpdate import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.room.roomNotificationSettings @@ -42,6 +43,7 @@ import io.element.android.libraries.matrix.impl.mapper.map import io.element.android.libraries.matrix.impl.room.history.map import io.element.android.libraries.matrix.impl.room.join.map import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest +import io.element.android.libraries.matrix.impl.room.location.map import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.roomdirectory.map import io.element.android.libraries.matrix.impl.timeline.RustTimeline @@ -66,6 +68,7 @@ import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.KnockRequestsListener +import org.matrix.rustcomponents.sdk.LiveLocationShareListener import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate import org.matrix.rustcomponents.sdk.SendQueueListener @@ -500,6 +503,34 @@ class JoinedRustRoom( } } + override fun subscribeToLiveLocationShares(): Flow> { + return mxCallbackFlow { + innerRoom.subscribeToLiveLocationShares(object : LiveLocationShareListener { + override fun call(liveLocationShares: List) { + trySend(liveLocationShares.map { it.map() }) + } + }) + } + } + + override suspend fun startLiveLocationShare(durationMillis: Long): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.startLiveLocationShare(durationMillis.toULong()) + } + } + + override suspend fun stopLiveLocationShare(): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.stopLiveLocationShare() + } + } + + override suspend fun sendLiveLocation(geoUri: String): Result = withContext(roomDispatcher) { + runCatchingExceptions { + innerRoom.sendLiveLocation(geoUri) + } + } + override fun close() = destroy() override fun destroy() { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt new file mode 100644 index 0000000000..3b80c1c61f --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/LiveLocationShareMapper.kt @@ -0,0 +1,21 @@ +/* + * 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.libraries.matrix.impl.room.location + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare +import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare + +fun RustLiveLocationShare.map(): LiveLocationShare { + return LiveLocationShare( + userId = UserId(userId), + lastGeoUri = lastLocation.location.geoUri, + lastTimestamp = lastLocation.ts.toLong(), + isLive = isLive, + ) +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt index 9c97e7787b..a4580334e4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt @@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.room.SendQueueUpdate import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.knock.KnockRequest +import io.element.android.libraries.matrix.api.room.location.LiveLocationShare import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility @@ -84,6 +85,10 @@ class FakeJoinedRoom( private val enableEncryptionResult: () -> Result = { lambdaError() }, private val updateJoinRuleResult: (JoinRule) -> Result = { lambdaError() }, private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> }, + private val liveLocationSharesFlow: Flow> = MutableStateFlow(emptyList()), + private val startLiveLocationShareResult: (Long) -> Result = { lambdaError() }, + private val stopLiveLocationShareResult: () -> Result = { lambdaError() }, + private val sendLiveLocationResult: (String) -> Result = { lambdaError() }, ) : JoinedRoom, BaseRoom by baseRoom { private val sendQueueUpdates = MutableSharedFlow(extraBufferCapacity = 10) @@ -227,6 +232,22 @@ class FakeJoinedRoom( return sendQueueUpdates } + override fun subscribeToLiveLocationShares(): Flow> { + return liveLocationSharesFlow + } + + override suspend fun startLiveLocationShare(durationMillis: Long): Result = simulateLongTask { + startLiveLocationShareResult(durationMillis) + } + + override suspend fun stopLiveLocationShare(): Result = simulateLongTask { + stopLiveLocationShareResult() + } + + override suspend fun sendLiveLocation(geoUri: String): Result = simulateLongTask { + sendLiveLocationResult(geoUri) + } + private suspend fun simulateSendMediaProgress(progressCallback: ProgressCallback?) { progressCallbackValues.forEach { (current, total) -> progressCallback?.onProgress(current, total) From 1f5a628b131e74a98ee41d247f37c7974e3aa379 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 18 Feb 2026 15:04:19 +0100 Subject: [PATCH 02/52] Add LiveLocationSharing ff --- .../android/libraries/featureflag/api/FeatureFlags.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index 3b47726b80..12c18955f4 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -147,4 +147,11 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + LiveLocationSharing( + key = "feature.liveLocationSharing", + title = "Live location sharing", + description = "Allow sharing live location in rooms.", + defaultValue = { false }, + isFinished = false, + ), } From 8c64f704cb86216d999ed54e96f6c96972f038bb Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 18 Feb 2026 15:20:21 +0100 Subject: [PATCH 03/52] Rename send location to share location --- ...tryPoint.kt => ShareLocationEntryPoint.kt} | 4 +- .../impl/send/SendLocationStateProvider.kt | 58 ------- .../DefaultShareLocationEntryPoint.kt} | 10 +- .../ShareLocationEvents.kt} | 18 +-- .../ShareLocationNode.kt} | 8 +- .../ShareLocationPresenter.kt} | 64 ++++---- .../ShareLocationState.kt} | 6 +- .../impl/share/ShareLocationStateProvider.kt | 58 +++++++ .../ShareLocationView.kt} | 46 +++--- .../DefaultShareLocationEntryPointTest.kt} | 14 +- .../ShareLocationPresenterTest.kt} | 146 +++++++++--------- ...oint.kt => FakeShareLocationEntryPoint.kt} | 4 +- .../messages/impl/MessagesFlowNode.kt | 6 +- .../impl/DefaultMessagesEntryPointTest.kt | 4 +- 14 files changed, 223 insertions(+), 223 deletions(-) rename features/location/api/src/main/kotlin/io/element/android/features/location/api/{SendLocationEntryPoint.kt => ShareLocationEntryPoint.kt} (89%) delete mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt rename features/location/impl/src/main/kotlin/io/element/android/features/location/impl/{send/DefaultSendLocationEntryPoint.kt => share/DefaultShareLocationEntryPoint.kt} (70%) rename features/location/impl/src/main/kotlin/io/element/android/features/location/impl/{send/SendLocationEvents.kt => share/ShareLocationEvents.kt} (54%) rename features/location/impl/src/main/kotlin/io/element/android/features/location/impl/{send/SendLocationNode.kt => share/ShareLocationNode.kt} (91%) rename features/location/impl/src/main/kotlin/io/element/android/features/location/impl/{send/SendLocationPresenter.kt => share/ShareLocationPresenter.kt} (73%) rename features/location/impl/src/main/kotlin/io/element/android/features/location/impl/{send/SendLocationState.kt => share/ShareLocationState.kt} (82%) create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt rename features/location/impl/src/main/kotlin/io/element/android/features/location/impl/{send/SendLocationView.kt => share/ShareLocationView.kt} (82%) rename features/location/impl/src/test/kotlin/io/element/android/features/location/impl/{send/DefaultSendLocationEntryPointTest.kt => share/DefaultShareLocationEntryPointTest.kt} (84%) rename features/location/impl/src/test/kotlin/io/element/android/features/location/impl/{send/SendLocationPresenterTest.kt => share/ShareLocationPresenterTest.kt} (77%) rename features/location/test/src/main/kotlin/io/element/android/features/location/test/{FakeSendLocationEntryPoint.kt => FakeShareLocationEntryPoint.kt} (83%) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShareLocationEntryPoint.kt similarity index 89% rename from features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt rename to features/location/api/src/main/kotlin/io/element/android/features/location/api/ShareLocationEntryPoint.kt index 48816b2bb4..62a81662ce 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/SendLocationEntryPoint.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShareLocationEntryPoint.kt @@ -14,11 +14,11 @@ import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.timeline.Timeline /** - * The "Send location" screen. + * The "Share location" screen. * * Allows a user to share a location message within a room. */ -interface SendLocationEntryPoint : FeatureEntryPoint { +interface ShareLocationEntryPoint : FeatureEntryPoint { fun createNode( parentNode: Node, buildContext: BuildContext, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt deleted file mode 100644 index 238201cbec..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationStateProvider.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.send - -import androidx.compose.ui.tooling.preview.PreviewParameterProvider - -private const val APP_NAME = "ApplicationName" - -class SendLocationStateProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - aSendLocationState( - permissionDialog = SendLocationState.Dialog.None, - mode = SendLocationState.Mode.PinLocation, - hasLocationPermission = false, - ), - aSendLocationState( - permissionDialog = SendLocationState.Dialog.PermissionDenied, - mode = SendLocationState.Mode.PinLocation, - hasLocationPermission = false, - ), - aSendLocationState( - permissionDialog = SendLocationState.Dialog.PermissionRationale, - mode = SendLocationState.Mode.PinLocation, - hasLocationPermission = false, - ), - aSendLocationState( - permissionDialog = SendLocationState.Dialog.None, - mode = SendLocationState.Mode.PinLocation, - hasLocationPermission = true, - ), - aSendLocationState( - permissionDialog = SendLocationState.Dialog.None, - mode = SendLocationState.Mode.SenderLocation, - hasLocationPermission = true, - ), - ) -} - -private fun aSendLocationState( - permissionDialog: SendLocationState.Dialog, - mode: SendLocationState.Mode, - hasLocationPermission: Boolean, -): SendLocationState { - return SendLocationState( - permissionDialog = permissionDialog, - mode = mode, - hasLocationPermission = hasLocationPermission, - appName = APP_NAME, - eventSink = {} - ) -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPoint.kt similarity index 70% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPoint.kt index 56399f7e9d..e077453ad9 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPoint.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPoint.kt @@ -6,26 +6,26 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.location.impl.send +package io.element.android.features.location.impl.share import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.location.api.SendLocationEntryPoint +import io.element.android.features.location.api.ShareLocationEntryPoint import io.element.android.libraries.architecture.createNode import io.element.android.libraries.matrix.api.timeline.Timeline @ContributesBinding(AppScope::class) -class DefaultSendLocationEntryPoint : SendLocationEntryPoint { +class DefaultShareLocationEntryPoint : ShareLocationEntryPoint { override fun createNode( parentNode: Node, buildContext: BuildContext, timelineMode: Timeline.Mode, ): Node { - return parentNode.createNode( + return parentNode.createNode( buildContext = buildContext, - plugins = listOf(SendLocationNode.Inputs(timelineMode)) + plugins = listOf(ShareLocationNode.Inputs(timelineMode)) ) } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvents.kt similarity index 54% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvents.kt index 0d266eefc7..93b75d8e38 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationEvents.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvents.kt @@ -6,15 +6,15 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.location.impl.send +package io.element.android.features.location.impl.share import io.element.android.features.location.api.Location -sealed interface SendLocationEvents { - data class SendLocation( +sealed interface ShareLocationEvents { + data class ShareLocation( val cameraPosition: CameraPosition, val location: Location?, - ) : SendLocationEvents { + ) : ShareLocationEvents { data class CameraPosition( val lat: Double, val lon: Double, @@ -22,9 +22,9 @@ sealed interface SendLocationEvents { ) } - data object SwitchToMyLocationMode : SendLocationEvents - data object SwitchToPinLocationMode : SendLocationEvents - data object DismissDialog : SendLocationEvents - data object RequestPermissions : SendLocationEvents - data object OpenAppSettings : SendLocationEvents + data object SwitchToMyLocationMode : ShareLocationEvents + data object SwitchToPinLocationMode : ShareLocationEvents + data object DismissDialog : ShareLocationEvents + data object RequestPermissions : ShareLocationEvents + data object OpenAppSettings : ShareLocationEvents } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationNode.kt similarity index 91% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationNode.kt index 2184b52b44..0b61ff0df8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationNode.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationNode.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.location.impl.send +package io.element.android.features.location.impl.share import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -26,10 +26,10 @@ import io.element.android.services.analytics.api.AnalyticsService @ContributesNode(RoomScope::class) @AssistedInject -class SendLocationNode( +class ShareLocationNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - presenterFactory: SendLocationPresenter.Factory, + presenterFactory: ShareLocationPresenter.Factory, analyticsService: AnalyticsService, ) : Node(buildContext, plugins = plugins) { data class Inputs( @@ -48,7 +48,7 @@ class SendLocationNode( @Composable override fun View(modifier: Modifier) { - SendLocationView( + ShareLocationView( state = presenter.present(), modifier = modifier, navigateUp = ::navigateUp, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt similarity index 73% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index b753820e55..5b2b7ebc25 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.location.impl.send +package io.element.android.features.location.impl.share import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -38,7 +38,7 @@ import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.launch @AssistedInject -class SendLocationPresenter( +class ShareLocationPresenter( permissionsPresenterFactory: PermissionsPresenter.Factory, private val room: JoinedRoom, @Assisted private val timelineMode: Timeline.Mode, @@ -46,60 +46,60 @@ class SendLocationPresenter( private val messageComposerContext: MessageComposerContext, private val locationActions: LocationActions, private val buildMeta: BuildMeta, -) : Presenter { +) : Presenter { @AssistedFactory fun interface Factory { - fun create(timelineMode: Timeline.Mode): SendLocationPresenter + fun create(timelineMode: Timeline.Mode): ShareLocationPresenter } private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions) @Composable - override fun present(): SendLocationState { + override fun present(): ShareLocationState { val permissionsState: PermissionsState = permissionsPresenter.present() - var mode: SendLocationState.Mode by remember { + var mode: ShareLocationState.Mode by remember { mutableStateOf( if (permissionsState.isAnyGranted) { - SendLocationState.Mode.SenderLocation + ShareLocationState.Mode.SenderLocation } else { - SendLocationState.Mode.PinLocation + ShareLocationState.Mode.PinLocation } ) } val appName by remember { derivedStateOf { buildMeta.applicationName } } - var permissionDialog: SendLocationState.Dialog by remember { - mutableStateOf(SendLocationState.Dialog.None) + var permissionDialog: ShareLocationState.Dialog by remember { + mutableStateOf(ShareLocationState.Dialog.None) } val scope = rememberCoroutineScope() LaunchedEffect(permissionsState.permissions) { if (permissionsState.isAnyGranted) { - mode = SendLocationState.Mode.SenderLocation - permissionDialog = SendLocationState.Dialog.None + mode = ShareLocationState.Mode.SenderLocation + permissionDialog = ShareLocationState.Dialog.None } } - fun handleEvent(event: SendLocationEvents) { + fun handleEvent(event: ShareLocationEvents) { when (event) { - is SendLocationEvents.SendLocation -> scope.launch { - sendLocation(event, mode) + is ShareLocationEvents.ShareLocation -> scope.launch { + shareLocation(event, mode) } - SendLocationEvents.SwitchToMyLocationMode -> when { - permissionsState.isAnyGranted -> mode = SendLocationState.Mode.SenderLocation - permissionsState.shouldShowRationale -> permissionDialog = SendLocationState.Dialog.PermissionRationale - else -> permissionDialog = SendLocationState.Dialog.PermissionDenied + ShareLocationEvents.SwitchToMyLocationMode -> when { + permissionsState.isAnyGranted -> mode = ShareLocationState.Mode.SenderLocation + permissionsState.shouldShowRationale -> permissionDialog = ShareLocationState.Dialog.PermissionRationale + else -> permissionDialog = ShareLocationState.Dialog.PermissionDenied } - SendLocationEvents.SwitchToPinLocationMode -> mode = SendLocationState.Mode.PinLocation - SendLocationEvents.DismissDialog -> permissionDialog = SendLocationState.Dialog.None - SendLocationEvents.OpenAppSettings -> { + ShareLocationEvents.SwitchToPinLocationMode -> mode = ShareLocationState.Mode.PinLocation + ShareLocationEvents.DismissDialog -> permissionDialog = ShareLocationState.Dialog.None + ShareLocationEvents.OpenAppSettings -> { locationActions.openSettings() - permissionDialog = SendLocationState.Dialog.None + permissionDialog = ShareLocationState.Dialog.None } - SendLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) + ShareLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) } } - return SendLocationState( + return ShareLocationState( permissionDialog = permissionDialog, mode = mode, hasLocationPermission = permissionsState.isAnyGranted, @@ -108,14 +108,14 @@ class SendLocationPresenter( ) } - private suspend fun sendLocation( - event: SendLocationEvents.SendLocation, - mode: SendLocationState.Mode, + private suspend fun shareLocation( + event: ShareLocationEvents.ShareLocation, + mode: ShareLocationState.Mode, ) { val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply val inReplyToEventId = replyMode?.eventId when (mode) { - SendLocationState.Mode.PinLocation -> { + ShareLocationState.Mode.PinLocation -> { val geoUri = event.cameraPosition.toGeoUri() getTimeline().flatMap { it.sendLocation( @@ -136,7 +136,7 @@ class SendLocationPresenter( ) ) } - SendLocationState.Mode.SenderLocation -> { + ShareLocationState.Mode.SenderLocation -> { val geoUri = event.toGeoUri() getTimeline().flatMap { it.sendLocation( @@ -168,8 +168,8 @@ class SendLocationPresenter( } } -private fun SendLocationEvents.SendLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri() +private fun ShareLocationEvents.ShareLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri() -private fun SendLocationEvents.SendLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon" +private fun ShareLocationEvents.ShareLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon" private fun generateBody(uri: String): String = "Location was shared at $uri" diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt similarity index 82% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index 4ca84c47a5..14975f1646 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -6,14 +6,14 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.location.impl.send +package io.element.android.features.location.impl.share -data class SendLocationState( +data class ShareLocationState( val permissionDialog: Dialog, val mode: Mode, val hasLocationPermission: Boolean, val appName: String, - val eventSink: (SendLocationEvents) -> Unit, + val eventSink: (ShareLocationEvents) -> Unit, ) { sealed interface Mode { data object SenderLocation : Mode diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt new file mode 100644 index 0000000000..df49881975 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector 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.share + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +private const val APP_NAME = "ApplicationName" + +class ShareLocationStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aShareLocationState( + permissionDialog = ShareLocationState.Dialog.None, + mode = ShareLocationState.Mode.PinLocation, + hasLocationPermission = false, + ), + aShareLocationState( + permissionDialog = ShareLocationState.Dialog.PermissionDenied, + mode = ShareLocationState.Mode.PinLocation, + hasLocationPermission = false, + ), + aShareLocationState( + permissionDialog = ShareLocationState.Dialog.PermissionRationale, + mode = ShareLocationState.Mode.PinLocation, + hasLocationPermission = false, + ), + aShareLocationState( + permissionDialog = ShareLocationState.Dialog.None, + mode = ShareLocationState.Mode.PinLocation, + hasLocationPermission = true, + ), + aShareLocationState( + permissionDialog = ShareLocationState.Dialog.None, + mode = ShareLocationState.Mode.SenderLocation, + hasLocationPermission = true, + ), + ) +} + +private fun aShareLocationState( + permissionDialog: ShareLocationState.Dialog, + mode: ShareLocationState.Mode, + hasLocationPermission: Boolean, +): ShareLocationState { + return ShareLocationState( + permissionDialog = permissionDialog, + mode = mode, + hasLocationPermission = hasLocationPermission, + appName = APP_NAME, + eventSink = {} + ) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt similarity index 82% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 452a4aa8ae..1c9e85c82b 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.location.impl.send +package io.element.android.features.location.impl.share import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -56,25 +56,25 @@ import org.maplibre.android.camera.CameraPosition @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SendLocationView( - state: SendLocationState, +fun ShareLocationView( + state: ShareLocationState, navigateUp: () -> Unit, modifier: Modifier = Modifier, ) { LaunchedEffect(Unit) { - state.eventSink(SendLocationEvents.RequestPermissions) + state.eventSink(ShareLocationEvents.RequestPermissions) } when (state.permissionDialog) { - SendLocationState.Dialog.None -> Unit - SendLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( - onContinue = { state.eventSink(SendLocationEvents.OpenAppSettings) }, - onDismiss = { state.eventSink(SendLocationEvents.DismissDialog) }, + ShareLocationState.Dialog.None -> Unit + ShareLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( + onContinue = { state.eventSink(ShareLocationEvents.OpenAppSettings) }, + onDismiss = { state.eventSink(ShareLocationEvents.DismissDialog) }, appName = state.appName, ) - SendLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog( - onContinue = { state.eventSink(SendLocationEvents.RequestPermissions) }, - onDismiss = { state.eventSink(SendLocationEvents.DismissDialog) }, + ShareLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog( + onContinue = { state.eventSink(ShareLocationEvents.RequestPermissions) }, + onDismiss = { state.eventSink(ShareLocationEvents.DismissDialog) }, appName = state.appName, ) } @@ -85,10 +85,10 @@ fun SendLocationView( LaunchedEffect(state.mode) { when (state.mode) { - SendLocationState.Mode.PinLocation -> { + ShareLocationState.Mode.PinLocation -> { cameraPositionState.cameraMode = CameraMode.NONE } - SendLocationState.Mode.SenderLocation -> { + ShareLocationState.Mode.SenderLocation -> { cameraPositionState.position = CameraPosition.Builder() .zoom(MapDefaults.DEFAULT_ZOOM) .build() @@ -99,7 +99,7 @@ fun SendLocationView( LaunchedEffect(cameraPositionState.isMoving) { if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { - state.eventSink(SendLocationEvents.SwitchToPinLocationMode) + state.eventSink(ShareLocationEvents.SwitchToPinLocationMode) } } @@ -114,8 +114,8 @@ fun SendLocationView( Text( stringResource( when (state.mode) { - SendLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action - SendLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action + ShareLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action + ShareLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action } ) ) @@ -125,8 +125,8 @@ fun SendLocationView( enabled = cameraPositionState.position.target != null ) { state.eventSink( - SendLocationEvents.SendLocation( - cameraPosition = SendLocationEvents.SendLocation.CameraPosition( + ShareLocationEvents.ShareLocation( + cameraPosition = ShareLocationEvents.ShareLocation.CameraPosition( lat = cameraPositionState.position.target!!.latitude, lon = cameraPositionState.position.target!!.longitude, zoom = cameraPositionState.position.zoom, @@ -190,8 +190,8 @@ fun SendLocationView( modifier = Modifier.centerBottomEdge(this), ) LocationFloatingActionButton( - isMapCenteredOnUser = state.mode == SendLocationState.Mode.SenderLocation, - onClick = { state.eventSink(SendLocationEvents.SwitchToMyLocationMode) }, + isMapCenteredOnUser = state.mode == ShareLocationState.Mode.SenderLocation, + onClick = { state.eventSink(ShareLocationEvents.SwitchToMyLocationMode) }, modifier = Modifier .align(Alignment.BottomEnd) .padding(end = 18.dp, bottom = 72.dp + navBarPadding), @@ -202,10 +202,10 @@ fun SendLocationView( @PreviewsDayNight @Composable -internal fun SendLocationViewPreview( - @PreviewParameter(SendLocationStateProvider::class) state: SendLocationState +internal fun ShareLocationViewPreview( + @PreviewParameter(ShareLocationStateProvider::class) state: ShareLocationState ) = ElementPreview { - SendLocationView( + ShareLocationView( state = state, navigateUp = {}, ) diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt similarity index 84% rename from features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPointTest.kt rename to features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt index a90afd5a9d..b6161b3a9c 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/DefaultSendLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.location.impl.send +package io.element.android.features.location.impl.share import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.bumble.appyx.core.modality.BuildContext @@ -22,19 +22,19 @@ import io.element.android.tests.testutils.node.TestParentNode import org.junit.Rule import org.junit.Test -class DefaultSendLocationEntryPointTest { +class DefaultShareLocationEntryPointTest { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() @Test fun `test node builder`() { - val entryPoint = DefaultSendLocationEntryPoint() + val entryPoint = DefaultShareLocationEntryPoint() val parentNode = TestParentNode.create { buildContext, plugins -> - SendLocationNode( + ShareLocationNode( buildContext = buildContext, plugins = plugins, presenterFactory = { timelineMode: Timeline.Mode -> - SendLocationPresenter( + ShareLocationPresenter( permissionsPresenterFactory = { FakePermissionsPresenter() }, room = FakeJoinedRoom(), timelineMode = timelineMode, @@ -53,7 +53,7 @@ class DefaultSendLocationEntryPointTest { buildContext = BuildContext.root(null), timelineMode = timelineMode, ) - assertThat(result).isInstanceOf(SendLocationNode::class.java) - assertThat(result.plugins).contains(SendLocationNode.Inputs(timelineMode)) + assertThat(result).isInstanceOf(ShareLocationNode::class.java) + assertThat(result.plugins).contains(ShareLocationNode.Inputs(timelineMode)) } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt similarity index 77% rename from features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt rename to features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 23ab3847cd..ead3fb2fe0 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.location.impl.send +package io.element.android.features.location.impl.share import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow @@ -40,7 +40,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test -class SendLocationPresenterTest { +class ShareLocationPresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -50,9 +50,9 @@ class SendLocationPresenterTest { private val fakeLocationActions = FakeLocationActions() private val fakeBuildMeta = aBuildMeta(applicationName = "app name") - private fun createSendLocationPresenter( + private fun createShareLocationPresenter( joinedRoom: JoinedRoom = FakeJoinedRoom(), - ): SendLocationPresenter = SendLocationPresenter( + ): ShareLocationPresenter = ShareLocationPresenter( permissionsPresenterFactory = object : PermissionsPresenter.Factory { override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter }, @@ -66,7 +66,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions granted`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() + val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.AllGranted, @@ -75,25 +75,25 @@ class SendLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() + shareLocationPresenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation) + assertThat(initialState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.SenderLocation) assertThat(initialState.hasLocationPermission).isTrue() // Swipe the map to switch mode - initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode) + initialState.eventSink(ShareLocationEvents.SwitchToPinLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isTrue() } } @Test fun `initial state with permissions partially granted`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() + val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.SomeGranted, @@ -102,25 +102,25 @@ class SendLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() + shareLocationPresenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.SenderLocation) + assertThat(initialState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.SenderLocation) assertThat(initialState.hasLocationPermission).isTrue() // Swipe the map to switch mode - initialState.eventSink(SendLocationEvents.SwitchToPinLocationMode) + initialState.eventSink(ShareLocationEvents.SwitchToPinLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isTrue() } } @Test fun `initial state with permissions denied`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() + val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, @@ -129,25 +129,25 @@ class SendLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() + shareLocationPresenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(initialState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(initialState.hasLocationPermission).isFalse() // Click on the button to switch mode - initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + initialState.eventSink(ShareLocationEvents.SwitchToMyLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionDenied) + assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() } } @Test fun `initial state with permissions denied once`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() + val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, @@ -156,25 +156,25 @@ class SendLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() + shareLocationPresenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(initialState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(initialState.hasLocationPermission).isFalse() // Click on the button to switch mode - initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + initialState.eventSink(ShareLocationEvents.SwitchToMyLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionRationale) + assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() } } @Test fun `rationale dialog dismiss`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() + val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, @@ -183,30 +183,30 @@ class SendLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() + shareLocationPresenter.present() }.test { // Skip initial state val initialState = awaitItem() // Click on the button to switch mode - initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + initialState.eventSink(ShareLocationEvents.SwitchToMyLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionRationale) + assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() // Dismiss the dialog - myLocationState.eventSink(SendLocationEvents.DismissDialog) + myLocationState.eventSink(ShareLocationEvents.DismissDialog) val dialogDismissedState = awaitItem() - assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(dialogDismissedState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(dialogDismissedState.hasLocationPermission).isFalse() } } @Test fun `rationale dialog continue`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() + val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, @@ -215,27 +215,27 @@ class SendLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() + shareLocationPresenter.present() }.test { // Skip initial state val initialState = awaitItem() // Click on the button to switch mode - initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + initialState.eventSink(ShareLocationEvents.SwitchToMyLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionRationale) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionRationale) + assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() // Continue the dialog sends permission request to the permissions presenter - myLocationState.eventSink(SendLocationEvents.RequestPermissions) + myLocationState.eventSink(ShareLocationEvents.RequestPermissions) assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) } } @Test fun `permission denied dialog dismiss`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() + val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, @@ -244,23 +244,23 @@ class SendLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() + shareLocationPresenter.present() }.test { // Skip initial state val initialState = awaitItem() // Click on the button to switch mode - initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + initialState.eventSink(ShareLocationEvents.SwitchToMyLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(SendLocationState.Dialog.PermissionDenied) - assertThat(myLocationState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionDenied) + assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() // Dismiss the dialog - myLocationState.eventSink(SendLocationEvents.DismissDialog) + myLocationState.eventSink(ShareLocationEvents.DismissDialog) val dialogDismissedState = awaitItem() - assertThat(dialogDismissedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) - assertThat(dialogDismissedState.mode).isEqualTo(SendLocationState.Mode.PinLocation) + assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(dialogDismissedState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(dialogDismissedState.hasLocationPermission).isFalse() } } @@ -275,7 +275,7 @@ class SendLocationPresenterTest { sendLocationLambda = sendLocationResult }, ) - val sendLocationPresenter = createSendLocationPresenter(joinedRoom) + val shareLocationPresenter = createShareLocationPresenter(joinedRoom) fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.AllGranted, @@ -284,15 +284,15 @@ class SendLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() + shareLocationPresenter.present() }.test { // Skip initial state val initialState = awaitItem() // Send location initialState.eventSink( - SendLocationEvents.SendLocation( - cameraPosition = SendLocationEvents.SendLocation.CameraPosition( + ShareLocationEvents.ShareLocation( + cameraPosition = ShareLocationEvents.ShareLocation.CameraPosition( lat = 0.0, lon = 1.0, zoom = 2.0, @@ -339,7 +339,7 @@ class SendLocationPresenterTest { sendLocationLambda = sendLocationResult }, ) - val sendLocationPresenter = createSendLocationPresenter(joinedRoom) + val shareLocationPresenter = createShareLocationPresenter(joinedRoom) fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, @@ -348,15 +348,15 @@ class SendLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() + shareLocationPresenter.present() }.test { // Skip initial state val initialState = awaitItem() // Send location initialState.eventSink( - SendLocationEvents.SendLocation( - cameraPosition = SendLocationEvents.SendLocation.CameraPosition( + ShareLocationEvents.ShareLocation( + cameraPosition = ShareLocationEvents.ShareLocation.CameraPosition( lat = 0.0, lon = 1.0, zoom = 2.0, @@ -403,7 +403,7 @@ class SendLocationPresenterTest { sendLocationLambda = sendLocationResult }, ) - val sendLocationPresenter = createSendLocationPresenter(joinedRoom) + val shareLocationPresenter = createShareLocationPresenter(joinedRoom) fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, @@ -418,15 +418,15 @@ class SendLocationPresenterTest { } moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() + shareLocationPresenter.present() }.test { // Skip initial state val initialState = awaitItem() // Send location initialState.eventSink( - SendLocationEvents.SendLocation( - cameraPosition = SendLocationEvents.SendLocation.CameraPosition( + ShareLocationEvents.ShareLocation( + cameraPosition = ShareLocationEvents.ShareLocation.CameraPosition( lat = 0.0, lon = 1.0, zoom = 2.0, @@ -451,7 +451,7 @@ class SendLocationPresenterTest { @Test fun `open settings activity`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() + val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, @@ -466,28 +466,28 @@ class SendLocationPresenterTest { } moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() + shareLocationPresenter.present() }.test { // Skip initial state val initialState = awaitItem() - initialState.eventSink(SendLocationEvents.SwitchToMyLocationMode) + initialState.eventSink(ShareLocationEvents.SwitchToMyLocationMode) val dialogShownState = awaitItem() // Open settings - dialogShownState.eventSink(SendLocationEvents.OpenAppSettings) + dialogShownState.eventSink(ShareLocationEvents.OpenAppSettings) val settingsOpenedState = awaitItem() - assertThat(settingsOpenedState.permissionDialog).isEqualTo(SendLocationState.Dialog.None) + assertThat(settingsOpenedState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) } } @Test fun `application name is in state`() = runTest { - val sendLocationPresenter = createSendLocationPresenter() + val shareLocationPresenter = createShareLocationPresenter() moleculeFlow(RecompositionMode.Immediate) { - sendLocationPresenter.present() + shareLocationPresenter.present() }.test { val initialState = awaitItem() assertThat(initialState.appName).isEqualTo("app name") diff --git a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeSendLocationEntryPoint.kt b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeShareLocationEntryPoint.kt similarity index 83% rename from features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeSendLocationEntryPoint.kt rename to features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeShareLocationEntryPoint.kt index 2a1741e6c8..9fbbf2a7a2 100644 --- a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeSendLocationEntryPoint.kt +++ b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeShareLocationEntryPoint.kt @@ -10,11 +10,11 @@ package io.element.android.features.location.test import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node -import io.element.android.features.location.api.SendLocationEntryPoint +import io.element.android.features.location.api.ShareLocationEntryPoint import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.tests.testutils.lambda.lambdaError -class FakeSendLocationEntryPoint : SendLocationEntryPoint { +class FakeShareLocationEntryPoint : ShareLocationEntryPoint { override fun createNode( parentNode: Node, buildContext: BuildContext, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 8342184b12..b28574cdbf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -30,7 +30,7 @@ import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint import io.element.android.features.location.api.Location import io.element.android.features.location.api.LocationService -import io.element.android.features.location.api.SendLocationEntryPoint +import io.element.android.features.location.api.ShareLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.impl.attachments.Attachment @@ -102,7 +102,7 @@ class MessagesFlowNode( @Assisted plugins: List, private val roomListService: RoomListService, private val sessionId: SessionId, - private val sendLocationEntryPoint: SendLocationEntryPoint, + private val shareLocationEntryPoint: ShareLocationEntryPoint, private val showLocationEntryPoint: ShowLocationEntryPoint, private val createPollEntryPoint: CreatePollEntryPoint, private val elementCallEntryPoint: ElementCallEntryPoint, @@ -374,7 +374,7 @@ class MessagesFlowNode( createNode(buildContext, listOf(inputs)) } is NavTarget.SendLocation -> { - sendLocationEntryPoint.createNode( + shareLocationEntryPoint.createNode( parentNode = this, buildContext = buildContext, timelineMode = navTarget.timelineMode, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt index 90c31b911a..a1db09dfda 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/DefaultMessagesEntryPointTest.kt @@ -17,7 +17,7 @@ import io.element.android.features.call.test.FakeElementCallEntryPoint import io.element.android.features.forward.test.FakeForwardEntryPoint import io.element.android.features.knockrequests.test.FakeKnockRequestsListEntryPoint import io.element.android.features.location.test.FakeLocationService -import io.element.android.features.location.test.FakeSendLocationEntryPoint +import io.element.android.features.location.test.FakeShareLocationEntryPoint import io.element.android.features.location.test.FakeShowLocationEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.impl.pinned.banner.createPinnedEventsTimelineProvider @@ -62,7 +62,7 @@ class DefaultMessagesEntryPointTest { plugins = plugins, roomListService = FakeRoomListService(), sessionId = A_SESSION_ID, - sendLocationEntryPoint = FakeSendLocationEntryPoint(), + shareLocationEntryPoint = FakeShareLocationEntryPoint(), showLocationEntryPoint = FakeShowLocationEntryPoint(), createPollEntryPoint = FakeCreatePollEntryPoint(), elementCallEntryPoint = FakeElementCallEntryPoint(), From add737b64654a0c55b4ccba6a76f8b58a8e2fafc Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 18 Feb 2026 17:24:20 +0100 Subject: [PATCH 04/52] Rename ShareLocationEvents -> ShareLocationEvent --- ...ocationEvents.kt => ShareLocationEvent.kt} | 18 +-- .../impl/share/ShareLocationPresenter.kt | 21 +-- .../location/impl/share/ShareLocationView.kt | 129 +++++++++++------- .../impl/share/ShareLocationPresenterTest.kt | 36 ++--- 4 files changed, 122 insertions(+), 82 deletions(-) rename features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/{ShareLocationEvents.kt => ShareLocationEvent.kt} (56%) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvents.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt similarity index 56% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvents.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt index 93b75d8e38..b5d45b8147 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvents.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt @@ -10,11 +10,11 @@ package io.element.android.features.location.impl.share import io.element.android.features.location.api.Location -sealed interface ShareLocationEvents { - data class ShareLocation( +sealed interface ShareLocationEvent { + data class ShareStaticLocation( val cameraPosition: CameraPosition, val location: Location?, - ) : ShareLocationEvents { + ) : ShareLocationEvent { data class CameraPosition( val lat: Double, val lon: Double, @@ -22,9 +22,11 @@ sealed interface ShareLocationEvents { ) } - data object SwitchToMyLocationMode : ShareLocationEvents - data object SwitchToPinLocationMode : ShareLocationEvents - data object DismissDialog : ShareLocationEvents - data object RequestPermissions : ShareLocationEvents - data object OpenAppSettings : ShareLocationEvents + data object SelectLiveLocationDuration: ShareLocationEvent + + data object SwitchToMyLocationMode : ShareLocationEvent + data object SwitchToPinLocationMode : ShareLocationEvent + data object DismissDialog : ShareLocationEvent + data object RequestPermissions : ShareLocationEvent + data object OpenAppSettings : ShareLocationEvent } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index 5b2b7ebc25..7f9f9a9874 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -79,23 +79,24 @@ class ShareLocationPresenter( } } - fun handleEvent(event: ShareLocationEvents) { + fun handleEvent(event: ShareLocationEvent) { when (event) { - is ShareLocationEvents.ShareLocation -> scope.launch { + is ShareLocationEvent.ShareStaticLocation -> scope.launch { shareLocation(event, mode) } - ShareLocationEvents.SwitchToMyLocationMode -> when { + ShareLocationEvent.SwitchToMyLocationMode -> when { permissionsState.isAnyGranted -> mode = ShareLocationState.Mode.SenderLocation permissionsState.shouldShowRationale -> permissionDialog = ShareLocationState.Dialog.PermissionRationale else -> permissionDialog = ShareLocationState.Dialog.PermissionDenied } - ShareLocationEvents.SwitchToPinLocationMode -> mode = ShareLocationState.Mode.PinLocation - ShareLocationEvents.DismissDialog -> permissionDialog = ShareLocationState.Dialog.None - ShareLocationEvents.OpenAppSettings -> { + ShareLocationEvent.SwitchToPinLocationMode -> mode = ShareLocationState.Mode.PinLocation + ShareLocationEvent.DismissDialog -> permissionDialog = ShareLocationState.Dialog.None + ShareLocationEvent.OpenAppSettings -> { locationActions.openSettings() permissionDialog = ShareLocationState.Dialog.None } - ShareLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) + ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) + ShareLocationEvent.SelectLiveLocationDuration -> Unit } } @@ -109,7 +110,7 @@ class ShareLocationPresenter( } private suspend fun shareLocation( - event: ShareLocationEvents.ShareLocation, + event: ShareLocationEvent.ShareStaticLocation, mode: ShareLocationState.Mode, ) { val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply @@ -168,8 +169,8 @@ class ShareLocationPresenter( } } -private fun ShareLocationEvents.ShareLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri() +private fun ShareLocationEvent.ShareStaticLocation.toGeoUri(): String = location?.toGeoUri() ?: cameraPosition.toGeoUri() -private fun ShareLocationEvents.ShareLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon" +private fun ShareLocationEvent.ShareStaticLocation.CameraPosition.toGeoUri(): String = "geo:$lat,$lon" private fun generateBody(uri: String): String = "Location was shared at $uri" diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 1c9e85c82b..6e83406367 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ListItem import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState @@ -31,24 +30,29 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter 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.api.internal.centerBottomEdge import io.element.android.features.location.api.internal.rememberTileStyleUrl -import io.element.android.features.location.impl.R import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.PermissionDeniedDialog import io.element.android.features.location.impl.common.PermissionRationaleDialog import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementPreview 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.IconSource +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.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.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 @@ -62,19 +66,19 @@ fun ShareLocationView( modifier: Modifier = Modifier, ) { LaunchedEffect(Unit) { - state.eventSink(ShareLocationEvents.RequestPermissions) + state.eventSink(ShareLocationEvent.RequestPermissions) } when (state.permissionDialog) { ShareLocationState.Dialog.None -> Unit ShareLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( - onContinue = { state.eventSink(ShareLocationEvents.OpenAppSettings) }, - onDismiss = { state.eventSink(ShareLocationEvents.DismissDialog) }, + onContinue = { state.eventSink(ShareLocationEvent.OpenAppSettings) }, + onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, appName = state.appName, ) ShareLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog( - onContinue = { state.eventSink(ShareLocationEvents.RequestPermissions) }, - onDismiss = { state.eventSink(ShareLocationEvents.DismissDialog) }, + onContinue = { state.eventSink(ShareLocationEvent.RequestPermissions) }, + onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, appName = state.appName, ) } @@ -99,7 +103,7 @@ fun ShareLocationView( LaunchedEffect(cameraPositionState.isMoving) { if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { - state.eventSink(ShareLocationEvents.SwitchToPinLocationMode) + state.eventSink(ShareLocationEvent.SwitchToPinLocationMode) } } @@ -108,48 +112,35 @@ fun ShareLocationView( BottomSheetScaffold( sheetContent = { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(Modifier.height(20.dp)) ListItem( headlineContent = { Text( - stringResource( - when (state.mode) { - ShareLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action - ShareLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action - } - ) + text = "Sharing options", + style = ElementTheme.typography.fontBodyLgMedium, ) - }, - modifier = Modifier.clickable( - // target is null when the map hasn't loaded (or api key is wrong) so we disable the button - enabled = cameraPositionState.position.target != null - ) { - state.eventSink( - ShareLocationEvents.ShareLocation( - cameraPosition = ShareLocationEvents.ShareLocation.CameraPosition( - lat = cameraPositionState.position.target!!.latitude, - lon = cameraPositionState.position.target!!.longitude, - zoom = cameraPositionState.position.zoom, - ), - location = cameraPositionState.location?.let { - Location( - lat = it.latitude, - lon = it.longitude, - accuracy = it.accuracy, - ) - } - ) - ) - navigateUp() - }, - leadingContent = { - Icon( - resourceId = R.drawable.pin_small, - contentDescription = null, - tint = Color.Unspecified, - ) - }, + } ) + 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() + } Spacer(modifier = Modifier.height(16.dp + navBarPadding)) }, modifier = modifier, @@ -191,7 +182,7 @@ fun ShareLocationView( ) LocationFloatingActionButton( isMapCenteredOnUser = state.mode == ShareLocationState.Mode.SenderLocation, - onClick = { state.eventSink(ShareLocationEvents.SwitchToMyLocationMode) }, + onClick = { state.eventSink(ShareLocationEvent.SwitchToMyLocationMode) }, modifier = Modifier .align(Alignment.BottomEnd) .padding(end = 18.dp, bottom = 72.dp + navBarPadding), @@ -200,6 +191,52 @@ fun ShareLocationView( } } +@Composable +private fun StaticLocationItem( + mode: ShareLocationState.Mode, + cameraPositionState: CameraPositionState, + onClick: ()->Unit, +) { + ListItem( + headlineContent = { + Text( + stringResource( + when (mode) { + ShareLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action + ShareLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action + } + ) + ) + }, + modifier = Modifier.clickable( + // 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( + iconSource = IconSource.Vector(CompoundIcons.LocationNavigatorCentred()) + ) + ) +} + +@Composable +private fun LiveLocationItem( + onClick: ()->Unit, +) { + ListItem( + headlineContent = { + Text("Share live location") + }, + modifier = Modifier.clickable( + onClick = onClick + ), + leadingContent = ListItemContent.Icon( + iconSource = IconSource.Vector(CompoundIcons.LocationPinSolid()), + tintColor = ElementTheme.colors.iconAccentPrimary, + ) + ) +} + @PreviewsDayNight @Composable internal fun ShareLocationViewPreview( diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index ead3fb2fe0..3d4a5b519e 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -83,7 +83,7 @@ class ShareLocationPresenterTest { assertThat(initialState.hasLocationPermission).isTrue() // Swipe the map to switch mode - initialState.eventSink(ShareLocationEvents.SwitchToPinLocationMode) + initialState.eventSink(ShareLocationEvent.SwitchToPinLocationMode) val myLocationState = awaitItem() assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) @@ -110,7 +110,7 @@ class ShareLocationPresenterTest { assertThat(initialState.hasLocationPermission).isTrue() // Swipe the map to switch mode - initialState.eventSink(ShareLocationEvents.SwitchToPinLocationMode) + initialState.eventSink(ShareLocationEvent.SwitchToPinLocationMode) val myLocationState = awaitItem() assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) @@ -137,7 +137,7 @@ class ShareLocationPresenterTest { assertThat(initialState.hasLocationPermission).isFalse() // Click on the button to switch mode - initialState.eventSink(ShareLocationEvents.SwitchToMyLocationMode) + initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) val myLocationState = awaitItem() assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionDenied) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) @@ -164,7 +164,7 @@ class ShareLocationPresenterTest { assertThat(initialState.hasLocationPermission).isFalse() // Click on the button to switch mode - initialState.eventSink(ShareLocationEvents.SwitchToMyLocationMode) + initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) val myLocationState = awaitItem() assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionRationale) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) @@ -189,14 +189,14 @@ class ShareLocationPresenterTest { val initialState = awaitItem() // Click on the button to switch mode - initialState.eventSink(ShareLocationEvents.SwitchToMyLocationMode) + initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) val myLocationState = awaitItem() assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionRationale) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() // Dismiss the dialog - myLocationState.eventSink(ShareLocationEvents.DismissDialog) + myLocationState.eventSink(ShareLocationEvent.DismissDialog) val dialogDismissedState = awaitItem() assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) assertThat(dialogDismissedState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) @@ -221,14 +221,14 @@ class ShareLocationPresenterTest { val initialState = awaitItem() // Click on the button to switch mode - initialState.eventSink(ShareLocationEvents.SwitchToMyLocationMode) + initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) val myLocationState = awaitItem() assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionRationale) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() // Continue the dialog sends permission request to the permissions presenter - myLocationState.eventSink(ShareLocationEvents.RequestPermissions) + myLocationState.eventSink(ShareLocationEvent.RequestPermissions) assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) } } @@ -250,14 +250,14 @@ class ShareLocationPresenterTest { val initialState = awaitItem() // Click on the button to switch mode - initialState.eventSink(ShareLocationEvents.SwitchToMyLocationMode) + initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) val myLocationState = awaitItem() assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionDenied) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() // Dismiss the dialog - myLocationState.eventSink(ShareLocationEvents.DismissDialog) + myLocationState.eventSink(ShareLocationEvent.DismissDialog) val dialogDismissedState = awaitItem() assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) assertThat(dialogDismissedState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) @@ -291,8 +291,8 @@ class ShareLocationPresenterTest { // Send location initialState.eventSink( - ShareLocationEvents.ShareLocation( - cameraPosition = ShareLocationEvents.ShareLocation.CameraPosition( + ShareLocationEvent.ShareStaticLocation( + cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition( lat = 0.0, lon = 1.0, zoom = 2.0, @@ -355,8 +355,8 @@ class ShareLocationPresenterTest { // Send location initialState.eventSink( - ShareLocationEvents.ShareLocation( - cameraPosition = ShareLocationEvents.ShareLocation.CameraPosition( + ShareLocationEvent.ShareStaticLocation( + cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition( lat = 0.0, lon = 1.0, zoom = 2.0, @@ -425,8 +425,8 @@ class ShareLocationPresenterTest { // Send location initialState.eventSink( - ShareLocationEvents.ShareLocation( - cameraPosition = ShareLocationEvents.ShareLocation.CameraPosition( + ShareLocationEvent.ShareStaticLocation( + cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition( lat = 0.0, lon = 1.0, zoom = 2.0, @@ -471,11 +471,11 @@ class ShareLocationPresenterTest { // Skip initial state val initialState = awaitItem() - initialState.eventSink(ShareLocationEvents.SwitchToMyLocationMode) + initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) val dialogShownState = awaitItem() // Open settings - dialogShownState.eventSink(ShareLocationEvents.OpenAppSettings) + dialogShownState.eventSink(ShareLocationEvent.OpenAppSettings) val settingsOpenedState = awaitItem() assertThat(settingsOpenedState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) From a129d1f9ca2258258c6ef5b967a7c97ff3c55262 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 18 Feb 2026 17:24:38 +0100 Subject: [PATCH 05/52] Add share live location item --- features/location/impl/build.gradle.kts | 2 ++ .../location/impl/share/ShareLocationPresenter.kt | 8 ++++++++ .../features/location/impl/share/ShareLocationState.kt | 3 ++- .../location/impl/share/ShareLocationStateProvider.kt | 2 ++ .../features/location/impl/share/ShareLocationView.kt | 5 +++++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index bd41c7ecdf..3b805d87ca 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -39,10 +39,12 @@ dependencies { implementation(projects.services.analytics.api) implementation(libs.accompanist.permission) implementation(projects.libraries.uiStrings) + implementation(projects.libraries.featureflag.api) testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.testtags) testImplementation(projects.services.analytics.test) testImplementation(projects.features.messages.test) + testImplementation(projects.libraries.featureflag.test) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index 7f9f9a9874..f92d8af92d 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -10,6 +10,7 @@ package io.element.android.features.location.impl.share import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -29,6 +30,8 @@ import io.element.android.features.messages.api.MessageComposerContext import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.room.CreateTimelineParams import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.location.AssetType @@ -46,6 +49,7 @@ class ShareLocationPresenter( private val messageComposerContext: MessageComposerContext, private val locationActions: LocationActions, private val buildMeta: BuildMeta, + private val featureFlagService: FeatureFlagService, ) : Presenter { @AssistedFactory fun interface Factory { @@ -66,6 +70,9 @@ class ShareLocationPresenter( } ) } + val isLiveLocationSharingEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing) + }.collectAsState(false) val appName by remember { derivedStateOf { buildMeta.applicationName } } var permissionDialog: ShareLocationState.Dialog by remember { mutableStateOf(ShareLocationState.Dialog.None) @@ -104,6 +111,7 @@ class ShareLocationPresenter( permissionDialog = permissionDialog, mode = mode, hasLocationPermission = permissionsState.isAnyGranted, + canShareLiveLocation = isLiveLocationSharingEnabled, appName = appName, eventSink = ::handleEvent, ) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index 14975f1646..69971b342e 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -13,7 +13,8 @@ data class ShareLocationState( val mode: Mode, val hasLocationPermission: Boolean, val appName: String, - val eventSink: (ShareLocationEvents) -> Unit, + val canShareLiveLocation: Boolean, + val eventSink: (ShareLocationEvent) -> Unit, ) { sealed interface Mode { data object SenderLocation : Mode diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index df49881975..b22e8e6606 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -47,11 +47,13 @@ private fun aShareLocationState( permissionDialog: ShareLocationState.Dialog, mode: ShareLocationState.Mode, hasLocationPermission: Boolean, + canShareLiveLocation: Boolean = false, ): ShareLocationState { return ShareLocationState( permissionDialog = permissionDialog, mode = mode, hasLocationPermission = hasLocationPermission, + canShareLiveLocation = canShareLiveLocation, appName = APP_NAME, eventSink = {} ) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 6e83406367..27ade66c88 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -141,6 +141,11 @@ fun ShareLocationView( ) navigateUp() } + if(state.canShareLiveLocation){ + LiveLocationItem { + state.eventSink(ShareLocationEvent.SelectLiveLocationDuration) + } + } Spacer(modifier = Modifier.height(16.dp + navBarPadding)) }, modifier = modifier, From 7472f889cf848c5e5f26d96e65e076be8037a167 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 18 Feb 2026 19:13:47 +0100 Subject: [PATCH 06/52] Allow picking duration for the live location share --- .../impl/share/LiveLocationDuration.kt | 21 +++++++++ .../location/impl/share/ShareLocationEvent.kt | 4 +- .../impl/share/ShareLocationPresenter.kt | 24 ++++++---- .../location/impl/share/ShareLocationState.kt | 3 +- .../impl/share/ShareLocationStateProvider.kt | 8 +++- .../location/impl/share/ShareLocationView.kt | 46 ++++++++++++++++++- .../impl/share/ShareLocationPresenterTest.kt | 28 +++++------ .../components/dialogs/ListDialog.kt | 7 ++- 8 files changed, 113 insertions(+), 28 deletions(-) create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt new file mode 100644 index 0000000000..8763263d57 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt @@ -0,0 +1,21 @@ +/* + * 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.share + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +enum class LiveLocationDuration( + val duration: Duration, + val label: String, +) { + FifteenMinutes(15.minutes, "15 minutes"), + OneHour(1.hours, "1 hour"), + EightHours(8.hours, "8 hours"); +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt index b5d45b8147..9ac15742d9 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.impl.share import io.element.android.features.location.api.Location +import kotlin.time.Duration sealed interface ShareLocationEvent { data class ShareStaticLocation( @@ -22,7 +23,8 @@ sealed interface ShareLocationEvent { ) } - data object SelectLiveLocationDuration: ShareLocationEvent + data object SelectLiveLocationDuration : ShareLocationEvent + data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent data object SwitchToMyLocationMode : ShareLocationEvent data object SwitchToPinLocationMode : ShareLocationEvent diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index f92d8af92d..d53b132314 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -74,7 +74,7 @@ class ShareLocationPresenter( featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing) }.collectAsState(false) val appName by remember { derivedStateOf { buildMeta.applicationName } } - var permissionDialog: ShareLocationState.Dialog by remember { + var dialogState: ShareLocationState.Dialog by remember { mutableStateOf(ShareLocationState.Dialog.None) } val scope = rememberCoroutineScope() @@ -82,7 +82,7 @@ class ShareLocationPresenter( LaunchedEffect(permissionsState.permissions) { if (permissionsState.isAnyGranted) { mode = ShareLocationState.Mode.SenderLocation - permissionDialog = ShareLocationState.Dialog.None + dialogState = ShareLocationState.Dialog.None } } @@ -93,22 +93,30 @@ class ShareLocationPresenter( } ShareLocationEvent.SwitchToMyLocationMode -> when { permissionsState.isAnyGranted -> mode = ShareLocationState.Mode.SenderLocation - permissionsState.shouldShowRationale -> permissionDialog = ShareLocationState.Dialog.PermissionRationale - else -> permissionDialog = ShareLocationState.Dialog.PermissionDenied + permissionsState.shouldShowRationale -> dialogState = ShareLocationState.Dialog.PermissionRationale + else -> dialogState = ShareLocationState.Dialog.PermissionDenied } ShareLocationEvent.SwitchToPinLocationMode -> mode = ShareLocationState.Mode.PinLocation - ShareLocationEvent.DismissDialog -> permissionDialog = ShareLocationState.Dialog.None + ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None ShareLocationEvent.OpenAppSettings -> { locationActions.openSettings() - permissionDialog = ShareLocationState.Dialog.None + dialogState = ShareLocationState.Dialog.None } ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) - ShareLocationEvent.SelectLiveLocationDuration -> Unit + ShareLocationEvent.SelectLiveLocationDuration -> dialogState = when { + permissionsState.isAnyGranted -> ShareLocationState.Dialog.LiveLocationDuration + permissionsState.shouldShowRationale -> ShareLocationState.Dialog.PermissionRationale + else -> ShareLocationState.Dialog.PermissionDenied + } + is ShareLocationEvent.StartLiveLocationShare -> scope.launch { + dialogState = ShareLocationState.Dialog.None + //room.startLiveLocationShare(event.duration.inWholeMilliseconds) + } } } return ShareLocationState( - permissionDialog = permissionDialog, + dialogState = dialogState, mode = mode, hasLocationPermission = permissionsState.isAnyGranted, canShareLiveLocation = isLiveLocationSharingEnabled, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index 69971b342e..952e728097 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -9,7 +9,7 @@ package io.element.android.features.location.impl.share data class ShareLocationState( - val permissionDialog: Dialog, + val dialogState: Dialog, val mode: Mode, val hasLocationPermission: Boolean, val appName: String, @@ -25,5 +25,6 @@ data class ShareLocationState( data object None : Dialog data object PermissionRationale : Dialog data object PermissionDenied : Dialog + data object LiveLocationDuration : Dialog } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index b22e8e6606..8832184f18 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -40,6 +40,12 @@ class ShareLocationStateProvider : PreviewParameterProvider mode = ShareLocationState.Mode.SenderLocation, hasLocationPermission = true, ), + aShareLocationState( + permissionDialog = ShareLocationState.Dialog.LiveLocationDuration, + mode = ShareLocationState.Mode.SenderLocation, + hasLocationPermission = true, + canShareLiveLocation = true, + ), ) } @@ -50,7 +56,7 @@ private fun aShareLocationState( canShareLiveLocation: Boolean = false, ): ShareLocationState { return ShareLocationState( - permissionDialog = permissionDialog, + dialogState = permissionDialog, mode = mode, hasLocationPermission = hasLocationPermission, canShareLiveLocation = canShareLiveLocation, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 27ade66c88..62cb38fd9b 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.impl.share import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -22,8 +23,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -40,7 +46,9 @@ 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.ui.LocationFloatingActionButton 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.list.ListItemContent +import io.element.android.libraries.designsystem.components.list.RadioButtonListItem import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold @@ -57,6 +65,7 @@ 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 org.maplibre.android.camera.CameraPosition +import kotlin.time.Duration @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -69,7 +78,7 @@ fun ShareLocationView( state.eventSink(ShareLocationEvent.RequestPermissions) } - when (state.permissionDialog) { + when (state.dialogState) { ShareLocationState.Dialog.None -> Unit ShareLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( onContinue = { state.eventSink(ShareLocationEvent.OpenAppSettings) }, @@ -81,6 +90,13 @@ fun ShareLocationView( onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, appName = state.appName, ) + ShareLocationState.Dialog.LiveLocationDuration -> LiveLocationDurationDialog( + onSelectDuration = { duration -> + state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration)) + navigateUp() + }, + onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, + ) } val cameraPositionState = rememberCameraPositionState { @@ -226,7 +242,7 @@ private fun StaticLocationItem( @Composable private fun LiveLocationItem( - onClick: ()->Unit, + onClick: () -> Unit, ) { ListItem( headlineContent = { @@ -242,6 +258,32 @@ private fun LiveLocationItem( ) } +@Composable +private fun LiveLocationDurationDialog( + onSelectDuration: (Duration) -> Unit, + onDismiss: () -> Unit, +) { + var selectedIndex by remember { mutableIntStateOf(0) } + ListDialog( + title = "Choose how long to share your live location.", + submitText = stringResource(CommonStrings.action_continue), + onSubmit = { onSelectDuration(LiveLocationDuration.entries[selectedIndex].duration) }, + onDismissRequest = onDismiss, + applyPaddingToContents = false, + verticalArrangement = Arrangement.Top + ) { + itemsIndexed(LiveLocationDuration.entries) { index, duration -> + RadioButtonListItem( + headline = duration.label, + selected = index == selectedIndex, + onSelect = { selectedIndex = index }, + compactLayout = true, + modifier = Modifier.padding(start = 8.dp) + ) + } + } +} + @PreviewsDayNight @Composable internal fun ShareLocationViewPreview( diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 3d4a5b519e..8d34fb99d6 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -78,14 +78,14 @@ class ShareLocationPresenterTest { shareLocationPresenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.SenderLocation) assertThat(initialState.hasLocationPermission).isTrue() // Swipe the map to switch mode initialState.eventSink(ShareLocationEvent.SwitchToPinLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isTrue() } @@ -105,14 +105,14 @@ class ShareLocationPresenterTest { shareLocationPresenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.SenderLocation) assertThat(initialState.hasLocationPermission).isTrue() // Swipe the map to switch mode initialState.eventSink(ShareLocationEvent.SwitchToPinLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isTrue() } @@ -132,14 +132,14 @@ class ShareLocationPresenterTest { shareLocationPresenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(initialState.hasLocationPermission).isFalse() // Click on the button to switch mode initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionDenied) + assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionDenied) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() } @@ -159,14 +159,14 @@ class ShareLocationPresenterTest { shareLocationPresenter.present() }.test { val initialState = awaitItem() - assertThat(initialState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(initialState.hasLocationPermission).isFalse() // Click on the button to switch mode initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionRationale) + assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionRationale) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() } @@ -191,14 +191,14 @@ class ShareLocationPresenterTest { // Click on the button to switch mode initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionRationale) + assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionRationale) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() // Dismiss the dialog myLocationState.eventSink(ShareLocationEvent.DismissDialog) val dialogDismissedState = awaitItem() - assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(dialogDismissedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(dialogDismissedState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(dialogDismissedState.hasLocationPermission).isFalse() } @@ -223,7 +223,7 @@ class ShareLocationPresenterTest { // Click on the button to switch mode initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionRationale) + assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionRationale) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() @@ -252,14 +252,14 @@ class ShareLocationPresenterTest { // Click on the button to switch mode initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) val myLocationState = awaitItem() - assertThat(myLocationState.permissionDialog).isEqualTo(ShareLocationState.Dialog.PermissionDenied) + assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionDenied) assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(myLocationState.hasLocationPermission).isFalse() // Dismiss the dialog myLocationState.eventSink(ShareLocationEvent.DismissDialog) val dialogDismissedState = awaitItem() - assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(dialogDismissedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(dialogDismissedState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) assertThat(dialogDismissedState.hasLocationPermission).isFalse() } @@ -478,7 +478,7 @@ class ShareLocationPresenterTest { dialogShownState.eventSink(ShareLocationEvent.OpenAppSettings) val settingsOpenedState = awaitItem() - assertThat(settingsOpenedState.permissionDialog).isEqualTo(ShareLocationState.Dialog.None) + assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt index ce1afae93d..ffaab12eba 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -42,6 +43,7 @@ fun ListDialog( enabled: Boolean = true, applyPaddingToContents: Boolean = true, destructiveSubmit: Boolean = false, + verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(16.dp), listItems: LazyListScope.() -> Unit, ) { val decoratedSubtitle: @Composable (() -> Unit)? = subtitle?.let { @@ -67,6 +69,7 @@ fun ListDialog( listItems = listItems, applyPaddingToContents = applyPaddingToContents, destructiveSubmit = destructiveSubmit, + verticalArrangement = verticalArrangement, ) } } @@ -82,6 +85,7 @@ private fun ListDialogContent( enabled: Boolean, applyPaddingToContents: Boolean, destructiveSubmit: Boolean, + verticalArrangement: Arrangement.Vertical, subtitle: @Composable (() -> Unit)? = null, ) { SimpleAlertDialogContent( @@ -99,7 +103,7 @@ private fun ListDialogContent( val horizontalPadding = if (applyPaddingToContents) 0.dp else 8.dp LazyColumn( modifier = Modifier.padding(horizontal = horizontalPadding), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = verticalArrangement, ) { listItems() } } } @@ -126,6 +130,7 @@ internal fun ListDialogContentPreview() { enabled = true, destructiveSubmit = false, applyPaddingToContents = true, + verticalArrangement = Arrangement.spacedBy(16.dp), ) } } From b49fd7b4677c7c796884e72a571f7bea03783200 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 26 Feb 2026 15:41:52 +0100 Subject: [PATCH 07/52] Add maplibre-compose to gradle catalog --- app/build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 969f401385..64bd398446 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -334,6 +334,7 @@ licensee { allow("BSD-2-Clause") allow("BSD-3-Clause") allow("EPL-1.0") + allowUrl("https://opensource.org/license/bsd-3-clause") allowUrl("https://opensource.org/licenses/MIT") allowUrl("https://developer.android.com/studio/terms.html") allowUrl("https://www.zetetic.net/sqlcipher/license/") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e70308cccd..e5fe99bf17 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -209,6 +209,7 @@ telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = " statemachine = "com.freeletics.flowredux:compose:1.2.2" maplibre = "org.maplibre.gl:android-sdk:13.0.0" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" +maplibre_compose = "org.maplibre.compose:maplibre-compose:0.12.1" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" opusencoder = "io.element.android:opusencoder:1.2.0" zxing_cpp = "io.github.zxing-cpp:android:3.0.2" From 29f9640053940ede3426be35a4ab8cee9bb0430d Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 26 Feb 2026 15:54:41 +0100 Subject: [PATCH 08/52] Location accuracy should be nullable --- .../android/features/location/api/Location.kt | 11 +++++++---- .../android/features/location/api/LocationKtTest.kt | 13 ++++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt index 7593867207..eac624fe8b 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/Location.kt @@ -19,7 +19,7 @@ private const val GEO_URI_REGEX = """geo:(?-?\d+(?:\.\d+)?),(? Date: Thu, 26 Feb 2026 18:28:46 +0100 Subject: [PATCH 09/52] First iteration using maplibre-compose --- features/location/impl/build.gradle.kts | 2 +- .../location/impl/common/MapDefaults.kt | 31 ++- .../common/ui/LocationFloatingActionButton.kt | 8 +- .../impl/common/ui/MapBottomSheetScaffold.kt | 130 +++++++++ .../location/impl/common/ui/UserLocation.kt | 52 ++++ .../location/impl/share/ShareLocationEvent.kt | 18 +- .../impl/share/ShareLocationPresenter.kt | 94 ++----- .../location/impl/share/ShareLocationState.kt | 7 +- .../impl/share/ShareLocationStateProvider.kt | 16 +- .../location/impl/share/ShareLocationView.kt | 253 +++++++++--------- .../location/impl/show/ShowLocationView.kt | 146 +++++----- 11 files changed, 456 insertions(+), 301 deletions(-) create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocation.kt diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 3b805d87ca..558cd86b25 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -27,7 +27,7 @@ setupDependencyInjection() dependencies { api(projects.features.location.api) implementation(projects.features.messages.api) - implementation(projects.libraries.maplibreCompose) + implementation(libs.maplibre.compose) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.di) 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 d01f3e7dd2..6c01c75508 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 @@ -9,21 +9,30 @@ package io.element.android.features.location.impl.common import android.Manifest -import android.view.Gravity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.ui.graphics.Color -import io.element.android.compound.theme.ElementTheme -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 +import androidx.compose.ui.Alignment +import org.maplibre.compose.map.GestureOptions +import org.maplibre.compose.map.MapOptions +import org.maplibre.compose.map.OrnamentOptions +import org.maplibre.compose.map.RenderOptions /** * Common configuration values for the map. */ 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 @Composable @ReadOnlyComposable @@ -60,6 +69,8 @@ object MapDefaults { .zoom(2.7) .build() + */ + 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/LocationFloatingActionButton.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt index 773544375f..99a4c7470f 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt @@ -9,6 +9,8 @@ package io.element.android.features.location.impl.common.ui 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.runtime.Composable import androidx.compose.ui.Modifier @@ -30,13 +32,11 @@ internal fun LocationFloatingActionButton( modifier: Modifier = Modifier, ) { FloatingActionButton( - shape = FloatingActionButtonDefaults.smallShape, + shape = CircleShape, containerColor = ElementTheme.colors.bgCanvasDefault, contentColor = ElementTheme.colors.iconPrimary, onClick = onClick, - modifier = modifier - // Note: design is 40dp, but min is 48 for accessibility. - .size(48.dp), + modifier = modifier.size(48.dp), ) { val iconImage = if (isMapCenteredOnUser) { CompoundIcons.LocationNavigatorCentred() 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 new file mode 100644 index 0000000000..0a1b0cff9a --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapBottomSheetScaffold.kt @@ -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) + } + } + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocation.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocation.kt new file mode 100644 index 0000000000..9a5b6121b0 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocation.kt @@ -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, + ) + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt index 9ac15742d9..eb641df9fb 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt @@ -13,21 +13,15 @@ import kotlin.time.Duration sealed interface ShareLocationEvent { data class ShareStaticLocation( - val cameraPosition: CameraPosition, - val location: Location?, - ) : ShareLocationEvent { - data class CameraPosition( - val lat: Double, - val lon: Double, - val zoom: Double, - ) - } + val location: Location, + val isPinned: Boolean, + ) : ShareLocationEvent - data object SelectLiveLocationDuration : ShareLocationEvent + data object ShowLiveLocationDurationPicker : ShareLocationEvent data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent - data object SwitchToMyLocationMode : ShareLocationEvent - data object SwitchToPinLocationMode : ShareLocationEvent + data object StartTrackingUserPosition : ShareLocationEvent + data object StopTrackingUserPosition : ShareLocationEvent data object DismissDialog : ShareLocationEvent data object RequestPermissions : ShareLocationEvent data object OpenAppSettings : ShareLocationEvent diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index d53b132314..bd82b823f0 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -61,15 +61,7 @@ class ShareLocationPresenter( @Composable override fun present(): ShareLocationState { val permissionsState: PermissionsState = permissionsPresenter.present() - var mode: ShareLocationState.Mode by remember { - mutableStateOf( - if (permissionsState.isAnyGranted) { - ShareLocationState.Mode.SenderLocation - } else { - ShareLocationState.Mode.PinLocation - } - ) - } + var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted) } val isLiveLocationSharingEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing) }.collectAsState(false) @@ -81,7 +73,7 @@ class ShareLocationPresenter( LaunchedEffect(permissionsState.permissions) { if (permissionsState.isAnyGranted) { - mode = ShareLocationState.Mode.SenderLocation + trackUserPosition = true dialogState = ShareLocationState.Dialog.None } } @@ -89,21 +81,21 @@ class ShareLocationPresenter( fun handleEvent(event: ShareLocationEvent) { when (event) { is ShareLocationEvent.ShareStaticLocation -> scope.launch { - shareLocation(event, mode) + shareStaticLocation(event) } - ShareLocationEvent.SwitchToMyLocationMode -> when { - permissionsState.isAnyGranted -> mode = ShareLocationState.Mode.SenderLocation + ShareLocationEvent.StartTrackingUserPosition -> when { + permissionsState.isAnyGranted -> trackUserPosition = true permissionsState.shouldShowRationale -> dialogState = ShareLocationState.Dialog.PermissionRationale else -> dialogState = ShareLocationState.Dialog.PermissionDenied } - ShareLocationEvent.SwitchToPinLocationMode -> mode = ShareLocationState.Mode.PinLocation + ShareLocationEvent.StopTrackingUserPosition -> trackUserPosition = false ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None ShareLocationEvent.OpenAppSettings -> { locationActions.openSettings() dialogState = ShareLocationState.Dialog.None } ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) - ShareLocationEvent.SelectLiveLocationDuration -> dialogState = when { + ShareLocationEvent.ShowLiveLocationDurationPicker -> dialogState = when { permissionsState.isAnyGranted -> ShareLocationState.Dialog.LiveLocationDuration permissionsState.shouldShowRationale -> ShareLocationState.Dialog.PermissionRationale else -> ShareLocationState.Dialog.PermissionDenied @@ -117,7 +109,7 @@ class ShareLocationPresenter( return ShareLocationState( dialogState = dialogState, - mode = mode, + trackUserLocation = trackUserPosition, hasLocationPermission = permissionsState.isAnyGranted, canShareLiveLocation = isLiveLocationSharingEnabled, appName = appName, @@ -125,56 +117,28 @@ class ShareLocationPresenter( ) } - private suspend fun shareLocation( - event: ShareLocationEvent.ShareStaticLocation, - mode: ShareLocationState.Mode, - ) { + private suspend fun shareStaticLocation(event: ShareLocationEvent.ShareStaticLocation) { val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply val inReplyToEventId = replyMode?.eventId - when (mode) { - ShareLocationState.Mode.PinLocation -> { - val geoUri = event.cameraPosition.toGeoUri() - getTimeline().flatMap { - it.sendLocation( - body = generateBody(geoUri), - geoUri = geoUri, - description = null, - zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), - 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, - ) - ) - } + val geoUri = event.location.toGeoUri() + getTimeline().flatMap { + it.sendLocation( + body = generateBody(geoUri), + geoUri = geoUri, + description = null, + zoomLevel = MapDefaults.DEFAULT_ZOOM.toInt(), + assetType = if (event.isPinned) AssetType.PIN else AssetType.SENDER, + inReplyToEventId = inReplyToEventId, + ) } + 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 { @@ -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" diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index 952e728097..947d286c5e 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -10,17 +10,12 @@ package io.element.android.features.location.impl.share data class ShareLocationState( val dialogState: Dialog, - val mode: Mode, + val trackUserLocation: Boolean, val hasLocationPermission: Boolean, val appName: String, val canShareLiveLocation: Boolean, val eventSink: (ShareLocationEvent) -> Unit, ) { - sealed interface Mode { - data object SenderLocation : Mode - data object PinLocation : Mode - } - sealed interface Dialog { data object None : Dialog data object PermissionRationale : Dialog diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index 8832184f18..45a81af1df 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -17,32 +17,32 @@ class ShareLocationStateProvider : PreviewParameterProvider get() = sequenceOf( aShareLocationState( permissionDialog = ShareLocationState.Dialog.None, - mode = ShareLocationState.Mode.PinLocation, + trackUserPosition = false, hasLocationPermission = false, ), aShareLocationState( permissionDialog = ShareLocationState.Dialog.PermissionDenied, - mode = ShareLocationState.Mode.PinLocation, + trackUserPosition = false, hasLocationPermission = false, ), aShareLocationState( permissionDialog = ShareLocationState.Dialog.PermissionRationale, - mode = ShareLocationState.Mode.PinLocation, + trackUserPosition = false, hasLocationPermission = false, ), aShareLocationState( permissionDialog = ShareLocationState.Dialog.None, - mode = ShareLocationState.Mode.PinLocation, + trackUserPosition = false, hasLocationPermission = true, ), aShareLocationState( permissionDialog = ShareLocationState.Dialog.None, - mode = ShareLocationState.Mode.SenderLocation, + trackUserPosition = true, hasLocationPermission = true, ), aShareLocationState( permissionDialog = ShareLocationState.Dialog.LiveLocationDuration, - mode = ShareLocationState.Mode.SenderLocation, + trackUserPosition = true, hasLocationPermission = true, canShareLiveLocation = true, ), @@ -51,13 +51,13 @@ class ShareLocationStateProvider : PreviewParameterProvider private fun aShareLocationState( permissionDialog: ShareLocationState.Dialog, - mode: ShareLocationState.Mode, + trackUserPosition: Boolean, hasLocationPermission: Boolean, canShareLiveLocation: Boolean = false, ): ShareLocationState { return ShareLocationState( dialogState = permissionDialog, - mode = mode, + trackUserLocation = trackUserPosition, hasLocationPermission = hasLocationPermission, canShareLiveLocation = canShareLiveLocation, appName = APP_NAME, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 62cb38fd9b..84829d4bf9 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -12,18 +12,14 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.features.location.api.Location import io.element.android.features.location.api.internal.centerBottomEdge -import io.element.android.features.location.api.internal.rememberTileStyleUrl import io.element.android.features.location.impl.common.MapDefaults 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.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.dialogs.ListDialog import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.RadioButtonListItem import io.element.android.libraries.designsystem.preview.ElementPreview 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.IconSource 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.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.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 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.Companion.minutes @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -99,76 +99,32 @@ fun ShareLocationView( ) } - val cameraPositionState = rememberCameraPositionState { - position = MapDefaults.centerCameraPosition + val scaffoldState = rememberBottomSheetScaffoldState( + 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) { - when (state.mode) { - ShareLocationState.Mode.PinLocation -> { - cameraPositionState.cameraMode = CameraMode.NONE - } - ShareLocationState.Mode.SenderLocation -> { - cameraPositionState.position = CameraPosition.Builder() - .zoom(MapDefaults.DEFAULT_ZOOM) - .build() - cameraPositionState.cameraMode = CameraMode.TRACKING - } + LaunchedEffect(cameraState.isCameraMoving) { + if (cameraState.moveReason == CameraMoveReason.GESTURE) { + state.eventSink(ShareLocationEvent.StopTrackingUserPosition) } } - LaunchedEffect(cameraPositionState.isMoving) { - if (cameraPositionState.cameraMoveStartedReason == CameraMoveStartedReason.GESTURE) { - 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)) - }, + MapBottomSheetScaffold( + cameraState = cameraState, modifier = modifier, - scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded), - ), - sheetDragHandle = {}, + scaffoldState = scaffoldState, + sheetDragHandle = null, sheetSwipeEnabled = false, topBar = { TopAppBar( @@ -178,62 +134,102 @@ fun ShareLocationView( }, ) }, - ) { - Box( - modifier = Modifier - .padding(it) - .consumeWindowInsets(it), - contentAlignment = Alignment.Center - ) { - MapLibreMap( - styleUri = rememberTileStyleUrl(), - modifier = Modifier.fillMaxSize(), - cameraPositionState = cameraPositionState, - uiSettings = MapDefaults.uiSettings, - symbolManagerSettings = MapDefaults.symbolManagerSettings, - locationSettings = MapDefaults.locationSettings.copy( - locationEnabled = state.hasLocationPermission, - ), + sheetContent = { + BottomSheetContent( + cameraState = cameraState, + state = state, + userLocationState = userLocationState, + navigateUp = navigateUp ) - Icon( - resourceId = CommonDrawables.pin, - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.centerBottomEdge(this), + }, + mapContent = { + UserLocation( + cameraState = cameraState, + locationState = userLocationState, + trackUserLocation = state.trackUserLocation ) - LocationFloatingActionButton( - isMapCenteredOnUser = state.mode == ShareLocationState.Mode.SenderLocation, - onClick = { state.eventSink(ShareLocationEvent.SwitchToMyLocationMode) }, + }, + overlayContent = { sheetPadding -> + Box( modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = 18.dp, bottom = 72.dp + navBarPadding), + .fillMaxSize() + .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 -private fun StaticLocationItem( - mode: ShareLocationState.Mode, - cameraPositionState: CameraPositionState, - onClick: ()->Unit, +private fun ShareCurrentLocationItem( + onClick: () -> Unit, ) { ListItem( headlineContent = { - Text( - stringResource( - when (mode) { - ShareLocationState.Mode.PinLocation -> CommonStrings.screen_share_this_location_action - ShareLocationState.Mode.SenderLocation -> CommonStrings.screen_share_my_location_action - } - ) - ) + Text(stringResource(CommonStrings.screen_share_my_location_action)) }, - modifier = Modifier.clickable( - // 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 - ), + modifier = Modifier.clickable(onClick = onClick), leadingContent = ListItemContent.Icon( iconSource = IconSource.Vector(CompoundIcons.LocationNavigatorCentred()) ) @@ -241,7 +237,22 @@ private fun StaticLocationItem( } @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, ) { ListItem( 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 6bc9e27bc6..f7d7a15207 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,15 +8,16 @@ package io.element.android.features.location.impl.show -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign 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 io.element.android.compound.tokens.generated.CompoundIcons 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.PermissionDeniedDialog 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.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.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.Scaffold import io.element.android.libraries.designsystem.theme.components.Text 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 kotlinx.collections.immutable.toImmutableMap -import org.maplibre.android.camera.CameraPosition -import org.maplibre.android.geometry.LatLng +import org.maplibre.compose.camera.CameraMoveReason +import org.maplibre.compose.camera.CameraPosition +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) @Composable @@ -71,33 +76,32 @@ fun ShowLocationView( ) } - val cameraPositionState = rememberCameraPositionState { - position = CameraPosition.Builder() - .target(LatLng(state.location.lat, state.location.lon)) - .zoom(MapDefaults.DEFAULT_ZOOM) - .build() + val cameraState = rememberCameraState( + firstPosition = CameraPosition( + target = Position(latitude = state.location.lat, longitude = state.location.lon), + zoom = MapDefaults.DEFAULT_ZOOM + ) + ) + val locationProvider = if (state.hasLocationPermission) { + rememberDefaultLocationProvider( + updateInterval = 1.minutes, + desiredAccuracy = DesiredAccuracy.Balanced, + minDistanceMeters = 50.0, + ) + } else { + rememberNullLocationProvider() } - - LaunchedEffect(state.isTrackMyLocation) { - when (state.isTrackMyLocation) { - 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) { + val userLocationState = rememberUserLocationState(locationProvider) + LaunchedEffect(cameraState.isCameraMoving) { + if (cameraState.moveReason == CameraMoveReason.GESTURE) { state.eventSink(ShowLocationEvents.TrackMyLocation(false)) } } - Scaffold( + MapBottomSheetScaffold( + cameraState = cameraState, modifier = modifier, + sheetPeekHeight = 80.dp, topBar = { TopAppBar( titleStr = stringResource(CommonStrings.screen_view_location_title), @@ -118,19 +122,7 @@ fun ShowLocationView( } ) }, - floatingActionButton = { - LocationFloatingActionButton( - isMapCenteredOnUser = state.isTrackMyLocation, - onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) }, - ) - }, - ) { paddingValues -> - Column( - modifier = Modifier - .padding(paddingValues) - .consumeWindowInsets(paddingValues) - .fillMaxSize(), - ) { + sheetContent = { state.description?.let { Text( text = it, @@ -143,28 +135,40 @@ fun ShowLocationView( .padding(8.dp), ) } - - MapLibreMap( - styleUri = rememberTileStyleUrl(), - modifier = Modifier.fillMaxSize(), - images = mapOf(PIN_ID to CommonDrawables.pin).toImmutableMap(), - cameraPositionState = cameraPositionState, - uiSettings = MapDefaults.uiSettings, - symbolManagerSettings = MapDefaults.symbolManagerSettings, - locationSettings = MapDefaults.locationSettings.copy( - locationEnabled = state.hasLocationPermission, - ), - ) { - Symbol( - iconId = PIN_ID, - state = rememberSymbolState( - position = LatLng(state.location.lat, state.location.lon) - ), - iconAnchor = IconAnchor.BOTTOM, + }, + mapContent = { + UserLocation( + cameraState = cameraState, + locationState = userLocationState, + trackUserLocation = state.isTrackMyLocation + ) + val senderLocation = rememberGeoJsonSource( + data = GeoJsonData.Features( + Point( + Position( + latitude = state.location.lat, + longitude = state.location.lon + ) + ) ) - } + ) + 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 @@ -175,5 +179,3 @@ internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider onBackClick = {}, ) } - -private const val PIN_ID = "pin" From 222b9f0c9ef53de3f225c09af8410392275d5d19 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 26 Feb 2026 19:03:28 +0100 Subject: [PATCH 10/52] Raname UserLocation to UserLocationPuck --- .../impl/common/ui/{UserLocation.kt => UserLocationPuck.kt} | 2 +- .../features/location/impl/share/ShareLocationView.kt | 4 ++-- .../android/features/location/impl/show/ShowLocationView.kt | 6 ++---- 3 files changed, 5 insertions(+), 7 deletions(-) rename features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/{UserLocation.kt => UserLocationPuck.kt} (98%) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocation.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt similarity index 98% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocation.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt index 9a5b6121b0..9d073f8942 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocation.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt @@ -18,7 +18,7 @@ import org.maplibre.compose.location.LocationTrackingEffect import org.maplibre.compose.location.UserLocationState @Composable -fun UserLocation( +fun UserLocationPuck( cameraState: CameraState, locationState: UserLocationState, trackUserLocation: Boolean, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 84829d4bf9..03638f8f90 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -41,7 +41,7 @@ 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.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.features.location.impl.common.ui.UserLocationPuck 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.list.ListItemContent @@ -143,7 +143,7 @@ fun ShareLocationView( ) }, mapContent = { - UserLocation( + UserLocationPuck( cameraState = cameraState, locationState = userLocationState, trackUserLocation = state.trackUserLocation 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 f7d7a15207..d53895d273 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,9 +8,7 @@ package io.element.android.features.location.impl.show -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable @@ -31,7 +29,7 @@ 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.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.features.location.impl.common.ui.UserLocationPuck 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 @@ -137,7 +135,7 @@ fun ShowLocationView( } }, mapContent = { - UserLocation( + UserLocationPuck( cameraState = cameraState, locationState = userLocationState, trackUserLocation = state.isTrackMyLocation From b4cf8c274e3b8a4f13289eaad1e644b7c2db2fbc Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 26 Feb 2026 22:07:30 +0100 Subject: [PATCH 11/52] Make sure we can display both Live and Static locations in ShowLocation --- .../location/api/ShowLocationEntryPoint.kt | 3 +- .../features/location/api/ShowLocationMode.kt | 28 +++++++ .../location/impl/show/ShowLocationNode.kt | 2 +- .../impl/show/ShowLocationPresenter.kt | 21 +++-- .../location/impl/show/ShowLocationState.kt | 5 +- .../impl/show/ShowLocationStateProvider.kt | 34 ++++++-- .../location/impl/show/ShowLocationView.kt | 83 ++++++++++++------- .../messages/impl/MessagesFlowNode.kt | 18 ++-- .../event/TimelineItemLocationContent.kt | 2 + .../TimelineItemContentMessageFactoryTest.kt | 7 +- .../api/timeline/item/event/MessageType.kt | 2 + .../matrix/impl/room/location/AssetType.kt | 12 ++- .../matrix/impl/timeline/RustTimeline.kt | 4 +- .../timeline/item/event/EventMessageMapper.kt | 8 +- .../impl/room/location/AssetTypeKtTest.kt | 4 +- .../reply/InReplyToDetailsProvider.kt | 2 +- 16 files changed, 168 insertions(+), 67 deletions(-) create mode 100644 features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationEntryPoint.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationEntryPoint.kt index 03693a7d68..c41d3aa6fc 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationEntryPoint.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationEntryPoint.kt @@ -15,8 +15,7 @@ import io.element.android.libraries.architecture.NodeInputs interface ShowLocationEntryPoint : FeatureEntryPoint { data class Inputs( - val location: Location, - val description: String?, + val mode: ShowLocationMode, ) : NodeInputs fun createNode( diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt new file mode 100644 index 0000000000..1227ddec46 --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/ShowLocationMode.kt @@ -0,0 +1,28 @@ +/* + * 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.api + +import android.os.Parcelable +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.AssetType +import kotlinx.parcelize.Parcelize + +sealed interface ShowLocationMode : Parcelable { + @Parcelize + data class Static( + val location: Location, + val senderName: String, + val senderId: UserId, + val senderAvatarUrl: String?, + val timestamp: Long, + val assetType: AssetType?, + ) : ShowLocationMode + + @Parcelize + data object Live : ShowLocationMode +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt index 86d7741752..f318851f99 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt @@ -40,7 +40,7 @@ class ShowLocationNode( } private val inputs: ShowLocationEntryPoint.Inputs = inputs() - private val presenter = presenterFactory.create(inputs.location, inputs.description) + private val presenter = presenterFactory.create(inputs.mode) @Composable override fun View(modifier: Modifier) { 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 3dcccef886..f402b8fac9 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 @@ -18,7 +18,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.features.location.api.Location +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.actions.LocationActions import io.element.android.features.location.impl.common.permissions.PermissionsEvents @@ -29,15 +29,14 @@ import io.element.android.libraries.core.meta.BuildMeta @AssistedInject class ShowLocationPresenter( - @Assisted private val location: Location, - @Assisted private val description: String?, + @Assisted private val mode: ShowLocationMode, permissionsPresenterFactory: PermissionsPresenter.Factory, private val locationActions: LocationActions, private val buildMeta: BuildMeta, ) : Presenter { @AssistedFactory fun interface Factory { - fun create(location: Location, description: String?): ShowLocationPresenter + fun create(mode: ShowLocationMode): ShowLocationPresenter } private val permissionsPresenter = permissionsPresenterFactory.create(MapDefaults.permissions) @@ -59,7 +58,16 @@ class ShowLocationPresenter( fun handleEvent(event: ShowLocationEvents) { when (event) { - ShowLocationEvents.Share -> locationActions.share(location, description) + ShowLocationEvents.Share -> { + when (mode) { + is ShowLocationMode.Static -> { + locationActions.share(mode.location, null) + } + ShowLocationMode.Live -> { + // TODO: Handle sharing for live locations + } + } + } is ShowLocationEvents.TrackMyLocation -> { if (event.enabled) { when { @@ -82,8 +90,7 @@ class ShowLocationPresenter( return ShowLocationState( permissionDialog = permissionDialog, - location = location, - description = description, + mode = mode, 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 96635d6df8..4eefa34053 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,12 +8,11 @@ package io.element.android.features.location.impl.show -import io.element.android.features.location.api.Location +import io.element.android.features.location.api.ShowLocationMode data class ShowLocationState( val permissionDialog: Dialog, - val location: Location, - val description: String?, + val mode: ShowLocationMode, val hasLocationPermission: Boolean, val isTrackMyLocation: Boolean, val appName: String, 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 7d03a1ebb2..4941d8984d 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 @@ -10,6 +10,9 @@ package io.element.android.features.location.impl.show import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.location.api.Location +import io.element.android.features.location.api.ShowLocationMode +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.location.AssetType private const val APP_NAME = "ApplicationName" @@ -31,32 +34,47 @@ class ShowLocationStateProvider : PreviewParameterProvider { isTrackMyLocation = true, ), aShowLocationState( - description = "My favourite place!", + mode = aStaticLocationMode(senderName = "My favourite place!"), ), aShowLocationState( - description = "For some reason I decided to to write a small essay that wraps at just two lines!", + mode = aStaticLocationMode( + senderName = "For some reason I decided to write a small essay that wraps at just two lines!" + ), ), aShowLocationState( - description = "For some reason I decided to write a small essay in the location description. " + - "It is so long that it will wrap onto more than two lines!", + mode = ShowLocationMode.Live, ), ) } fun aShowLocationState( permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None, - location: Location = Location(1.23, 2.34, 4f), - description: String? = null, + mode: ShowLocationMode = aStaticLocationMode(), hasLocationPermission: Boolean = false, isTrackMyLocation: Boolean = false, appName: String = APP_NAME, eventSink: (ShowLocationEvents) -> Unit = {}, ) = ShowLocationState( permissionDialog = permissionDialog, - location = location, - description = description, + mode = mode, hasLocationPermission = hasLocationPermission, isTrackMyLocation = isTrackMyLocation, appName = appName, eventSink = eventSink, ) + +fun aStaticLocationMode( + location: Location = Location(1.23, 2.34, 4f), + senderName: String = "Alice", + senderId: UserId = UserId("@alice:matrix.org"), + senderAvatarUrl: String? = null, + timestamp: Long = System.currentTimeMillis(), + assetType: AssetType? = null, +) = ShowLocationMode.Static( + location = location, + senderName = senderName, + senderId = senderId, + senderAvatarUrl = senderAvatarUrl, + timestamp = timestamp, + assetType = assetType, +) 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 d53895d273..8e16ef7c5c 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 @@ -11,6 +11,9 @@ package io.element.android.features.location.impl.show import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding 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.ui.Alignment @@ -23,6 +26,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.compound.tokens.generated.TypographyTokens +import io.element.android.features.location.api.ShowLocationMode 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.PermissionDeniedDialog @@ -74,12 +78,16 @@ fun ShowLocationView( ) } - val cameraState = rememberCameraState( - firstPosition = CameraPosition( - target = Position(latitude = state.location.lat, longitude = state.location.lon), + val initialPosition = when (val mode = state.mode) { + is ShowLocationMode.Static -> CameraPosition( + target = Position(latitude = mode.location.lat, longitude = mode.location.lon), zoom = MapDefaults.DEFAULT_ZOOM ) - ) + ShowLocationMode.Live -> CameraPosition( + zoom = MapDefaults.DEFAULT_ZOOM + ) + } + val cameraState = rememberCameraState(firstPosition = initialPosition) val locationProvider = if (state.hasLocationPermission) { rememberDefaultLocationProvider( updateInterval = 1.minutes, @@ -96,10 +104,13 @@ fun ShowLocationView( } } + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState() + ) MapBottomSheetScaffold( + scaffoldState = scaffoldState, cameraState = cameraState, modifier = modifier, - sheetPeekHeight = 80.dp, topBar = { TopAppBar( titleStr = stringResource(CommonStrings.screen_view_location_title), @@ -121,17 +132,22 @@ fun ShowLocationView( ) }, sheetContent = { - state.description?.let { - Text( - text = it, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = TypographyTokens.fontBodyMdRegular, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - ) + when (val mode = state.mode) { + is ShowLocationMode.Static -> { + Text( + text = mode.senderName, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = TypographyTokens.fontBodyMdRegular, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + ) + } + ShowLocationMode.Live -> { + // TODO: Show list of active live location sharers + } } }, mapContent = { @@ -140,22 +156,29 @@ fun ShowLocationView( locationState = userLocationState, trackUserLocation = state.isTrackMyLocation ) - val senderLocation = rememberGeoJsonSource( - data = GeoJsonData.Features( - Point( - Position( - latitude = state.location.lat, - longitude = state.location.lon + when (val mode = state.mode) { + is ShowLocationMode.Static -> { + val senderLocation = rememberGeoJsonSource( + data = GeoJsonData.Features( + Point( + Position( + latitude = mode.location.lat, + longitude = mode.location.lon + ) + ) ) ) - ) - ) - val marker = painterResource(R.drawable.pin_small) - SymbolLayer( - id = "sender_location", - source = senderLocation, - iconImage = image(marker) - ) + val marker = painterResource(R.drawable.pin_small) + SymbolLayer( + id = "sender_location", + source = senderLocation, + iconImage = image(marker) + ) + } + ShowLocationMode.Live -> { + // TODO: Show pins for all active live location sharers + } + } }, overlayContent = { LocationFloatingActionButton( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index b28574cdbf..9ad7c48e36 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -28,10 +28,10 @@ import io.element.android.features.call.api.CallType import io.element.android.features.call.api.ElementCallEntryPoint import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint -import io.element.android.features.location.api.Location import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.ShareLocationEntryPoint import io.element.android.features.location.api.ShowLocationEntryPoint +import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.AttachmentsPreviewNode @@ -75,6 +75,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.room.joinedRoomMembers +import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo @@ -148,7 +149,7 @@ class MessagesFlowNode( data class AttachmentPreview(val timelineMode: Timeline.Mode, val attachment: Attachment, val inReplyToEventId: EventId?) : NavTarget @Parcelize - data class LocationViewer(val location: Location, val description: String?) : NavTarget + data class LocationViewer(val mode: ShowLocationMode) : NavTarget @Parcelize data class EventDebugInfo(val eventId: EventId?, val debugInfo: TimelineItemDebugInfo) : NavTarget @@ -336,7 +337,7 @@ class MessagesFlowNode( createNode(buildContext, listOf(inputs)) } is NavTarget.LocationViewer -> { - val inputs = ShowLocationEntryPoint.Inputs(navTarget.location, navTarget.description) + val inputs = ShowLocationEntryPoint.Inputs(navTarget.mode) showLocationEntryPoint.createNode( parentNode = this, buildContext = buildContext, @@ -558,9 +559,16 @@ class MessagesFlowNode( ) } is TimelineItemLocationContent -> { - NavTarget.LocationViewer( + val mode = ShowLocationMode.Static( location = event.content.location, - description = event.content.description, + senderName = event.safeSenderName, + senderId = event.senderId, + senderAvatarUrl = event.senderAvatar.url, + timestamp = event.sentTimeMillis, + assetType = event.content.assetType, + ) + NavTarget.LocationViewer( + mode = mode ).takeIf { locationService.isServiceAvailable() } } else -> null diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt index 1114b2ab15..5547eb29c3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt @@ -9,11 +9,13 @@ package io.element.android.features.messages.impl.timeline.model.event import io.element.android.features.location.api.Location +import io.element.android.libraries.matrix.api.room.location.AssetType data class TimelineItemLocationContent( val body: String, val location: Location, val description: String? = null, + val assetType: AssetType? = null, ) : TimelineItemEventContent { override val type: String = "TimelineItemLocationContent" } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 9f23388ecb..24f8c30ab1 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.ThumbnailInfo import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType @@ -98,8 +99,9 @@ class TimelineItemContentMessageFactoryTest { @Test fun `test create LocationMessageType not null`() = runTest { val sut = createTimelineItemContentMessageFactory() + val assetType = AssetType.SENDER val result = sut.create( - content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description")), + content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description", assetType)), senderDisambiguatedDisplayName = "Bob", eventId = AN_EVENT_ID, ) @@ -107,6 +109,7 @@ class TimelineItemContentMessageFactoryTest { body = "body", location = Location(lat = 1.0, lon = 2.0, accuracy = 0.0F), description = "description", + assetType = assetType, ) assertThat(result).isEqualTo(expected) } @@ -115,7 +118,7 @@ class TimelineItemContentMessageFactoryTest { fun `test create LocationMessageType null`() = runTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( - content = createMessageContent(type = LocationMessageType("body", "", null)), + content = createMessageContent(type = LocationMessageType("body", "", null, null)), senderDisambiguatedDisplayName = "Bob", eventId = AN_EVENT_ID, ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt index 6de2876f61..0cc5ca0ff7 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/MessageType.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.room.location.AssetType @Immutable sealed interface MessageType @@ -55,6 +56,7 @@ data class LocationMessageType( val body: String, val geoUri: String, val description: String?, + val assetType: AssetType?, ) : MessageType data class AudioMessageType( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt index c7c2c88fcc..40a55cede1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt @@ -9,8 +9,14 @@ package io.element.android.libraries.matrix.impl.room.location import io.element.android.libraries.matrix.api.room.location.AssetType +import org.matrix.rustcomponents.sdk.AssetType as RustAssetType -fun AssetType.toInner(): org.matrix.rustcomponents.sdk.AssetType = when (this) { - AssetType.SENDER -> org.matrix.rustcomponents.sdk.AssetType.SENDER - AssetType.PIN -> org.matrix.rustcomponents.sdk.AssetType.PIN +fun AssetType.into(): RustAssetType = when (this) { + AssetType.SENDER -> RustAssetType.SENDER + AssetType.PIN -> RustAssetType.PIN +} + +fun RustAssetType.into(): AssetType = when(this){ + RustAssetType.SENDER -> AssetType.SENDER + RustAssetType.PIN -> AssetType.PIN } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 0ee7239933..3996155871 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -32,7 +32,7 @@ import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl import io.element.android.libraries.matrix.impl.media.map import io.element.android.libraries.matrix.impl.poll.toInner import io.element.android.libraries.matrix.impl.room.RoomContentForwarder -import io.element.android.libraries.matrix.impl.room.location.toInner +import io.element.android.libraries.matrix.impl.room.location.into import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper @@ -478,7 +478,7 @@ class RustTimeline( geoUri = geoUri, description = description, zoomLevel = zoomLevel?.toUByte(), - assetType = assetType?.toInner(), + assetType = assetType?.into(), repliedToEventId = inReplyToEventId?.value, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt index 813bf0ec11..d89d2766eb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.impl.media.map +import io.element.android.libraries.matrix.impl.room.location.into import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper import org.matrix.rustcomponents.sdk.InReplyToDetails import org.matrix.rustcomponents.sdk.MessageType @@ -112,7 +113,12 @@ class EventMessageMapper { ) } is RustMessageType.Location -> { - LocationMessageType(type.content.body, type.content.geoUri, type.content.description) + LocationMessageType( + body = type.content.body, + geoUri = type.content.geoUri, + description = type.content.description, + assetType = type.content.asset?.into() + ) } is MessageType.Other -> { OtherMessageType(type.msgtype, type.body) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetTypeKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetTypeKtTest.kt index 9b12d12a04..f20ae940cf 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetTypeKtTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetTypeKtTest.kt @@ -15,7 +15,7 @@ import org.junit.Test class AssetTypeKtTest { @Test fun toInner() { - assertThat(AssetType.SENDER.toInner()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.SENDER) - assertThat(AssetType.PIN.toInner()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.PIN) + assertThat(AssetType.SENDER.into()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.SENDER) + assertThat(AssetType.PIN.into()).isEqualTo(org.matrix.rustcomponents.sdk.AssetType.PIN) } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt index 0727b0b7ec..4b2f18a362 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt @@ -71,7 +71,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider ), aMessageContent( body = "Location", - type = LocationMessageType("Location", "geo:1,2", null), + type = LocationMessageType("Location", "geo:1,2", null, assetType = null), ), aMessageContent( body = "Notice", From a6e31b5c451d4a2d0a80f10e5e34fdf43ace1ea5 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 26 Feb 2026 22:14:05 +0100 Subject: [PATCH 12/52] Use ListItem.onClick method --- .../features/location/impl/share/ShareLocationView.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 03638f8f90..f1e782cb26 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -229,7 +229,7 @@ private fun ShareCurrentLocationItem( headlineContent = { Text(stringResource(CommonStrings.screen_share_my_location_action)) }, - modifier = Modifier.clickable(onClick = onClick), + onClick = onClick, leadingContent = ListItemContent.Icon( iconSource = IconSource.Vector(CompoundIcons.LocationNavigatorCentred()) ) @@ -244,7 +244,7 @@ private fun SharePinLocationItem( headlineContent = { Text(stringResource(CommonStrings.screen_share_this_location_action)) }, - modifier = Modifier.clickable(onClick = onClick), + onClick = onClick, leadingContent = ListItemContent.Icon( iconSource = IconSource.Vector(CompoundIcons.LocationNavigator()) ) @@ -259,9 +259,7 @@ private fun ShareLiveLocationItem( headlineContent = { Text("Share live location") }, - modifier = Modifier.clickable( - onClick = onClick - ), + onClick = onClick, leadingContent = ListItemContent.Icon( iconSource = IconSource.Vector(CompoundIcons.LocationPinSolid()), tintColor = ElementTheme.colors.iconAccentPrimary, From f6c1ad47d76bb50be5e573db4bef392a040353c2 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 27 Feb 2026 14:51:16 +0100 Subject: [PATCH 13/52] Fix MapBottomSheetScaffold paddings --- .../impl/common/ui/MapBottomSheetScaffold.kt | 25 +++++++++++++------ .../location/impl/show/ShowLocationView.kt | 1 + 2 files changed, 18 insertions(+), 8 deletions(-) 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 0a1b0cff9a..78364a6f4e 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 @@ -7,15 +7,21 @@ package io.element.android.features.location.impl.common.ui +import androidx.compose.foundation.background 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.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api @@ -31,6 +37,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -39,13 +46,13 @@ 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 +import kotlin.math.roundToInt /** * A reusable scaffold component for map views with a bottom sheet. @@ -87,7 +94,8 @@ fun MapBottomSheetScaffold( ) { val density = LocalDensity.current - BoxWithConstraints(modifier = modifier.safeDrawingPadding()) { + val windowInsets = WindowInsets.safeContent.only(WindowInsetsSides.Horizontal) + BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) { val layoutHeightPx by rememberUpdatedState(constraints.maxHeight) val sheetPadding by remember { derivedStateOf { @@ -101,12 +109,12 @@ fun MapBottomSheetScaffold( LaunchedEffect(sheetPadding) { cameraState.position = cameraState.position.copy(padding = sheetPadding) } - BottomSheetScaffold( + modifier = Modifier, sheetPeekHeight = sheetPeekHeight, sheetContent = { sheetContent() - Spacer(modifier = Modifier.navigationBarsPadding()) + Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) }, scaffoldState = scaffoldState, sheetDragHandle = sheetDragHandle, @@ -115,9 +123,10 @@ fun MapBottomSheetScaffold( topBar = topBar, ) { val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding) - Box { + val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions) + Box{ MaplibreMap( - options = mapOptions.copy(ornamentOptions = ornamentOptions), + options = mapOptions, baseStyle = BaseStyle.Uri(rememberTileStyleUrl()), modifier = Modifier.fillMaxSize(), cameraState = cameraState, 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 8e16ef7c5c..fc00f00c79 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 @@ -108,6 +108,7 @@ fun ShowLocationView( bottomSheetState = rememberStandardBottomSheetState() ) MapBottomSheetScaffold( + sheetPeekHeight = 180.dp, scaffoldState = scaffoldState, cameraState = cameraState, modifier = modifier, From 904657e86f9b0b7e8c5e5695d7a1f8464370dca7 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 3 Mar 2026 21:45:16 +0100 Subject: [PATCH 14/52] Introduce LocationPinMarker --- .../components/LocationPinMarker.kt | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt new file mode 100644 index 0000000000..a7b25554b5 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt @@ -0,0 +1,267 @@ +/* + * 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.libraries.designsystem.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +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.components.avatar.avatarShape +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import kotlin.math.cos +import kotlin.math.sin + +/** + * Variants of location pin markers. + */ +sealed interface PinVariant { + data class UserLocation( + val avatarData: AvatarData, + val isLive: Boolean, + ) : PinVariant + + data object PinnedLocation : PinVariant + data object StaleLocation : PinVariant +} + +private val PIN_MARKER_WIDTH = 42.dp +private val PIN_MARKER_HEIGHT = (PIN_MARKER_WIDTH * 1.2f) +private val DOT_RADIUS = 6.dp +private val CONTENT_OFFSET = 5.dp + +/** + * A location pin marker composable that supports multiple variants. + * + * Based on Figma design: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4665-2890&m=dev + */ +@Composable +fun LocationPinMarker( + variant: PinVariant, + modifier: Modifier = Modifier, +) { + val colors = LocationPinColors.fromVariant(variant) + Box( + modifier = modifier.size(width = PIN_MARKER_WIDTH, height = PIN_MARKER_HEIGHT), + ) { + // Draw the pin shape + Canvas( + modifier = Modifier.matchParentSize() + ) { + drawPinShape( + fillColor = colors.fill, + strokeColor = colors.stroke, + strokeWidth = 1.dp.toPx(), + ) + } + + val avatarSize = PIN_MARKER_WIDTH - CONTENT_OFFSET * 2 + val contentModifier = Modifier + .align(Alignment.TopCenter) + .offset(y = CONTENT_OFFSET) + + when (variant) { + is PinVariant.UserLocation -> { + Avatar( + avatarData = variant.avatarData, + forcedAvatarSize = avatarSize, + avatarType = AvatarType.User, + modifier = contentModifier + .border(width = 1.dp, color = colors.avatarStoke, shape = AvatarType.User.avatarShape()), + ) + } + PinVariant.PinnedLocation, PinVariant.StaleLocation -> { + Canvas( + modifier = contentModifier.size(avatarSize) + ) { + drawCircle( + color = colors.dotColor, + radius = DOT_RADIUS.toPx(), + center = center, + ) + } + } + } + } +} + +private data class LocationPinColors( + val fill: Color, + val stroke: Color, + val dotColor: Color, + val avatarStoke: Color, +) { + companion object { + @Composable + fun fromVariant(variant: PinVariant): LocationPinColors { + return when (variant) { + is PinVariant.UserLocation -> + if (variant.isLive) { + LocationPinColors( + fill = ElementTheme.colors.iconAccentPrimary, + stroke = ElementTheme.colors.bgCanvasDefault, + dotColor = Color.Transparent, + avatarStoke = ElementTheme.colors.bgCanvasDefault, + ) + } else { + LocationPinColors( + fill = ElementTheme.colors.bgCanvasDefault, + stroke = ElementTheme.colors.iconQuaternaryAlpha, + dotColor = Color.Transparent, + avatarStoke = ElementTheme.colors.iconQuaternaryAlpha, + ) + } + PinVariant.PinnedLocation -> LocationPinColors( + fill = ElementTheme.colors.bgCanvasDefault, + stroke = ElementTheme.colors.iconSecondaryAlpha, + dotColor = ElementTheme.colors.iconPrimary, + avatarStoke = Color.Transparent, + ) + PinVariant.StaleLocation -> LocationPinColors( + fill = ElementTheme.colors.bgSubtlePrimary, + stroke = ElementTheme.colors.borderInteractiveSecondary, + dotColor = ElementTheme.colors.iconDisabled, + avatarStoke = Color.Transparent, + ) + } + } + } +} + +/** + * Draws a teardrop-shaped pin with smooth curves. + * + * Based on SVG reference with dimensions 40x48 (ratio 1:1.2). + * Uses quadratic Bezier curves for smooth transitions from circle to tip. + */ +private fun DrawScope.drawPinShape( + fillColor: Color, + strokeColor: Color, + strokeWidth: Float, +) { + val width = size.width + val height = size.height + + val circleRadius = width / 2 - strokeWidth + val circleCenterX = width / 2 + val circleCenterY = width / 2 + + // The tip at the bottom + val tipX = width / 2 + val tipY = height - strokeWidth + + // Angle from the bottom of circle where it transitions to curves (in degrees) + val transitionAngleDeg = 65f + + val rightTransitionAngle = 90f - transitionAngleDeg + val leftTransitionAngle = 90f + transitionAngleDeg + + // Calculate transition points on the circle + val rightTransitionX = circleCenterX + circleRadius * cos(Math.toRadians(rightTransitionAngle.toDouble())).toFloat() + val rightTransitionY = circleCenterY + circleRadius * sin(Math.toRadians(rightTransitionAngle.toDouble())).toFloat() + val leftTransitionX = circleCenterX + circleRadius * cos(Math.toRadians(leftTransitionAngle.toDouble())).toFloat() + val leftTransitionY = circleCenterY + circleRadius * sin(Math.toRadians(leftTransitionAngle.toDouble())).toFloat() + + // Arc sweep: counter-clockwise over the top + val arcSweepAngle = -(360f - 2 * transitionAngleDeg) + + // For cubic Bezier: tangent direction at transition points + // Shorter tangent for smoother transition from circle + val tangentLength = (tipY - leftTransitionY) * 0.45f + + // Left side control points (from left transition to tip) + val leftTangentAngle = leftTransitionAngle - 90.0 + val leftC1X = leftTransitionX + tangentLength * cos(Math.toRadians(leftTangentAngle)).toFloat() + val leftC1Y = leftTransitionY + tangentLength * sin(Math.toRadians(leftTangentAngle)).toFloat() + // C2 control points - horizontal approach creates rounded tip + val tipOffset = 20f + val leftC2X = tipX - tipOffset + val leftC2Y = tipY - strokeWidth + // Right side control points (from tip to right transition) + val rightTangentAngle = rightTransitionAngle + 90.0 + val rightC1X = tipX + tipOffset + val rightC1Y = tipY - strokeWidth + val rightC2X = rightTransitionX + tangentLength * cos(Math.toRadians(rightTangentAngle)).toFloat() + val rightC2Y = rightTransitionY + tangentLength * sin(Math.toRadians(rightTangentAngle)).toFloat() + + val path = Path().apply { + moveTo(rightTransitionX, rightTransitionY) + arcTo( + rect = Rect( + center = Offset(circleCenterX, circleCenterY), + radius = circleRadius, + ), + startAngleDegrees = rightTransitionAngle, + sweepAngleDegrees = arcSweepAngle, + forceMoveTo = false, + ) + + // Cubic Bezier from left transition point to tip + cubicTo(leftC1X, leftC1Y, leftC2X, leftC2Y, tipX, tipY) + // Cubic Bezier from tip back to right transition point + cubicTo(rightC1X, rightC1Y, rightC2X, rightC2Y, rightTransitionX, rightTransitionY) + + close() + } + + drawPath(path = path, color = fillColor, style = Fill) + drawPath(path = path, color = strokeColor, style = Stroke(width = strokeWidth)) +} + +@PreviewsDayNight +@Composable +internal fun LocationPinMarkerPreview() = ElementPreview { + val sampleAvatarData = AvatarData( + id = "@alice:matrix.org", + name = "Alice", + url = null, + size = AvatarSize.SelectedUser + ) + + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + LocationPinMarker( + variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = false), + ) + LocationPinMarker( + variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = true), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + LocationPinMarker( + variant = PinVariant.PinnedLocation, + ) + LocationPinMarker( + variant = PinVariant.StaleLocation, + ) + } + } +} From ec1d6ebabb71f23fa016e46214adfd33b7269632 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 3 Mar 2026 21:56:45 +0100 Subject: [PATCH 15/52] Add current user to ShareLocationState --- .../features/location/impl/share/ShareLocationPresenter.kt | 6 ++++++ .../features/location/impl/share/ShareLocationState.kt | 3 +++ .../location/impl/share/ShareLocationStateProvider.kt | 4 ++++ 3 files changed, 13 insertions(+) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index bd82b823f0..63d4b7ce8d 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -32,10 +32,13 @@ import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.CreateTimelineParams import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.launch @@ -50,6 +53,7 @@ class ShareLocationPresenter( private val locationActions: LocationActions, private val buildMeta: BuildMeta, private val featureFlagService: FeatureFlagService, + private val client: MatrixClient, ) : Presenter { @AssistedFactory fun interface Factory { @@ -69,6 +73,7 @@ class ShareLocationPresenter( var dialogState: ShareLocationState.Dialog by remember { mutableStateOf(ShareLocationState.Dialog.None) } + val currentUser by client.userProfile.collectAsState() val scope = rememberCoroutineScope() LaunchedEffect(permissionsState.permissions) { @@ -108,6 +113,7 @@ class ShareLocationPresenter( } return ShareLocationState( + currentUser = currentUser, dialogState = dialogState, trackUserLocation = trackUserPosition, hasLocationPermission = permissionsState.isAnyGranted, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index 947d286c5e..547bb99856 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -8,7 +8,10 @@ package io.element.android.features.location.impl.share +import io.element.android.libraries.matrix.api.user.MatrixUser + data class ShareLocationState( + val currentUser: MatrixUser, val dialogState: Dialog, val trackUserLocation: Boolean, val hasLocationPermission: Boolean, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index 45a81af1df..0d3c448f15 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -9,6 +9,8 @@ package io.element.android.features.location.impl.share import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser private const val APP_NAME = "ApplicationName" @@ -50,12 +52,14 @@ class ShareLocationStateProvider : PreviewParameterProvider } private fun aShareLocationState( + currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), permissionDialog: ShareLocationState.Dialog, trackUserPosition: Boolean, hasLocationPermission: Boolean, canShareLiveLocation: Boolean = false, ): ShareLocationState { return ShareLocationState( + currentUser = currentUser, dialogState = permissionDialog, trackUserLocation = trackUserPosition, hasLocationPermission = hasLocationPermission, From f4bf596e3bfb28911fd1b6ae564581695b883ec5 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 3 Mar 2026 22:05:17 +0100 Subject: [PATCH 16/52] Start using LocationPinMarker in Share and Show locations --- .../location/impl/common/ui/MapProjected.kt | 37 ++++++++++ .../location/impl/share/ShareLocationView.kt | 19 ++--- .../location/impl/show/ShowLocationView.kt | 70 +++++++------------ 3 files changed, 74 insertions(+), 52 deletions(-) create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapProjected.kt diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapProjected.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapProjected.kt new file mode 100644 index 0000000000..503e6ea3d7 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapProjected.kt @@ -0,0 +1,37 @@ +/* + * 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.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import org.maplibre.compose.camera.CameraState +import org.maplibre.spatialk.geojson.Position + +@Composable +fun MapProjected( + target: Position, + cameraState: CameraState, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .graphicsLayer { + cameraState.position + val offset = cameraState.projection?.screenLocationFromPosition(target) + if (offset != null) { + translationX = offset.x.toPx() - size.width / 2 + translationY = offset.y.toPx() - size.height + } + } + ) { + content() + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index f1e782cb26..02b209ae43 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -8,7 +8,6 @@ package io.element.android.features.location.impl.share -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer @@ -28,7 +27,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -42,18 +40,20 @@ import io.element.android.features.location.impl.common.PermissionRationaleDialo 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.UserLocationPuck +import io.element.android.libraries.designsystem.components.LocationPinMarker +import io.element.android.libraries.designsystem.components.PinVariant +import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.list.ListItemContent import io.element.android.libraries.designsystem.components.list.RadioButtonListItem 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.IconSource 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.TopAppBar -import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.ui.strings.CommonStrings import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraPosition @@ -155,10 +155,13 @@ fun ShareLocationView( .fillMaxSize() .padding(sheetPadding) ) { - Icon( - resourceId = CommonDrawables.pin, - contentDescription = null, - tint = Color.Unspecified, + val variant = if (state.trackUserLocation) { + PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.SelectedUser)) + } else { + PinVariant.PinnedLocation + } + LocationPinMarker( + variant = variant, modifier = Modifier.centerBottomEdge(this), ) } 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 fc00f00c79..69ac04e0c5 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 @@ -9,6 +9,7 @@ package io.element.android.features.location.impl.show import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue @@ -18,22 +19,26 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter 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.compound.tokens.generated.TypographyTokens import io.element.android.features.location.api.ShowLocationMode -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.PermissionDeniedDialog 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.MapBottomSheetScaffold +import io.element.android.features.location.impl.common.ui.MapProjected import io.element.android.features.location.impl.common.ui.UserLocationPuck +import io.element.android.libraries.designsystem.components.LocationPinMarker +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 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 @@ -41,19 +46,15 @@ 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.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.ui.strings.CommonStrings import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraPosition 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 @@ -105,10 +106,9 @@ fun ShowLocationView( } val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState() + bottomSheetState = rememberStandardBottomSheetState(skipHiddenState = false, initialValue = SheetValue.Hidden) ) MapBottomSheetScaffold( - sheetPeekHeight = 180.dp, scaffoldState = scaffoldState, cameraState = cameraState, modifier = modifier, @@ -132,56 +132,38 @@ fun ShowLocationView( } ) }, - sheetContent = { - when (val mode = state.mode) { - is ShowLocationMode.Static -> { - Text( - text = mode.senderName, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = TypographyTokens.fontBodyMdRegular, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - ) - } - ShowLocationMode.Live -> { - // TODO: Show list of active live location sharers - } - } - }, mapContent = { UserLocationPuck( cameraState = cameraState, locationState = userLocationState, trackUserLocation = state.isTrackMyLocation ) + }, + overlayContent = { when (val mode = state.mode) { is ShowLocationMode.Static -> { - val senderLocation = rememberGeoJsonSource( - data = GeoJsonData.Features( - Point( - Position( - latitude = mode.location.lat, - longitude = mode.location.lon - ) - ) + val pinVariant = if (mode.assetType == AssetType.PIN) { + PinVariant.PinnedLocation + } else { + PinVariant.UserLocation( + avatarData = AvatarData(mode.senderId.value, mode.senderName, mode.senderAvatarUrl, AvatarSize.UserListItem), + isLive = false ) + } + val position = Position( + latitude = mode.location.lat, + longitude = mode.location.lon ) - val marker = painterResource(R.drawable.pin_small) - SymbolLayer( - id = "sender_location", - source = senderLocation, - iconImage = image(marker) - ) + MapProjected(target = position, cameraState = cameraState) { + LocationPinMarker(variant = pinVariant) + } } ShowLocationMode.Live -> { // TODO: Show pins for all active live location sharers } } - }, - overlayContent = { + + LocationFloatingActionButton( isMapCenteredOnUser = state.isTrackMyLocation, onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) }, From 046d135e4b2246f7f83b18367efb87c865d09141 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 3 Mar 2026 22:22:02 +0100 Subject: [PATCH 17/52] Introduce LiveLocationContent for the timeline (needs sdk) --- .../features/location/api/StaticMapView.kt | 11 ++- .../internal/MapTilerStaticMapUrlBuilder.kt | 2 +- .../impl/timeline/TimelineStateProvider.kt | 4 +- .../event/TimelineItemLocationView.kt | 35 ++++---- .../event/TimelineItemContentFactory.kt | 25 +++++- .../TimelineItemContentMessageFactory.kt | 22 +++++- .../impl/timeline/groups/Groupability.kt | 4 +- .../event/TimelineItemEventContentProvider.kt | 3 +- .../event/TimelineItemLocationContent.kt | 42 ++++++++++ .../TimelineItemLocationContentProvider.kt | 21 ++++- .../impl/fixtures/MessageEventFixtures.kt | 4 +- .../TimelineItemContentMessageFactoryTest.kt | 79 +++++++++++++------ .../groups/TimelineItemGrouperTest.kt | 4 +- .../impl/DefaultRoomLatestEventFormatter.kt | 5 ++ .../impl/DefaultTimelineEventFormatter.kt | 2 + .../api/room/location/LiveLocationInfo.kt | 15 ++++ .../api/timeline/item/event/EventContent.kt | 11 +++ .../reply/InReplyToDetailsProvider.kt | 4 +- .../ui/messages/reply/InReplyToMetadata.kt | 22 +++--- .../impl/datasource/EventItemFactory.kt | 2 + 20 files changed, 235 insertions(+), 82 deletions(-) create mode 100644 libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index c58cadd66b..7d7752ba62 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -32,6 +32,8 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.features.location.api.internal.StaticMapPlaceholder import io.element.android.features.location.api.internal.StaticMapUrlBuilder import io.element.android.features.location.api.internal.centerBottomEdge +import io.element.android.libraries.designsystem.components.LocationPinMarker +import io.element.android.libraries.designsystem.components.PinVariant 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 @@ -45,6 +47,7 @@ fun StaticMapView( lat: Double, lon: Double, zoom: Double, + pinVariant: PinVariant, contentDescription: String?, modifier: Modifier = Modifier, darkMode: Boolean = !ElementTheme.isLightTheme, @@ -95,12 +98,7 @@ fun StaticMapView( // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case. contentScale = ContentScale.Fit, ) - Icon( - resourceId = CommonDrawables.pin, - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier.centerBottomEdge(this), - ) + LocationPinMarker(variant = pinVariant, modifier = Modifier.centerBottomEdge(this)) } else { StaticMapPlaceholder( showProgress = collectedState.value.isLoading(), @@ -127,6 +125,7 @@ internal fun StaticMapViewPreview() = ElementPreview { lon = 0.0, zoom = 0.0, contentDescription = null, + pinVariant = PinVariant.PinnedLocation, modifier = Modifier.size(400.dp), ) } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt index 839cda0237..666ca07a08 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt @@ -58,7 +58,7 @@ internal class MapTilerStaticMapUrlBuilder( // image smaller than the available space in pixels. // The resulting image will have to be scaled to fit the available space in order // to keep the perceived content size constant at the expense of sharpness. - return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=bottomleft" + return "$baseUrl/$mapId/static/$lon,$lat,$finalZoom/${finalWidth}x${finalHeight}$scale.webp?key=$apiKey&attribution=topright" } override fun isServiceAvailable() = apiKey.isNotEmpty() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt index adfaa93cce..184acf1386 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt @@ -39,7 +39,7 @@ import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugIn import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails -import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -166,7 +166,7 @@ internal fun aTimelineItemEvent( isMine = isMine, isEditable = isEditable, canBeRepliedTo = canBeRepliedTo, - senderProfile = aProfileTimelineDetailsReady( + senderProfile = aProfileDetailsReady( displayName = senderDisplayName, displayNameAmbiguous = displayNameAmbiguous, ), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt index 9ebe35a51b..b5c0152685 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -8,10 +8,8 @@ package io.element.android.features.messages.impl.timeline.components.event -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.PreviewParameter @@ -19,33 +17,28 @@ import androidx.compose.ui.unit.dp import io.element.android.features.location.api.StaticMapView import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider +import io.element.android.libraries.designsystem.components.PinVariant +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.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.theme.components.Text @Composable fun TimelineItemLocationView( content: TimelineItemLocationContent, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.fillMaxWidth()) { - content.description?.let { - Text( - text = it, - modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), - ) - } - - StaticMapView( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 188.dp), - lat = content.location.lat, - lon = content.location.lon, - zoom = 15.0, - contentDescription = content.body - ) - } + StaticMapView( + modifier = modifier + .fillMaxWidth() + .heightIn(max = 188.dp), + pinVariant = content.pinVariant, + lat = content.location.lat, + lon = content.location.lon, + zoom = 15.0, + contentDescription = content.body + ) } @PreviewsDayNight diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index 31cf689ef8..2b5c0fa98a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt @@ -9,8 +9,10 @@ package io.element.android.features.messages.impl.timeline.factories.event import dev.zacsweers.metro.Inject +import io.element.android.features.location.api.Location import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent +import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRtcNotificationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent import io.element.android.libraries.matrix.api.core.EventId @@ -22,6 +24,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent @@ -70,10 +73,10 @@ class TimelineItemContentFactory( is FailedToParseMessageLikeContent -> failedToParseMessageFactory.create(itemContent) is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent) is MessageContent -> { - val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(sender) messageFactory.create( + senderId = sender, + senderProfile = senderProfile, content = itemContent, - senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, eventId = eventId, ) } @@ -96,6 +99,24 @@ class TimelineItemContentFactory( is UnableToDecryptContent -> utdFactory.create(itemContent) is CallNotifyContent -> TimelineItemRtcNotificationContent() is UnknownContent -> TimelineItemUnknownContent + is LiveLocationContent -> { + val lastKnownLocation = itemContent.locations.mapNotNull { beacon -> + Location.fromGeoUri(beacon.geoUri) + }.lastOrNull() + if (lastKnownLocation != null) { + TimelineItemLocationContent( + body = itemContent.body.trimEnd(), + description = itemContent.description?.trimEnd(), + assetType = itemContent.assetType, + senderId = sender, + senderProfile = senderProfile, + location = lastKnownLocation, + mode = TimelineItemLocationContent.Mode.Live(isActive = itemContent.isLive) + ) + } else { + TimelineItemUnknownContent + } + } } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 1db6ad304c..829c11df6e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -29,8 +29,13 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.text.safeLinkify import io.element.android.libraries.core.mimetype.MimeTypes +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 import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType @@ -39,10 +44,13 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType +import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl +import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.ui.messages.toHtmlDocument import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor import kotlinx.collections.immutable.persistentListOf @@ -65,11 +73,13 @@ class TimelineItemContentMessageFactory( ) { fun create( content: MessageContent, - senderDisambiguatedDisplayName: String, + senderId: UserId, + senderProfile: ProfileDetails, eventId: EventId?, ): TimelineItemEventContent { return when (val messageType = content.type) { is EmoteMessageType -> { + val senderDisambiguatedDisplayName = senderProfile.getDisambiguatedDisplayName(senderId) val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}" val dom = messageType.formatted?.toHtmlDocument( permalinkParser = permalinkParser, @@ -135,8 +145,8 @@ class TimelineItemContentMessageFactory( } is LocationMessageType -> { val location = Location.fromGeoUri(messageType.geoUri) + val body = messageType.body.trimEnd() if (location == null) { - val body = messageType.body.trimEnd() TimelineItemTextContent( body = body, htmlDocument = null, @@ -145,9 +155,13 @@ class TimelineItemContentMessageFactory( ) } else { TimelineItemLocationContent( - body = messageType.body.trimEnd(), + body = body, location = location, - description = messageType.description + description = messageType.description, + senderId = senderId, + senderProfile = senderProfile, + assetType = messageType.assetType, + mode = TimelineItemLocationContent.Mode.Static ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt index 12c457175f..6f369417dd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyCon import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent @@ -81,7 +82,8 @@ internal fun MatrixTimelineItem.Event.canBeDisplayedInBubbleBlock(): Boolean { RedactedContent, is StickerContent, is PollContent, - is UnableToDecryptContent -> true + is UnableToDecryptContent, + is LiveLocationContent -> true // Can't be grouped is FailedToParseStateContent, is ProfileChangeContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index ac93a0ac4f..017fba1902 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -28,7 +28,8 @@ class TimelineItemEventContentProvider : PreviewParameterProvider { + if (mode.isActive) { + PinVariant.UserLocation(avatarData = senderAvatar(), isLive = true) + } else { + PinVariant.StaleLocation + } + } + Mode.Static -> { + when (assetType) { + AssetType.PIN -> PinVariant.PinnedLocation + AssetType.SENDER, + null -> PinVariant.UserLocation(avatarData = senderAvatar(), isLive = false) + } + } + } + + private fun senderAvatar() = AvatarData( + senderId.value, + name = senderProfile.getDisplayName(), + url = senderProfile.getAvatarUrl(), + // Size is irrelevant as the PinMarker will override anyway. + size = AvatarSize.TimelineSender + ) + + sealed interface Mode { + data object Static : Mode + data class Live(val isActive: Boolean) : Mode + } + override val type: String = "TimelineItemLocationContent" } + diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt index 0fd3f5f41b..07ab392f1e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt @@ -10,21 +10,34 @@ package io.element.android.features.messages.impl.timeline.model.event import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.location.api.Location +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady open class TimelineItemLocationContentProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aTimelineItemLocationContent(), - aTimelineItemLocationContent("This is a description!"), + aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = true)), + aTimelineItemLocationContent(mode = TimelineItemLocationContent.Mode.Live(isActive = false)), ) } -fun aTimelineItemLocationContent(description: String? = null) = TimelineItemLocationContent( - body = "User location geo:52.2445,0.7186;u=5000", +fun aTimelineItemLocationContent( + body: String = "", + senderId: UserId = UserId("@sender:matrix.org"), + senderProfile: ProfileDetails = aProfileDetailsReady(), + mode: TimelineItemLocationContent.Mode = TimelineItemLocationContent.Mode.Static, +) = TimelineItemLocationContent( + body = body, location = Location( lat = 52.2445, lon = 0.7186, accuracy = 5000f, ), - description = description, + senderId = senderId, + senderProfile = senderProfile, + mode = mode ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt index 516cd9ea77..1b9394f2ee 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt @@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.core.FakeSendHandle import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails -import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady import kotlinx.collections.immutable.toImmutableList internal fun aMessageEvent( @@ -52,7 +52,7 @@ internal fun aMessageEvent( eventId = eventId, transactionId = transactionId, senderId = A_USER_ID, - senderProfile = aProfileTimelineDetailsReady(displayName = A_USER_NAME), + senderProfile = aProfileDetailsReady(displayName = A_USER_NAME), senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender), content = content, sentTime = "", diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 24f8c30ab1..957b01d1ed 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -60,8 +60,10 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.timeline.aProfileDetails import io.element.android.libraries.matrix.test.timeline.aStickerContent import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation @@ -84,7 +86,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = OtherMessageType(msgType = "a_type", body = "body")), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemTextContent( @@ -102,14 +105,18 @@ class TimelineItemContentMessageFactoryTest { val assetType = AssetType.SENDER val result = sut.create( content = createMessageContent(type = LocationMessageType("body", "geo:1,2", "description", assetType)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemLocationContent( body = "body", - location = Location(lat = 1.0, lon = 2.0, accuracy = 0.0F), + location = Location(lat = 1.0, lon = 2.0, accuracy = null), description = "description", assetType = assetType, + mode = TimelineItemLocationContent.Mode.Static, + senderId = A_USER_ID, + senderProfile = aProfileDetails(), ) assertThat(result).isEqualTo(expected) } @@ -119,7 +126,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = LocationMessageType("body", "", null, null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemTextContent( @@ -136,7 +144,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = TextMessageType("body", null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemTextContent( @@ -153,7 +162,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = TextMessageType("https://www.example.org", null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) as TimelineItemTextContent val expected = TimelineItemTextContent( @@ -200,7 +210,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, expected.toString()) ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected) @@ -218,7 +229,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body")) @@ -229,7 +241,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = VideoMessageType("filename", null, null, MediaSource("url"), null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemVideoContent( @@ -282,7 +295,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemVideoContent( @@ -312,7 +326,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = AudioMessageType("filename", null, null, MediaSource("url"), null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemAudioContent( @@ -348,7 +363,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemAudioContent( @@ -371,7 +387,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = VoiceMessageType("filename", null, null, MediaSource("url"), null, null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemVoiceContent( @@ -413,7 +430,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemVoiceContent( @@ -438,7 +456,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = ImageMessageType("filename", "body", null, MediaSource("url"), null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemImageContent( @@ -518,7 +537,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemImageContent( @@ -547,7 +567,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = FileMessageType("filename", null, null, MediaSource("url"), null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemFileContent( @@ -589,7 +610,8 @@ class TimelineItemContentMessageFactoryTest { ), isEdited = true, ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemFileContent( @@ -612,7 +634,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = NoticeMessageType("body", null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) val expected = TimelineItemNoticeContent( @@ -634,7 +657,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "formatted") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) (result as TimelineItemNoticeContent).formattedBody.assertSpannedEquals(SpannedString("formatted")) @@ -645,7 +669,8 @@ class TimelineItemContentMessageFactoryTest { val sut = createTimelineItemContentMessageFactory() val result = sut.create( content = createMessageContent(type = EmoteMessageType("body", null)), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails("Bob"), eventId = AN_EVENT_ID, ) val expected = TimelineItemEmoteContent( @@ -667,7 +692,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "formatted") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails("Bob"), eventId = AN_EVENT_ID, ) @@ -693,7 +719,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "Test me@matrix.org") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned) @@ -718,7 +745,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) (result as TimelineItemTextContent).formattedBody.assertSpannedEquals(expectedSpanned) @@ -744,7 +772,8 @@ class TimelineItemContentMessageFactoryTest { formatted = FormattedBody(MessageFormat.HTML, "Test https://www.example.org") ) ), - senderDisambiguatedDisplayName = "Bob", + senderId = A_USER_ID, + senderProfile = aProfileDetails(), eventId = AN_EVENT_ID, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt index 7a7d4cdfd4..726646f5e9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/groups/TimelineItemGrouperTest.kt @@ -23,7 +23,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSen import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.core.FakeSendHandle -import io.element.android.libraries.matrix.ui.messages.reply.aProfileTimelineDetailsReady +import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady import kotlinx.collections.immutable.toImmutableList import org.junit.Test @@ -34,7 +34,7 @@ class TimelineItemGrouperTest { id = UniqueId("0"), senderId = A_USER_ID, senderAvatar = anAvatarData(), - senderProfile = aProfileTimelineDetailsReady(displayName = ""), + senderProfile = aProfileDetailsReady(displayName = ""), content = TimelineItemStateEventContent(body = "a state event"), reactionsState = aTimelineItemReactions(count = 0), readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt index 009547f9eb..68dd4cd332 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageType @@ -115,6 +116,10 @@ class DefaultRoomLatestEventFormatter( val message = sp.getString(CommonStrings.common_unsupported_event) message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) } + is LiveLocationContent -> { + val message = sp.getString(CommonStrings.common_shared_location) + message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing) + } is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call) is CallNotifyContent -> sp.getString(CommonStrings.common_call_started) }?.take(DEFAULT_SAFE_LENGTH) diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt index 9657f87bd0..ff5cce7a59 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultTimelineEventFormatter.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventTimeline import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent @@ -69,6 +70,7 @@ class DefaultTimelineEventFormatter( is MessageContent, is FailedToParseMessageLikeContent, is FailedToParseStateContent, + is LiveLocationContent, is UnknownContent -> { if (buildMeta.isDebuggable) { error("You should not use this formatter for this event content: $content") diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt new file mode 100644 index 0000000000..50b5a0ec82 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt @@ -0,0 +1,15 @@ +/* + * 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.libraries.matrix.api.room.location + +data class LiveLocationInfo( + val description: String?, + val geoUri: String, + val timestamp: Long, +) + diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index b6ed7dc602..e7b61e8dd6 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.poll.PollAnswer import io.element.android.libraries.matrix.api.poll.PollKind +import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.matrix.api.room.location.LiveLocationInfo import io.element.android.libraries.matrix.api.timeline.item.EventThreadInfo import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -102,6 +104,15 @@ data class FailedToParseStateContent( val error: String ) : EventContent +data class LiveLocationContent( + val body: String, + val isLive: Boolean, + val description: String?, + val timeout: Long, + val assetType: AssetType?, + val locations: List, +): EventContent + data object LegacyCallInviteContent : EventContent data object CallNotifyContent : EventContent diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt index 4b2f18a362..5ddf57b723 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt @@ -152,13 +152,13 @@ private fun aInReplyToDetails( eventId = EventId("\$event"), eventContent = eventContent, senderId = UserId("@Sender:domain"), - senderProfile = aProfileTimelineDetailsReady( + senderProfile = aProfileDetailsReady( displayNameAmbiguous = displayNameAmbiguous, ), textContent = (eventContent as? MessageContent)?.body.orEmpty(), ) -fun aProfileTimelineDetailsReady( +fun aProfileDetailsReady( displayName: String? = "Sender", displayNameAmbiguous: Boolean = false, avatarUrl: String? = null, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt index 4d0a0f045b..9e5e468cd9 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadata.kt @@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.PollContent @@ -32,6 +33,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToMetadata.Text +import io.element.android.libraries.matrix.ui.messages.reply.InReplyToMetadata.Thumbnail import io.element.android.libraries.ui.strings.CommonStrings @Immutable @@ -60,7 +63,7 @@ internal sealed interface InReplyToMetadata { @Composable internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetadata? = when (eventContent) { is MessageContent -> when (val type = eventContent.type) { - is ImageMessageType -> InReplyToMetadata.Thumbnail( + is ImageMessageType -> Thumbnail( AttachmentThumbnailInfo( thumbnailSource = (type.info?.thumbnailSource ?: type.source).takeUnless { hideImage }, textContent = eventContent.body, @@ -68,7 +71,7 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad blurHash = type.info?.blurhash, ) ) - is VideoMessageType -> InReplyToMetadata.Thumbnail( + is VideoMessageType -> Thumbnail( AttachmentThumbnailInfo( thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage }, textContent = eventContent.body, @@ -76,34 +79,34 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad blurHash = type.info?.blurhash, ) ) - is FileMessageType -> InReplyToMetadata.Thumbnail( + is FileMessageType -> Thumbnail( AttachmentThumbnailInfo( thumbnailSource = type.info?.thumbnailSource?.takeUnless { hideImage }, textContent = eventContent.body, type = AttachmentThumbnailType.File, ) ) - is LocationMessageType -> InReplyToMetadata.Thumbnail( + is LocationMessageType -> Thumbnail( AttachmentThumbnailInfo( textContent = stringResource(CommonStrings.common_shared_location), type = AttachmentThumbnailType.Location, ) ) - is AudioMessageType -> InReplyToMetadata.Thumbnail( + is AudioMessageType -> Thumbnail( AttachmentThumbnailInfo( textContent = eventContent.body, type = AttachmentThumbnailType.Audio, ) ) - is VoiceMessageType -> InReplyToMetadata.Thumbnail( + is VoiceMessageType -> Thumbnail( AttachmentThumbnailInfo( textContent = stringResource(CommonStrings.common_voice_message), type = AttachmentThumbnailType.Voice, ) ) - else -> InReplyToMetadata.Text(textContent ?: eventContent.body) + else -> Text(textContent ?: eventContent.body) } - is StickerContent -> InReplyToMetadata.Thumbnail( + is StickerContent -> Thumbnail( AttachmentThumbnailInfo( thumbnailSource = eventContent.source.takeUnless { hideImage }, textContent = eventContent.body, @@ -111,7 +114,7 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad blurHash = eventContent.info.blurhash, ) ) - is PollContent -> InReplyToMetadata.Thumbnail( + is PollContent -> Thumbnail( AttachmentThumbnailInfo( textContent = eventContent.question, type = AttachmentThumbnailType.Poll, @@ -127,5 +130,6 @@ internal fun InReplyToDetails.Ready.metadata(hideImage: Boolean): InReplyToMetad UnknownContent, is LegacyCallInviteContent, is CallNotifyContent, + is LiveLocationContent, null -> null } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt index edbf9dc51e..67b73d616d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParse import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent +import io.element.android.libraries.matrix.api.timeline.item.event.LiveLocationContent import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType @@ -75,6 +76,7 @@ class EventItemFactory( is StateContent, is StickerContent, is UnableToDecryptContent, + is LiveLocationContent, UnknownContent -> { Timber.w("Should not happen: ${content.javaClass.simpleName}") null From ba89201f37dee2e7bf2c29c252b0fc61a88c98fd Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 4 Mar 2026 16:32:05 +0100 Subject: [PATCH 18/52] Better LocationPinMarker --- .../components/LocationPinMarker.kt | 106 ++++++------------ 1 file changed, 33 insertions(+), 73 deletions(-) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt index a7b25554b5..e0550911f8 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt @@ -18,9 +18,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Matrix import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill @@ -34,8 +33,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.avatar.avatarShape import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import kotlin.math.cos -import kotlin.math.sin /** * Variants of location pin markers. @@ -69,17 +66,13 @@ fun LocationPinMarker( Box( modifier = modifier.size(width = PIN_MARKER_WIDTH, height = PIN_MARKER_HEIGHT), ) { - // Draw the pin shape - Canvas( - modifier = Modifier.matchParentSize() - ) { + Canvas(modifier = Modifier.matchParentSize()) { drawPinShape( fillColor = colors.fill, strokeColor = colors.stroke, strokeWidth = 1.dp.toPx(), ) } - val avatarSize = PIN_MARKER_WIDTH - CONTENT_OFFSET * 2 val contentModifier = Modifier .align(Alignment.TopCenter) @@ -143,8 +136,8 @@ private data class LocationPinColors( avatarStoke = Color.Transparent, ) PinVariant.StaleLocation -> LocationPinColors( - fill = ElementTheme.colors.bgSubtlePrimary, - stroke = ElementTheme.colors.borderInteractiveSecondary, + fill = ElementTheme.colors.bgSubtleSecondary, + stroke = ElementTheme.colors.iconDisabled, dotColor = ElementTheme.colors.iconDisabled, avatarStoke = Color.Transparent, ) @@ -156,77 +149,44 @@ private data class LocationPinColors( /** * Draws a teardrop-shaped pin with smooth curves. * - * Based on SVG reference with dimensions 40x48 (ratio 1:1.2). - * Uses quadratic Bezier curves for smooth transitions from circle to tip. + * Based on SVG path with dimensions 40x48 (ratio 1:1.2). + * Scales automatically to fit the canvas size. */ private fun DrawScope.drawPinShape( fillColor: Color, strokeColor: Color, strokeWidth: Float, ) { - val width = size.width - val height = size.height - - val circleRadius = width / 2 - strokeWidth - val circleCenterX = width / 2 - val circleCenterY = width / 2 - - // The tip at the bottom - val tipX = width / 2 - val tipY = height - strokeWidth - - // Angle from the bottom of circle where it transitions to curves (in degrees) - val transitionAngleDeg = 65f - - val rightTransitionAngle = 90f - transitionAngleDeg - val leftTransitionAngle = 90f + transitionAngleDeg - - // Calculate transition points on the circle - val rightTransitionX = circleCenterX + circleRadius * cos(Math.toRadians(rightTransitionAngle.toDouble())).toFloat() - val rightTransitionY = circleCenterY + circleRadius * sin(Math.toRadians(rightTransitionAngle.toDouble())).toFloat() - val leftTransitionX = circleCenterX + circleRadius * cos(Math.toRadians(leftTransitionAngle.toDouble())).toFloat() - val leftTransitionY = circleCenterY + circleRadius * sin(Math.toRadians(leftTransitionAngle.toDouble())).toFloat() - - // Arc sweep: counter-clockwise over the top - val arcSweepAngle = -(360f - 2 * transitionAngleDeg) - - // For cubic Bezier: tangent direction at transition points - // Shorter tangent for smoother transition from circle - val tangentLength = (tipY - leftTransitionY) * 0.45f - - // Left side control points (from left transition to tip) - val leftTangentAngle = leftTransitionAngle - 90.0 - val leftC1X = leftTransitionX + tangentLength * cos(Math.toRadians(leftTangentAngle)).toFloat() - val leftC1Y = leftTransitionY + tangentLength * sin(Math.toRadians(leftTangentAngle)).toFloat() - // C2 control points - horizontal approach creates rounded tip - val tipOffset = 20f - val leftC2X = tipX - tipOffset - val leftC2Y = tipY - strokeWidth - // Right side control points (from tip to right transition) - val rightTangentAngle = rightTransitionAngle + 90.0 - val rightC1X = tipX + tipOffset - val rightC1Y = tipY - strokeWidth - val rightC2X = rightTransitionX + tangentLength * cos(Math.toRadians(rightTangentAngle)).toFloat() - val rightC2Y = rightTransitionY + tangentLength * sin(Math.toRadians(rightTangentAngle)).toFloat() + val svgWidth = 40f + val svgHeight = 48f + val inset = strokeWidth / 2 + val scaleX = (size.width - strokeWidth) / svgWidth + val scaleY = (size.height - strokeWidth) / svgHeight val path = Path().apply { - moveTo(rightTransitionX, rightTransitionY) - arcTo( - rect = Rect( - center = Offset(circleCenterX, circleCenterY), - radius = circleRadius, - ), - startAngleDegrees = rightTransitionAngle, - sweepAngleDegrees = arcSweepAngle, - forceMoveTo = false, - ) - - // Cubic Bezier from left transition point to tip - cubicTo(leftC1X, leftC1Y, leftC2X, leftC2Y, tipX, tipY) - // Cubic Bezier from tip back to right transition point - cubicTo(rightC1X, rightC1Y, rightC2X, rightC2Y, rightTransitionX, rightTransitionY) - + moveTo(20f, 48f) + cubicTo(19.4167f, 48f, 18.8333f, 47.8965f, 18.25f, 47.6895f) + cubicTo(17.6667f, 47.4825f, 17.1458f, 47.1721f, 16.6875f, 46.7581f) + cubicTo(13.9792f, 44.2743f, 11.5833f, 41.8525f, 9.5f, 39.4929f) + cubicTo(7.41667f, 37.1332f, 5.67708f, 34.8461f, 4.28125f, 32.6313f) + cubicTo(2.88542f, 30.4166f, 1.82292f, 28.2846f, 1.09375f, 26.2354f) + cubicTo(0.364583f, 24.1863f, 0f, 22.2303f, 0f, 20.3674f) + cubicTo(0f, 14.1578f, 2.01042f, 9.21087f, 6.03125f, 5.52652f) + cubicTo(10.0521f, 1.84217f, 14.7083f, 0f, 20f, 0f) + cubicTo(25.2917f, 0f, 29.9479f, 1.84217f, 33.9688f, 5.52652f) + cubicTo(37.9896f, 9.21087f, 40f, 14.1578f, 40f, 20.3674f) + cubicTo(40f, 22.2303f, 39.6354f, 24.1863f, 38.9062f, 26.2354f) + cubicTo(38.1771f, 28.2846f, 37.1146f, 30.4166f, 35.7188f, 32.6313f) + cubicTo(34.3229f, 34.8461f, 32.5833f, 37.1332f, 30.5f, 39.4929f) + cubicTo(28.4167f, 41.8525f, 26.0208f, 44.2743f, 23.3125f, 46.7581f) + cubicTo(22.8542f, 47.1721f, 22.3333f, 47.4825f, 21.75f, 47.6895f) + cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f) close() + + transform(Matrix().apply { + scale(scaleX, scaleY) + translate(inset / scaleX, inset / scaleY) + }) } drawPath(path = path, color = fillColor, style = Fill) From 34aad88023e2b58d7ddd0ba26699a097ede5a50f Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 4 Mar 2026 16:36:41 +0100 Subject: [PATCH 19/52] Remove PinIcon --- .../designsystem/components/PinIcon.kt | 48 ------------------- .../ui/components/AttachmentThumbnail.kt | 9 +--- 2 files changed, 1 insertion(+), 56 deletions(-) delete mode 100644 libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt deleted file mode 100644 index 88287ef44b..0000000000 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/PinIcon.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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.libraries.designsystem.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -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.graphics.Color -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.R -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 - -@Composable -fun PinIcon( - modifier: Modifier = Modifier -) { - Box( - modifier = modifier - .background(ElementTheme.colors.bgSubtlePrimary) - ) { - Icon( - modifier = Modifier - .align(Alignment.Center) - .width(22.dp), - resourceId = R.drawable.pin, - contentDescription = null, - tint = Color.Unspecified, - ) - } -} - -@PreviewsDayNight -@Composable -internal fun PinIconPreview() = ElementPreview { - PinIcon() -} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt index 8ffdc1b003..9b55c85524 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.libraries.designsystem.components.PinIcon import io.element.android.libraries.designsystem.components.blurhash.BlurHashAsyncImage import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -99,16 +98,10 @@ fun AttachmentThumbnail( ) } AttachmentThumbnailType.Location -> { - PinIcon( - modifier = Modifier.fillMaxSize() - ) - /* - // For coherency across the app, we should us this instead. Waiting for design decision. Icon( - resourceId = R.drawable.ic_september_location, + imageVector = CompoundIcons.LocationPin(), contentDescription = info.textContent, ) - */ } AttachmentThumbnailType.Poll -> { Icon( From 3cce8caec41830cbf5cda44ed107ae9ddac56b38 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 5 Mar 2026 14:50:55 +0100 Subject: [PATCH 20/52] Introduce LocationPinMarkers composable --- .../features/location/api/StaticMapView.kt | 7 +- .../impl/common/ui/LocationPinMarkers.kt | 188 ++++++++++++++++++ .../location/impl/common/ui/MapProjected.kt | 37 ---- .../impl/common/ui/RememberMarkerBitmap.kt | 87 ++++++++ .../location/impl/share/ShareLocationView.kt | 4 +- .../location/impl/show/ShowLocationView.kt | 76 +++++-- .../{LocationPinMarker.kt => LocationPin.kt} | 14 +- 7 files changed, 343 insertions(+), 70 deletions(-) create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt delete mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapProjected.kt create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt rename libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/{LocationPinMarker.kt => LocationPin.kt} (96%) diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index 7d7752ba62..0657bae634 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -19,7 +19,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -32,12 +31,10 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.features.location.api.internal.StaticMapPlaceholder import io.element.android.features.location.api.internal.StaticMapUrlBuilder import io.element.android.features.location.api.internal.centerBottomEdge -import io.element.android.libraries.designsystem.components.LocationPinMarker +import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.PinVariant 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.utils.CommonDrawables /** * Shows a static map image downloaded via a third party service's static maps API. @@ -98,7 +95,7 @@ fun StaticMapView( // We apply ContentScale.Fit to scale the image to fill the AsyncImage should this be the case. contentScale = ContentScale.Fit, ) - LocationPinMarker(variant = pinVariant, modifier = Modifier.centerBottomEdge(this)) + LocationPin(variant = pinVariant, modifier = Modifier.centerBottomEdge(this)) } else { StaticMapPlaceholder( showProgress = collectedState.value.isLoading(), diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt new file mode 100644 index 0000000000..ad3a4c48e1 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt @@ -0,0 +1,188 @@ +/* + * 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.runtime.remember +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.location.api.Location +import io.element.android.libraries.designsystem.components.LocationPin +import io.element.android.libraries.designsystem.components.PinVariant +import kotlinx.serialization.json.JsonPrimitive +import org.maplibre.compose.expressions.dsl.and +import org.maplibre.compose.expressions.dsl.asNumber +import org.maplibre.compose.expressions.dsl.asString +import org.maplibre.compose.expressions.dsl.const +import org.maplibre.compose.expressions.dsl.eq +import org.maplibre.compose.expressions.dsl.feature +import org.maplibre.compose.expressions.dsl.image +import org.maplibre.compose.expressions.dsl.not +import org.maplibre.compose.expressions.dsl.step +import org.maplibre.compose.expressions.value.SymbolAnchor +import org.maplibre.compose.expressions.value.SymbolPlacement +import org.maplibre.compose.layers.CircleLayer +import org.maplibre.compose.layers.SymbolLayer +import org.maplibre.compose.sources.GeoJsonData +import org.maplibre.compose.sources.GeoJsonOptions +import org.maplibre.compose.sources.GeoJsonSource +import org.maplibre.compose.sources.rememberGeoJsonSource +import org.maplibre.compose.util.ClickResult +import org.maplibre.spatialk.geojson.Feature +import org.maplibre.spatialk.geojson.FeatureCollection +import org.maplibre.spatialk.geojson.Point +import org.maplibre.spatialk.geojson.Position +import org.maplibre.spatialk.geojson.toJson + +/** + * Data class representing a marker on the map. + * + * @param id Unique identifier for the marker + * @param location The geographic location of the marker + * @param variant The visual variant of the pin (user location, pinned, stale) + */ +data class LocationMarkerData( + val id: String, + val location: Location, + val variant: PinVariant, +) + +/** + * A composable that renders location markers on a MapLibre map with clustering support. + * + * Uses GeoJSON source with clustering enabled to group nearby markers. + * Individual markers are rendered using [LocationPin] composable converted to bitmaps. + * Clusters are rendered as circles with point counts. + * + * Must be used within a MaplibreMap content block. + * + * @param markers List of markers to display on the map + * @param clusterRadius Radius of each cluster when clustering points (default 50) + * @param clusterMaxZoom Maximum zoom level at which to cluster points (default 14) + * @param onMarkerClick Callback when a marker is clicked + * @param onClusterClick Callback when a cluster is clicked, provides cluster center position + */ +@Composable +fun LocationPinMarkers( + markers: List, + onMarkerClick: ((LocationMarkerData) -> Unit)? = null, + onClusterClick: ((Position) -> Unit)? = null, +) { + if (markers.isEmpty()) return + val clusterColor = ElementTheme.colors.bgAccentRest + val clusterStrokeColor = ElementTheme.colors.iconOnSolidPrimary + val clusterTextColor = ElementTheme.colors.textOnSolidPrimary + val clusterTextStyle = ElementTheme.typography.fontBodyMdMedium + + // Convert markers to GeoJSON + val geoJsonString = remember(markers) { + val features = markers.map { marker -> + Feature( + id = JsonPrimitive(marker.id), + geometry = Point(Position(marker.location.lon, marker.location.lat)), + properties = mapOf( + "id" to JsonPrimitive(marker.id), + ) + ) + } + FeatureCollection(features).toJson() + } + + // Create GeoJSON source with clustering + val markersSource = rememberGeoJsonSource( + data = GeoJsonData.JsonString(geoJsonString), + options = GeoJsonOptions( + cluster = true, + clusterMinPoints = 3, + clusterRadius = 30 + ), + ) + + // Cluster circle layer + CircleLayer( + id = "cluster-circles", + source = markersSource, + filter = feature.has("point_count"), + color = const(clusterColor), + radius = const(24.dp), + strokeWidth = const(1.dp), + strokeColor = const(clusterStrokeColor), + onClick = { features -> + features.firstOrNull()?.let { feat -> + val point = feat.geometry as? Point + if (point != null && onClusterClick != null) { + onClusterClick(point.coordinates) + ClickResult.Consume + } else { + ClickResult.Pass + } + } ?: ClickResult.Pass + }, + ) + + // Cluster count text layer + SymbolLayer( + id = "cluster-count", + source = markersSource, + filter = feature.has("point_count"), + textField = feature["point_count_abbreviated"].asString(), + textColor = const(clusterTextColor), + textSize = const(clusterTextStyle.fontSize), + textFont = const(listOfNotNull(clusterTextStyle.fontFamily?.toString())), + textLetterSpacing = const(clusterTextStyle.letterSpacing), + ) + + // Individual marker layers - one per marker for unique avatars + markers.forEach { marker -> + LocationPinMarkerLayer( + marker = marker, + source = markersSource, + onMarkerClick = onMarkerClick, + ) + } +} + +@Composable +private fun LocationPinMarkerLayer( + marker: LocationMarkerData, + source: GeoJsonSource, + onMarkerClick: ((LocationMarkerData) -> Unit)?, +) { + val imageBitmap = rememberLocationPinImage(marker.variant) + SymbolLayer( + id = "pin-marker-${marker.id}", + source = source, + filter = !feature.has("point_count") and (feature["id"].asString() eq const(marker.id)), + iconImage = image(imageBitmap), + iconAnchor = const(SymbolAnchor.Bottom), + iconAllowOverlap = const(true), + onClick = { features -> + if (features.isNotEmpty() && onMarkerClick != null) { + onMarkerClick(marker) + ClickResult.Consume + } else { + ClickResult.Pass + } + }, + ) +} + +/** + * Renders a LocationPin composable to an ImageBitmap for use in SymbolLayer. + */ +@Composable +private fun rememberLocationPinImage(variant: PinVariant): ImageBitmap { + val bitmap = rememberMarkerBitmap(variant) { + LocationPin(variant = variant) + } + return bitmap.asImageBitmap() +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapProjected.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapProjected.kt deleted file mode 100644 index 503e6ea3d7..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/MapProjected.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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.Box -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import org.maplibre.compose.camera.CameraState -import org.maplibre.spatialk.geojson.Position - -@Composable -fun MapProjected( - target: Position, - cameraState: CameraState, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - Box( - modifier = modifier - .graphicsLayer { - cameraState.position - val offset = cameraState.projection?.screenLocationFromPosition(target) - if (offset != null) { - translationX = offset.x.toPx() - size.width / 2 - translationY = offset.y.toPx() - size.height - } - } - ) { - content() - } -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt new file mode 100644 index 0000000000..6c083e2a45 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt @@ -0,0 +1,87 @@ +/* + * 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 android.graphics.Bitmap +import android.graphics.Canvas +import android.os.Build +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalView +import androidx.core.graphics.createBitmap + +/** + * Renders a composable [content] to an Android [Bitmap]. + * Useful for MapLibre SymbolLayer rendering. + * + * Uses a temporary ComposeView to render off-screen without + * adding to the visible composition tree. + * + * Note: This function provides a software-only ImageLoader to avoid + * "Software rendering doesn't support hardware bitmaps" errors when + * rendering Coil images to a Canvas. + * + * @param keys to trigger recomposition. + * @return The rendered Android [Bitmap]. + */ +@Composable +fun rememberMarkerBitmap( + vararg keys: Any, + content: @Composable () -> Unit, +): Bitmap { + val parent = LocalView.current as ViewGroup + val compositionContext = rememberCompositionContext() + return remember(parent, compositionContext, *keys) { + renderComposableToBitmap(parent, compositionContext, content) + } +} + +private fun renderComposableToBitmap( + parent: ViewGroup, + compositionContext: CompositionContext, + content: @Composable () -> Unit, +): Bitmap { + val composeView = ComposeView(parent.context).apply { + setParentCompositionContext(compositionContext) + setContent(content) + } + // Temporarily add to parent for measurement + parent.addView( + composeView, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + ) + // Measure + composeView.measure( + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + + val width = composeView.measuredWidth + val height = composeView.measuredHeight + + // Layout + composeView.layout(0, 0, width, height) + + // Draw to bitmap + val bitmap = createBitmap(width, height) + val canvas = Canvas(bitmap) + composeView.draw(canvas) + + // Cleanup + parent.removeView(composeView) + + return bitmap +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 02b209ae43..a0705221c0 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -40,7 +40,7 @@ import io.element.android.features.location.impl.common.PermissionRationaleDialo 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.UserLocationPuck -import io.element.android.libraries.designsystem.components.LocationPinMarker +import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton @@ -160,7 +160,7 @@ fun ShareLocationView( } else { PinVariant.PinnedLocation } - LocationPinMarker( + LocationPin( variant = variant, modifier = Modifier.centerBottomEdge(this), ) 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 69ac04e0c5..d1a3537d7a 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,8 +8,6 @@ package io.element.android.features.location.impl.show -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue @@ -17,25 +15,23 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewParameter 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.compound.tokens.generated.TypographyTokens +import io.element.android.features.location.api.Location 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.features.location.impl.common.ui.LocationFloatingActionButton +import io.element.android.features.location.impl.common.ui.LocationMarkerData +import io.element.android.features.location.impl.common.ui.LocationPinMarkers import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold -import io.element.android.features.location.impl.common.ui.MapProjected import io.element.android.features.location.impl.common.ui.UserLocationPuck -import io.element.android.libraries.designsystem.components.LocationPinMarker 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 @@ -44,7 +40,6 @@ 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.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.ui.strings.CommonStrings @@ -56,6 +51,10 @@ import org.maplibre.compose.location.rememberDefaultLocationProvider import org.maplibre.compose.location.rememberNullLocationProvider import org.maplibre.compose.location.rememberUserLocationState import org.maplibre.spatialk.geojson.Position +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt +import kotlin.random.Random import kotlin.time.Duration.Companion.minutes @OptIn(ExperimentalMaterial3Api::class) @@ -138,8 +137,6 @@ fun ShowLocationView( locationState = userLocationState, trackUserLocation = state.isTrackMyLocation ) - }, - overlayContent = { when (val mode = state.mode) { is ShowLocationMode.Static -> { val pinVariant = if (mode.assetType == AssetType.PIN) { @@ -147,23 +144,64 @@ fun ShowLocationView( } else { PinVariant.UserLocation( avatarData = AvatarData(mode.senderId.value, mode.senderName, mode.senderAvatarUrl, AvatarSize.UserListItem), - isLive = false + isLive = true ) } - val position = Position( - latitude = mode.location.lat, - longitude = mode.location.lon - ) - MapProjected(target = position, cameraState = cameraState) { - LocationPinMarker(variant = pinVariant) + // Generate test markers around the original location + val testMarkers = remember { + buildList { + // Add the original marker + add( + LocationMarkerData( + id = "original", + location = mode.location, + variant = pinVariant + ) + ) + // Generate 10 random points within 50 meters + val radiusInMeters = 50.0 + val metersPerDegreeLat = 111_320.0 + val metersPerDegreeLon = 111_320.0 * cos(Math.toRadians(mode.location.lat)) + val variants = listOf( + PinVariant.StaleLocation, + PinVariant.UserLocation(AvatarData("@alice", "Alice", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@bob", "Bob", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@cassy", "Cassy", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@daisy", "Daisy", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@en", "G", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@f", "H", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@g", "I", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@h", "J", null, AvatarSize.TimelineSender), isLive = true), + PinVariant.UserLocation(AvatarData("@i", "K", null, AvatarSize.TimelineSender), isLive = true), + ) + repeat(10) { index -> + // Random point in a circle using sqrt for uniform distribution + val angle = Random.nextDouble() * 2 * Math.PI + val distance = sqrt(Random.nextDouble()) * radiusInMeters + val latOffset = (distance * cos(angle)) / metersPerDegreeLat + val lonOffset = (distance * sin(angle)) / metersPerDegreeLon + add( + LocationMarkerData( + id = "test_$index", + location = Location( + lat = mode.location.lat + latOffset, + lon = mode.location.lon + lonOffset + ), + variant = variants[index % (variants.size-1)] + ) + ) + } + } } + LocationPinMarkers(testMarkers) } ShowLocationMode.Live -> { // TODO: Show pins for all active live location sharers } } - + }, + overlayContent = { LocationFloatingActionButton( isMapCenteredOnUser = state.isTrackMyLocation, onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) }, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt similarity index 96% rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt rename to libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt index e0550911f8..60bccc1228 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPinMarker.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt @@ -53,12 +53,12 @@ private val DOT_RADIUS = 6.dp private val CONTENT_OFFSET = 5.dp /** - * A location pin marker composable that supports multiple variants. + * A location pin composable that supports multiple variants. * * Based on Figma design: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=4665-2890&m=dev */ @Composable -fun LocationPinMarker( +fun LocationPin( variant: PinVariant, modifier: Modifier = Modifier, ) { @@ -195,7 +195,7 @@ private fun DrawScope.drawPinShape( @PreviewsDayNight @Composable -internal fun LocationPinMarkerPreview() = ElementPreview { +internal fun LocationPinPreview() = ElementPreview { val sampleAvatarData = AvatarData( id = "@alice:matrix.org", name = "Alice", @@ -208,18 +208,18 @@ internal fun LocationPinMarkerPreview() = ElementPreview { horizontalAlignment = Alignment.CenterHorizontally, ) { Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - LocationPinMarker( + LocationPin( variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = false), ) - LocationPinMarker( + LocationPin( variant = PinVariant.UserLocation(avatarData = sampleAvatarData, isLive = true), ) } Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - LocationPinMarker( + LocationPin( variant = PinVariant.PinnedLocation, ) - LocationPinMarker( + LocationPin( variant = PinVariant.StaleLocation, ) } From 4704a6fc2a758e821a587efb6efd9de6e60c5b88 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 5 Mar 2026 18:03:29 +0100 Subject: [PATCH 21/52] LocationPin : disable hardware rendering if needed --- .../location/impl/common/ui/LocationPinMarkers.kt | 6 +++++- .../designsystem/components/LocationPin.kt | 14 ++++++++++---- .../components/avatar/internal/ImageAvatar.kt | 10 +++++++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt index ad3a4c48e1..6fed056a28 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt @@ -182,7 +182,11 @@ private fun LocationPinMarkerLayer( @Composable private fun rememberLocationPinImage(variant: PinVariant): ImageBitmap { val bitmap = rememberMarkerBitmap(variant) { - LocationPin(variant = variant) + LocationPin( + variant = variant, + // Disable as it doesn't work with the rememberMarkerBitmap method + allowHardwareBitmapRendering = false + ) } return bitmap.asImageBitmap() } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt index 60bccc1228..60f83ffea1 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt @@ -25,12 +25,13 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp +import coil3.request.allowHardware import io.element.android.compound.theme.ElementTheme -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.components.avatar.avatarShape +import io.element.android.libraries.designsystem.components.avatar.internal.ImageAvatar import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -61,6 +62,7 @@ private val CONTENT_OFFSET = 5.dp fun LocationPin( variant: PinVariant, modifier: Modifier = Modifier, + allowHardwareBitmapRendering: Boolean = true, ) { val colors = LocationPinColors.fromVariant(variant) Box( @@ -80,12 +82,16 @@ fun LocationPin( when (variant) { is PinVariant.UserLocation -> { - Avatar( + val avatarShape = AvatarType.User.avatarShape() + ImageAvatar( avatarData = variant.avatarData, forcedAvatarSize = avatarSize, - avatarType = AvatarType.User, + avatarShape = avatarShape, modifier = contentModifier - .border(width = 1.dp, color = colors.avatarStoke, shape = AvatarType.User.avatarShape()), + .border(width = 1.dp, color = colors.avatarStoke, shape = avatarShape), + configureRequest = { builder -> + builder.allowHardware(allowHardwareBitmapRendering) + } ) } PinVariant.PinnedLocation, PinVariant.StaleLocation -> { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt index da57fbcbe1..10136bb5c1 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt @@ -17,10 +17,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent +import coil3.request.ImageRequest import io.element.android.libraries.designsystem.components.avatar.AvatarData import timber.log.Timber @@ -31,10 +33,16 @@ internal fun ImageAvatar( forcedAvatarSize: Dp?, modifier: Modifier = Modifier, contentDescription: String? = null, + configureRequest: (ImageRequest.Builder) -> ImageRequest.Builder = { it }, ) { val size = forcedAvatarSize ?: avatarData.size.dp + val request = ImageRequest.Builder(LocalContext.current) + .data(avatarData) + .let(configureRequest) + .build() + SubcomposeAsyncImage( - model = avatarData, + model = request, contentDescription = contentDescription, contentScale = ContentScale.Crop, modifier = modifier From d53db78856c5e56b881534926dfbf0c2cd1b4327 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 5 Mar 2026 21:50:56 +0100 Subject: [PATCH 22/52] Use android.graphic.canvas to create proper bitmap --- features/location/impl/build.gradle.kts | 1 + .../impl/common/ui/LocationPinMarkers.kt | 60 +-- .../impl/common/ui/RememberMarkerBitmap.kt | 87 ---- .../designsystem/components/LocationPin.kt | 438 ++++++++++++------ 4 files changed, 320 insertions(+), 266 deletions(-) delete mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 558cd86b25..94f1ea07dd 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { api(projects.features.location.api) implementation(projects.features.messages.api) implementation(libs.maplibre.compose) + implementation(libs.coil) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.di) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt index 6fed056a28..7fce6ab281 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt @@ -9,27 +9,20 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import io.element.android.compound.theme.ElementTheme import io.element.android.features.location.api.Location -import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.PinVariant +import io.element.android.libraries.designsystem.components.rememberLocationPinBitmap import kotlinx.serialization.json.JsonPrimitive import org.maplibre.compose.expressions.dsl.and -import org.maplibre.compose.expressions.dsl.asNumber import org.maplibre.compose.expressions.dsl.asString import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.eq import org.maplibre.compose.expressions.dsl.feature import org.maplibre.compose.expressions.dsl.image import org.maplibre.compose.expressions.dsl.not -import org.maplibre.compose.expressions.dsl.step import org.maplibre.compose.expressions.value.SymbolAnchor -import org.maplibre.compose.expressions.value.SymbolPlacement import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.sources.GeoJsonData @@ -60,14 +53,12 @@ data class LocationMarkerData( * A composable that renders location markers on a MapLibre map with clustering support. * * Uses GeoJSON source with clustering enabled to group nearby markers. - * Individual markers are rendered using [LocationPin] composable converted to bitmaps. + * Individual markers are rendered using Canvas-based pin rendering with Coil for avatar loading. * Clusters are rendered as circles with point counts. * * Must be used within a MaplibreMap content block. * * @param markers List of markers to display on the map - * @param clusterRadius Radius of each cluster when clustering points (default 50) - * @param clusterMaxZoom Maximum zoom level at which to cluster points (default 14) * @param onMarkerClick Callback when a marker is clicked * @param onClusterClick Callback when a cluster is clicked, provides cluster center position */ @@ -157,36 +148,23 @@ private fun LocationPinMarkerLayer( source: GeoJsonSource, onMarkerClick: ((LocationMarkerData) -> Unit)?, ) { - val imageBitmap = rememberLocationPinImage(marker.variant) - SymbolLayer( - id = "pin-marker-${marker.id}", - source = source, - filter = !feature.has("point_count") and (feature["id"].asString() eq const(marker.id)), - iconImage = image(imageBitmap), - iconAnchor = const(SymbolAnchor.Bottom), - iconAllowOverlap = const(true), - onClick = { features -> - if (features.isNotEmpty() && onMarkerClick != null) { - onMarkerClick(marker) - ClickResult.Consume - } else { - ClickResult.Pass - } - }, - ) -} - -/** - * Renders a LocationPin composable to an ImageBitmap for use in SymbolLayer. - */ -@Composable -private fun rememberLocationPinImage(variant: PinVariant): ImageBitmap { - val bitmap = rememberMarkerBitmap(variant) { - LocationPin( - variant = variant, - // Disable as it doesn't work with the rememberMarkerBitmap method - allowHardwareBitmapRendering = false + val imageBitmap = rememberLocationPinBitmap(marker.variant) + if (imageBitmap != null) { + SymbolLayer( + id = "pin-marker-${marker.id}", + source = source, + filter = !feature.has("point_count") and (feature["id"].asString() eq const(marker.id)), + iconImage = image(imageBitmap), + iconAnchor = const(SymbolAnchor.Bottom), + iconAllowOverlap = const(true), + onClick = { features -> + if (features.isNotEmpty() && onMarkerClick != null) { + onMarkerClick(marker) + ClickResult.Consume + } else { + ClickResult.Pass + } + }, ) } - return bitmap.asImageBitmap() } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt deleted file mode 100644 index 6c083e2a45..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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 android.graphics.Bitmap -import android.graphics.Canvas -import android.os.Build -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionContext -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCompositionContext -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalView -import androidx.core.graphics.createBitmap - -/** - * Renders a composable [content] to an Android [Bitmap]. - * Useful for MapLibre SymbolLayer rendering. - * - * Uses a temporary ComposeView to render off-screen without - * adding to the visible composition tree. - * - * Note: This function provides a software-only ImageLoader to avoid - * "Software rendering doesn't support hardware bitmaps" errors when - * rendering Coil images to a Canvas. - * - * @param keys to trigger recomposition. - * @return The rendered Android [Bitmap]. - */ -@Composable -fun rememberMarkerBitmap( - vararg keys: Any, - content: @Composable () -> Unit, -): Bitmap { - val parent = LocalView.current as ViewGroup - val compositionContext = rememberCompositionContext() - return remember(parent, compositionContext, *keys) { - renderComposableToBitmap(parent, compositionContext, content) - } -} - -private fun renderComposableToBitmap( - parent: ViewGroup, - compositionContext: CompositionContext, - content: @Composable () -> Unit, -): Bitmap { - val composeView = ComposeView(parent.context).apply { - setParentCompositionContext(compositionContext) - setContent(content) - } - // Temporarily add to parent for measurement - parent.addView( - composeView, - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - ) - // Measure - composeView.measure( - View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), - View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) - ) - - val width = composeView.measuredWidth - val height = composeView.measuredHeight - - // Layout - composeView.layout(0, 0, width, height) - - // Draw to bitmap - val bitmap = createBitmap(width, height) - val canvas = Canvas(bitmap) - composeView.draw(canvas) - - // Cleanup - parent.removeView(composeView) - - return bitmap -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt index 60f83ffea1..75129f6795 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt @@ -7,31 +7,42 @@ package io.element.android.libraries.designsystem.components +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF import androidx.compose.foundation.Canvas -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Matrix -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Fill -import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap +import androidx.core.graphics.withSave +import coil3.Image +import coil3.SingletonImageLoader +import coil3.request.ImageRequest import coil3.request.allowHardware +import coil3.toBitmap import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.colors.AvatarColorsProvider 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.components.avatar.avatarShape -import io.element.android.libraries.designsystem.components.avatar.internal.ImageAvatar import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -48,11 +59,6 @@ sealed interface PinVariant { data object StaleLocation : PinVariant } -private val PIN_MARKER_WIDTH = 42.dp -private val PIN_MARKER_HEIGHT = (PIN_MARKER_WIDTH * 1.2f) -private val DOT_RADIUS = 6.dp -private val CONTENT_OFFSET = 5.dp - /** * A location pin composable that supports multiple variants. * @@ -62,141 +68,297 @@ private val CONTENT_OFFSET = 5.dp fun LocationPin( variant: PinVariant, modifier: Modifier = Modifier, - allowHardwareBitmapRendering: Boolean = true, ) { - val colors = LocationPinColors.fromVariant(variant) - Box( - modifier = modifier.size(width = PIN_MARKER_WIDTH, height = PIN_MARKER_HEIGHT), - ) { - Canvas(modifier = Modifier.matchParentSize()) { - drawPinShape( - fillColor = colors.fill, - strokeColor = colors.stroke, - strokeWidth = 1.dp.toPx(), - ) - } - val avatarSize = PIN_MARKER_WIDTH - CONTENT_OFFSET * 2 - val contentModifier = Modifier - .align(Alignment.TopCenter) - .offset(y = CONTENT_OFFSET) - - when (variant) { - is PinVariant.UserLocation -> { - val avatarShape = AvatarType.User.avatarShape() - ImageAvatar( - avatarData = variant.avatarData, - forcedAvatarSize = avatarSize, - avatarShape = avatarShape, - modifier = contentModifier - .border(width = 1.dp, color = colors.avatarStoke, shape = avatarShape), - configureRequest = { builder -> - builder.allowHardware(allowHardwareBitmapRendering) - } - ) - } - PinVariant.PinnedLocation, PinVariant.StaleLocation -> { - Canvas( - modifier = contentModifier.size(avatarSize) - ) { - drawCircle( - color = colors.dotColor, - radius = DOT_RADIUS.toPx(), - center = center, - ) - } - } - } - } -} - -private data class LocationPinColors( - val fill: Color, - val stroke: Color, - val dotColor: Color, - val avatarStoke: Color, -) { - companion object { - @Composable - fun fromVariant(variant: PinVariant): LocationPinColors { - return when (variant) { - is PinVariant.UserLocation -> - if (variant.isLive) { - LocationPinColors( - fill = ElementTheme.colors.iconAccentPrimary, - stroke = ElementTheme.colors.bgCanvasDefault, - dotColor = Color.Transparent, - avatarStoke = ElementTheme.colors.bgCanvasDefault, - ) - } else { - LocationPinColors( - fill = ElementTheme.colors.bgCanvasDefault, - stroke = ElementTheme.colors.iconQuaternaryAlpha, - dotColor = Color.Transparent, - avatarStoke = ElementTheme.colors.iconQuaternaryAlpha, - ) - } - PinVariant.PinnedLocation -> LocationPinColors( - fill = ElementTheme.colors.bgCanvasDefault, - stroke = ElementTheme.colors.iconSecondaryAlpha, - dotColor = ElementTheme.colors.iconPrimary, - avatarStoke = Color.Transparent, - ) - PinVariant.StaleLocation -> LocationPinColors( - fill = ElementTheme.colors.bgSubtleSecondary, - stroke = ElementTheme.colors.iconDisabled, - dotColor = ElementTheme.colors.iconDisabled, - avatarStoke = Color.Transparent, - ) - } + val image = rememberLocationPinBitmap(variant) + Canvas(modifier = modifier.size(PIN_WIDTH, PIN_HEIGHT)) { + if (image != null) { + drawImage(image) } } } /** - * Draws a teardrop-shaped pin with smooth curves. - * - * Based on SVG path with dimensions 40x48 (ratio 1:1.2). - * Scales automatically to fit the canvas size. + * Renders a location pin to an [ImageBitmap] using Canvas operations. + * @param variant The pin variant to render + * @return The rendered [ImageBitmap], or null if still loading */ -private fun DrawScope.drawPinShape( - fillColor: Color, - strokeColor: Color, - strokeWidth: Float, +@Composable +fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? { + val context = LocalContext.current + val density = LocalDensity.current + val colors = pinColors(variant) + return produceState(initialValue = null, variant, colors) { + val renderer = LocationPinRenderer(context, density) + val bitmap = renderer.renderPin(variant, colors) + value = bitmap.asImageBitmap() + }.value +} + +private val PIN_WIDTH = 42.dp +private val PIN_HEIGHT = PIN_WIDTH * 1.2f +private val AVATAR_SIZE = PIN_WIDTH - 10.dp +private val CONTENT_OFFSET = 5.dp +private val DOT_RADIUS = 6.dp +private val STROKE_WIDTH = 1.dp + +@Composable +private fun pinColors(variant: PinVariant): PinColors { + return when (variant) { + is PinVariant.UserLocation -> { + val avatarColors = AvatarColorsProvider.provide(variant.avatarData.id) + if (variant.isLive) { + PinColors( + fill = ElementTheme.colors.iconAccentPrimary, + stroke = Color.Transparent, + dot = Color.Transparent, + avatarStroke = ElementTheme.colors.bgCanvasDefault, + avatarBackground = avatarColors.background, + avatarForeground = avatarColors.foreground, + ) + } else { + PinColors( + fill = ElementTheme.colors.bgCanvasDefault, + stroke = ElementTheme.colors.iconQuaternaryAlpha, + dot = Color.Transparent, + avatarStroke = ElementTheme.colors.iconQuaternaryAlpha, + avatarBackground = avatarColors.background, + avatarForeground = avatarColors.foreground, + ) + } + } + PinVariant.PinnedLocation -> PinColors( + fill = ElementTheme.colors.bgCanvasDefault, + stroke = ElementTheme.colors.iconSecondaryAlpha, + avatarStroke = Color.Transparent, + avatarBackground = Color.Transparent, + avatarForeground = Color.Transparent, + dot = ElementTheme.colors.iconPrimary, + ) + PinVariant.StaleLocation -> PinColors( + fill = ElementTheme.colors.bgSubtleSecondary, + stroke = ElementTheme.colors.iconDisabled, + avatarStroke = Color.Transparent, + avatarBackground = Color.Transparent, + avatarForeground = Color.Transparent, + dot = ElementTheme.colors.iconDisabled, + ) + } +} + +/** + * Color configuration for rendering a location pin. + */ +data class PinColors( + val fill: Color, + val stroke: Color, + val dot: Color, + val avatarStroke: Color, + val avatarBackground: Color, + val avatarForeground: Color, +) + +/** + * Renders location pins to bitmaps using Canvas operations. + * Uses Coil for avatar loading with proper memory management. + */ +class LocationPinRenderer( + private val context: Context, + private val density: Density, ) { - val svgWidth = 40f - val svgHeight = 48f - val inset = strokeWidth / 2 - val scaleX = (size.width - strokeWidth) / svgWidth - val scaleY = (size.height - strokeWidth) / svgHeight + // Dimensions in pixels + private val pinWidthPx = with(density) { PIN_WIDTH.toPx() } + private val pinHeightPx = with(density) { PIN_HEIGHT.toPx() } + private val avatarSizePx = with(density) { AVATAR_SIZE.toPx() } + private val avatarOffsetPx = with(density) { CONTENT_OFFSET.toPx() } + private val dotRadiusPx = with(density) { DOT_RADIUS.toPx() } + private val strokeWidthPx = with(density) { STROKE_WIDTH.toPx() } - val path = Path().apply { - moveTo(20f, 48f) - cubicTo(19.4167f, 48f, 18.8333f, 47.8965f, 18.25f, 47.6895f) - cubicTo(17.6667f, 47.4825f, 17.1458f, 47.1721f, 16.6875f, 46.7581f) - cubicTo(13.9792f, 44.2743f, 11.5833f, 41.8525f, 9.5f, 39.4929f) - cubicTo(7.41667f, 37.1332f, 5.67708f, 34.8461f, 4.28125f, 32.6313f) - cubicTo(2.88542f, 30.4166f, 1.82292f, 28.2846f, 1.09375f, 26.2354f) - cubicTo(0.364583f, 24.1863f, 0f, 22.2303f, 0f, 20.3674f) - cubicTo(0f, 14.1578f, 2.01042f, 9.21087f, 6.03125f, 5.52652f) - cubicTo(10.0521f, 1.84217f, 14.7083f, 0f, 20f, 0f) - cubicTo(25.2917f, 0f, 29.9479f, 1.84217f, 33.9688f, 5.52652f) - cubicTo(37.9896f, 9.21087f, 40f, 14.1578f, 40f, 20.3674f) - cubicTo(40f, 22.2303f, 39.6354f, 24.1863f, 38.9062f, 26.2354f) - cubicTo(38.1771f, 28.2846f, 37.1146f, 30.4166f, 35.7188f, 32.6313f) - cubicTo(34.3229f, 34.8461f, 32.5833f, 37.1332f, 30.5f, 39.4929f) - cubicTo(28.4167f, 41.8525f, 26.0208f, 44.2743f, 23.3125f, 46.7581f) - cubicTo(22.8542f, 47.1721f, 22.3333f, 47.4825f, 21.75f, 47.6895f) - cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f) - close() + /** + * Renders a pin variant to bitmap. Suspending for async avatar loading. + */ + suspend fun renderPin( + variant: PinVariant, + colors: PinColors, + ): Bitmap { + val bitmap = createBitmap(pinWidthPx.toInt(), pinHeightPx.toInt()) + val canvas = Canvas(bitmap) + // Draw pin shape (fill + stroke) + canvas.drawPinShape(colors.fill, colors.stroke) + when (variant) { + is PinVariant.UserLocation -> { + val avatarImage = loadAvatarImage(variant.avatarData) + canvas.drawAvatar( + avatarImage = avatarImage, + avatarData = variant.avatarData, + borderColor = colors.avatarStroke, + backgroundColor = colors.avatarBackground, + foregroundColor = colors.avatarForeground + ) + } + PinVariant.PinnedLocation, + PinVariant.StaleLocation -> canvas.drawDot(colors.dot) + } + return bitmap + } - transform(Matrix().apply { - scale(scaleX, scaleY) - translate(inset / scaleX, inset / scaleY) + private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color) { + val path = createPinPath() + // Fill + drawPath(path, Paint().apply { + color = fillColor.toArgb() + style = Paint.Style.FILL + isAntiAlias = true + }) + // Stroke + drawPath(path, Paint().apply { + color = strokeColor.toArgb() + style = Paint.Style.STROKE + strokeWidth = strokeWidthPx + isAntiAlias = true }) } - drawPath(path = path, color = fillColor, style = Fill) - drawPath(path = path, color = strokeColor, style = Stroke(width = strokeWidth)) + /** + * Creates the teardrop-shaped pin path. + * Based on SVG path with dimensions 40x48 (ratio 1:1.2). + * Scales automatically to fit the actual size. + */ + private fun createPinPath(): Path { + val svgWidth = 40f + val svgHeight = 48f + val inset = strokeWidthPx / 2 + val scaleX = (pinWidthPx - strokeWidthPx) / svgWidth + val scaleY = (pinHeightPx - strokeWidthPx) / svgHeight + + val path = Path().apply { + moveTo(20f, 48f) + cubicTo(19.4167f, 48f, 18.8333f, 47.8965f, 18.25f, 47.6895f) + cubicTo(17.6667f, 47.4825f, 17.1458f, 47.1721f, 16.6875f, 46.7581f) + cubicTo(13.9792f, 44.2743f, 11.5833f, 41.8525f, 9.5f, 39.4929f) + cubicTo(7.41667f, 37.1332f, 5.67708f, 34.8461f, 4.28125f, 32.6313f) + cubicTo(2.88542f, 30.4166f, 1.82292f, 28.2846f, 1.09375f, 26.2354f) + cubicTo(0.364583f, 24.1863f, 0f, 22.2303f, 0f, 20.3674f) + cubicTo(0f, 14.1578f, 2.01042f, 9.21087f, 6.03125f, 5.52652f) + cubicTo(10.0521f, 1.84217f, 14.7083f, 0f, 20f, 0f) + cubicTo(25.2917f, 0f, 29.9479f, 1.84217f, 33.9688f, 5.52652f) + cubicTo(37.9896f, 9.21087f, 40f, 14.1578f, 40f, 20.3674f) + cubicTo(40f, 22.2303f, 39.6354f, 24.1863f, 38.9062f, 26.2354f) + cubicTo(38.1771f, 28.2846f, 37.1146f, 30.4166f, 35.7188f, 32.6313f) + cubicTo(34.3229f, 34.8461f, 32.5833f, 37.1332f, 30.5f, 39.4929f) + cubicTo(28.4167f, 41.8525f, 26.0208f, 44.2743f, 23.3125f, 46.7581f) + cubicTo(22.8542f, 47.1721f, 22.3333f, 47.4825f, 21.75f, 47.6895f) + cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f) + close() + } + // Scale and translate the path + val matrix = Matrix().apply { + setScale(scaleX, scaleY) + postTranslate(inset, inset) + } + path.transform(matrix) + return path + } + + private suspend fun loadAvatarImage(avatarData: AvatarData): Image? { + val imageLoader = SingletonImageLoader.get(context) + val request = ImageRequest.Builder(context) + .data(avatarData) + .size(avatarSizePx.toInt()) + // Disable hardware rendering for Canvas + .allowHardware(false) + .build() + + return imageLoader.execute(request).image + } + + private fun Canvas.drawAvatar( + avatarImage: Image?, + avatarData: AvatarData, + borderColor: Color, + backgroundColor: Color, + foregroundColor: Color, + ) { + val centerX = pinWidthPx / 2 + val avatarY = avatarOffsetPx + val avatarRadius = avatarSizePx / 2 + + withSave { + val clipPath = Path().apply { + addCircle(centerX, avatarY + avatarRadius, avatarRadius, Path.Direction.CW) + } + clipPath(clipPath) + if (avatarImage != null) { + // Draw the loaded avatar image + val destRect = RectF( + centerX - avatarRadius, + avatarY, + centerX + avatarRadius, + avatarY + avatarSizePx + ) + drawBitmap(avatarImage.toBitmap(), null, destRect, null) + } else { + // Fallback: draw initial letter circle + drawInitialLetterAvatar( + avatarData = avatarData, + centerX = centerX, + centerY = avatarY + avatarRadius, + radius = avatarRadius, + foreground = foregroundColor.toArgb(), + background = backgroundColor.toArgb() + ) + } + } + val paintBorder = Paint().apply { + color = borderColor.toArgb() + style = Paint.Style.STROKE + strokeWidth = strokeWidthPx + isAntiAlias = true + } + drawCircle(centerX, avatarY + avatarRadius, avatarRadius, paintBorder) + } + + private fun Canvas.drawInitialLetterAvatar( + avatarData: AvatarData, + centerX: Float, + centerY: Float, + radius: Float, + foreground: Int, + background: Int, + ) { + // Draw background circle + drawCircle(centerX, centerY, radius, Paint().apply { + color = background + style = Paint.Style.FILL + isAntiAlias = true + }) + // Draw initial letter + val textPaint = Paint().apply { + color = foreground + textSize = radius * 1.2f + textAlign = Paint.Align.CENTER + isAntiAlias = true + isFakeBoldText = true + } + // Center text vertically + val textBounds = Rect() + textPaint.getTextBounds(avatarData.initialLetter, 0, 1, textBounds) + val textY = centerY + textBounds.height() / 2f + drawText(avatarData.initialLetter, centerX, textY, textPaint) + } + + private fun Canvas.drawDot(dotColor: Color) { + if (dotColor == Color.Transparent) return + + val centerX = pinWidthPx / 2 + // Position dot in the center of the circular part of the pin + val centerY = avatarOffsetPx + avatarSizePx / 2 + + drawCircle(centerX, centerY, dotRadiusPx, Paint().apply { + color = dotColor.toArgb() + style = Paint.Style.FILL + isAntiAlias = true + }) + } } @PreviewsDayNight From fa9f0a93c04aa34df0f5ce4d75380d45390abbd3 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 6 Mar 2026 13:19:09 +0100 Subject: [PATCH 23/52] Improve LocationPin rendering with caching mechanism --- .../designsystem/components/LocationPin.kt | 232 ++++++++++-------- .../components/avatar/internal/ImageAvatar.kt | 8 +- 2 files changed, 128 insertions(+), 112 deletions(-) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt index 75129f6795..9fc0d1d27e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -35,7 +36,10 @@ import androidx.compose.ui.unit.dp import androidx.core.graphics.createBitmap import androidx.core.graphics.withSave import coil3.Image +import coil3.ImageLoader import coil3.SingletonImageLoader +import coil3.asImage +import coil3.memory.MemoryCache import coil3.request.ImageRequest import coil3.request.allowHardware import coil3.toBitmap @@ -46,6 +50,13 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +private val PIN_WIDTH = 42.dp +private val PIN_HEIGHT = PIN_WIDTH * 1.2f +private val AVATAR_SIZE = PIN_WIDTH - 10.dp +private val CONTENT_OFFSET = 5.dp +private val DOT_RADIUS = 6.dp +private val STROKE_WIDTH = 1.dp + /** * Variants of location pin markers. */ @@ -86,21 +97,23 @@ fun LocationPin( fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? { val context = LocalContext.current val density = LocalDensity.current + val imageLoader = SingletonImageLoader.get(context) val colors = pinColors(variant) - return produceState(initialValue = null, variant, colors) { - val renderer = LocationPinRenderer(context, density) - val bitmap = renderer.renderPin(variant, colors) - value = bitmap.asImageBitmap() + val cacheKey = rememberCacheKey(variant) + return produceState(initialValue = null, cacheKey) { + val memoryCacheKey = MemoryCache.Key(cacheKey) + val cached = imageLoader.memoryCache?.get(memoryCacheKey) + if (cached != null) { + value = cached.image.toBitmap().asImageBitmap() + } else { + val dimensions = PinDimensions(density) + val bitmap = LocationPinRenderer.renderPin(variant, colors, dimensions, context, imageLoader) + imageLoader.memoryCache?.set(memoryCacheKey, MemoryCache.Value(bitmap.asImage())) + value = bitmap.asImageBitmap() + } }.value } -private val PIN_WIDTH = 42.dp -private val PIN_HEIGHT = PIN_WIDTH * 1.2f -private val AVATAR_SIZE = PIN_WIDTH - 10.dp -private val CONTENT_OFFSET = 5.dp -private val DOT_RADIUS = 6.dp -private val STROKE_WIDTH = 1.dp - @Composable private fun pinColors(variant: PinVariant): PinColors { return when (variant) { @@ -148,7 +161,7 @@ private fun pinColors(variant: PinVariant): PinColors { /** * Color configuration for rendering a location pin. */ -data class PinColors( +private data class PinColors( val fill: Color, val stroke: Color, val dot: Color, @@ -158,20 +171,37 @@ data class PinColors( ) /** - * Renders location pins to bitmaps using Canvas operations. - * Uses Coil for avatar loading with proper memory management. + * Pre-calculated pixel dimensions for rendering a location pin. */ -class LocationPinRenderer( - private val context: Context, - private val density: Density, -) { - // Dimensions in pixels - private val pinWidthPx = with(density) { PIN_WIDTH.toPx() } - private val pinHeightPx = with(density) { PIN_HEIGHT.toPx() } - private val avatarSizePx = with(density) { AVATAR_SIZE.toPx() } - private val avatarOffsetPx = with(density) { CONTENT_OFFSET.toPx() } - private val dotRadiusPx = with(density) { DOT_RADIUS.toPx() } - private val strokeWidthPx = with(density) { STROKE_WIDTH.toPx() } +private class PinDimensions(density: Density) { + val pinWidth = with(density) { PIN_WIDTH.toPx() } + val pinHeight = with(density) { PIN_HEIGHT.toPx() } + val avatarSize: Float = with(density) { AVATAR_SIZE.toPx() } + val avatarOffset: Float = with(density) { CONTENT_OFFSET.toPx() } + val dotRadius: Float = with(density) { DOT_RADIUS.toPx() } + val strokeWidth: Float = with(density) { STROKE_WIDTH.toPx() } +} + +/** + * Renders location pins to bitmaps using Canvas operations. + * Uses Coil for avatar loading. + * Paint objects are shared across all renders. + */ +private object LocationPinRenderer { + // Shared Paint objects to avoid allocations + private val fillPaint = Paint().apply { + style = Paint.Style.FILL + isAntiAlias = true + } + private val strokePaint = Paint().apply { + style = Paint.Style.STROKE + isAntiAlias = true + } + private val textPaint = Paint().apply { + textAlign = Paint.Align.CENTER + isAntiAlias = true + isFakeBoldText = true + } /** * Renders a pin variant to bitmap. Suspending for async avatar loading. @@ -179,56 +209,50 @@ class LocationPinRenderer( suspend fun renderPin( variant: PinVariant, colors: PinColors, + dimensions: PinDimensions, + context: Context, + imageLoader: ImageLoader, ): Bitmap { - val bitmap = createBitmap(pinWidthPx.toInt(), pinHeightPx.toInt()) + val bitmap = createBitmap(dimensions.pinWidth.toInt(), dimensions.pinHeight.toInt()) val canvas = Canvas(bitmap) - // Draw pin shape (fill + stroke) - canvas.drawPinShape(colors.fill, colors.stroke) + canvas.drawPinShape(colors.fill, colors.stroke, dimensions) when (variant) { is PinVariant.UserLocation -> { - val avatarImage = loadAvatarImage(variant.avatarData) + val avatarImage = loadAvatarImage(variant.avatarData, context, imageLoader) canvas.drawAvatar( avatarImage = avatarImage, avatarData = variant.avatarData, borderColor = colors.avatarStroke, backgroundColor = colors.avatarBackground, - foregroundColor = colors.avatarForeground + foregroundColor = colors.avatarForeground, + dimensions = dimensions, ) } PinVariant.PinnedLocation, - PinVariant.StaleLocation -> canvas.drawDot(colors.dot) + PinVariant.StaleLocation -> canvas.drawDot(colors.dot, dimensions) } return bitmap } - private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color) { - val path = createPinPath() - // Fill - drawPath(path, Paint().apply { - color = fillColor.toArgb() - style = Paint.Style.FILL - isAntiAlias = true - }) - // Stroke - drawPath(path, Paint().apply { - color = strokeColor.toArgb() - style = Paint.Style.STROKE - strokeWidth = strokeWidthPx - isAntiAlias = true - }) + private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color, dimensions: PinDimensions) { + val path = createPinPath(dimensions) + fillPaint.color = fillColor.toArgb() + drawPath(path, fillPaint) + strokePaint.color = strokeColor.toArgb() + strokePaint.strokeWidth = dimensions.strokeWidth + drawPath(path, strokePaint) } /** - * Creates the teardrop-shaped pin path. + * Updates the teardrop-shaped pin path to match dimensions. * Based on SVG path with dimensions 40x48 (ratio 1:1.2). - * Scales automatically to fit the actual size. */ - private fun createPinPath(): Path { + private fun createPinPath(dimensions: PinDimensions): Path { val svgWidth = 40f val svgHeight = 48f - val inset = strokeWidthPx / 2 - val scaleX = (pinWidthPx - strokeWidthPx) / svgWidth - val scaleY = (pinHeightPx - strokeWidthPx) / svgHeight + val inset = dimensions.strokeWidth / 2 + val scaleX = (dimensions.pinWidth - dimensions.strokeWidth) / svgWidth + val scaleY = (dimensions.pinHeight - dimensions.strokeWidth) / svgHeight val path = Path().apply { moveTo(20f, 48f) @@ -250,7 +274,6 @@ class LocationPinRenderer( cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f) close() } - // Scale and translate the path val matrix = Matrix().apply { setScale(scaleX, scaleY) postTranslate(inset, inset) @@ -259,15 +282,16 @@ class LocationPinRenderer( return path } - private suspend fun loadAvatarImage(avatarData: AvatarData): Image? { - val imageLoader = SingletonImageLoader.get(context) + private suspend fun loadAvatarImage( + avatarData: AvatarData, + context: Context, + imageLoader: ImageLoader, + ): Image? { val request = ImageRequest.Builder(context) .data(avatarData) - .size(avatarSizePx.toInt()) // Disable hardware rendering for Canvas .allowHardware(false) .build() - return imageLoader.execute(request).image } @@ -277,29 +301,34 @@ class LocationPinRenderer( borderColor: Color, backgroundColor: Color, foregroundColor: Color, + dimensions: PinDimensions, ) { - val centerX = pinWidthPx / 2 - val avatarY = avatarOffsetPx - val avatarRadius = avatarSizePx / 2 + val centerX = dimensions.pinWidth / 2 + val avatarY = dimensions.avatarOffset + val avatarRadius = dimensions.avatarSize / 2 withSave { - val clipPath = Path().apply { - addCircle(centerX, avatarY + avatarRadius, avatarRadius, Path.Direction.CW) - } - clipPath(clipPath) if (avatarImage != null) { - // Draw the loaded avatar image + val bitmap = avatarImage.toBitmap() + // Calculate centered square crop (ContentScale.Crop behavior) + val srcSize = minOf(bitmap.width, bitmap.height) + val srcX = (bitmap.width - srcSize) / 2 + val srcY = (bitmap.height - srcSize) / 2 + val srcRect = Rect(srcX, srcY, srcX + srcSize, srcY + srcSize) val destRect = RectF( centerX - avatarRadius, avatarY, centerX + avatarRadius, - avatarY + avatarSizePx + avatarY + dimensions.avatarSize ) - drawBitmap(avatarImage.toBitmap(), null, destRect, null) + val clipPath = Path().apply { + addCircle(centerX, avatarY + avatarRadius, avatarRadius, Path.Direction.CW) + } + clipPath(clipPath) + drawBitmap(bitmap, srcRect, destRect, null) } else { - // Fallback: draw initial letter circle drawInitialLetterAvatar( - avatarData = avatarData, + initialLetter = avatarData.initialLetter, centerX = centerX, centerY = avatarY + avatarRadius, radius = avatarRadius, @@ -308,56 +337,35 @@ class LocationPinRenderer( ) } } - val paintBorder = Paint().apply { - color = borderColor.toArgb() - style = Paint.Style.STROKE - strokeWidth = strokeWidthPx - isAntiAlias = true - } - drawCircle(centerX, avatarY + avatarRadius, avatarRadius, paintBorder) + strokePaint.color = borderColor.toArgb() + strokePaint.strokeWidth = dimensions.strokeWidth + drawCircle(centerX, avatarY + avatarRadius, avatarRadius, strokePaint) } private fun Canvas.drawInitialLetterAvatar( - avatarData: AvatarData, + initialLetter: String, centerX: Float, centerY: Float, radius: Float, foreground: Int, background: Int, ) { - // Draw background circle - drawCircle(centerX, centerY, radius, Paint().apply { - color = background - style = Paint.Style.FILL - isAntiAlias = true - }) - // Draw initial letter - val textPaint = Paint().apply { - color = foreground - textSize = radius * 1.2f - textAlign = Paint.Align.CENTER - isAntiAlias = true - isFakeBoldText = true - } - // Center text vertically + fillPaint.color = background + drawCircle(centerX, centerY, radius, fillPaint) + textPaint.color = foreground + textPaint.textSize = radius * 1.2f val textBounds = Rect() - textPaint.getTextBounds(avatarData.initialLetter, 0, 1, textBounds) + textPaint.getTextBounds(initialLetter, 0, 1, textBounds) val textY = centerY + textBounds.height() / 2f - drawText(avatarData.initialLetter, centerX, textY, textPaint) + drawText(initialLetter, centerX, textY, textPaint) } - private fun Canvas.drawDot(dotColor: Color) { + private fun Canvas.drawDot(dotColor: Color, dimensions: PinDimensions) { if (dotColor == Color.Transparent) return - - val centerX = pinWidthPx / 2 - // Position dot in the center of the circular part of the pin - val centerY = avatarOffsetPx + avatarSizePx / 2 - - drawCircle(centerX, centerY, dotRadiusPx, Paint().apply { - color = dotColor.toArgb() - style = Paint.Style.FILL - isAntiAlias = true - }) + val centerX = dimensions.pinWidth / 2 + val centerY = dimensions.avatarOffset + dimensions.avatarSize / 2 + fillPaint.color = dotColor.toArgb() + drawCircle(centerX, centerY, dimensions.dotRadius, fillPaint) } } @@ -393,3 +401,17 @@ internal fun LocationPinPreview() = ElementPreview { } } } + +@Composable +private fun rememberCacheKey(variant: PinVariant): String { + val isLightTheme = ElementTheme.isLightTheme + val density = LocalDensity.current.density + return remember(isLightTheme, density, variant) { + val pinVariant = when (variant) { + PinVariant.PinnedLocation -> "pin_pinned" + PinVariant.StaleLocation -> "pin_stale" + is PinVariant.UserLocation -> "pin_user_${variant.avatarData.id}_${variant.isLive}" + } + "${pinVariant}_{$isLightTheme}_{$density}" + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt index 10136bb5c1..e626e0f069 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt @@ -33,16 +33,10 @@ internal fun ImageAvatar( forcedAvatarSize: Dp?, modifier: Modifier = Modifier, contentDescription: String? = null, - configureRequest: (ImageRequest.Builder) -> ImageRequest.Builder = { it }, ) { val size = forcedAvatarSize ?: avatarData.size.dp - val request = ImageRequest.Builder(LocalContext.current) - .data(avatarData) - .let(configureRequest) - .build() - SubcomposeAsyncImage( - model = request, + model = avatarData, contentDescription = contentDescription, contentScale = ContentScale.Crop, modifier = modifier From 91626bd217a14960f926d589f062de568ac0e066 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 6 Mar 2026 16:38:06 +0100 Subject: [PATCH 24/52] Start cleaning up location code --- .../impl/common/ui/LocationPinMarkers.kt | 6 +- .../impl/show/ShowLocationPresenter.kt | 34 ++++++++ .../location/impl/show/ShowLocationState.kt | 2 + .../impl/show/ShowLocationStateProvider.kt | 46 +++++++++-- .../location/impl/show/ShowLocationView.kt | 77 +------------------ 5 files changed, 80 insertions(+), 85 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt index 7fce6ab281..762a719cf4 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt @@ -36,6 +36,8 @@ import org.maplibre.spatialk.geojson.Point import org.maplibre.spatialk.geojson.Position import org.maplibre.spatialk.geojson.toJson +private const val LOCATION_MARKER_ID = "LOCATION_MARKER_ID" + /** * Data class representing a marker on the map. * @@ -81,7 +83,7 @@ fun LocationPinMarkers( id = JsonPrimitive(marker.id), geometry = Point(Position(marker.location.lon, marker.location.lat)), properties = mapOf( - "id" to JsonPrimitive(marker.id), + LOCATION_MARKER_ID to JsonPrimitive(marker.id), ) ) } @@ -153,7 +155,7 @@ private fun LocationPinMarkerLayer( SymbolLayer( id = "pin-marker-${marker.id}", source = source, - filter = !feature.has("point_count") and (feature["id"].asString() eq const(marker.id)), + filter = !feature.has("point_count") and (feature[LOCATION_MARKER_ID].asString() eq const(marker.id)), iconImage = image(imageBitmap), iconAnchor = const(SymbolAnchor.Bottom), iconAllowOverlap = const(true), 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 f402b8fac9..9bbfe35b83 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 @@ -24,8 +24,13 @@ import io.element.android.features.location.impl.common.actions.LocationActions import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsState +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.designsystem.components.PinVariant +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize +import io.element.android.libraries.matrix.api.room.location.AssetType @AssistedInject class ShowLocationPresenter( @@ -88,9 +93,38 @@ class ShowLocationPresenter( } } + val markers = remember(mode) { + when (mode) { + is ShowLocationMode.Static -> { + val pinVariant = if (mode.assetType == AssetType.PIN) { + PinVariant.PinnedLocation + } else { + PinVariant.UserLocation( + avatarData = AvatarData( + id = mode.senderId.value, + name = mode.senderName, + url = mode.senderAvatarUrl, + size = AvatarSize.UserListItem, + ), + isLive = false, + ) + } + listOf( + LocationMarkerData( + id = mode.senderId.value, + location = mode.location, + variant = pinVariant, + ) + ) + } + ShowLocationMode.Live -> emptyList() + } + } + return ShowLocationState( permissionDialog = permissionDialog, mode = mode, + markers = markers, 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 4eefa34053..ec29cd7c54 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 @@ -9,10 +9,12 @@ package io.element.android.features.location.impl.show import io.element.android.features.location.api.ShowLocationMode +import io.element.android.features.location.impl.common.ui.LocationMarkerData data class ShowLocationState( val permissionDialog: Dialog, val mode: ShowLocationMode, + val markers: List, val hasLocationPermission: Boolean, val isTrackMyLocation: Boolean, val appName: String, 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 4941d8984d..944645fa1f 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 @@ -11,6 +11,10 @@ package io.element.android.features.location.impl.show import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.PinVariant +import io.element.android.libraries.designsystem.components.avatar.AvatarData +import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.location.AssetType @@ -50,18 +54,44 @@ class ShowLocationStateProvider : PreviewParameterProvider { fun aShowLocationState( permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None, mode: ShowLocationMode = aStaticLocationMode(), + markers: List? = null, hasLocationPermission: Boolean = false, isTrackMyLocation: Boolean = false, appName: String = APP_NAME, eventSink: (ShowLocationEvents) -> Unit = {}, -) = ShowLocationState( - permissionDialog = permissionDialog, - mode = mode, - hasLocationPermission = hasLocationPermission, - isTrackMyLocation = isTrackMyLocation, - appName = appName, - eventSink = eventSink, -) +): ShowLocationState { + val effectiveMarkers = markers ?: when (mode) { + is ShowLocationMode.Static -> listOf( + LocationMarkerData( + id = mode.senderId.value, + location = mode.location, + variant = if (mode.assetType == AssetType.PIN) { + PinVariant.PinnedLocation + } else { + PinVariant.UserLocation( + avatarData = AvatarData( + id = mode.senderId.value, + name = mode.senderName, + url = mode.senderAvatarUrl, + size = AvatarSize.UserListItem, + ), + isLive = true, + ) + } + ) + ) + ShowLocationMode.Live -> emptyList() + } + return ShowLocationState( + permissionDialog = permissionDialog, + mode = mode, + markers = effectiveMarkers, + hasLocationPermission = hasLocationPermission, + isTrackMyLocation = isTrackMyLocation, + appName = appName, + eventSink = eventSink, + ) +} fun aStaticLocationMode( location: Location = Location(1.23, 2.34, 4f), 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 d1a3537d7a..8839dca52f 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 @@ -15,33 +15,26 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.features.location.api.Location 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.features.location.impl.common.ui.LocationFloatingActionButton -import io.element.android.features.location.impl.common.ui.LocationMarkerData import io.element.android.features.location.impl.common.ui.LocationPinMarkers 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.components.PinVariant -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.button.BackButton 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.TopAppBar -import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.ui.strings.CommonStrings import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraPosition @@ -51,10 +44,6 @@ import org.maplibre.compose.location.rememberDefaultLocationProvider import org.maplibre.compose.location.rememberNullLocationProvider import org.maplibre.compose.location.rememberUserLocationState import org.maplibre.spatialk.geojson.Position -import kotlin.math.cos -import kotlin.math.sin -import kotlin.math.sqrt -import kotlin.random.Random import kotlin.time.Duration.Companion.minutes @OptIn(ExperimentalMaterial3Api::class) @@ -105,7 +94,7 @@ fun ShowLocationView( } val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState(skipHiddenState = false, initialValue = SheetValue.Hidden) + bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.PartiallyExpanded) ) MapBottomSheetScaffold( scaffoldState = scaffoldState, @@ -137,69 +126,7 @@ fun ShowLocationView( locationState = userLocationState, trackUserLocation = state.isTrackMyLocation ) - when (val mode = state.mode) { - is ShowLocationMode.Static -> { - val pinVariant = if (mode.assetType == AssetType.PIN) { - PinVariant.PinnedLocation - } else { - PinVariant.UserLocation( - avatarData = AvatarData(mode.senderId.value, mode.senderName, mode.senderAvatarUrl, AvatarSize.UserListItem), - isLive = true - ) - } - // Generate test markers around the original location - val testMarkers = remember { - buildList { - // Add the original marker - add( - LocationMarkerData( - id = "original", - location = mode.location, - variant = pinVariant - ) - ) - // Generate 10 random points within 50 meters - val radiusInMeters = 50.0 - val metersPerDegreeLat = 111_320.0 - val metersPerDegreeLon = 111_320.0 * cos(Math.toRadians(mode.location.lat)) - val variants = listOf( - PinVariant.StaleLocation, - PinVariant.UserLocation(AvatarData("@alice", "Alice", null, AvatarSize.TimelineSender), isLive = true), - PinVariant.UserLocation(AvatarData("@bob", "Bob", null, AvatarSize.TimelineSender), isLive = true), - PinVariant.UserLocation(AvatarData("@cassy", "Cassy", null, AvatarSize.TimelineSender), isLive = true), - PinVariant.UserLocation(AvatarData("@daisy", "Daisy", null, AvatarSize.TimelineSender), isLive = true), - PinVariant.UserLocation(AvatarData("@en", "G", null, AvatarSize.TimelineSender), isLive = true), - PinVariant.UserLocation(AvatarData("@f", "H", null, AvatarSize.TimelineSender), isLive = true), - PinVariant.UserLocation(AvatarData("@g", "I", null, AvatarSize.TimelineSender), isLive = true), - PinVariant.UserLocation(AvatarData("@h", "J", null, AvatarSize.TimelineSender), isLive = true), - PinVariant.UserLocation(AvatarData("@i", "K", null, AvatarSize.TimelineSender), isLive = true), - ) - repeat(10) { index -> - // Random point in a circle using sqrt for uniform distribution - val angle = Random.nextDouble() * 2 * Math.PI - val distance = sqrt(Random.nextDouble()) * radiusInMeters - val latOffset = (distance * cos(angle)) / metersPerDegreeLat - val lonOffset = (distance * sin(angle)) / metersPerDegreeLon - add( - LocationMarkerData( - id = "test_$index", - location = Location( - lat = mode.location.lat + latOffset, - lon = mode.location.lon + lonOffset - ), - variant = variants[index % (variants.size-1)] - ) - ) - } - } - } - LocationPinMarkers(testMarkers) - } - ShowLocationMode.Live -> { - // TODO: Show pins for all active live location sharers - } - } - + LocationPinMarkers(state.markers) }, overlayContent = { LocationFloatingActionButton( From cbec9dbe2c872f9b319f9c3d306d34a87a1e5c14 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 6 Mar 2026 18:37:02 +0100 Subject: [PATCH 25/52] Start implementing location shares sheet content --- features/location/impl/build.gradle.kts | 2 + .../location/impl/common/MapDefaults.kt | 11 +- .../impl/common/ui/LocationShareRow.kt | 143 ++++++++++++++++++ .../impl/common/ui/MapBottomSheetScaffold.kt | 4 +- .../location/impl/show/ShowLocationEvents.kt | 4 +- .../impl/show/ShowLocationPresenter.kt | 41 +++-- .../location/impl/show/ShowLocationState.kt | 18 +++ .../impl/show/ShowLocationStateProvider.kt | 21 +++ .../location/impl/show/ShowLocationView.kt | 58 +++++-- 9 files changed, 271 insertions(+), 31 deletions(-) create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationShareRow.kt 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, From 60b262a19ef411c65488e13d8b0adf75f94523f2 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 9 Mar 2026 21:19:29 +0100 Subject: [PATCH 26/52] Fix compilation --- .../impl/timeline/model/event/TimelineItemLocationContent.kt | 1 + .../android/libraries/matrix/api/room/location/AssetType.kt | 3 ++- .../android/libraries/matrix/impl/room/location/AssetType.kt | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt index 4d96327bb9..74147c697f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt @@ -40,6 +40,7 @@ data class TimelineItemLocationContent( when (assetType) { AssetType.PIN -> PinVariant.PinnedLocation AssetType.SENDER, + AssetType.UNKNOWN, null -> PinVariant.UserLocation(avatarData = senderAvatar(), isLive = false) } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/AssetType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/AssetType.kt index 42d384fd74..ea65d61bdf 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/AssetType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/AssetType.kt @@ -10,5 +10,6 @@ package io.element.android.libraries.matrix.api.room.location enum class AssetType { SENDER, - PIN + PIN, + UNKNOWN } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt index 40a55cede1..a88a2524c2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt @@ -14,9 +14,11 @@ import org.matrix.rustcomponents.sdk.AssetType as RustAssetType fun AssetType.into(): RustAssetType = when (this) { AssetType.SENDER -> RustAssetType.SENDER AssetType.PIN -> RustAssetType.PIN + AssetType.UNKNOWN -> RustAssetType.UNKNOWN } fun RustAssetType.into(): AssetType = when(this){ RustAssetType.SENDER -> AssetType.SENDER RustAssetType.PIN -> AssetType.PIN + RustAssetType.UNKNOWN -> AssetType.UNKNOWN } From 4bfe467ac1c5d53fa7ddb1c02eb39fd50f900996 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 9 Mar 2026 21:20:06 +0100 Subject: [PATCH 27/52] Remove local maplibre compose library --- libraries/maplibre-compose/build.gradle.kts | 28 -- .../libraries/maplibre/compose/CameraMode.kt | 48 ---- .../compose/CameraMoveStartedReason.kt | 48 ---- .../maplibre/compose/CameraPositionState.kt | 179 ------------- .../libraries/maplibre/compose/IconAnchor.kt | 39 --- .../libraries/maplibre/compose/MapApplier.kt | 57 ---- .../libraries/maplibre/compose/MapLibreMap.kt | 247 ------------------ .../maplibre/compose/MapLibreMapComposable.kt | 30 --- .../maplibre/compose/MapLocationSettings.kt | 31 --- .../compose/MapSymbolManagerSettings.kt | 22 -- .../maplibre/compose/MapUiSettings.kt | 32 --- .../libraries/maplibre/compose/MapUpdater.kt | 153 ----------- .../libraries/maplibre/compose/Symbol.kt | 114 -------- 13 files changed, 1028 deletions(-) delete mode 100644 libraries/maplibre-compose/build.gradle.kts delete mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt delete mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt delete mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt delete mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt delete mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt delete mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMap.kt delete mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt delete mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt delete mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt delete mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt delete mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt delete mode 100644 libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt diff --git a/libraries/maplibre-compose/build.gradle.kts b/libraries/maplibre-compose/build.gradle.kts deleted file mode 100644 index 5552aae37f..0000000000 --- a/libraries/maplibre-compose/build.gradle.kts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2022-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -plugins { - id("io.element.android-compose-library") - id("kotlin-parcelize") -} - -android { - namespace = "io.element.android.libraries.maplibre.compose" - - kotlin { - compilerOptions { - explicitApi() - } - } -} - -dependencies { - api(libs.maplibre) - api(libs.maplibre.ktx) - api(libs.maplibre.annotation) -} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt deleted file mode 100644 index 8ef02f5bbc..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMode.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.runtime.Immutable -import org.maplibre.android.location.modes.CameraMode as InternalCameraMode - -@Immutable -public enum class CameraMode { - NONE, - NONE_COMPASS, - NONE_GPS, - TRACKING, - TRACKING_COMPASS, - TRACKING_GPS, - TRACKING_GPS_NORTH; - - @InternalCameraMode.Mode - internal fun toInternal(): Int = when (this) { - NONE -> InternalCameraMode.NONE - NONE_COMPASS -> InternalCameraMode.NONE_COMPASS - NONE_GPS -> InternalCameraMode.NONE_GPS - TRACKING -> InternalCameraMode.TRACKING - TRACKING_COMPASS -> InternalCameraMode.TRACKING_COMPASS - TRACKING_GPS -> InternalCameraMode.TRACKING_GPS - TRACKING_GPS_NORTH -> InternalCameraMode.TRACKING_GPS_NORTH - } - - internal companion object { - fun fromInternal(@InternalCameraMode.Mode mode: Int): CameraMode = when (mode) { - InternalCameraMode.NONE -> NONE - InternalCameraMode.NONE_COMPASS -> NONE_COMPASS - InternalCameraMode.NONE_GPS -> NONE_GPS - InternalCameraMode.TRACKING -> TRACKING - InternalCameraMode.TRACKING_COMPASS -> TRACKING_COMPASS - InternalCameraMode.TRACKING_GPS -> TRACKING_GPS - InternalCameraMode.TRACKING_GPS_NORTH -> TRACKING_GPS_NORTH - else -> error("Unknown camera mode: $mode") - } - } -} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt deleted file mode 100644 index 2683de1655..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraMoveStartedReason.kt +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.runtime.Immutable -import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_ANIMATION -import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE -import org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION - -/** - * Enumerates the different reasons why the map camera started to move. - * - * Based on enum values from https://docs.maptiler.com/maplibre-gl-native-android/org.maplibre.android.maps/#oncameramovestartedlistener. - * - * [NO_MOVEMENT_YET] is used as the initial state before any map movement has been observed. - * - * [UNKNOWN] is used to represent when an unsupported integer value is provided to [fromInt] - this - * may be a new constant value from the Maps SDK that isn't supported by maps-compose yet, in which - * case this library should be updated to include a new enum value for that constant. - */ -@Immutable -public enum class CameraMoveStartedReason(public val value: Int) { - UNKNOWN(-2), - NO_MOVEMENT_YET(-1), - GESTURE(REASON_API_GESTURE), - API_ANIMATION(REASON_API_ANIMATION), - DEVELOPER_ANIMATION(REASON_DEVELOPER_ANIMATION); - - public companion object { - /** - * Converts from the Maps SDK [org.maplibre.android.maps.MapLibreMap.OnCameraMoveStartedListener] - * constants to [CameraMoveStartedReason], or returns [UNKNOWN] if there is no such - * [CameraMoveStartedReason] for the given [value]. - * - * See https://docs.maptiler.com/maplibre-gl-native-android/org.maplibre.android.maps/#oncameramovestartedlistener. - */ - public fun fromInt(value: Int): CameraMoveStartedReason { - return values().firstOrNull { it.value == value } ?: return UNKNOWN - } - } -} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt deleted file mode 100644 index 1999526718..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/CameraPositionState.kt +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import android.location.Location -import android.os.Parcelable -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ReadOnlyComposable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf -import kotlinx.parcelize.Parcelize -import org.maplibre.android.camera.CameraPosition -import org.maplibre.android.camera.CameraUpdateFactory -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.Projection - -/** - * Create and [rememberSaveable] a [CameraPositionState] using [CameraPositionState.Saver]. - * [init] will be called when the [CameraPositionState] is first created to configure its - * initial state. - */ -@Composable -public inline fun rememberCameraPositionState( - crossinline init: CameraPositionState.() -> Unit = {} -): CameraPositionState = rememberSaveable(saver = CameraPositionState.Saver) { - CameraPositionState().apply(init) -} - -/** - * A state object that can be hoisted to control and observe the map's camera state. - * A [CameraPositionState] may only be used by a single [MapLibreMap] composable at a time - * as it reflects instance state for a single view of a map. - * - * @param position the initial camera position - * @param cameraMode the initial camera mode - */ -public class CameraPositionState( - position: CameraPosition = CameraPosition.Builder().build(), - cameraMode: CameraMode = CameraMode.NONE, -) { - /** - * Whether the camera is currently moving or not. This includes any kind of movement: - * panning, zooming, or rotation. - */ - public var isMoving: Boolean by mutableStateOf(false) - internal set - - /** - * The reason for the start of the most recent camera moment, or - * [CameraMoveStartedReason.NO_MOVEMENT_YET] if the camera hasn't moved yet or - * [CameraMoveStartedReason.UNKNOWN] if an unknown constant is received from the Maps SDK. - */ - public var cameraMoveStartedReason: CameraMoveStartedReason by mutableStateOf( - CameraMoveStartedReason.NO_MOVEMENT_YET - ) - internal set - - /** - * Returns the current [Projection] to be used for converting between screen - * coordinates and lat/lng. - */ - public val projection: Projection? - get() = map?.projection - - /** - * Local source of truth for the current camera position. - * While [map] is non-null this reflects the current position of [map] as it changes. - * While [map] is null it reflects the last known map position, or the last value set by - * explicitly setting [position]. - */ - internal var rawPosition by mutableStateOf(position) - - /** - * Current position of the camera on the map. - */ - public var position: CameraPosition - get() = rawPosition - set(value) { - synchronized(lock) { - val map = map - if (map == null) { - rawPosition = value - } else { - map.moveCamera(CameraUpdateFactory.newCameraPosition(value)) - } - } - } - - /** - * Local source of truth for the current camera mode. - * While [map] is non-null this reflects the current camera mode as it changes. - * While [map] is null it reflects the last known camera mode, or the last value set by - * explicitly setting [cameraMode]. - */ - internal var rawCameraMode by mutableStateOf(cameraMode) - - /** - * Current tracking mode of the camera. - */ - public var cameraMode: CameraMode - get() = rawCameraMode - set(value) { - synchronized(lock) { - val map = map - if (map == null) { - rawCameraMode = value - } else { - map.locationComponent.cameraMode = value.toInternal() - } - } - } - - /** - * The user's last available location. - */ - public var location: Location? by mutableStateOf(null) - internal set - - // Used to perform side effects thread-safely. - // Guards all mutable properties that are not `by mutableStateOf`. - private val lock = Unit - - // The map currently associated with this CameraPositionState. - // Guarded by `lock`. - private var map: MapLibreMap? by mutableStateOf(null) - - // The current map is set and cleared by side effect. - // There can be only one associated at a time. - internal fun setMap(map: MapLibreMap?) { - synchronized(lock) { - if (this.map == null && map == null) return - if (this.map != null && map != null) { - error("CameraPositionState may only be associated with one MapLibreMap at a time") - } - this.map = map - if (map == null) { - isMoving = false - } else { - map.moveCamera(CameraUpdateFactory.newCameraPosition(position)) - map.locationComponent.cameraMode = cameraMode.toInternal() - } - } - } - - public companion object { - /** - * The default saver implementation for [CameraPositionState]. - */ - public val Saver: Saver = Saver( - save = { SaveableCameraPositionData(it.position, it.cameraMode.toInternal()) }, - restore = { CameraPositionState(it.position, CameraMode.fromInternal(it.cameraMode)) } - ) - } -} - -/** Provides the [CameraPositionState] used by the map. */ -internal val LocalCameraPositionState = staticCompositionLocalOf { CameraPositionState() } - -/** The current [CameraPositionState] used by the map. */ -public val currentCameraPositionState: CameraPositionState - @[MapLibreMapComposable ReadOnlyComposable Composable] - get() = LocalCameraPositionState.current - -@Parcelize -public data class SaveableCameraPositionData( - val position: CameraPosition, - val cameraMode: Int -) : Parcelable diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt deleted file mode 100644 index e46dcdcf65..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/IconAnchor.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.runtime.Immutable -import org.maplibre.android.style.layers.Property - -@Immutable -public enum class IconAnchor { - CENTER, - LEFT, - RIGHT, - TOP, - BOTTOM, - TOP_LEFT, - TOP_RIGHT, - BOTTOM_LEFT, - BOTTOM_RIGHT; - - @Property.ICON_ANCHOR - internal fun toInternal(): String = when (this) { - CENTER -> Property.ICON_ANCHOR_CENTER - LEFT -> Property.ICON_ANCHOR_LEFT - RIGHT -> Property.ICON_ANCHOR_RIGHT - TOP -> Property.ICON_ANCHOR_TOP - BOTTOM -> Property.ICON_ANCHOR_BOTTOM - TOP_LEFT -> Property.ICON_ANCHOR_TOP_LEFT - TOP_RIGHT -> Property.ICON_ANCHOR_TOP_RIGHT - BOTTOM_LEFT -> Property.ICON_ANCHOR_BOTTOM_LEFT - BOTTOM_RIGHT -> Property.ICON_ANCHOR_BOTTOM_RIGHT - } -} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt deleted file mode 100644 index f8fd64c537..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapApplier.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.runtime.AbstractApplier -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.Style -import org.maplibre.android.plugins.annotation.SymbolManager - -internal interface MapNode { - fun onAttached() {} - fun onRemoved() {} - fun onCleared() {} -} - -private object MapNodeRoot : MapNode - -internal class MapApplier( - val map: MapLibreMap, - val style: Style, - val symbolManager: SymbolManager, -) : AbstractApplier(MapNodeRoot) { - private val decorations = mutableListOf() - - override fun onClear() { - symbolManager.deleteAll() - decorations.forEach { it.onCleared() } - decorations.clear() - } - - override fun insertBottomUp(index: Int, instance: MapNode) { - decorations.add(index, instance) - instance.onAttached() - } - - override fun insertTopDown(index: Int, instance: MapNode) { - // insertBottomUp is preferred - } - - override fun move(from: Int, to: Int, count: Int) { - decorations.move(from, to, count) - } - - override fun remove(index: Int, count: Int) { - repeat(count) { - decorations[index + it].onRemoved() - } - decorations.remove(index, count) - } -} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMap.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMap.kt deleted file mode 100644 index 62c29fbd04..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMap.kt +++ /dev/null @@ -1,247 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import android.content.ComponentCallbacks2 -import android.content.Context -import android.content.res.Configuration -import android.os.Bundle -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Composition -import androidx.compose.runtime.CompositionContext -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCompositionContext -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.persistentMapOf -import kotlinx.coroutines.awaitCancellation -import org.maplibre.android.MapLibre -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.MapView -import org.maplibre.android.maps.Style -import org.maplibre.android.plugins.annotation.SymbolManager -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -/** - * A compose container for a MapLibre [MapView]. - * - * Heavily inspired by https://github.com/googlemaps/android-maps-compose - * - * @param styleUri a URI where to asynchronously fetch a style for the map - * @param modifier Modifier to be applied to the MapLibreMap - * @param images images added to the map's style to be later used with [Symbol] - * @param cameraPositionState the [CameraPositionState] to be used to control or observe the map's - * camera state - * @param uiSettings the [MapUiSettings] to be used for UI-specific settings on the map - * @param symbolManagerSettings the [MapSymbolManagerSettings] to be used for symbol manager settings - * @param locationSettings the [MapLocationSettings] to be used for location settings - * @param content the content of the map - */ -@Composable -public fun MapLibreMap( - styleUri: String, - modifier: Modifier = Modifier, - images: ImmutableMap = persistentMapOf(), - cameraPositionState: CameraPositionState = rememberCameraPositionState(), - uiSettings: MapUiSettings = DefaultMapUiSettings, - symbolManagerSettings: MapSymbolManagerSettings = DefaultMapSymbolManagerSettings, - locationSettings: MapLocationSettings = DefaultMapLocationSettings, - content: (@Composable @MapLibreMapComposable () -> Unit)? = null, -) { - // When in preview, early return a Box with the received modifier preserving layout - if (LocalInspectionMode.current) { - @Suppress("ModifierReused") // False positive, the modifier is not reused due to the early return. - Box( - modifier = modifier.background(Color.DarkGray) - ) { - Text("[Map]", modifier = Modifier.align(Alignment.Center)) - } - return - } - - val context = LocalContext.current - val mapView = remember { - MapLibre.getInstance(context) - MapView(context) - } - - @Suppress("ModifierReused") - AndroidView(modifier = modifier, factory = { mapView }) - MapLifecycle(mapView) - - // rememberUpdatedState and friends are used here to make these values observable to - // the subcomposition without providing a new content function each recomposition - val currentCameraPositionState by rememberUpdatedState(cameraPositionState) - val currentUiSettings by rememberUpdatedState(uiSettings) - val currentMapLocationSettings by rememberUpdatedState(locationSettings) - val currentSymbolManagerSettings by rememberUpdatedState(symbolManagerSettings) - - val parentComposition = rememberCompositionContext() - val currentContent by rememberUpdatedState(content) - - LaunchedEffect(styleUri, images) { - disposingComposition { - parentComposition.newComposition( - context = context, - mapView = mapView, - styleUri = styleUri, - images = images, - ) { - MapUpdater( - cameraPositionState = currentCameraPositionState, - uiSettings = currentUiSettings, - locationSettings = currentMapLocationSettings, - symbolManagerSettings = currentSymbolManagerSettings, - ) - CompositionLocalProvider( - LocalCameraPositionState provides cameraPositionState, - ) { - currentContent?.invoke() - } - } - } - } -} - -private suspend inline fun disposingComposition(factory: () -> Composition) { - val composition = factory() - try { - awaitCancellation() - } finally { - composition.dispose() - } -} - -private suspend inline fun CompositionContext.newComposition( - context: Context, - mapView: MapView, - styleUri: String, - images: ImmutableMap, - noinline content: @Composable () -> Unit -): Composition { - val map = mapView.awaitMap() - val style = map.awaitStyle(context, styleUri, images) - val symbolManager = SymbolManager(mapView, map, style) - return Composition( - MapApplier(map, style, symbolManager), - this - ).apply { - setContent(content) - } -} - -private suspend inline fun MapView.awaitMap(): MapLibreMap = suspendCoroutine { continuation -> - getMapAsync { map -> - continuation.resume(map) - } -} - -private suspend inline fun MapLibreMap.awaitStyle( - context: Context, - styleUri: String, - images: ImmutableMap, -): Style = suspendCoroutine { continuation -> - setStyle( - Style.Builder().apply { - fromUri(styleUri) - images.forEach { (id, drawableRes) -> - withImage(id, checkNotNull(context.getDrawable(drawableRes)) { - "Drawable resource $drawableRes with id $id not found" - }) - } - } - ) { style -> - continuation.resume(style) - } -} - -/** - * Registers lifecycle observers to the local [MapView]. - */ -@Composable -private fun MapLifecycle(mapView: MapView) { - val context = LocalContext.current - val lifecycle = LocalLifecycleOwner.current.lifecycle - val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) } - DisposableEffect(context, lifecycle, mapView) { - val mapLifecycleObserver = mapView.lifecycleObserver(previousState) - val callbacks = mapView.componentCallbacks() - - lifecycle.addObserver(mapLifecycleObserver) - context.registerComponentCallbacks(callbacks) - - onDispose { - lifecycle.removeObserver(mapLifecycleObserver) - context.unregisterComponentCallbacks(callbacks) - } - } - DisposableEffect(mapView) { - onDispose { - mapView.onDestroy() - mapView.removeAllViews() - } - } -} - -private fun MapView.lifecycleObserver(previousState: MutableState): LifecycleEventObserver = - LifecycleEventObserver { _, event -> - event.targetState - when (event) { - Lifecycle.Event.ON_CREATE -> { - // Skip calling mapView.onCreate if the lifecycle did not go through onDestroy - in - // this case the MapLibreMap composable also doesn't leave the composition. So, - // recreating the map does not restore state properly which must be avoided. - if (previousState.value != Lifecycle.Event.ON_STOP) { - this.onCreate(Bundle()) - } - } - Lifecycle.Event.ON_START -> this.onStart() - Lifecycle.Event.ON_RESUME -> this.onResume() - Lifecycle.Event.ON_PAUSE -> this.onPause() - Lifecycle.Event.ON_STOP -> this.onStop() - Lifecycle.Event.ON_DESTROY -> { - // handled in onDispose - } - Lifecycle.Event.ON_ANY -> error("ON_ANY should never be used") - } - previousState.value = event - } - -private fun MapView.componentCallbacks(): ComponentCallbacks2 = - object : ComponentCallbacks2 { - override fun onConfigurationChanged(config: Configuration) = Unit - - @Suppress("OVERRIDE_DEPRECATION") - override fun onLowMemory() = Unit - - override fun onTrimMemory(level: Int) { - // We call the `MapView.onLowMemory` method for any memory trim level - this@componentCallbacks.onLowMemory() - } - } diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt deleted file mode 100644 index c819dee711..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLibreMapComposable.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.runtime.ComposableTargetMarker - -/** - * An annotation that can be used to mark a composable function as being expected to be use in a - * composable function that is also marked or inferred to be marked as a [MapLibreMapComposable]. - * - * This will produce build warnings when [MapLibreMapComposable] composable functions are used outside - * of a [MapLibreMapComposable] content lambda, and vice versa. - */ -@Retention(AnnotationRetention.BINARY) -@ComposableTargetMarker(description = "MapLibre Map Composable") -@Target( - AnnotationTarget.FILE, - AnnotationTarget.FUNCTION, - AnnotationTarget.PROPERTY_GETTER, - AnnotationTarget.TYPE, - AnnotationTarget.TYPE_PARAMETER, -) -public annotation class MapLibreMapComposable diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt deleted file mode 100644 index 7fb777aeba..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapLocationSettings.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.ui.graphics.Color - -internal val DefaultMapLocationSettings = MapLocationSettings() - -/** - * Data class for UI-related settings on the map. - * - * Note: Should not be a data class if in need of maintaining binary compatibility - * on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/ - */ -public data class MapLocationSettings( - public val locationEnabled: Boolean = false, - public val backgroundTintColor: Color = Color.Unspecified, - public val foregroundTintColor: Color = Color.Unspecified, - public val backgroundStaleTintColor: Color = Color.Unspecified, - public val foregroundStaleTintColor: Color = Color.Unspecified, - public val accuracyColor: Color = Color.Unspecified, - public val pulseEnabled: Boolean = false, - public val pulseColor: Color = Color.Unspecified -) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt deleted file mode 100644 index 93c7b2118b..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapSymbolManagerSettings.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -internal val DefaultMapSymbolManagerSettings = MapSymbolManagerSettings() - -/** - * Data class for UI-related settings on the map. - * - * Note: Should not be a data class if in need of maintaining binary compatibility - * on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/ - */ -public data class MapSymbolManagerSettings( - public val iconAllowOverlap: Boolean = false, -) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt deleted file mode 100644 index edee9b4dc5..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUiSettings.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import android.view.Gravity -import androidx.compose.ui.graphics.Color - -internal val DefaultMapUiSettings = MapUiSettings() - -/** - * Data class for UI-related settings on the map. - * - * Note: Should not be a data class if in need of maintaining binary compatibility - * on future changes. See: https://jakewharton.com/public-api-challenges-in-kotlin/ - */ -public data class MapUiSettings( - public val compassEnabled: Boolean = true, - public val rotationGesturesEnabled: Boolean = true, - public val scrollGesturesEnabled: Boolean = true, - public val tiltGesturesEnabled: Boolean = true, - public val zoomGesturesEnabled: Boolean = true, - public val logoGravity: Int = Gravity.BOTTOM, - public val attributionGravity: Int = Gravity.BOTTOM, - public val attributionTintColor: Color = Color.Unspecified, -) diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt deleted file mode 100644 index a07a596fe3..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/MapUpdater.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ -@file:Suppress("MatchingDeclarationName") - -package io.element.android.libraries.maplibre.compose - -import android.annotation.SuppressLint -import android.content.Context -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ComposeNode -import androidx.compose.runtime.currentComposer -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import org.maplibre.android.location.LocationComponentActivationOptions -import org.maplibre.android.location.LocationComponentOptions -import org.maplibre.android.location.OnCameraTrackingChangedListener -import org.maplibre.android.location.engine.LocationEngineRequest -import org.maplibre.android.maps.MapLibreMap -import org.maplibre.android.maps.Style - -private const val LOCATION_REQUEST_INTERVAL = 750L - -internal class MapPropertiesNode( - val map: MapLibreMap, - style: Style, - context: Context, - cameraPositionState: CameraPositionState, - locationSettings: MapLocationSettings, -) : MapNode { - init { - map.locationComponent.activateLocationComponent( - LocationComponentActivationOptions.Builder(context, style) - .locationComponentOptions( - LocationComponentOptions.builder(context) - .backgroundTintColor(locationSettings.backgroundTintColor.toArgb()) - .foregroundTintColor(locationSettings.foregroundTintColor.toArgb()) - .backgroundStaleTintColor(locationSettings.backgroundStaleTintColor.toArgb()) - .foregroundStaleTintColor(locationSettings.foregroundStaleTintColor.toArgb()) - .accuracyColor(locationSettings.accuracyColor.toArgb()) - .pulseEnabled(locationSettings.pulseEnabled) - .pulseColor(locationSettings.pulseColor.toArgb()) - .build() - ) - .locationEngineRequest( - LocationEngineRequest.Builder(LOCATION_REQUEST_INTERVAL) - .setPriority(LocationEngineRequest.PRIORITY_HIGH_ACCURACY) - .setFastestInterval(LOCATION_REQUEST_INTERVAL) - .build() - ) - .build() - ) - cameraPositionState.setMap(map) - } - - var cameraPositionState = cameraPositionState - set(value) { - if (value == field) return - field.setMap(null) - field = value - value.setMap(map) - } - - override fun onAttached() { - map.addOnCameraIdleListener { - cameraPositionState.isMoving = false - // addOnCameraIdleListener is only invoked when the camera position - // is changed via .animate(). To handle updating state when .move() - // is used, it's necessary to set the camera's position here as well - cameraPositionState.rawPosition = map.cameraPosition - // Updating user location on every camera move due to lack of a better location updates API. - cameraPositionState.location = map.locationComponent.lastKnownLocation - } - map.addOnCameraMoveCancelListener { - cameraPositionState.isMoving = false - } - map.addOnCameraMoveStartedListener { - cameraPositionState.cameraMoveStartedReason = CameraMoveStartedReason.fromInt(it) - cameraPositionState.isMoving = true - } - map.addOnCameraMoveListener { - cameraPositionState.rawPosition = map.cameraPosition - // Updating user location on every camera move due to lack of a better location updates API. - cameraPositionState.location = map.locationComponent.lastKnownLocation - } - map.locationComponent.addOnCameraTrackingChangedListener(object : OnCameraTrackingChangedListener { - override fun onCameraTrackingDismissed() {} - - override fun onCameraTrackingChanged(currentMode: Int) { - cameraPositionState.rawCameraMode = CameraMode.fromInternal(currentMode) - } - }) - } - - override fun onRemoved() { - cameraPositionState.setMap(null) - } - - override fun onCleared() { - cameraPositionState.setMap(null) - } -} - -/** - * Used to keep the primary map properties up to date. This should never leave the map composition. - */ -@SuppressLint("MissingPermission") -@Suppress("NOTHING_TO_INLINE") -@Composable -internal inline fun MapUpdater( - cameraPositionState: CameraPositionState, - locationSettings: MapLocationSettings, - uiSettings: MapUiSettings, - symbolManagerSettings: MapSymbolManagerSettings, -) { - val mapApplier = currentComposer.applier as MapApplier - val map = mapApplier.map - val style = mapApplier.style - val symbolManager = mapApplier.symbolManager - val context = LocalContext.current - ComposeNode( - factory = { - MapPropertiesNode( - map = map, - style = style, - context = context, - cameraPositionState = cameraPositionState, - locationSettings = locationSettings, - ) - }, - update = { - set(locationSettings.locationEnabled) { map.locationComponent.isLocationComponentEnabled = it } - - set(uiSettings.compassEnabled) { map.uiSettings.isCompassEnabled = it } - set(uiSettings.rotationGesturesEnabled) { map.uiSettings.isRotateGesturesEnabled = it } - set(uiSettings.scrollGesturesEnabled) { map.uiSettings.isScrollGesturesEnabled = it } - set(uiSettings.tiltGesturesEnabled) { map.uiSettings.isTiltGesturesEnabled = it } - set(uiSettings.zoomGesturesEnabled) { map.uiSettings.isZoomGesturesEnabled = it } - set(uiSettings.logoGravity) { map.uiSettings.logoGravity = it } - set(uiSettings.attributionGravity) { map.uiSettings.attributionGravity = it } - set(uiSettings.attributionTintColor) { map.uiSettings.setAttributionTintColor(it.toArgb()) } - - set(symbolManagerSettings.iconAllowOverlap) { symbolManager.iconAllowOverlap = it } - - update(cameraPositionState) { this.cameraPositionState = it } - } - ) -} diff --git a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt b/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt deleted file mode 100644 index e6a5c3f632..0000000000 --- a/libraries/maplibre-compose/src/main/kotlin/io/element/android/libraries/maplibre/compose/Symbol.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * Copyright 2021 Google LLC - * - * 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.libraries.maplibre.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.ComposeNode -import androidx.compose.runtime.currentComposer -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import org.maplibre.android.geometry.LatLng -import org.maplibre.android.plugins.annotation.Symbol -import org.maplibre.android.plugins.annotation.SymbolManager -import org.maplibre.android.plugins.annotation.SymbolOptions - -internal class SymbolNode( - val symbolManager: SymbolManager, - val symbol: Symbol, -) : MapNode { - override fun onRemoved() { - symbolManager.delete(symbol) - } - - override fun onCleared() { - symbolManager.delete(symbol) - } -} - -/** - * A state object that can be hoisted to control and observe the symbol state. - * - * @param position the initial symbol position - */ -public class SymbolState( - position: LatLng -) { - /** - * Current position of the symbol. - */ - public var position: LatLng by mutableStateOf(position) - - public companion object { - /** - * The default saver implementation for [SymbolState]. - */ - public val Saver: Saver = Saver( - save = { it.position }, - restore = { SymbolState(it) } - ) - } -} - -@Composable -public fun rememberSymbolState( - position: LatLng = LatLng(0.0, 0.0) -): SymbolState = rememberSaveable(saver = SymbolState.Saver) { - SymbolState(position) -} - -/** - * A composable for a symbol on the map. - * - * @param iconId an id of an image from the current [Style] - * @param state the [SymbolState] to be used to control or observe the symbol - * state such as its position and info window - * @param iconAnchor the anchor for the symbol image - */ -@Composable -@MapLibreMapComposable -public fun Symbol( - iconId: String, - state: SymbolState = rememberSymbolState(), - iconAnchor: IconAnchor? = null, -) { - val mapApplier = currentComposer.applier as MapApplier - val symbolManager = mapApplier.symbolManager - ComposeNode( - factory = { - SymbolNode( - symbolManager = symbolManager, - symbol = symbolManager.create( - SymbolOptions().apply { - withLatLng(state.position) - withIconImage(iconId) - iconAnchor?.let { withIconAnchor(it.toInternal()) } - } - ), - ) - }, - update = { - update(state.position) { - this.symbol.latLng = it - symbolManager.update(this.symbol) - } - update(iconId) { - this.symbol.iconImage = it - symbolManager.update(this.symbol) - } - update(iconAnchor) { - this.symbol.iconAnchor = it?.toInternal() - symbolManager.update(this.symbol) - } - } - ) -} From b284984dad97ef187c6ef53760fc4ebfbb6030ba Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 10 Mar 2026 21:48:37 +0100 Subject: [PATCH 28/52] Check location is enabled --- .../common/actions/AndroidLocationActions.kt | 20 +++++++++ .../impl/common/actions/LocationActions.kt | 2 + .../ui/LocationServiceDisabledDialog.kt | 27 ++++++++++++ .../location/impl/share/ShareLocationEvent.kt | 1 + .../impl/share/ShareLocationPresenter.kt | 22 ++++++++-- .../location/impl/share/ShareLocationState.kt | 1 + .../impl/share/ShareLocationStateProvider.kt | 5 +++ .../location/impl/share/ShareLocationView.kt | 5 +++ .../location/impl/show/ShowLocationEvents.kt | 1 + .../impl/show/ShowLocationPresenter.kt | 12 +++++- .../location/impl/show/ShowLocationState.kt | 1 + .../impl/show/ShowLocationStateProvider.kt | 4 ++ .../location/impl/show/ShowLocationView.kt | 41 +++++++++++-------- .../common/actions/FakeLocationActions.kt | 19 ++++++++- 14 files changed, 139 insertions(+), 22 deletions(-) create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationServiceDisabledDialog.kt diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt index 0284c25a0a..d69eb018e1 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt @@ -10,8 +10,11 @@ package io.element.android.features.location.impl.common.actions import android.content.Context import android.content.Intent +import android.location.LocationManager import android.net.Uri +import android.provider.Settings import androidx.annotation.VisibleForTesting +import androidx.core.location.LocationManagerCompat import androidx.core.net.toUri import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding @@ -43,6 +46,23 @@ class AndroidLocationActions( override fun openSettings() { context.openAppSettingsPage() } + + override fun isLocationEnabled(): Boolean { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + return LocationManagerCompat.isLocationEnabled(locationManager) + } + + override fun openLocationSettings() { + runCatchingExceptions { + val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + }.onSuccess { + Timber.v("Open location settings succeed") + }.onFailure { + Timber.e(it, "Open location settings failed") + } + } } // Ref: https://developer.android.com/guide/components/intents-common#ViewMap diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt index cd9efbd261..c4c5db40d0 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt @@ -13,4 +13,6 @@ import io.element.android.features.location.api.Location interface LocationActions { fun share(location: Location, label: String?) fun openSettings() + fun isLocationEnabled(): Boolean + fun openLocationSettings() } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationServiceDisabledDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationServiceDisabledDialog.kt new file mode 100644 index 0000000000..5184845632 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationServiceDisabledDialog.kt @@ -0,0 +1,27 @@ +/* + * 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.res.stringResource +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun LocationServiceDisabledDialog( + onContinue: () -> Unit, + onDismiss: () -> Unit, +) { + ConfirmationDialog( + content = "Location services are disabled. Please enable them in your device settings to use this feature.", + onSubmitClick = onContinue, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_continue), + cancelText = stringResource(CommonStrings.action_cancel), + ) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt index eb641df9fb..7e68ecf358 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt @@ -25,4 +25,5 @@ sealed interface ShareLocationEvent { data object DismissDialog : ShareLocationEvent data object RequestPermissions : ShareLocationEvent data object OpenAppSettings : ShareLocationEvent + data object OpenLocationSettings : ShareLocationEvent } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index 63d4b7ce8d..df70cddacb 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -33,12 +33,10 @@ import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.CreateTimelineParams import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.launch @@ -89,7 +87,13 @@ class ShareLocationPresenter( shareStaticLocation(event) } ShareLocationEvent.StartTrackingUserPosition -> when { - permissionsState.isAnyGranted -> trackUserPosition = true + permissionsState.isAnyGranted -> { + if (!locationActions.isLocationEnabled()) { + dialogState = ShareLocationState.Dialog.LocationServiceDisabled + } else { + trackUserPosition = true + } + } permissionsState.shouldShowRationale -> dialogState = ShareLocationState.Dialog.PermissionRationale else -> dialogState = ShareLocationState.Dialog.PermissionDenied } @@ -99,9 +103,19 @@ class ShareLocationPresenter( locationActions.openSettings() dialogState = ShareLocationState.Dialog.None } + ShareLocationEvent.OpenLocationSettings -> { + locationActions.openLocationSettings() + dialogState = ShareLocationState.Dialog.None + } ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) ShareLocationEvent.ShowLiveLocationDurationPicker -> dialogState = when { - permissionsState.isAnyGranted -> ShareLocationState.Dialog.LiveLocationDuration + permissionsState.isAnyGranted -> { + if (!locationActions.isLocationEnabled()) { + ShareLocationState.Dialog.LocationServiceDisabled + } else { + ShareLocationState.Dialog.LiveLocationDuration + } + } permissionsState.shouldShowRationale -> ShareLocationState.Dialog.PermissionRationale else -> ShareLocationState.Dialog.PermissionDenied } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index 547bb99856..c204357a7a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -23,6 +23,7 @@ data class ShareLocationState( data object None : Dialog data object PermissionRationale : Dialog data object PermissionDenied : Dialog + data object LocationServiceDisabled : Dialog data object LiveLocationDuration : Dialog } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index 0d3c448f15..71b143fcef 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -32,6 +32,11 @@ class ShareLocationStateProvider : PreviewParameterProvider trackUserPosition = false, hasLocationPermission = false, ), + aShareLocationState( + permissionDialog = ShareLocationState.Dialog.LocationServiceDisabled, + trackUserPosition = false, + hasLocationPermission = true, + ), aShareLocationState( permissionDialog = ShareLocationState.Dialog.None, trackUserPosition = false, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index a0705221c0..cc88a2d427 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -38,6 +38,7 @@ 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.features.location.impl.common.ui.LocationFloatingActionButton +import io.element.android.features.location.impl.common.ui.LocationServiceDisabledDialog 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.components.LocationPin @@ -90,6 +91,10 @@ fun ShareLocationView( onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, appName = state.appName, ) + ShareLocationState.Dialog.LocationServiceDisabled -> LocationServiceDisabledDialog( + onContinue = { state.eventSink(ShareLocationEvent.OpenLocationSettings) }, + onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, + ) ShareLocationState.Dialog.LiveLocationDuration -> LiveLocationDurationDialog( onSelectDuration = { duration -> state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration)) 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 672cab9beb..52ff87b393 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 @@ -16,4 +16,5 @@ sealed interface ShowLocationEvents { data object DismissDialog : ShowLocationEvents data object RequestPermissions : ShowLocationEvents data object OpenAppSettings : ShowLocationEvents + data object OpenLocationSettings : 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 b131d0d137..2fe40af256 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 @@ -72,7 +72,13 @@ class ShowLocationPresenter( is ShowLocationEvents.TrackMyLocation -> { if (event.enabled) { when { - permissionsState.isAnyGranted -> isTrackMyLocation = true + permissionsState.isAnyGranted -> { + if (!locationActions.isLocationEnabled()) { + permissionDialog = ShowLocationState.Dialog.LocationServiceDisabled + } else { + isTrackMyLocation = true + } + } permissionsState.shouldShowRationale -> permissionDialog = ShowLocationState.Dialog.PermissionRationale else -> permissionDialog = ShowLocationState.Dialog.PermissionDenied } @@ -85,6 +91,10 @@ class ShowLocationPresenter( locationActions.openSettings() permissionDialog = ShowLocationState.Dialog.None } + ShowLocationEvents.OpenLocationSettings -> { + locationActions.openLocationSettings() + permissionDialog = ShowLocationState.Dialog.None + } ShowLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) } } 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 118d7e9f61..2f66dcb26c 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 @@ -32,6 +32,7 @@ data class ShowLocationState( data object None : Dialog data object PermissionRationale : Dialog data object PermissionDenied : Dialog + data object LocationServiceDisabled : Dialog } } 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 a11b317170..a28edf11d0 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 @@ -30,6 +30,10 @@ class ShowLocationStateProvider : PreviewParameterProvider { aShowLocationState( permissionDialog = ShowLocationState.Dialog.PermissionRationale, ), + aShowLocationState( + permissionDialog = ShowLocationState.Dialog.LocationServiceDisabled, + hasLocationPermission = true, + ), aShowLocationState( hasLocationPermission = true, ), 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 0193e52827..5b7c9b3d08 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 @@ -9,6 +9,8 @@ package io.element.android.features.location.impl.show import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -23,23 +25,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.compound.theme.ElementTheme import io.element.android.features.location.api.ShowLocationMode +import io.element.android.features.location.impl.common.ui.LocationServiceDisabledDialog 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 -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.designsystem.theme.components.TopAppBar import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -72,6 +72,10 @@ fun ShowLocationView( onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, appName = state.appName, ) + ShowLocationState.Dialog.LocationServiceDisabled -> LocationServiceDisabledDialog( + onContinue = { state.eventSink(ShowLocationEvents.OpenLocationSettings) }, + onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, + ) } val initialPosition = when (val mode = state.mode) { @@ -99,18 +103,18 @@ fun ShowLocationView( } val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState(initialValue = - if(state.isSheetDraggable) { - SheetValue.PartiallyExpanded - }else { - SheetValue.Expanded - } + bottomSheetState = rememberStandardBottomSheetState( + initialValue = + if (state.isSheetDraggable) { + SheetValue.PartiallyExpanded + } else { + SheetValue.Expanded + } ) ) MapBottomSheetScaffold( - //sheetPeekHeight = 180.dp, - sheetDragHandle = if(state.isSheetDraggable) { - {BottomSheetDefaults.DragHandle()} + sheetDragHandle = if (state.isSheetDraggable) { + { BottomSheetDefaults.DragHandle() } } else { null }, @@ -130,8 +134,9 @@ fun ShowLocationView( }, sheetContent = { sheetPaddings -> val coroutineScope = rememberCoroutineScope() + Spacer(Modifier.height(20.dp)) Text( - text = "On the map", + text = stringResource(CommonStrings.screen_static_location_sheet_title), style = ElementTheme.typography.fontBodyLgMedium, color = ElementTheme.colors.textPrimary, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -142,7 +147,11 @@ fun ShowLocationView( 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) + val position = CameraPosition( + padding = sheetPaddings, + target = Position(locationShare.location.lon, locationShare.location.lat), + zoom = MapDefaults.DEFAULT_ZOOM + ) coroutineScope.launch { cameraState.animateTo(finalPosition = position) } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt index 94dc972213..795e36fa1a 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt @@ -10,7 +10,9 @@ package io.element.android.features.location.impl.common.actions import io.element.android.features.location.api.Location -class FakeLocationActions : LocationActions { +class FakeLocationActions( + private var isLocationEnabled: Boolean = true, +) : LocationActions { var sharedLocation: Location? = null private set @@ -20,6 +22,9 @@ class FakeLocationActions : LocationActions { var openSettingsInvocationsCount = 0 private set + var openLocationSettingsInvocationsCount = 0 + private set + override fun share(location: Location, label: String?) { sharedLocation = location sharedLabel = label @@ -28,4 +33,16 @@ class FakeLocationActions : LocationActions { override fun openSettings() { openSettingsInvocationsCount++ } + + override fun isLocationEnabled(): Boolean { + return isLocationEnabled + } + + override fun openLocationSettings() { + openLocationSettingsInvocationsCount++ + } + + fun givenLocationEnabled(enabled: Boolean) { + isLocationEnabled = enabled + } } From 22a2bba354f07a11de73069d8b57458a29f58261 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 10 Mar 2026 21:48:54 +0100 Subject: [PATCH 29/52] Fix previews --- .../impl/common/ui/UserLocationPuck.kt | 21 +++++++++++++++++++ .../location/impl/share/ShareLocationView.kt | 17 ++------------- .../location/impl/show/ShowLocationView.kt | 17 ++------------- 3 files changed, 25 insertions(+), 30 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt index 9d073f8942..9a0623a361 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt @@ -8,14 +8,20 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import org.maplibre.compose.camera.CameraState +import org.maplibre.compose.location.DesiredAccuracy 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 +import org.maplibre.compose.location.rememberAndroidLocationProvider +import org.maplibre.compose.location.rememberNullLocationProvider +import org.maplibre.compose.location.rememberUserLocationState +import kotlin.time.Duration.Companion.minutes @Composable fun UserLocationPuck( @@ -50,3 +56,18 @@ fun UserLocationPuck( ) } } + +@Composable +fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState { + val isPreview = LocalInspectionMode.current + val locationProvider = if (isPreview || !hasLocationPermission) { + rememberNullLocationProvider() + } else { + rememberAndroidLocationProvider( + updateInterval = 1.minutes, + desiredAccuracy = DesiredAccuracy.Balanced, + minDistanceMeters = 50f, + ) + } + return rememberUserLocationState(locationProvider) +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index cc88a2d427..6fb649a59a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -41,6 +41,7 @@ import io.element.android.features.location.impl.common.ui.LocationFloatingActio import io.element.android.features.location.impl.common.ui.LocationServiceDisabledDialog import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold import io.element.android.features.location.impl.common.ui.UserLocationPuck +import io.element.android.features.location.impl.common.ui.rememberUserLocationState import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -60,13 +61,8 @@ 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.Companion.minutes @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -108,16 +104,7 @@ fun ShareLocationView( 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) + val userLocationState = rememberUserLocationState(state.hasLocationPermission) LaunchedEffect(cameraState.isCameraMoving) { if (cameraState.moveReason == CameraMoveReason.GESTURE) { 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 5b7c9b3d08..eec67ad956 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 @@ -36,6 +36,7 @@ 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.features.location.impl.common.ui.rememberUserLocationState 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 @@ -46,12 +47,7 @@ import kotlinx.coroutines.launch import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.rememberCameraState -import org.maplibre.compose.location.DesiredAccuracy -import org.maplibre.compose.location.rememberDefaultLocationProvider -import org.maplibre.compose.location.rememberNullLocationProvider -import org.maplibre.compose.location.rememberUserLocationState import org.maplibre.spatialk.geojson.Position -import kotlin.time.Duration.Companion.minutes @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -86,16 +82,7 @@ fun ShowLocationView( ShowLocationMode.Live -> MapDefaults.centerCameraPosition } val cameraState = rememberCameraState(firstPosition = initialPosition) - val locationProvider = if (state.hasLocationPermission) { - rememberDefaultLocationProvider( - updateInterval = 1.minutes, - desiredAccuracy = DesiredAccuracy.Balanced, - minDistanceMeters = 50.0, - ) - } else { - rememberNullLocationProvider() - } - val userLocationState = rememberUserLocationState(locationProvider) + val userLocationState = rememberUserLocationState(state.hasLocationPermission) LaunchedEffect(cameraState.isCameraMoving) { if (cameraState.moveReason == CameraMoveReason.GESTURE) { state.eventSink(ShowLocationEvents.TrackMyLocation(false)) From e664fb0a61758c9f0520535b28809b2982c300c1 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 12 Mar 2026 10:28:36 +0100 Subject: [PATCH 30/52] Make sure zoom is at least DEFAULT_ZOOM when following user position --- .../features/location/impl/common/ui/UserLocationPuck.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt index 9a0623a361..f014debe08 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.features.location.impl.common.MapDefaults import org.maplibre.compose.camera.CameraState import org.maplibre.compose.location.DesiredAccuracy import org.maplibre.compose.location.LocationPuck @@ -33,7 +34,12 @@ fun UserLocationPuck( locationState = locationState, enabled = trackUserLocation, ) { - cameraState.updateFromLocation() + val finalPosition = cameraState.position.copy( + target = currentLocation.position, + bearing = currentLocation.bearing ?: cameraState.position.bearing, + zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM) + ) + cameraState.animateTo(finalPosition) } val location = locationState.location if (location != null) { From 7b305e34efa52099d8fd9848e04801001a3647ee Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 12 Mar 2026 10:29:20 +0100 Subject: [PATCH 31/52] Set LocationShareRow max lines to Text components --- .../features/location/impl/common/ui/LocationShareRow.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 index 35817a91e8..ec059b61b3 100644 --- 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 @@ -19,6 +19,7 @@ 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.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons @@ -62,6 +63,8 @@ fun LocationShareRow( text = item.displayName, style = ElementTheme.typography.fontBodyLgMedium, color = ElementTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) Row( verticalAlignment = Alignment.CenterVertically, @@ -82,12 +85,13 @@ fun LocationShareRow( tint = ElementTheme.colors.iconSecondary, modifier = Modifier.size(16.dp), ) - } Text( text = item.formattedTimestamp, style = ElementTheme.typography.fontBodySmRegular, color = ElementTheme.colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } From 392fa9de9b5edb3357e6c8a2547f5f78e94ecdaa Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 12 Mar 2026 11:53:36 +0100 Subject: [PATCH 32/52] Add Constraints check for permissions and GPS check --- .../impl/common/LocationConstraintsCheck.kt | 45 +++++++ .../location/impl/common/MapDefaults.kt | 40 +----- .../impl/common/PermissionDeniedDialog.kt | 29 ----- .../impl/common/PermissionRationaleDialog.kt | 29 ----- .../common/actions/AndroidLocationActions.kt | 2 +- .../impl/common/actions/LocationActions.kt | 2 +- .../common/ui/LocationConstraintsDialog.kt | 55 ++++++++ .../ui/LocationServiceDisabledDialog.kt | 27 ---- .../location/impl/share/ShareLocationEvent.kt | 7 +- .../impl/share/ShareLocationPresenter.kt | 53 ++++---- .../location/impl/share/ShareLocationState.kt | 5 +- .../impl/share/ShareLocationStateProvider.kt | 19 +-- .../location/impl/share/ShareLocationView.kt | 78 +++++------- .../impl/show/ShowLocationPresenter.kt | 34 +++-- .../location/impl/show/ShowLocationState.kt | 11 +- .../impl/show/ShowLocationStateProvider.kt | 22 +--- .../location/impl/show/ShowLocationView.kt | 31 ++--- .../common/LocationConstraintsCheckTest.kt | 79 ++++++++++++ .../common/actions/FakeLocationActions.kt | 2 +- .../DefaultShareLocationEntryPointTest.kt | 4 + .../impl/share/ShareLocationPresenterTest.kt | 2 +- .../impl/show/ShowLocationPresenterTest.kt | 119 ++++++++++++------ .../impl/show/ShowLocationViewTest.kt | 13 +- 23 files changed, 382 insertions(+), 326 deletions(-) create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt delete mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt delete mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt create mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt delete mode 100644 features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationServiceDisabledDialog.kt create mode 100644 features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt new file mode 100644 index 0000000000..51d57713c5 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt @@ -0,0 +1,45 @@ +/* + * 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 + +import io.element.android.features.location.impl.common.actions.LocationActions +import io.element.android.features.location.impl.common.permissions.PermissionsState +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState + +sealed interface LocationConstraintsCheckResult { + data object Success : LocationConstraintsCheckResult + data object PermissionRationale : LocationConstraintsCheckResult + data object PermissionDenied : LocationConstraintsCheckResult + data object LocationServiceDisabled : LocationConstraintsCheckResult +} + +fun checkLocationConstraints( + permissionsState: PermissionsState, + locationActions: LocationActions, +): LocationConstraintsCheckResult { + return when { + permissionsState.isAnyGranted -> { + if (locationActions.isLocationEnabled()) { + LocationConstraintsCheckResult.Success + } else { + LocationConstraintsCheckResult.LocationServiceDisabled + } + } + permissionsState.shouldShowRationale -> LocationConstraintsCheckResult.PermissionRationale + else -> LocationConstraintsCheckResult.PermissionDenied + } +} + +fun LocationConstraintsCheckResult.toDialogState(): LocationConstraintsDialogState { + return when (this) { + LocationConstraintsCheckResult.Success -> LocationConstraintsDialogState.None + LocationConstraintsCheckResult.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale + LocationConstraintsCheckResult.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied + LocationConstraintsCheckResult.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled + } +} 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 c515ff6c72..1093e5760a 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 @@ -34,43 +34,9 @@ object MapDefaults { ) ) - /* - val uiSettings: MapUiSettings - @Composable - @ReadOnlyComposable - get() = MapUiSettings( - compassEnabled = false, - rotationGesturesEnabled = false, - scrollGesturesEnabled = true, - tiltGesturesEnabled = false, - zoomGesturesEnabled = true, - logoGravity = Gravity.TOP, - attributionGravity = Gravity.TOP, - attributionTintColor = ElementTheme.colors.iconPrimary - ) - - val symbolManagerSettings: MapSymbolManagerSettings - get() = MapSymbolManagerSettings( - iconAllowOverlap = true - ) - - val locationSettings: MapLocationSettings - get() = MapLocationSettings( - locationEnabled = false, - backgroundTintColor = Color.White, - foregroundTintColor = Color.Black, - backgroundStaleTintColor = Color.White, - foregroundStaleTintColor = Color.Black, - accuracyColor = Color.Black, - pulseEnabled = true, - pulseColor = Color.Black, - ) - - */ - - val centerCameraPosition = CameraPosition( - target = Position(49.843, 9.902056), - zoom = 2.7, + val defaultCameraPosition = CameraPosition( + target = Position(0.0, 0.0), + zoom = 0.0, ) const val DEFAULT_ZOOM = 15.0 diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt deleted file mode 100644 index 6817f579e5..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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 - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog -import io.element.android.libraries.ui.strings.CommonStrings - -@Composable -internal fun PermissionDeniedDialog( - onContinue: () -> Unit, - onDismiss: () -> Unit, - appName: String, -) { - ConfirmationDialog( - content = stringResource(CommonStrings.error_missing_location_auth_android, appName), - onSubmitClick = onContinue, - onDismiss = onDismiss, - submitText = stringResource(CommonStrings.action_continue), - cancelText = stringResource(CommonStrings.action_cancel), - ) -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt deleted file mode 100644 index 7aef07e32b..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector 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 - -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog -import io.element.android.libraries.ui.strings.CommonStrings - -@Composable -internal fun PermissionRationaleDialog( - onContinue: () -> Unit, - onDismiss: () -> Unit, - appName: String, -) { - ConfirmationDialog( - content = stringResource(CommonStrings.error_missing_location_rationale_android, appName), - onSubmitClick = onContinue, - onDismiss = onDismiss, - submitText = stringResource(CommonStrings.action_continue), - cancelText = stringResource(CommonStrings.action_cancel), - ) -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt index d69eb018e1..7994a6e6b1 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/AndroidLocationActions.kt @@ -43,7 +43,7 @@ class AndroidLocationActions( } } - override fun openSettings() { + override fun openAppSettings() { context.openAppSettingsPage() } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt index c4c5db40d0..bc8e558c55 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/actions/LocationActions.kt @@ -12,7 +12,7 @@ import io.element.android.features.location.api.Location interface LocationActions { fun share(location: Location, label: String?) - fun openSettings() + fun openAppSettings() fun isLocationEnabled(): Boolean fun openLocationSettings() } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt new file mode 100644 index 0000000000..d42a551254 --- /dev/null +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt @@ -0,0 +1,55 @@ +/* + * 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.res.stringResource +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +sealed interface LocationConstraintsDialogState { + data object None : LocationConstraintsDialogState + data object PermissionRationale : LocationConstraintsDialogState + data object PermissionDenied : LocationConstraintsDialogState + data object LocationServiceDisabled : LocationConstraintsDialogState +} + +@Composable +fun LocationConstraintsDialog( + state: LocationConstraintsDialogState, + appName: String, + onRequestPermissions: () -> Unit, + onOpenAppSettings: () -> Unit, + onOpenLocationSettings: () -> Unit, + onDismiss: () -> Unit, +) { + when (state) { + LocationConstraintsDialogState.None -> Unit + LocationConstraintsDialogState.PermissionRationale -> ConfirmationDialog( + content = stringResource(CommonStrings.error_missing_location_rationale_android, appName), + onSubmitClick = onRequestPermissions, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_continue), + cancelText = stringResource(CommonStrings.action_cancel), + ) + LocationConstraintsDialogState.PermissionDenied -> ConfirmationDialog( + content = stringResource(CommonStrings.error_missing_location_auth_android, appName), + onSubmitClick = onOpenAppSettings, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_continue), + cancelText = stringResource(CommonStrings.action_cancel), + ) + LocationConstraintsDialogState.LocationServiceDisabled -> ConfirmationDialog( + content = "Please enable your GPS to access location-based features.", + onSubmitClick = onOpenLocationSettings, + onDismiss = onDismiss, + submitText = stringResource(CommonStrings.action_continue), + cancelText = stringResource(CommonStrings.action_cancel), + ) + } +} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationServiceDisabledDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationServiceDisabledDialog.kt deleted file mode 100644 index 5184845632..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationServiceDisabledDialog.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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.res.stringResource -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog -import io.element.android.libraries.ui.strings.CommonStrings - -@Composable -internal fun LocationServiceDisabledDialog( - onContinue: () -> Unit, - onDismiss: () -> Unit, -) { - ConfirmationDialog( - content = "Location services are disabled. Please enable them in your device settings to use this feature.", - onSubmitClick = onContinue, - onDismiss = onDismiss, - submitText = stringResource(CommonStrings.action_continue), - cancelText = stringResource(CommonStrings.action_cancel), - ) -} diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt index 7e68ecf358..b95e86084b 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt @@ -20,10 +20,11 @@ sealed interface ShareLocationEvent { data object ShowLiveLocationDurationPicker : ShareLocationEvent data class StartLiveLocationShare(val duration: Duration) : ShareLocationEvent - data object StartTrackingUserPosition : ShareLocationEvent - data object StopTrackingUserPosition : ShareLocationEvent + data object StartTrackingUserLocation : ShareLocationEvent + data object StopTrackingUserLocation : ShareLocationEvent data object DismissDialog : ShareLocationEvent - data object RequestPermissions : ShareLocationEvent + + data object RequestPermissions: ShareLocationEvent data object OpenAppSettings : ShareLocationEvent data object OpenLocationSettings : ShareLocationEvent } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index df70cddacb..7ca8cbb7e3 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -21,11 +21,15 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.Composer +import io.element.android.features.location.impl.common.LocationConstraintsCheckResult import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.actions.LocationActions +import io.element.android.features.location.impl.common.checkLocationConstraints import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsState +import io.element.android.features.location.impl.common.toDialogState +import io.element.android.features.location.impl.share.ShareLocationState.Dialog.Constraints import io.element.android.features.messages.api.MessageComposerContext import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.extensions.flatMap @@ -63,7 +67,7 @@ class ShareLocationPresenter( @Composable override fun present(): ShareLocationState { val permissionsState: PermissionsState = permissionsPresenter.present() - var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted) } + var trackUserPosition: Boolean by remember { mutableStateOf(permissionsState.isAnyGranted && locationActions.isLocationEnabled()) } val isLiveLocationSharingEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.LiveLocationSharing) }.collectAsState(false) @@ -74,55 +78,46 @@ class ShareLocationPresenter( val currentUser by client.userProfile.collectAsState() val scope = rememberCoroutineScope() - LaunchedEffect(permissionsState.permissions) { - if (permissionsState.isAnyGranted) { - trackUserPosition = true - dialogState = ShareLocationState.Dialog.None - } + fun checkLocationConstraints() { + val locationConstraints = checkLocationConstraints(permissionsState, locationActions) + dialogState = Constraints(locationConstraints.toDialogState()) + trackUserPosition = locationConstraints is LocationConstraintsCheckResult.Success } + LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() } + fun handleEvent(event: ShareLocationEvent) { when (event) { is ShareLocationEvent.ShareStaticLocation -> scope.launch { shareStaticLocation(event) } - ShareLocationEvent.StartTrackingUserPosition -> when { - permissionsState.isAnyGranted -> { - if (!locationActions.isLocationEnabled()) { - dialogState = ShareLocationState.Dialog.LocationServiceDisabled - } else { - trackUserPosition = true - } - } - permissionsState.shouldShowRationale -> dialogState = ShareLocationState.Dialog.PermissionRationale - else -> dialogState = ShareLocationState.Dialog.PermissionDenied - } - ShareLocationEvent.StopTrackingUserPosition -> trackUserPosition = false + ShareLocationEvent.StartTrackingUserLocation -> checkLocationConstraints() + ShareLocationEvent.StopTrackingUserLocation -> trackUserPosition = false ShareLocationEvent.DismissDialog -> dialogState = ShareLocationState.Dialog.None ShareLocationEvent.OpenAppSettings -> { - locationActions.openSettings() + locationActions.openAppSettings() dialogState = ShareLocationState.Dialog.None } ShareLocationEvent.OpenLocationSettings -> { locationActions.openLocationSettings() dialogState = ShareLocationState.Dialog.None } - ShareLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) - ShareLocationEvent.ShowLiveLocationDurationPicker -> dialogState = when { - permissionsState.isAnyGranted -> { - if (!locationActions.isLocationEnabled()) { - ShareLocationState.Dialog.LocationServiceDisabled - } else { - ShareLocationState.Dialog.LiveLocationDuration - } + ShareLocationEvent.ShowLiveLocationDurationPicker -> { + val constraintsResult = checkLocationConstraints(permissionsState, locationActions) + dialogState = if (constraintsResult is LocationConstraintsCheckResult.Success) { + ShareLocationState.Dialog.LiveLocationDuration + } else { + Constraints(constraintsResult.toDialogState()) } - permissionsState.shouldShowRationale -> ShareLocationState.Dialog.PermissionRationale - else -> ShareLocationState.Dialog.PermissionDenied } is ShareLocationEvent.StartLiveLocationShare -> scope.launch { dialogState = ShareLocationState.Dialog.None //room.startLiveLocationShare(event.duration.inWholeMilliseconds) } + ShareLocationEvent.RequestPermissions -> { + dialogState = ShareLocationState.Dialog.None + permissionsState.eventSink(PermissionsEvents.RequestPermissions) + } } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index c204357a7a..72f94d5b06 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -8,6 +8,7 @@ package io.element.android.features.location.impl.share +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.matrix.api.user.MatrixUser data class ShareLocationState( @@ -21,9 +22,7 @@ data class ShareLocationState( ) { sealed interface Dialog { data object None : Dialog - data object PermissionRationale : Dialog - data object PermissionDenied : Dialog - data object LocationServiceDisabled : Dialog + data class Constraints(val state: LocationConstraintsDialogState) : Dialog data object LiveLocationDuration : Dialog } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index 71b143fcef..768d1a5d50 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.impl.share import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser @@ -18,37 +19,37 @@ class ShareLocationStateProvider : PreviewParameterProvider override val values: Sequence get() = sequenceOf( aShareLocationState( - permissionDialog = ShareLocationState.Dialog.None, + dialogState = ShareLocationState.Dialog.None, trackUserPosition = false, hasLocationPermission = false, ), aShareLocationState( - permissionDialog = ShareLocationState.Dialog.PermissionDenied, + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied), trackUserPosition = false, hasLocationPermission = false, ), aShareLocationState( - permissionDialog = ShareLocationState.Dialog.PermissionRationale, + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale), trackUserPosition = false, hasLocationPermission = false, ), aShareLocationState( - permissionDialog = ShareLocationState.Dialog.LocationServiceDisabled, + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled), trackUserPosition = false, hasLocationPermission = true, ), aShareLocationState( - permissionDialog = ShareLocationState.Dialog.None, + dialogState = ShareLocationState.Dialog.None, trackUserPosition = false, hasLocationPermission = true, ), aShareLocationState( - permissionDialog = ShareLocationState.Dialog.None, + dialogState = ShareLocationState.Dialog.None, trackUserPosition = true, hasLocationPermission = true, ), aShareLocationState( - permissionDialog = ShareLocationState.Dialog.LiveLocationDuration, + dialogState = ShareLocationState.Dialog.LiveLocationDuration, trackUserPosition = true, hasLocationPermission = true, canShareLiveLocation = true, @@ -58,14 +59,14 @@ class ShareLocationStateProvider : PreviewParameterProvider private fun aShareLocationState( currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), - permissionDialog: ShareLocationState.Dialog, + dialogState: ShareLocationState.Dialog, trackUserPosition: Boolean, hasLocationPermission: Boolean, canShareLiveLocation: Boolean = false, ): ShareLocationState { return ShareLocationState( currentUser = currentUser, - dialogState = permissionDialog, + dialogState = dialogState, trackUserLocation = trackUserPosition, hasLocationPermission = hasLocationPermission, canShareLiveLocation = canShareLiveLocation, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 6fb649a59a..e5fa9d8c0c 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -35,10 +35,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.location.api.Location import io.element.android.features.location.api.internal.centerBottomEdge import io.element.android.features.location.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.features.location.impl.common.ui.LocationConstraintsDialog import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton -import io.element.android.features.location.impl.common.ui.LocationServiceDisabledDialog import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold import io.element.android.features.location.impl.common.ui.UserLocationPuck import io.element.android.features.location.impl.common.ui.rememberUserLocationState @@ -58,7 +56,6 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.ui.strings.CommonStrings 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.UserLocationState @@ -71,24 +68,14 @@ fun ShareLocationView( navigateUp: () -> Unit, modifier: Modifier = Modifier, ) { - LaunchedEffect(Unit) { - state.eventSink(ShareLocationEvent.RequestPermissions) - } - - when (state.dialogState) { + when (val dialogState = state.dialogState) { ShareLocationState.Dialog.None -> Unit - ShareLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( - onContinue = { state.eventSink(ShareLocationEvent.OpenAppSettings) }, - onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, + is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog( + state = dialogState.state, appName = state.appName, - ) - ShareLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog( - onContinue = { state.eventSink(ShareLocationEvent.RequestPermissions) }, - onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, - appName = state.appName, - ) - ShareLocationState.Dialog.LocationServiceDisabled -> LocationServiceDisabledDialog( - onContinue = { state.eventSink(ShareLocationEvent.OpenLocationSettings) }, + onRequestPermissions = { state.eventSink(ShareLocationEvent.RequestPermissions) }, + onOpenAppSettings = { state.eventSink(ShareLocationEvent.OpenAppSettings) }, + onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) }, onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, ) ShareLocationState.Dialog.LiveLocationDuration -> LiveLocationDurationDialog( @@ -103,12 +90,12 @@ fun ShareLocationView( val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberStandardBottomSheetState(initialValue = SheetValue.Expanded) ) - val cameraState = rememberCameraState(firstPosition = CameraPosition(zoom = MapDefaults.DEFAULT_ZOOM)) + val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition) val userLocationState = rememberUserLocationState(state.hasLocationPermission) LaunchedEffect(cameraState.isCameraMoving) { if (cameraState.moveReason == CameraMoveReason.GESTURE) { - state.eventSink(ShareLocationEvent.StopTrackingUserPosition) + state.eventSink(ShareLocationEvent.StopTrackingUserLocation) } } @@ -159,7 +146,7 @@ fun ShareLocationView( } LocationFloatingActionButton( isMapCenteredOnUser = state.trackUserLocation, - onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserPosition) }, + onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserLocation) }, modifier = Modifier .align(Alignment.TopEnd) .padding(all = 16.dp), @@ -176,39 +163,34 @@ private fun BottomSheetContent( navigateUp: () -> Unit, ) { Spacer(Modifier.height(20.dp)) - SharePinLocationItem( - onClick = { - val positionTarget = cameraState.position.target + val userLocation = userLocationState.location + if (state.trackUserLocation && userLocation != null) { + ShareCurrentLocationItem { state.eventSink( ShareLocationEvent.ShareStaticLocation( - location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude), - isPinned = true + location = Location( + lat = userLocation.position.latitude, + lon = userLocation.position.longitude + ), + isPinned = false ) ) 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 - ) + } else { + SharePinLocationItem( + onClick = { + val positionTarget = cameraState.position.target + state.eventSink( + ShareLocationEvent.ShareStaticLocation( + location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude), + isPinned = true ) - navigateUp() - } + ) + navigateUp() } - } - ) + ) + } if (state.canShareLiveLocation) { ShareLiveLocationItem { state.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) 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 2fe40af256..c48c2c27d2 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 @@ -19,11 +19,15 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.location.api.ShowLocationMode +import io.element.android.features.location.impl.common.LocationConstraintsCheckResult +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.actions.LocationActions +import io.element.android.features.location.impl.common.checkLocationConstraints import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsState +import io.element.android.features.location.impl.common.toDialogState 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 @@ -54,13 +58,13 @@ class ShowLocationPresenter( val permissionsState: PermissionsState = permissionsPresenter.present() var isTrackMyLocation by remember { mutableStateOf(false) } val appName by remember { derivedStateOf { buildMeta.applicationName } } - var permissionDialog: ShowLocationState.Dialog by remember { - mutableStateOf(ShowLocationState.Dialog.None) + var dialogState: LocationConstraintsDialogState by remember { + mutableStateOf(LocationConstraintsDialogState.None) } LaunchedEffect(permissionsState.permissions) { if (permissionsState.isAnyGranted) { - permissionDialog = ShowLocationState.Dialog.None + dialogState = LocationConstraintsDialogState.None } } @@ -71,29 +75,21 @@ class ShowLocationPresenter( } is ShowLocationEvents.TrackMyLocation -> { if (event.enabled) { - when { - permissionsState.isAnyGranted -> { - if (!locationActions.isLocationEnabled()) { - permissionDialog = ShowLocationState.Dialog.LocationServiceDisabled - } else { - isTrackMyLocation = true - } - } - permissionsState.shouldShowRationale -> permissionDialog = ShowLocationState.Dialog.PermissionRationale - else -> permissionDialog = ShowLocationState.Dialog.PermissionDenied - } + val locationConstraints = checkLocationConstraints(permissionsState, locationActions) + isTrackMyLocation = locationConstraints is LocationConstraintsCheckResult.Success + dialogState = locationConstraints.toDialogState() } else { isTrackMyLocation = false } } - ShowLocationEvents.DismissDialog -> permissionDialog = ShowLocationState.Dialog.None + ShowLocationEvents.DismissDialog -> dialogState = LocationConstraintsDialogState.None ShowLocationEvents.OpenAppSettings -> { - locationActions.openSettings() - permissionDialog = ShowLocationState.Dialog.None + locationActions.openAppSettings() + dialogState = LocationConstraintsDialogState.None } ShowLocationEvents.OpenLocationSettings -> { locationActions.openLocationSettings() - permissionDialog = ShowLocationState.Dialog.None + dialogState = LocationConstraintsDialogState.None } ShowLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) } @@ -154,7 +150,7 @@ class ShowLocationPresenter( } return ShowLocationState( - permissionDialog = permissionDialog, + dialogState = dialogState, mode = mode, markers = markers, locationShares = locationShares, 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 2f66dcb26c..3a8fcf51de 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 @@ -10,13 +10,14 @@ 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.LocationConstraintsDialogState 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 dialogState: LocationConstraintsDialogState, val mode: ShowLocationMode, val markers: List, val locationShares: List, @@ -25,15 +26,7 @@ data class ShowLocationState( 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 object LocationServiceDisabled : Dialog - } } data class LocationShareItem( 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 a28edf11d0..6880ed1bf5 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 @@ -11,6 +11,7 @@ package io.element.android.features.location.impl.show import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationMarkerData import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.avatar.AvatarData @@ -25,13 +26,13 @@ class ShowLocationStateProvider : PreviewParameterProvider { get() = sequenceOf( aShowLocationState(), aShowLocationState( - permissionDialog = ShowLocationState.Dialog.PermissionDenied, + constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, ), aShowLocationState( - permissionDialog = ShowLocationState.Dialog.PermissionRationale, + constraintsDialogState = LocationConstraintsDialogState.PermissionRationale, ), aShowLocationState( - permissionDialog = ShowLocationState.Dialog.LocationServiceDisabled, + constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled, hasLocationPermission = true, ), aShowLocationState( @@ -41,22 +42,11 @@ class ShowLocationStateProvider : PreviewParameterProvider { hasLocationPermission = true, isTrackMyLocation = true, ), - aShowLocationState( - mode = aStaticLocationMode(senderName = "My favourite place!"), - ), - aShowLocationState( - mode = aStaticLocationMode( - senderName = "For some reason I decided to write a small essay that wraps at just two lines!" - ), - ), - aShowLocationState( - mode = ShowLocationMode.Live, - ), ) } fun aShowLocationState( - permissionDialog: ShowLocationState.Dialog = ShowLocationState.Dialog.None, + constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, mode: ShowLocationMode = aStaticLocationMode(), markers: List? = null, locationSharers: List? = null, @@ -107,7 +97,7 @@ fun aShowLocationState( ShowLocationMode.Live -> emptyList() } return ShowLocationState( - permissionDialog = permissionDialog, + dialogState = constraintsDialogState, mode = mode, markers = effectiveMarkers, locationShares = effectiveLocationSharers, 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 eec67ad956..3d3dc6f5c6 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 @@ -27,10 +27,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.location.api.ShowLocationMode -import io.element.android.features.location.impl.common.ui.LocationServiceDisabledDialog +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog 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.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 @@ -56,30 +54,21 @@ fun ShowLocationView( onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - when (state.permissionDialog) { - ShowLocationState.Dialog.None -> Unit - ShowLocationState.Dialog.PermissionDenied -> PermissionDeniedDialog( - onContinue = { state.eventSink(ShowLocationEvents.OpenAppSettings) }, - onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, - appName = state.appName, - ) - ShowLocationState.Dialog.PermissionRationale -> PermissionRationaleDialog( - onContinue = { state.eventSink(ShowLocationEvents.RequestPermissions) }, - onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, - appName = state.appName, - ) - ShowLocationState.Dialog.LocationServiceDisabled -> LocationServiceDisabledDialog( - onContinue = { state.eventSink(ShowLocationEvents.OpenLocationSettings) }, - onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, - ) - } + LocationConstraintsDialog( + state = state.dialogState, + appName = state.appName, + onRequestPermissions = { state.eventSink(ShowLocationEvents.RequestPermissions) }, + onOpenAppSettings = { state.eventSink(ShowLocationEvents.OpenAppSettings) }, + onOpenLocationSettings = { state.eventSink(ShowLocationEvents.OpenLocationSettings) }, + onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, + ) val initialPosition = when (val mode = state.mode) { is ShowLocationMode.Static -> CameraPosition( target = Position(latitude = mode.location.lat, longitude = mode.location.lon), zoom = MapDefaults.DEFAULT_ZOOM ) - ShowLocationMode.Live -> MapDefaults.centerCameraPosition + ShowLocationMode.Live -> MapDefaults.defaultCameraPosition } val cameraState = rememberCameraState(firstPosition = initialPosition) val userLocationState = rememberUserLocationState(state.hasLocationPermission) diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt new file mode 100644 index 0000000000..801d58e5da --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt @@ -0,0 +1,79 @@ +/* + * 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 + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.location.impl.aPermissionsState +import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.PermissionsState +import org.junit.Test + +class LocationConstraintsCheckTest { + @Test + fun `checkLocationConstraints returns Success when permissions granted and location enabled`() { + val permissionsState = aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + ) + val locationActions = FakeLocationActions(isLocationEnabled = true) + + val result = checkLocationConstraints(permissionsState, locationActions) + + assertThat(result).isEqualTo(LocationConstraintsCheckResult.Success) + } + + @Test + fun `checkLocationConstraints returns Success when some permissions granted and location enabled`() { + val permissionsState = aPermissionsState( + permissions = PermissionsState.Permissions.SomeGranted, + ) + val locationActions = FakeLocationActions(isLocationEnabled = true) + + val result = checkLocationConstraints(permissionsState, locationActions) + + assertThat(result).isEqualTo(LocationConstraintsCheckResult.Success) + } + + @Test + fun `checkLocationConstraints returns LocationServiceDisabled when permissions granted but location disabled`() { + val permissionsState = aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + ) + val locationActions = FakeLocationActions(isLocationEnabled = false) + + val result = checkLocationConstraints(permissionsState, locationActions) + + assertThat(result).isEqualTo(LocationConstraintsCheckResult.LocationServiceDisabled) + } + + @Test + fun `checkLocationConstraints returns PermissionRationale when permissions denied with rationale`() { + val permissionsState = aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = true, + ) + val locationActions = FakeLocationActions(isLocationEnabled = true) + + val result = checkLocationConstraints(permissionsState, locationActions) + + assertThat(result).isEqualTo(LocationConstraintsCheckResult.PermissionRationale) + } + + @Test + fun `checkLocationConstraints returns PermissionDenied when permissions denied without rationale`() { + val permissionsState = aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + val locationActions = FakeLocationActions(isLocationEnabled = true) + + val result = checkLocationConstraints(permissionsState, locationActions) + + assertThat(result).isEqualTo(LocationConstraintsCheckResult.PermissionDenied) + } + +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt index 795e36fa1a..e05787d6a6 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/actions/FakeLocationActions.kt @@ -30,7 +30,7 @@ class FakeLocationActions( sharedLabel = label } - override fun openSettings() { + override fun openAppSettings() { openSettingsInvocationsCount++ } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt index b6161b3a9c..100e660820 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt @@ -14,7 +14,9 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.services.analytics.test.FakeAnalyticsService @@ -42,6 +44,8 @@ class DefaultShareLocationEntryPointTest { messageComposerContext = FakeMessageComposerContext(), locationActions = FakeLocationActions(), buildMeta = aBuildMeta(), + featureFlagService = FakeFeatureFlagService(), + client = FakeMatrixClient(), ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 8d34fb99d6..32d862893a 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -228,7 +228,7 @@ class ShareLocationPresenterTest { assertThat(myLocationState.hasLocationPermission).isFalse() // Continue the dialog sends permission request to the permissions presenter - myLocationState.eventSink(ShareLocationEvent.RequestPermissions) + myLocationState.eventSink(ShareLocationEvent.StartTrackingUserLocation) assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index df22863c7c..ebbad8e9d1 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -13,12 +13,15 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.location.api.Location +import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.aPermissionsState +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsEvents -import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsState +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.delay @@ -33,15 +36,25 @@ class ShowLocationPresenterTest { private val fakePermissionsPresenter = FakePermissionsPresenter() private val fakeLocationActions = FakeLocationActions() private val fakeBuildMeta = aBuildMeta(applicationName = "app name") + private val fakeDateFormatter = FakeDateFormatter() private val location = Location(1.23, 4.56, 7.8f) - private val presenter = ShowLocationPresenter( - permissionsPresenterFactory = object : PermissionsPresenter.Factory { - override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter - }, - locationActions = fakeLocationActions, + + private fun createShowLocationPresenter( + mode: ShowLocationMode = ShowLocationMode.Static( + location = location, + senderName = "Alice", + senderId = UserId("@alice:matrix.org"), + senderAvatarUrl = null, + timestamp = System.currentTimeMillis(), + assetType = null, + ), + locationActions: FakeLocationActions = fakeLocationActions, + ) = ShowLocationPresenter( + mode = mode, + permissionsPresenterFactory = { fakePermissionsPresenter }, + locationActions = locationActions, buildMeta = fakeBuildMeta, - location = location, - description = A_DESCRIPTION, + dateFormatter = fakeDateFormatter, ) @Test @@ -54,11 +67,9 @@ class ShowLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - presenter.present() + createShowLocationPresenter().present() }.test { val initialState = awaitItem() - assertThat(initialState.location).isEqualTo(location) - assertThat(initialState.description).isEqualTo(A_DESCRIPTION) assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.isTrackMyLocation).isFalse() } @@ -74,11 +85,9 @@ class ShowLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - presenter.present() + createShowLocationPresenter().present() }.test { val initialState = awaitItem() - assertThat(initialState.location).isEqualTo(location) - assertThat(initialState.description).isEqualTo(A_DESCRIPTION) assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.isTrackMyLocation).isFalse() } @@ -89,11 +98,9 @@ class ShowLocationPresenterTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) moleculeFlow(RecompositionMode.Immediate) { - presenter.present() + createShowLocationPresenter().present() }.test { val initialState = awaitItem() - assertThat(initialState.location).isEqualTo(location) - assertThat(initialState.description).isEqualTo(A_DESCRIPTION) assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.isTrackMyLocation).isFalse() } @@ -104,11 +111,9 @@ class ShowLocationPresenterTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) moleculeFlow(RecompositionMode.Immediate) { - presenter.present() + createShowLocationPresenter().present() }.test { val initialState = awaitItem() - assertThat(initialState.location).isEqualTo(location) - assertThat(initialState.description).isEqualTo(A_DESCRIPTION) assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.isTrackMyLocation).isFalse() } @@ -117,13 +122,12 @@ class ShowLocationPresenterTest { @Test fun `uses action to share location`() = runTest { moleculeFlow(RecompositionMode.Immediate) { - presenter.present() + createShowLocationPresenter().present() }.test { val initialState = awaitItem() - initialState.eventSink(ShowLocationEvents.Share) + initialState.eventSink(ShowLocationEvents.Share(location)) assertThat(fakeLocationActions.sharedLocation).isEqualTo(location) - assertThat(fakeLocationActions.sharedLabel).isEqualTo(A_DESCRIPTION) } } @@ -132,7 +136,7 @@ class ShowLocationPresenterTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) moleculeFlow(RecompositionMode.Immediate) { - presenter.present() + createShowLocationPresenter().present() }.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isTrue() @@ -149,7 +153,7 @@ class ShowLocationPresenterTest { // Swipe the map to switch mode initialState.eventSink(ShowLocationEvents.TrackMyLocation(false)) val trackLocationDisabledState = awaitItem() - assertThat(trackLocationDisabledState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) + assertThat(trackLocationDisabledState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse() assertThat(trackLocationDisabledState.hasLocationPermission).isTrue() } @@ -165,7 +169,7 @@ class ShowLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - presenter.present() + createShowLocationPresenter().present() }.test { // Skip initial state val initialState = awaitItem() @@ -173,14 +177,14 @@ class ShowLocationPresenterTest { // Click on the button to switch mode initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) val trackLocationState = awaitItem() - assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale) + assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale) assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse() // Dismiss the dialog initialState.eventSink(ShowLocationEvents.DismissDialog) val dialogDismissedState = awaitItem() - assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) + assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(dialogDismissedState.isTrackMyLocation).isFalse() assertThat(dialogDismissedState.hasLocationPermission).isFalse() } @@ -196,7 +200,7 @@ class ShowLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - presenter.present() + createShowLocationPresenter().present() }.test { // Skip initial state val initialState = awaitItem() @@ -204,7 +208,7 @@ class ShowLocationPresenterTest { // Click on the button to switch mode initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) val trackLocationState = awaitItem() - assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionRationale) + assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale) assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse() @@ -224,7 +228,7 @@ class ShowLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - presenter.present() + createShowLocationPresenter().present() }.test { // Skip initial state val initialState = awaitItem() @@ -232,14 +236,14 @@ class ShowLocationPresenterTest { // Click on the button to switch mode initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) val trackLocationState = awaitItem() - assertThat(trackLocationState.permissionDialog).isEqualTo(ShowLocationState.Dialog.PermissionDenied) + assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionDenied) assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse() // Dismiss the dialog initialState.eventSink(ShowLocationEvents.DismissDialog) val dialogDismissedState = awaitItem() - assertThat(dialogDismissedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) + assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(dialogDismissedState.isTrackMyLocation).isFalse() assertThat(dialogDismissedState.hasLocationPermission).isFalse() } @@ -255,7 +259,7 @@ class ShowLocationPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { - presenter.present() + createShowLocationPresenter().present() }.test { // Skip initial state val initialState = awaitItem() @@ -267,7 +271,7 @@ class ShowLocationPresenterTest { dialogShownState.eventSink(ShowLocationEvents.OpenAppSettings) val settingsOpenedState = awaitItem() - assertThat(settingsOpenedState.permissionDialog).isEqualTo(ShowLocationState.Dialog.None) + assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) } } @@ -275,14 +279,53 @@ class ShowLocationPresenterTest { @Test fun `application name is in state`() = runTest { moleculeFlow(RecompositionMode.Immediate) { - presenter.present() + createShowLocationPresenter().present() }.test { val initialState = awaitItem() assertThat(initialState.appName).isEqualTo("app name") } } - companion object { - private const val A_DESCRIPTION = "My happy place" + @Test + fun `location service disabled shows dialog`() = runTest { + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + fakeLocationActions.givenLocationEnabled(false) + + moleculeFlow(RecompositionMode.Immediate) { + createShowLocationPresenter(locationActions = fakeLocationActions).present() + }.test { + val initialState = awaitItem() + assertThat(initialState.hasLocationPermission).isTrue() + + // Try to track location when location services are disabled + initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + val dialogShownState = awaitItem() + + assertThat(dialogShownState.dialogState).isEqualTo(LocationConstraintsDialogState.LocationServiceDisabled) + assertThat(dialogShownState.isTrackMyLocation).isFalse() + } + } + + @Test + fun `open location settings from dialog`() = runTest { + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + fakeLocationActions.givenLocationEnabled(false) + + moleculeFlow(RecompositionMode.Immediate) { + createShowLocationPresenter(locationActions = fakeLocationActions).present() + }.test { + val initialState = awaitItem() + + initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + val dialogShownState = awaitItem() + assertThat(dialogShownState.dialogState).isEqualTo(LocationConstraintsDialogState.LocationServiceDisabled) + + // Open location settings + dialogShownState.eventSink(ShowLocationEvents.OpenLocationSettings) + val settingsOpenedState = awaitItem() + + assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) + assertThat(fakeLocationActions.openLocationSettingsInvocationsCount).isEqualTo(1) + } } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt index 2245360bb2..a70d3441c4 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt @@ -17,6 +17,8 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.location.api.Location +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled @@ -58,7 +60,8 @@ class ShowLocationViewTest { ) val shareContentDescription = rule.activity.getString(CommonStrings.action_share) rule.onNodeWithContentDescription(shareContentDescription).performClick() - eventsRecorder.assertSingle(ShowLocationEvents.Share) + // The default aStaticLocationMode uses Location(1.23, 2.34, 4f) + eventsRecorder.assertSingle(ShowLocationEvents.Share(Location(1.23, 2.34, 4f))) } @Test @@ -79,7 +82,7 @@ class ShowLocationViewTest { val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( - permissionDialog = ShowLocationState.Dialog.PermissionDenied, + constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, eventSink = eventsRecorder ), onBackClick = EnsureNeverCalled(), @@ -93,7 +96,7 @@ class ShowLocationViewTest { val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( - permissionDialog = ShowLocationState.Dialog.PermissionDenied, + constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, eventSink = eventsRecorder ), onBackClick = EnsureNeverCalled(), @@ -107,7 +110,7 @@ class ShowLocationViewTest { val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( - permissionDialog = ShowLocationState.Dialog.PermissionRationale, + constraintsDialogState = LocationConstraintsDialogState.PermissionRationale, eventSink = eventsRecorder ), onBackClick = EnsureNeverCalled(), @@ -121,7 +124,7 @@ class ShowLocationViewTest { val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( - permissionDialog = ShowLocationState.Dialog.PermissionRationale, + constraintsDialogState = LocationConstraintsDialogState.PermissionRationale, eventSink = eventsRecorder ), onBackClick = EnsureNeverCalled(), From 8274ef220d145e7b22e6510fc19a4c763240a355 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 12 Mar 2026 12:31:51 +0100 Subject: [PATCH 33/52] Fix and add tests related to location --- .../impl/share/ShareLocationStateProvider.kt | 14 +- .../impl/share/ShareLocationPresenterTest.kt | 435 ++++++++---------- .../impl/share/ShareLocationViewTest.kt | 163 +++++++ .../show/DefaultShowLocationEntryPointTest.kt | 21 +- .../impl/show/ShowLocationPresenterTest.kt | 62 +-- 5 files changed, 404 insertions(+), 291 deletions(-) create mode 100644 features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index 768d1a5d50..efc7fdc8a1 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -57,12 +57,14 @@ class ShareLocationStateProvider : PreviewParameterProvider ) } -private fun aShareLocationState( +fun aShareLocationState( currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), - dialogState: ShareLocationState.Dialog, - trackUserPosition: Boolean, - hasLocationPermission: Boolean, + dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None, + trackUserPosition: Boolean = false, + hasLocationPermission: Boolean = false, canShareLiveLocation: Boolean = false, + appName: String = APP_NAME, + eventSink: (ShareLocationEvent) -> Unit = {}, ): ShareLocationState { return ShareLocationState( currentUser = currentUser, @@ -70,7 +72,7 @@ private fun aShareLocationState( trackUserLocation = trackUserPosition, hasLocationPermission = hasLocationPermission, canShareLiveLocation = canShareLiveLocation, - appName = APP_NAME, - eventSink = {} + appName = appName, + eventSink = eventSink ) } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 32d862893a..b9311188d2 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -20,22 +20,25 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsState +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.Timeline -import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId -import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.room.FakeJoinedRoom import io.element.android.libraries.matrix.test.timeline.FakeTimeline -import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -49,9 +52,12 @@ class ShareLocationPresenterTest { private val fakeMessageComposerContext = FakeMessageComposerContext() private val fakeLocationActions = FakeLocationActions() private val fakeBuildMeta = aBuildMeta(applicationName = "app name") + private val fakeFeatureFlagService = FakeFeatureFlagService() + private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID) private fun createShareLocationPresenter( joinedRoom: JoinedRoom = FakeJoinedRoom(), + locationActions: FakeLocationActions = fakeLocationActions, ): ShareLocationPresenter = ShareLocationPresenter( permissionsPresenterFactory = object : PermissionsPresenter.Factory { override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter @@ -60,13 +66,14 @@ class ShareLocationPresenterTest { timelineMode = Timeline.Mode.Live, analyticsService = fakeAnalyticsService, messageComposerContext = fakeMessageComposerContext, - locationActions = fakeLocationActions, + locationActions = locationActions, buildMeta = fakeBuildMeta, + featureFlagService = fakeFeatureFlagService, + client = fakeMatrixClient, ) @Test - fun `initial state with permissions granted`() = runTest { - val shareLocationPresenter = createShareLocationPresenter() + fun `initial state with permissions granted and location enabled`() = runTest { fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.AllGranted, @@ -74,25 +81,18 @@ class ShareLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.SenderLocation) - assertThat(initialState.hasLocationPermission).isTrue() - - // Swipe the map to switch mode - initialState.eventSink(ShareLocationEvent.SwitchToPinLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isTrue() + val shareLocationPresenter = createShareLocationPresenter() + shareLocationPresenter.test { + skipItems(1) + val state = awaitItem() + assertThat(state.trackUserLocation).isTrue() + assertThat(state.hasLocationPermission).isTrue() + assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None)) } } @Test - fun `initial state with permissions partially granted`() = runTest { + fun `initial state with permissions partially granted and location enabled`() = runTest { val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( @@ -104,17 +104,11 @@ class ShareLocationPresenterTest { moleculeFlow(RecompositionMode.Immediate) { shareLocationPresenter.present() }.test { + skipItems(1) val initialState = awaitItem() - assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.SenderLocation) + assertThat(initialState.trackUserLocation).isTrue() assertThat(initialState.hasLocationPermission).isTrue() - - // Swipe the map to switch mode - initialState.eventSink(ShareLocationEvent.SwitchToPinLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isTrue() + assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None)) } } @@ -131,22 +125,18 @@ class ShareLocationPresenterTest { moleculeFlow(RecompositionMode.Immediate) { shareLocationPresenter.present() }.test { + skipItems(1) val initialState = awaitItem() - assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) + assertThat(initialState.trackUserLocation).isFalse() assertThat(initialState.hasLocationPermission).isFalse() - - // Click on the button to switch mode - initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionDenied) - assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isFalse() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied) + ) } } @Test - fun `initial state with permissions denied once`() = runTest { + fun `initial state with permissions denied with rationale`() = runTest { val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( @@ -155,25 +145,62 @@ class ShareLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { + shareLocationPresenter.test { + skipItems(1) val initialState = awaitItem() - assertThat(initialState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(initialState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) + assertThat(initialState.trackUserLocation).isFalse() assertThat(initialState.hasLocationPermission).isFalse() - - // Click on the button to switch mode - initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionRationale) - assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isFalse() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale) + ) } } @Test - fun `rationale dialog dismiss`() = runTest { + fun `initial state with location services disabled`() = runTest { + val locationActions = FakeLocationActions(isLocationEnabled = false) + val shareLocationPresenter = createShareLocationPresenter(locationActions = locationActions) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.trackUserLocation).isFalse() + assertThat(initialState.hasLocationPermission).isTrue() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled) + ) + } + } + + @Test + fun `StopTrackingUserLocation event sets trackUserLocation to false`() = runTest { + val shareLocationPresenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.trackUserLocation).isTrue() + + initialState.eventSink(ShareLocationEvent.StopTrackingUserLocation) + val stoppedState = awaitItem() + assertThat(stoppedState.trackUserLocation).isFalse() + } + } + + @Test + fun `DismissDialog event clears dialog state`() = runTest { val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( @@ -182,30 +209,21 @@ class ShareLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - // Skip initial state + shareLocationPresenter.test { + skipItems(1) val initialState = awaitItem() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale) + ) - // Click on the button to switch mode - initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionRationale) - assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isFalse() - - // Dismiss the dialog - myLocationState.eventSink(ShareLocationEvent.DismissDialog) - val dialogDismissedState = awaitItem() - assertThat(dialogDismissedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(dialogDismissedState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) - assertThat(dialogDismissedState.hasLocationPermission).isFalse() + initialState.eventSink(ShareLocationEvent.DismissDialog) + val dismissedState = awaitItem() + assertThat(dismissedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) } } @Test - fun `rationale dialog continue`() = runTest { + fun `RequestPermissions event triggers permission request`() = runTest { val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( @@ -214,27 +232,20 @@ class ShareLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - // Skip initial state + shareLocationPresenter.test { val initialState = awaitItem() + initialState.eventSink(ShareLocationEvent.RequestPermissions) - // Click on the button to switch mode - initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionRationale) - assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isFalse() + // Wait for dialog to be dismissed + awaitItem() - // Continue the dialog sends permission request to the permissions presenter - myLocationState.eventSink(ShareLocationEvent.StartTrackingUserLocation) assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) + cancelAndIgnoreRemainingEvents() } } @Test - fun `permission denied dialog dismiss`() = runTest { + fun `OpenAppSettings event opens settings and clears dialog`() = runTest { val shareLocationPresenter = createShareLocationPresenter() fakePermissionsPresenter.givenState( aPermissionsState( @@ -243,31 +254,94 @@ class ShareLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - // Skip initial state + shareLocationPresenter.test { + skipItems(1) val initialState = awaitItem() + initialState.eventSink(ShareLocationEvent.OpenAppSettings) + val settingsOpenedState = awaitItem() - // Click on the button to switch mode - initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) - val myLocationState = awaitItem() - assertThat(myLocationState.dialogState).isEqualTo(ShareLocationState.Dialog.PermissionDenied) - assertThat(myLocationState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) - assertThat(myLocationState.hasLocationPermission).isFalse() - - // Dismiss the dialog - myLocationState.eventSink(ShareLocationEvent.DismissDialog) - val dialogDismissedState = awaitItem() - assertThat(dialogDismissedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(dialogDismissedState.mode).isEqualTo(ShareLocationState.Mode.PinLocation) - assertThat(dialogDismissedState.hasLocationPermission).isFalse() + assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) + assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) } } @Test - fun `share sender location`() = runTest { - val sendLocationResult = lambdaRecorder> { _, _, _, _, _, _ -> + fun `OpenLocationSettings event opens location settings and clears dialog`() = runTest { + val locationActions = FakeLocationActions(isLocationEnabled = false) + val shareLocationPresenter = createShareLocationPresenter(locationActions = locationActions) + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled) + ) + + initialState.eventSink(ShareLocationEvent.OpenLocationSettings) + val settingsOpenedState = awaitItem() + + assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) + assertThat(locationActions.openLocationSettingsInvocationsCount).isEqualTo(1) + } + } + + @Test + fun `ShowLiveLocationDurationPicker shows duration dialog when constraints pass`() = runTest { + val shareLocationPresenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.AllGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) + val durationDialogState = awaitItem() + + assertThat(durationDialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDuration) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `ShowLiveLocationDurationPicker shows constraint dialog when permissions denied`() = runTest { + val shareLocationPresenter = createShareLocationPresenter() + fakePermissionsPresenter.givenState( + aPermissionsState( + permissions = PermissionsState.Permissions.NoneGranted, + shouldShowRationale = false, + ) + ) + + shareLocationPresenter.test { + skipItems(1) + val initialState = awaitItem() + // Dismiss initial dialog + initialState.eventSink(ShareLocationEvent.DismissDialog) + val dismissedState = awaitItem() + + dismissedState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) + val constraintDialogState = awaitItem() + + assertThat(constraintDialogState.dialogState).isEqualTo( + ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied) + ) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `ShareStaticLocation sends user location`() = runTest { + val sendLocationResult = lambdaRecorder { _: String, _: String, _: String?, _: Int?, _: AssetType?, _: EventId? -> Result.success(Unit) } val joinedRoom = FakeJoinedRoom( @@ -283,29 +357,18 @@ class ShareLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - // Skip initial state + shareLocationPresenter.test { + skipItems(1) val initialState = awaitItem() - // Send location initialState.eventSink( ShareLocationEvent.ShareStaticLocation( - cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition( - lat = 0.0, - lon = 1.0, - zoom = 2.0, - ), - location = Location( - lat = 3.0, - lon = 4.0, - accuracy = 5.0f, - ) + location = Location(lat = 3.0, lon = 4.0, accuracy = 5.0f), + isPinned = false, ) ) - delay(1) // Wait for the coroutine to finish + advanceUntilIdle() sendLocationResult.assertions().isCalledOnce() .with( @@ -326,12 +389,13 @@ class ShareLocationPresenterTest { messageType = Composer.MessageType.LocationUser, ) ) + cancelAndIgnoreRemainingEvents() } } @Test - fun `share pin location`() = runTest { - val sendLocationResult = lambdaRecorder> { _, _, _, _, _, _ -> + fun `ShareStaticLocation sends pinned location`() = runTest { + val sendLocationResult = lambdaRecorder { _: String, _: String, _: String?, _: Int?, _: AssetType?, _: EventId? -> Result.success(Unit) } val joinedRoom = FakeJoinedRoom( @@ -342,39 +406,26 @@ class ShareLocationPresenterTest { val shareLocationPresenter = createShareLocationPresenter(joinedRoom) fakePermissionsPresenter.givenState( aPermissionsState( - permissions = PermissionsState.Permissions.NoneGranted, + permissions = PermissionsState.Permissions.AllGranted, shouldShowRationale = false, ) ) - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - // Skip initial state + shareLocationPresenter.test { val initialState = awaitItem() - // Send location initialState.eventSink( ShareLocationEvent.ShareStaticLocation( - cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition( - lat = 0.0, - lon = 1.0, - zoom = 2.0, - ), - location = Location( - lat = 3.0, - lon = 4.0, - accuracy = 5.0f, - ) + location = Location(lat = 1.0, lon = 2.0, accuracy = 3.0f), + isPinned = true, ) ) - delay(1) // Wait for the coroutine to finish - + advanceUntilIdle() sendLocationResult.assertions().isCalledOnce() .with( - value("Location was shared at geo:0.0,1.0"), - value("geo:0.0,1.0"), + value("Location was shared at geo:1.0,2.0;u=3.0"), + value("geo:1.0,2.0;u=3.0"), value(null), value(15), value(AssetType.PIN), @@ -390,107 +441,7 @@ class ShareLocationPresenterTest { messageType = Composer.MessageType.LocationPin, ) ) - } - } - - @Test - fun `composer context passes through analytics`() = runTest { - val sendLocationResult = lambdaRecorder> { _, _, _, _, _, _ -> - Result.success(Unit) - } - val joinedRoom = FakeJoinedRoom( - liveTimeline = FakeTimeline().apply { - sendLocationLambda = sendLocationResult - }, - ) - val shareLocationPresenter = createShareLocationPresenter(joinedRoom) - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.NoneGranted, - shouldShowRationale = false, - ) - ) - fakeMessageComposerContext.apply { - composerMode = MessageComposerMode.Edit( - eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(), - content = "" - ) - } - - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - // Skip initial state - val initialState = awaitItem() - - // Send location - initialState.eventSink( - ShareLocationEvent.ShareStaticLocation( - cameraPosition = ShareLocationEvent.ShareStaticLocation.CameraPosition( - lat = 0.0, - lon = 1.0, - zoom = 2.0, - ), - location = null - ) - ) - - delay(1) // Wait for the coroutine to finish - - assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(1) - assertThat(fakeAnalyticsService.capturedEvents.last()).isEqualTo( - Composer( - inThread = false, - isEditing = true, - isReply = false, - messageType = Composer.MessageType.LocationPin, - ) - ) - } - } - - @Test - fun `open settings activity`() = runTest { - val shareLocationPresenter = createShareLocationPresenter() - fakePermissionsPresenter.givenState( - aPermissionsState( - permissions = PermissionsState.Permissions.NoneGranted, - shouldShowRationale = false, - ) - ) - fakeMessageComposerContext.apply { - composerMode = MessageComposerMode.Edit( - eventOrTransactionId = AN_EVENT_ID.toEventOrTransactionId(), - content = "" - ) - } - - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - // Skip initial state - val initialState = awaitItem() - - initialState.eventSink(ShareLocationEvent.SwitchToMyLocationMode) - val dialogShownState = awaitItem() - - // Open settings - dialogShownState.eventSink(ShareLocationEvent.OpenAppSettings) - val settingsOpenedState = awaitItem() - - assertThat(settingsOpenedState.dialogState).isEqualTo(ShareLocationState.Dialog.None) - assertThat(fakeLocationActions.openSettingsInvocationsCount).isEqualTo(1) - } - } - - @Test - fun `application name is in state`() = runTest { - val shareLocationPresenter = createShareLocationPresenter() - moleculeFlow(RecompositionMode.Immediate) { - shareLocationPresenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.appName).isEqualTo("app name") + cancelAndIgnoreRemainingEvents() } } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt new file mode 100644 index 0000000000..a3e221f77e --- /dev/null +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2024, 2025 New Vector 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.share + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShareLocationViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `test back action`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setShareLocationView( + state = aShareLocationState( + eventSink = eventsRecorder + ), + navigateUp = callback, + ) + rule.pressBack() + } + } + + @Test + fun `test fab click`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() + eventsRecorder.assertSingle(ShareLocationEvent.StartTrackingUserLocation) + } + + @Test + fun `when permission denied is displayed user can open the settings`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied), + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ShareLocationEvent.OpenAppSettings) + } + + @Test + fun `when permission denied is displayed user can close the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionDenied), + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog) + } + + @Test + fun `when permission rationale is displayed user can request permissions`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale), + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ShareLocationEvent.RequestPermissions) + } + + @Test + fun `when permission rationale is displayed user can close the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.PermissionRationale), + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog) + } + + @Test + fun `when location service disabled is displayed user can open location settings`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled), + hasLocationPermission = true, + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_continue) + eventsRecorder.assertSingle(ShareLocationEvent.OpenLocationSettings) + } + + @Test + fun `when location service disabled is displayed user can close the dialog`() { + val eventsRecorder = EventsRecorder() + rule.setShareLocationView( + aShareLocationState( + dialogState = ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.LocationServiceDisabled), + hasLocationPermission = true, + eventSink = eventsRecorder + ), + navigateUp = EnsureNeverCalled(), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(ShareLocationEvent.DismissDialog) + } +} + +private fun AndroidComposeTestRule.setShareLocationView( + state: ShareLocationState, + navigateUp: () -> Unit = EnsureNeverCalled(), +) { + setContent { + // Simulate a LocalInspectionMode for MapLibreMap + CompositionLocalProvider(LocalInspectionMode provides true) { + ShareLocationView( + state = state, + navigateUp = navigateUp, + ) + } + } +} diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt index a49b887a42..b8a32e5912 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -13,8 +13,11 @@ import com.bumble.appyx.core.modality.BuildContext import com.google.common.truth.Truth.assertThat import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationEntryPoint +import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter +import io.element.android.libraries.dateformatter.test.FakeDateFormatter +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.node.TestParentNode @@ -32,21 +35,27 @@ class DefaultShowLocationEntryPointTest { ShowLocationNode( buildContext = buildContext, plugins = plugins, - presenterFactory = { location: Location, description: String? -> - ShowLocationPresenter( + presenterFactory = object : ShowLocationPresenter.Factory { + override fun create(mode: ShowLocationMode) = ShowLocationPresenter( + mode = mode, permissionsPresenterFactory = { FakePermissionsPresenter() }, locationActions = FakeLocationActions(), buildMeta = aBuildMeta(), - location = location, - description = description, + dateFormatter = FakeDateFormatter(), ) }, analyticsService = FakeAnalyticsService(), ) } val inputs = ShowLocationEntryPoint.Inputs( - location = Location(37.4219983, -122.084, 10f), - description = "My location", + mode = ShowLocationMode.Static( + location = Location(37.4219983, -122.084, 10f), + senderName = "Alice", + senderId = UserId("@alice:matrix.org"), + senderAvatarUrl = null, + timestamp = System.currentTimeMillis(), + assetType = null, + ), ) val result = entryPoint.createNode( parentNode = parentNode, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index ebbad8e9d1..e1fe3691c0 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -66,9 +67,8 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.isTrackMyLocation).isFalse() @@ -84,9 +84,8 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isFalse() assertThat(initialState.isTrackMyLocation).isFalse() @@ -97,9 +96,8 @@ class ShowLocationPresenterTest { fun `emits initial state with location permission`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.isTrackMyLocation).isFalse() @@ -110,9 +108,8 @@ class ShowLocationPresenterTest { fun `emits initial state with partial location permission`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.isTrackMyLocation).isFalse() @@ -121,9 +118,8 @@ class ShowLocationPresenterTest { @Test fun `uses action to share location`() = runTest { - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() initialState.eventSink(ShowLocationEvents.Share(location)) @@ -135,9 +131,8 @@ class ShowLocationPresenterTest { fun `centers on user location`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.isTrackMyLocation).isFalse() @@ -168,9 +163,8 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { // Skip initial state val initialState = awaitItem() @@ -198,10 +192,8 @@ class ShowLocationPresenterTest { shouldShowRationale = true, ) ) - - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { // Skip initial state val initialState = awaitItem() @@ -227,9 +219,8 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { // Skip initial state val initialState = awaitItem() @@ -258,9 +249,8 @@ class ShowLocationPresenterTest { ) ) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter().present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { // Skip initial state val initialState = awaitItem() @@ -291,9 +281,8 @@ class ShowLocationPresenterTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) fakeLocationActions.givenLocationEnabled(false) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter(locationActions = fakeLocationActions).present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.hasLocationPermission).isTrue() @@ -311,9 +300,8 @@ class ShowLocationPresenterTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) fakeLocationActions.givenLocationEnabled(false) - moleculeFlow(RecompositionMode.Immediate) { - createShowLocationPresenter(locationActions = fakeLocationActions).present() - }.test { + val presenter = createShowLocationPresenter() + presenter.test { val initialState = awaitItem() initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) From fb775833c7ade300c691bf2ddf543326730af096 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 12 Mar 2026 12:35:46 +0100 Subject: [PATCH 34/52] Code cleanup --- .../features/location/api/LocationKtTest.kt | 1 - .../impl/common/LocationConstraintsCheck.kt | 32 +++++++++---------- .../common/ui/LocationFloatingActionButton.kt | 2 -- .../impl/common/ui/LocationShareRow.kt | 8 ++--- .../impl/common/ui/MapBottomSheetScaffold.kt | 6 ++-- .../impl/share/LiveLocationDuration.kt | 4 +-- .../location/impl/share/ShareLocationEvent.kt | 2 +- .../impl/share/ShareLocationPresenter.kt | 8 ++--- .../location/impl/share/ShareLocationView.kt | 8 ++--- .../impl/show/ShowLocationPresenter.kt | 6 ++-- .../location/impl/show/ShowLocationView.kt | 2 +- .../src/main/res/drawable-night/pin_small.xml | 19 ----------- .../impl/src/main/res/drawable/pin_small.xml | 19 ----------- .../common/LocationConstraintsCheckTest.kt | 11 +++---- .../impl/share/ShareLocationPresenterTest.kt | 1 - .../impl/share/ShareLocationViewTest.kt | 3 +- .../impl/show/ShowLocationPresenterTest.kt | 2 +- .../messages/impl/MessagesFlowNode.kt | 1 - .../event/TimelineItemLocationView.kt | 4 --- .../TimelineItemContentMessageFactory.kt | 5 --- .../event/TimelineItemLocationContent.kt | 2 -- .../TimelineItemLocationContentProvider.kt | 2 -- .../components/avatar/internal/ImageAvatar.kt | 2 -- .../components/dialogs/ListDialog.kt | 1 - .../api/room/location/LiveLocationInfo.kt | 1 - .../api/timeline/item/event/EventContent.kt | 2 +- .../matrix/impl/room/location/AssetType.kt | 2 +- .../ui/components/AttachmentThumbnail.kt | 1 - 28 files changed, 44 insertions(+), 113 deletions(-) delete mode 100644 features/location/impl/src/main/res/drawable-night/pin_small.xml delete mode 100644 features/location/impl/src/main/res/drawable/pin_small.xml diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt index b0f80e4fcf..74bb7fe953 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/LocationKtTest.kt @@ -79,4 +79,3 @@ internal class LocationKtTest { .isEqualTo("geo:1.0,2.0;u=3.0") } } - diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt index 51d57713c5..a0b0cd4734 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheck.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. @@ -11,35 +11,35 @@ import io.element.android.features.location.impl.common.actions.LocationActions import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState -sealed interface LocationConstraintsCheckResult { - data object Success : LocationConstraintsCheckResult - data object PermissionRationale : LocationConstraintsCheckResult - data object PermissionDenied : LocationConstraintsCheckResult - data object LocationServiceDisabled : LocationConstraintsCheckResult +sealed interface LocationConstraintsCheck { + data object Success : LocationConstraintsCheck + data object PermissionRationale : LocationConstraintsCheck + data object PermissionDenied : LocationConstraintsCheck + data object LocationServiceDisabled : LocationConstraintsCheck } fun checkLocationConstraints( permissionsState: PermissionsState, locationActions: LocationActions, -): LocationConstraintsCheckResult { +): LocationConstraintsCheck { return when { permissionsState.isAnyGranted -> { if (locationActions.isLocationEnabled()) { - LocationConstraintsCheckResult.Success + LocationConstraintsCheck.Success } else { - LocationConstraintsCheckResult.LocationServiceDisabled + LocationConstraintsCheck.LocationServiceDisabled } } - permissionsState.shouldShowRationale -> LocationConstraintsCheckResult.PermissionRationale - else -> LocationConstraintsCheckResult.PermissionDenied + permissionsState.shouldShowRationale -> LocationConstraintsCheck.PermissionRationale + else -> LocationConstraintsCheck.PermissionDenied } } -fun LocationConstraintsCheckResult.toDialogState(): LocationConstraintsDialogState { +fun LocationConstraintsCheck.toDialogState(): LocationConstraintsDialogState { return when (this) { - LocationConstraintsCheckResult.Success -> LocationConstraintsDialogState.None - LocationConstraintsCheckResult.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale - LocationConstraintsCheckResult.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied - LocationConstraintsCheckResult.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled + LocationConstraintsCheck.Success -> LocationConstraintsDialogState.None + LocationConstraintsCheck.PermissionRationale -> LocationConstraintsDialogState.PermissionRationale + LocationConstraintsCheck.PermissionDenied -> LocationConstraintsDialogState.PermissionDenied + LocationConstraintsCheck.LocationServiceDisabled -> LocationConstraintsDialogState.LocationServiceDisabled } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt index 99a4c7470f..28e3f1992e 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationFloatingActionButton.kt @@ -10,8 +10,6 @@ package io.element.android.features.location.impl.common.ui 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.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource 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 index ec059b61b3..9e5e35b2ba 100644 --- 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 @@ -77,8 +77,8 @@ fun LocationShareRow( tint = ElementTheme.colors.iconAccentPrimary, modifier = Modifier.size(16.dp), ) - }else { - val icon = if(item.assetType == AssetType.PIN) CompoundIcons.LocationNavigator() else CompoundIcons.LocationNavigatorCentred() + } else { + val icon = if (item.assetType == AssetType.PIN) CompoundIcons.LocationNavigator() else CompoundIcons.LocationNavigatorCentred() Icon( imageVector = icon, contentDescription = null, @@ -122,7 +122,7 @@ internal fun LocationShareRowPreview() = ElementPreview { formattedTimestamp = "Shared 1 min ago", assetType = AssetType.SENDER, isLive = true, - location = Location(0.0,0.0) + location = Location(0.0, 0.0) ), onShareClick = {}, ) @@ -139,7 +139,7 @@ internal fun LocationShareRowPreview() = ElementPreview { assetType = AssetType.PIN, formattedTimestamp = "Shared 5 hours ago", isLive = false, - location = Location(0.0,0.0) + 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 97ed6baa62..33726de6e8 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 @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. @@ -7,7 +7,6 @@ package io.element.android.features.location.impl.common.ui -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints @@ -37,7 +36,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -124,7 +122,7 @@ fun MapBottomSheetScaffold( ) { val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding) val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions) - Box{ + Box { MaplibreMap( options = mapOptions, baseStyle = BaseStyle.Uri(rememberTileStyleUrl()), diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt index 8763263d57..e4ecf331f6 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. @@ -17,5 +17,5 @@ enum class LiveLocationDuration( ) { FifteenMinutes(15.minutes, "15 minutes"), OneHour(1.hours, "1 hour"), - EightHours(8.hours, "8 hours"); + EightHours(8.hours, "8 hours") } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt index b95e86084b..d9ebc8b5af 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationEvent.kt @@ -24,7 +24,7 @@ sealed interface ShareLocationEvent { data object StopTrackingUserLocation : ShareLocationEvent data object DismissDialog : ShareLocationEvent - data object RequestPermissions: ShareLocationEvent + data object RequestPermissions : ShareLocationEvent data object OpenAppSettings : ShareLocationEvent data object OpenLocationSettings : ShareLocationEvent } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index 7ca8cbb7e3..56b7b073d9 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -21,7 +21,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.location.impl.common.LocationConstraintsCheckResult +import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.actions.LocationActions import io.element.android.features.location.impl.common.checkLocationConstraints @@ -81,7 +81,7 @@ class ShareLocationPresenter( fun checkLocationConstraints() { val locationConstraints = checkLocationConstraints(permissionsState, locationActions) dialogState = Constraints(locationConstraints.toDialogState()) - trackUserPosition = locationConstraints is LocationConstraintsCheckResult.Success + trackUserPosition = locationConstraints is LocationConstraintsCheck.Success } LaunchedEffect(permissionsState.permissions) { checkLocationConstraints() } @@ -104,7 +104,7 @@ class ShareLocationPresenter( } ShareLocationEvent.ShowLiveLocationDurationPicker -> { val constraintsResult = checkLocationConstraints(permissionsState, locationActions) - dialogState = if (constraintsResult is LocationConstraintsCheckResult.Success) { + dialogState = if (constraintsResult is LocationConstraintsCheck.Success) { ShareLocationState.Dialog.LiveLocationDuration } else { Constraints(constraintsResult.toDialogState()) @@ -112,7 +112,7 @@ class ShareLocationPresenter( } is ShareLocationEvent.StartLiveLocationShare -> scope.launch { dialogState = ShareLocationState.Dialog.None - //room.startLiveLocationShare(event.duration.inWholeMilliseconds) + // room.startLiveLocationShare(event.duration.inWholeMilliseconds) } ShareLocationEvent.RequestPermissions -> { dialogState = ShareLocationState.Dialog.None diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index e5fa9d8c0c..f562647bc9 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -8,13 +8,11 @@ package io.element.android.features.location.impl.share -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberBottomSheetScaffoldState @@ -22,8 +20,6 @@ import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,9 +40,7 @@ import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.list.ListItemContent -import io.element.android.libraries.designsystem.components.list.RadioButtonListItem import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.IconSource @@ -249,6 +243,7 @@ private fun LiveLocationDurationDialog( onSelectDuration: (Duration) -> Unit, onDismiss: () -> Unit, ) { + /* var selectedIndex by remember { mutableIntStateOf(0) } ListDialog( title = "Choose how long to share your live location.", @@ -268,6 +263,7 @@ private fun LiveLocationDurationDialog( ) } } + */ } @PreviewsDayNight 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 c48c2c27d2..01021b9972 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 @@ -19,8 +19,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.location.api.ShowLocationMode -import io.element.android.features.location.impl.common.LocationConstraintsCheckResult -import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState +import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.actions.LocationActions import io.element.android.features.location.impl.common.checkLocationConstraints @@ -28,6 +27,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.toDialogState +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationMarkerData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta @@ -76,7 +76,7 @@ class ShowLocationPresenter( is ShowLocationEvents.TrackMyLocation -> { if (event.enabled) { val locationConstraints = checkLocationConstraints(permissionsState, locationActions) - isTrackMyLocation = locationConstraints is LocationConstraintsCheckResult.Success + isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success dialogState = locationConstraints.toDialogState() } else { isTrackMyLocation = false 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 3d3dc6f5c6..55fecf3e39 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 @@ -27,8 +27,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.location.api.ShowLocationMode -import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog import io.element.android.features.location.impl.common.MapDefaults +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton import io.element.android.features.location.impl.common.ui.LocationPinMarkers import io.element.android.features.location.impl.common.ui.LocationShareRow diff --git a/features/location/impl/src/main/res/drawable-night/pin_small.xml b/features/location/impl/src/main/res/drawable-night/pin_small.xml deleted file mode 100644 index 2e8a54b70e..0000000000 --- a/features/location/impl/src/main/res/drawable-night/pin_small.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/features/location/impl/src/main/res/drawable/pin_small.xml b/features/location/impl/src/main/res/drawable/pin_small.xml deleted file mode 100644 index 0e277a1ed2..0000000000 --- a/features/location/impl/src/main/res/drawable/pin_small.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt index 801d58e5da..c8e1f21a48 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/LocationConstraintsCheckTest.kt @@ -23,7 +23,7 @@ class LocationConstraintsCheckTest { val result = checkLocationConstraints(permissionsState, locationActions) - assertThat(result).isEqualTo(LocationConstraintsCheckResult.Success) + assertThat(result).isEqualTo(LocationConstraintsCheck.Success) } @Test @@ -35,7 +35,7 @@ class LocationConstraintsCheckTest { val result = checkLocationConstraints(permissionsState, locationActions) - assertThat(result).isEqualTo(LocationConstraintsCheckResult.Success) + assertThat(result).isEqualTo(LocationConstraintsCheck.Success) } @Test @@ -47,7 +47,7 @@ class LocationConstraintsCheckTest { val result = checkLocationConstraints(permissionsState, locationActions) - assertThat(result).isEqualTo(LocationConstraintsCheckResult.LocationServiceDisabled) + assertThat(result).isEqualTo(LocationConstraintsCheck.LocationServiceDisabled) } @Test @@ -60,7 +60,7 @@ class LocationConstraintsCheckTest { val result = checkLocationConstraints(permissionsState, locationActions) - assertThat(result).isEqualTo(LocationConstraintsCheckResult.PermissionRationale) + assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionRationale) } @Test @@ -73,7 +73,6 @@ class LocationConstraintsCheckTest { val result = checkLocationConstraints(permissionsState, locationActions) - assertThat(result).isEqualTo(LocationConstraintsCheckResult.PermissionDenied) + assertThat(result).isEqualTo(LocationConstraintsCheck.PermissionDenied) } - } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index b9311188d2..125d5036fe 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -37,7 +37,6 @@ import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test -import kotlinx.coroutines.delay import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Rule diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt index a3e221f77e..317fbf8fed 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationViewTest.kt @@ -1,6 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2024, 2025 New Vector Ltd. + * 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. diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index e1fe3691c0..a875800672 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -15,11 +15,11 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.location.api.Location import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.aPermissionsState -import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsState +import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.core.aBuildMeta diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 9ad7c48e36..38d0504258 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -75,7 +75,6 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.room.joinedRoomMembers -import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt index b5c0152685..592b95a337 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemLocationView.kt @@ -17,10 +17,6 @@ import androidx.compose.ui.unit.dp import io.element.android.features.location.api.StaticMapView import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContentProvider -import io.element.android.libraries.designsystem.components.PinVariant -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.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 829c11df6e..723ab6feac 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -29,13 +29,9 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.androidutils.text.safeLinkify import io.element.android.libraries.core.mimetype.MimeTypes -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 import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkParser -import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType @@ -49,7 +45,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessag import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName import io.element.android.libraries.matrix.ui.messages.toHtmlDocument import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt index 74147c697f..fce44debd2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt @@ -27,7 +27,6 @@ data class TimelineItemLocationContent( val assetType: AssetType? = null, val mode: Mode, ) : TimelineItemEventContent { - val pinVariant = when (mode) { is Mode.Live -> { if (mode.isActive) { @@ -61,4 +60,3 @@ data class TimelineItemLocationContent( override val type: String = "TimelineItemLocationContent" } - diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt index 07ab392f1e..362e9b4cda 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContentProvider.kt @@ -11,9 +11,7 @@ package io.element.android.features.messages.impl.timeline.model.event import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.location.api.Location import io.element.android.libraries.matrix.api.core.UserId -import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails -import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady open class TimelineItemLocationContentProvider : PreviewParameterProvider { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt index e626e0f069..da57fbcbe1 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt @@ -17,12 +17,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent -import coil3.request.ImageRequest import io.element.android.libraries.designsystem.components.avatar.AvatarData import timber.log.Timber diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt index ffaab12eba..91c058a0e4 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.designsystem.components.dialogs import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Arrangement.HorizontalOrVertical import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt index 50b5a0ec82..a04ef2dfb9 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/location/LiveLocationInfo.kt @@ -12,4 +12,3 @@ data class LiveLocationInfo( val geoUri: String, val timestamp: Long, ) - diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index e7b61e8dd6..95d4327c07 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -111,7 +111,7 @@ data class LiveLocationContent( val timeout: Long, val assetType: AssetType?, val locations: List, -): EventContent +) : EventContent data object LegacyCallInviteContent : EventContent diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt index a88a2524c2..5f8fa70f59 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/location/AssetType.kt @@ -17,7 +17,7 @@ fun AssetType.into(): RustAssetType = when (this) { AssetType.UNKNOWN -> RustAssetType.UNKNOWN } -fun RustAssetType.into(): AssetType = when(this){ +fun RustAssetType.into(): AssetType = when (this) { RustAssetType.SENDER -> AssetType.SENDER RustAssetType.PIN -> AssetType.PIN RustAssetType.UNKNOWN -> AssetType.UNKNOWN diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt index 9b55c85524..e5149682e3 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AttachmentThumbnail.kt @@ -11,7 +11,6 @@ package io.element.android.libraries.matrix.ui.components import android.os.Parcelable import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme From 089b0cd84771df747a2724cd7cb842347a4869e8 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 12 Mar 2026 12:40:10 +0100 Subject: [PATCH 35/52] Disable live location sharing for now (nothing done) --- .../features/location/impl/share/ShareLocationPresenter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index 56b7b073d9..ec5414de77 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -126,7 +126,7 @@ class ShareLocationPresenter( dialogState = dialogState, trackUserLocation = trackUserPosition, hasLocationPermission = permissionsState.isAnyGranted, - canShareLiveLocation = isLiveLocationSharingEnabled, + canShareLiveLocation = false, appName = appName, eventSink = ::handleEvent, ) From 30919dcff45f91c6e272e535c2f992a3fca8af09 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 12 Mar 2026 12:47:52 +0100 Subject: [PATCH 36/52] Rename ShowLocationEvents -> ShowLocationEvent --- ...LocationEvents.kt => ShowLocationEvent.kt} | 14 +++++----- .../impl/show/ShowLocationPresenter.kt | 14 +++++----- .../location/impl/show/ShowLocationState.kt | 2 +- .../impl/show/ShowLocationStateProvider.kt | 2 +- .../location/impl/show/ShowLocationView.kt | 16 +++++------ .../impl/show/ShowLocationPresenterTest.kt | 28 +++++++++---------- .../impl/show/ShowLocationViewTest.kt | 26 ++++++++--------- 7 files changed, 51 insertions(+), 51 deletions(-) rename features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/{ShowLocationEvents.kt => ShowLocationEvent.kt} (64%) 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/ShowLocationEvent.kt similarity index 64% rename from features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvents.kt rename to features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationEvent.kt index 52ff87b393..6a3e3521e0 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/ShowLocationEvent.kt @@ -10,11 +10,11 @@ package io.element.android.features.location.impl.show import io.element.android.features.location.api.Location -sealed interface ShowLocationEvents { - data class Share(val location: Location) : ShowLocationEvents - data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvents - data object DismissDialog : ShowLocationEvents - data object RequestPermissions : ShowLocationEvents - data object OpenAppSettings : ShowLocationEvents - data object OpenLocationSettings : ShowLocationEvents +sealed interface ShowLocationEvent { + data class Share(val location: Location) : ShowLocationEvent + data class TrackMyLocation(val enabled: Boolean) : ShowLocationEvent + data object DismissDialog : ShowLocationEvent + data object RequestPermissions : ShowLocationEvent + data object OpenAppSettings : ShowLocationEvent + data object OpenLocationSettings : ShowLocationEvent } 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 01021b9972..d74d2f36e1 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 @@ -68,12 +68,12 @@ class ShowLocationPresenter( } } - fun handleEvent(event: ShowLocationEvents) { + fun handleEvent(event: ShowLocationEvent) { when (event) { - is ShowLocationEvents.Share -> { + is ShowLocationEvent.Share -> { locationActions.share(event.location, null) } - is ShowLocationEvents.TrackMyLocation -> { + is ShowLocationEvent.TrackMyLocation -> { if (event.enabled) { val locationConstraints = checkLocationConstraints(permissionsState, locationActions) isTrackMyLocation = locationConstraints is LocationConstraintsCheck.Success @@ -82,16 +82,16 @@ class ShowLocationPresenter( isTrackMyLocation = false } } - ShowLocationEvents.DismissDialog -> dialogState = LocationConstraintsDialogState.None - ShowLocationEvents.OpenAppSettings -> { + ShowLocationEvent.DismissDialog -> dialogState = LocationConstraintsDialogState.None + ShowLocationEvent.OpenAppSettings -> { locationActions.openAppSettings() dialogState = LocationConstraintsDialogState.None } - ShowLocationEvents.OpenLocationSettings -> { + ShowLocationEvent.OpenLocationSettings -> { locationActions.openLocationSettings() dialogState = LocationConstraintsDialogState.None } - ShowLocationEvents.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) + ShowLocationEvent.RequestPermissions -> permissionsState.eventSink(PermissionsEvents.RequestPermissions) } } 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 3a8fcf51de..85d79f1192 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 @@ -24,7 +24,7 @@ data class ShowLocationState( val hasLocationPermission: Boolean, val isTrackMyLocation: Boolean, val appName: String, - val eventSink: (ShowLocationEvents) -> Unit, + val eventSink: (ShowLocationEvent) -> Unit, ) { val isSheetDraggable = locationShares.any { item -> item.isLive } } 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 6880ed1bf5..7289ec000c 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 @@ -53,7 +53,7 @@ fun aShowLocationState( hasLocationPermission: Boolean = false, isTrackMyLocation: Boolean = false, appName: String = APP_NAME, - eventSink: (ShowLocationEvents) -> Unit = {}, + eventSink: (ShowLocationEvent) -> Unit = {}, ): ShowLocationState { val effectiveMarkers = markers ?: when (mode) { is ShowLocationMode.Static -> listOf( 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 55fecf3e39..fdeb027b1e 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 @@ -57,10 +57,10 @@ fun ShowLocationView( LocationConstraintsDialog( state = state.dialogState, appName = state.appName, - onRequestPermissions = { state.eventSink(ShowLocationEvents.RequestPermissions) }, - onOpenAppSettings = { state.eventSink(ShowLocationEvents.OpenAppSettings) }, - onOpenLocationSettings = { state.eventSink(ShowLocationEvents.OpenLocationSettings) }, - onDismiss = { state.eventSink(ShowLocationEvents.DismissDialog) }, + onRequestPermissions = { state.eventSink(ShowLocationEvent.RequestPermissions) }, + onOpenAppSettings = { state.eventSink(ShowLocationEvent.OpenAppSettings) }, + onOpenLocationSettings = { state.eventSink(ShowLocationEvent.OpenLocationSettings) }, + onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) }, ) val initialPosition = when (val mode = state.mode) { @@ -74,7 +74,7 @@ fun ShowLocationView( val userLocationState = rememberUserLocationState(state.hasLocationPermission) LaunchedEffect(cameraState.isCameraMoving) { if (cameraState.moveReason == CameraMoveReason.GESTURE) { - state.eventSink(ShowLocationEvents.TrackMyLocation(false)) + state.eventSink(ShowLocationEvent.TrackMyLocation(false)) } } @@ -120,9 +120,9 @@ fun ShowLocationView( state.locationShares.forEach { locationShare -> LocationShareRow( item = locationShare, - onShareClick = { state.eventSink(ShowLocationEvents.Share(locationShare.location)) }, + onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) }, modifier = Modifier.clickable { - state.eventSink(ShowLocationEvents.TrackMyLocation(false)) + state.eventSink(ShowLocationEvent.TrackMyLocation(false)) val position = CameraPosition( padding = sheetPaddings, target = Position(locationShare.location.lon, locationShare.location.lat), @@ -146,7 +146,7 @@ fun ShowLocationView( overlayContent = { LocationFloatingActionButton( isMapCenteredOnUser = state.isTrackMyLocation, - onClick = { state.eventSink(ShowLocationEvents.TrackMyLocation(true)) }, + onClick = { state.eventSink(ShowLocationEvent.TrackMyLocation(true)) }, modifier = Modifier .align(Alignment.TopEnd) .padding(all = 16.dp), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index a875800672..8a29f0463d 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -121,7 +121,7 @@ class ShowLocationPresenterTest { val presenter = createShowLocationPresenter() presenter.test { val initialState = awaitItem() - initialState.eventSink(ShowLocationEvents.Share(location)) + initialState.eventSink(ShowLocationEvent.Share(location)) assertThat(fakeLocationActions.sharedLocation).isEqualTo(location) } @@ -137,7 +137,7 @@ class ShowLocationPresenterTest { assertThat(initialState.hasLocationPermission).isTrue() assertThat(initialState.isTrackMyLocation).isFalse() - initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) val trackMyLocationState = awaitItem() delay(1) @@ -146,7 +146,7 @@ class ShowLocationPresenterTest { assertThat(trackMyLocationState.isTrackMyLocation).isTrue() // Swipe the map to switch mode - initialState.eventSink(ShowLocationEvents.TrackMyLocation(false)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(false)) val trackLocationDisabledState = awaitItem() assertThat(trackLocationDisabledState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(trackLocationDisabledState.isTrackMyLocation).isFalse() @@ -169,14 +169,14 @@ class ShowLocationPresenterTest { val initialState = awaitItem() // Click on the button to switch mode - initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) val trackLocationState = awaitItem() assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale) assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse() // Dismiss the dialog - initialState.eventSink(ShowLocationEvents.DismissDialog) + initialState.eventSink(ShowLocationEvent.DismissDialog) val dialogDismissedState = awaitItem() assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(dialogDismissedState.isTrackMyLocation).isFalse() @@ -198,14 +198,14 @@ class ShowLocationPresenterTest { val initialState = awaitItem() // Click on the button to switch mode - initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) val trackLocationState = awaitItem() assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionRationale) assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse() // Continue the dialog sends permission request to the permissions presenter - trackLocationState.eventSink(ShowLocationEvents.RequestPermissions) + trackLocationState.eventSink(ShowLocationEvent.RequestPermissions) assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) } } @@ -225,14 +225,14 @@ class ShowLocationPresenterTest { val initialState = awaitItem() // Click on the button to switch mode - initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) val trackLocationState = awaitItem() assertThat(trackLocationState.dialogState).isEqualTo(LocationConstraintsDialogState.PermissionDenied) assertThat(trackLocationState.isTrackMyLocation).isFalse() assertThat(trackLocationState.hasLocationPermission).isFalse() // Dismiss the dialog - initialState.eventSink(ShowLocationEvents.DismissDialog) + initialState.eventSink(ShowLocationEvent.DismissDialog) val dialogDismissedState = awaitItem() assertThat(dialogDismissedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) assertThat(dialogDismissedState.isTrackMyLocation).isFalse() @@ -254,11 +254,11 @@ class ShowLocationPresenterTest { // Skip initial state val initialState = awaitItem() - initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) val dialogShownState = awaitItem() // Open settings - dialogShownState.eventSink(ShowLocationEvents.OpenAppSettings) + dialogShownState.eventSink(ShowLocationEvent.OpenAppSettings) val settingsOpenedState = awaitItem() assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) @@ -287,7 +287,7 @@ class ShowLocationPresenterTest { assertThat(initialState.hasLocationPermission).isTrue() // Try to track location when location services are disabled - initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) val dialogShownState = awaitItem() assertThat(dialogShownState.dialogState).isEqualTo(LocationConstraintsDialogState.LocationServiceDisabled) @@ -304,12 +304,12 @@ class ShowLocationPresenterTest { presenter.test { val initialState = awaitItem() - initialState.eventSink(ShowLocationEvents.TrackMyLocation(true)) + initialState.eventSink(ShowLocationEvent.TrackMyLocation(true)) val dialogShownState = awaitItem() assertThat(dialogShownState.dialogState).isEqualTo(LocationConstraintsDialogState.LocationServiceDisabled) // Open location settings - dialogShownState.eventSink(ShowLocationEvents.OpenLocationSettings) + dialogShownState.eventSink(ShowLocationEvent.OpenLocationSettings) val settingsOpenedState = awaitItem() assertThat(settingsOpenedState.dialogState).isEqualTo(LocationConstraintsDialogState.None) diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt index a70d3441c4..fecbbdbf89 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt @@ -37,7 +37,7 @@ class ShowLocationViewTest { @Test fun `test back action`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { callback -> rule.setShowLocationView( state = aShowLocationState( @@ -51,7 +51,7 @@ class ShowLocationViewTest { @Test fun `test share action`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( eventSink = eventsRecorder @@ -61,12 +61,12 @@ class ShowLocationViewTest { val shareContentDescription = rule.activity.getString(CommonStrings.action_share) rule.onNodeWithContentDescription(shareContentDescription).performClick() // The default aStaticLocationMode uses Location(1.23, 2.34, 4f) - eventsRecorder.assertSingle(ShowLocationEvents.Share(Location(1.23, 2.34, 4f))) + eventsRecorder.assertSingle(ShowLocationEvent.Share(Location(1.23, 2.34, 4f))) } @Test fun `test fab click`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( eventSink = eventsRecorder @@ -74,12 +74,12 @@ class ShowLocationViewTest { onBackClick = EnsureNeverCalled(), ) rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() - eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true)) + eventsRecorder.assertSingle(ShowLocationEvent.TrackMyLocation(true)) } @Test fun `when permission denied is displayed user can open the settings`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, @@ -88,12 +88,12 @@ class ShowLocationViewTest { onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_continue) - eventsRecorder.assertSingle(ShowLocationEvents.OpenAppSettings) + eventsRecorder.assertSingle(ShowLocationEvent.OpenAppSettings) } @Test fun `when permission denied is displayed user can close the dialog`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( constraintsDialogState = LocationConstraintsDialogState.PermissionDenied, @@ -102,12 +102,12 @@ class ShowLocationViewTest { onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_cancel) - eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) + eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog) } @Test fun `when permission rationale is displayed user can request permissions`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( constraintsDialogState = LocationConstraintsDialogState.PermissionRationale, @@ -116,12 +116,12 @@ class ShowLocationViewTest { onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_continue) - eventsRecorder.assertSingle(ShowLocationEvents.RequestPermissions) + eventsRecorder.assertSingle(ShowLocationEvent.RequestPermissions) } @Test fun `when permission rationale is displayed user can close the dialog`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setShowLocationView( aShowLocationState( constraintsDialogState = LocationConstraintsDialogState.PermissionRationale, @@ -130,7 +130,7 @@ class ShowLocationViewTest { onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_cancel) - eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) + eventsRecorder.assertSingle(ShowLocationEvent.DismissDialog) } } From cdc773dab101b962321d78e105aa8c4c1387d6f9 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 12 Mar 2026 15:58:27 +0100 Subject: [PATCH 37/52] Remove hardcoded string --- .../location/impl/common/ui/LocationConstraintsDialog.kt | 2 +- libraries/ui-strings/src/main/res/values/localazy.xml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt index d42a551254..1ae4fa2182 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt @@ -45,7 +45,7 @@ fun LocationConstraintsDialog( cancelText = stringResource(CommonStrings.action_cancel), ) LocationConstraintsDialogState.LocationServiceDisabled -> ConfirmationDialog( - content = "Please enable your GPS to access location-based features.", + content = stringResource(CommonStrings.error_location_service_disabled_android), onSubmitClick = onOpenLocationSettings, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index d655532e6c..0aa2b29f2e 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -438,6 +438,7 @@ Are you sure you want to continue?" "%1$s could not access your location. Please try again later." "Failed to upload your voice message." "The room no longer exists or the invite is no longer valid." + "Please enable your GPS to access location-based features." "Message not found" "%1$s does not have permission to access your location. You can enable access in Settings." "%1$s does not have permission to access your location. Enable access below." From 02b3c61edc5ef06269ab7cbcdc1f5e9ef1f89eb2 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 12 Mar 2026 21:12:52 +0100 Subject: [PATCH 38/52] Fix quality! --- .../impl/common/ui/LocationConstraintsDialog.kt | 16 +++++++++------- .../impl/common/ui/MapBottomSheetScaffold.kt | 7 ++++--- .../location/impl/common/ui/UserLocationPuck.kt | 2 ++ .../impl/share/ShareLocationPresenter.kt | 2 +- .../location/impl/share/ShareLocationView.kt | 14 +++++++++++--- .../impl/show/ShowLocationStateProvider.kt | 2 +- .../model/event/TimelineItemLocationContent.kt | 3 +-- .../impl/src/main/res/values/localazy.xml | 2 +- .../networkmonitor/impl/DefaultNetworkMonitor.kt | 5 ++++- .../designsystem/components/LocationPin.kt | 2 ++ .../designsystem/components/avatar/AvatarSize.kt | 2 +- 11 files changed, 37 insertions(+), 20 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt index 1ae4fa2182..188f8129f8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt @@ -8,17 +8,11 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.ui.res.stringResource import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.ui.strings.CommonStrings -sealed interface LocationConstraintsDialogState { - data object None : LocationConstraintsDialogState - data object PermissionRationale : LocationConstraintsDialogState - data object PermissionDenied : LocationConstraintsDialogState - data object LocationServiceDisabled : LocationConstraintsDialogState -} - @Composable fun LocationConstraintsDialog( state: LocationConstraintsDialogState, @@ -53,3 +47,11 @@ fun LocationConstraintsDialog( ) } } + +@Immutable +sealed interface LocationConstraintsDialogState { + data object None : LocationConstraintsDialogState + data object PermissionRationale : LocationConstraintsDialogState + data object PermissionDenied : LocationConstraintsDialogState + data object LocationServiceDisabled : LocationConstraintsDialogState +} 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 33726de6e8..09c5067e1a 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 @@ -60,15 +60,16 @@ import kotlin.math.roundToInt * - 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 cameraState The camera state for the map + * @param mapOptions The options to configure the map * @param sheetPeekHeight The height of the sheet when collapsed * @param sheetDragHandle Optional drag handle for the sheet * @param sheetSwipeEnabled Whether the sheet can be swiped + * @param topBar The top app bar content * @param snackbarHost The snackbar host content + * @param sheetContent The content to display in the bottom sheet * @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.) * @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.) */ diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt index f014debe08..8b89f77be4 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/UserLocationPuck.kt @@ -7,6 +7,7 @@ package io.element.android.features.location.impl.common.ui +import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.unit.dp @@ -63,6 +64,7 @@ fun UserLocationPuck( } } +@SuppressLint("MissingPermission") @Composable fun rememberUserLocationState(hasLocationPermission: Boolean): UserLocationState { val isPreview = LocalInspectionMode.current diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index ec5414de77..56b7b073d9 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -126,7 +126,7 @@ class ShareLocationPresenter( dialogState = dialogState, trackUserLocation = trackUserPosition, hasLocationPermission = permissionsState.isAnyGranted, - canShareLiveLocation = false, + canShareLiveLocation = isLiveLocationSharingEnabled, appName = appName, eventSink = ::handleEvent, ) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index f562647bc9..ecb4cf9dd4 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -8,11 +8,13 @@ package io.element.android.features.location.impl.share +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberBottomSheetScaffoldState @@ -20,9 +22,12 @@ import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -36,11 +41,14 @@ import io.element.android.features.location.impl.common.ui.LocationFloatingActio import io.element.android.features.location.impl.common.ui.MapBottomSheetScaffold import io.element.android.features.location.impl.common.ui.UserLocationPuck import io.element.android.features.location.impl.common.ui.rememberUserLocationState +import io.element.android.libraries.androidutils.system.toast import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.avatar.AvatarSize 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.list.ListItemContent +import io.element.android.libraries.designsystem.components.list.RadioButtonListItem import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.IconSource @@ -62,6 +70,7 @@ fun ShareLocationView( navigateUp: () -> Unit, modifier: Modifier = Modifier, ) { + val context = LocalContext.current when (val dialogState = state.dialogState) { ShareLocationState.Dialog.None -> Unit is ShareLocationState.Dialog.Constraints -> LocationConstraintsDialog( @@ -75,6 +84,7 @@ fun ShareLocationView( ShareLocationState.Dialog.LiveLocationDuration -> LiveLocationDurationDialog( onSelectDuration = { duration -> state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration)) + context.toast("Not implemented yet!") navigateUp() }, onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, @@ -129,7 +139,7 @@ fun ShareLocationView( .padding(sheetPadding) ) { val variant = if (state.trackUserLocation) { - PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.SelectedUser)) + PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin)) } else { PinVariant.PinnedLocation } @@ -243,7 +253,6 @@ private fun LiveLocationDurationDialog( onSelectDuration: (Duration) -> Unit, onDismiss: () -> Unit, ) { - /* var selectedIndex by remember { mutableIntStateOf(0) } ListDialog( title = "Choose how long to share your live location.", @@ -263,7 +272,6 @@ private fun LiveLocationDurationDialog( ) } } - */ } @PreviewsDayNight 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 7289ec000c..9bbd818f1a 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 @@ -68,7 +68,7 @@ fun aShowLocationState( id = mode.senderId.value, name = mode.senderName, url = mode.senderAvatarUrl, - size = AvatarSize.UserListItem, + size = AvatarSize.LocationPin, ), isLive = true, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt index fce44debd2..aa9fb6b71e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemLocationContent.kt @@ -49,8 +49,7 @@ data class TimelineItemLocationContent( senderId.value, name = senderProfile.getDisplayName(), url = senderProfile.getAvatarUrl(), - // Size is irrelevant as the PinMarker will override anyway. - size = AvatarSize.TimelineSender + size = AvatarSize.LocationPin ) sealed interface Mode { diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index e8f4d4e6e5..f5629f5e2d 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -35,7 +35,7 @@ "Record video" "Attachment" "Photo & Video Library" - "Location" + "Share location" "Poll" "Text Formatting" "Message history is currently unavailable." diff --git a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt index 949db720ce..7cffa057bc 100644 --- a/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt +++ b/features/networkmonitor/impl/src/main/kotlin/io/element/android/features/networkmonitor/impl/DefaultNetworkMonitor.kt @@ -15,6 +15,7 @@ import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest +import android.os.Build import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn @@ -83,7 +84,9 @@ class DefaultNetworkMonitor( if (network.networkHandle == connectivityManager.activeNetwork?.networkHandle) { // If the network doesn't have the NET_CAPABILITY_VALIDATED capability, it means that the network is not able to reach the internet // (according to Google), which is a common case in air-gapped environments. - isInAirGappedEnvironment.value = !networkCapabilities.capabilities.contains(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + isInAirGappedEnvironment.value = !networkCapabilities.capabilities.contains(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt index 9fc0d1d27e..af8e29d518 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -60,6 +61,7 @@ private val STROKE_WIDTH = 1.dp /** * Variants of location pin markers. */ +@Immutable sealed interface PinVariant { data class UserLocation( val avatarData: AvatarData, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt index 8407445394..cd29773a5b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarSize.kt @@ -75,6 +75,6 @@ enum class AvatarSize(val dp: Dp) { SpaceMember(24.dp), LeaveSpaceRoom(32.dp), SelectParentSpace(32.dp), - AccountItem(32.dp), + LocationPin(32.dp) } From 461b1c0e525b82f506352053b06b6fc839e576f6 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 12 Mar 2026 20:37:13 +0000 Subject: [PATCH 39/52] Update screenshots --- ...ures.location.impl.common.ui_LocationShareRow_Day_0_en.png | 3 +++ ...es.location.impl.common.ui_LocationShareRow_Night_0_en.png | 3 +++ .../features.location.impl.send_SendLocationView_Day_0_en.png | 3 --- .../features.location.impl.send_SendLocationView_Day_1_en.png | 3 --- .../features.location.impl.send_SendLocationView_Day_2_en.png | 3 --- .../features.location.impl.send_SendLocationView_Day_3_en.png | 3 --- .../features.location.impl.send_SendLocationView_Day_4_en.png | 3 --- ...eatures.location.impl.send_SendLocationView_Night_0_en.png | 3 --- ...eatures.location.impl.send_SendLocationView_Night_1_en.png | 3 --- ...eatures.location.impl.send_SendLocationView_Night_2_en.png | 3 --- ...eatures.location.impl.send_SendLocationView_Night_3_en.png | 3 --- ...eatures.location.impl.send_SendLocationView_Night_4_en.png | 3 --- ...eatures.location.impl.share_ShareLocationView_Day_0_en.png | 3 +++ ...eatures.location.impl.share_ShareLocationView_Day_1_en.png | 3 +++ ...eatures.location.impl.share_ShareLocationView_Day_2_en.png | 3 +++ ...eatures.location.impl.share_ShareLocationView_Day_3_en.png | 3 +++ ...eatures.location.impl.share_ShareLocationView_Day_4_en.png | 3 +++ ...eatures.location.impl.share_ShareLocationView_Day_5_en.png | 3 +++ ...eatures.location.impl.share_ShareLocationView_Day_6_en.png | 3 +++ ...tures.location.impl.share_ShareLocationView_Night_0_en.png | 3 +++ ...tures.location.impl.share_ShareLocationView_Night_1_en.png | 3 +++ ...tures.location.impl.share_ShareLocationView_Night_2_en.png | 3 +++ ...tures.location.impl.share_ShareLocationView_Night_3_en.png | 3 +++ ...tures.location.impl.share_ShareLocationView_Night_4_en.png | 3 +++ ...tures.location.impl.share_ShareLocationView_Night_5_en.png | 3 +++ ...tures.location.impl.share_ShareLocationView_Night_6_en.png | 3 +++ .../features.location.impl.show_ShowLocationView_Day_0_en.png | 4 ++-- .../features.location.impl.show_ShowLocationView_Day_1_en.png | 4 ++-- .../features.location.impl.show_ShowLocationView_Day_2_en.png | 4 ++-- .../features.location.impl.show_ShowLocationView_Day_3_en.png | 4 ++-- .../features.location.impl.show_ShowLocationView_Day_4_en.png | 4 ++-- .../features.location.impl.show_ShowLocationView_Day_5_en.png | 4 ++-- .../features.location.impl.show_ShowLocationView_Day_6_en.png | 3 --- .../features.location.impl.show_ShowLocationView_Day_7_en.png | 3 --- ...eatures.location.impl.show_ShowLocationView_Night_0_en.png | 4 ++-- ...eatures.location.impl.show_ShowLocationView_Night_1_en.png | 4 ++-- ...eatures.location.impl.show_ShowLocationView_Night_2_en.png | 4 ++-- ...eatures.location.impl.show_ShowLocationView_Night_3_en.png | 4 ++-- ...eatures.location.impl.show_ShowLocationView_Night_4_en.png | 4 ++-- ...eatures.location.impl.show_ShowLocationView_Night_5_en.png | 4 ++-- ...eatures.location.impl.show_ShowLocationView_Night_6_en.png | 3 --- ...eatures.location.impl.show_ShowLocationView_Night_7_en.png | 3 --- ...pl.messagecomposer_AttachmentSourcePickerMenu_Day_0_en.png | 4 ++-- ....messagecomposer_AttachmentSourcePickerMenu_Night_0_en.png | 4 ++-- ...ine.components.event_TimelineItemLocationView_Day_1_en.png | 4 ++-- ...ine.components.event_TimelineItemLocationView_Day_2_en.png | 3 +++ ...e.components.event_TimelineItemLocationView_Night_1_en.png | 4 ++-- ...e.components.event_TimelineItemLocationView_Night_2_en.png | 3 +++ ...line.components_TimelineItemEventRowWithReply_Day_8_en.png | 4 ++-- ...ne.components_TimelineItemEventRowWithReply_Night_8_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_10_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_11_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_12_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_13_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_14_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_15_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_16_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_17_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_18_en.png | 3 +++ ...atures.messages.impl.timeline_TimelineView_Night_10_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_11_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_12_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_13_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_14_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_15_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_16_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_17_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_18_en.png | 3 +++ .../images/features.messages.impl_MessagesView_Day_1_en.png | 4 ++-- .../images/features.messages.impl_MessagesView_Night_1_en.png | 4 ++-- ...libraries.designsystem.components_LocationPin_Day_0_en.png | 3 +++ ...braries.designsystem.components_LocationPin_Night_0_en.png | 3 +++ .../libraries.designsystem.components_PinIcon_Day_0_en.png | 3 --- .../libraries.designsystem.components_PinIcon_Night_0_en.png | 3 --- ...ries.matrix.ui.components_AttachmentThumbnail_Day_6_en.png | 4 ++-- ...es.matrix.ui.components_AttachmentThumbnail_Night_6_en.png | 4 ++-- ...raries.matrix.ui.messages.reply_InReplyToView_Day_8_en.png | 4 ++-- ...ries.matrix.ui.messages.reply_InReplyToView_Night_8_en.png | 4 ++-- ...es.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png | 4 ++-- ....textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_8_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_8_en.png | 4 ++-- 82 files changed, 154 insertions(+), 136 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_1_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_2_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_3_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_4_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_1_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_2_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_3_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_5_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_6_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_18_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_18_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Night_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Day_0_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Night_0_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png new file mode 100644 index 0000000000..e48b3cd8ab --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2550638ee12b4181cea31caff0b5838a9cdb3a180c01d1188bc7c2726051b863 +size 16578 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png new file mode 100644 index 0000000000..0f17f6d6a1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.common.ui_LocationShareRow_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c880e4d01495868b3f0689d20d3cbf2050d6261be936421343bc1ac210aabeec +size 15959 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_0_en.png deleted file mode 100644 index 7b7a97ff45..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:95a8d94f223cdb1f45fb43406688c7ae103a0e6c8cead84c7726795c553a3b54 -size 19773 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_1_en.png deleted file mode 100644 index 3a820c29a3..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0cf81beb22fca6641de9e08d69da0b6f2ed1e2593296987ac7c052c89010ee75 -size 35449 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_2_en.png deleted file mode 100644 index 6bb166ec80..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0281adefc90c5ec6a9788a142bca2bc486f1957e943791bb61c1e20e707ab0a7 -size 33926 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_3_en.png deleted file mode 100644 index 7b7a97ff45..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:95a8d94f223cdb1f45fb43406688c7ae103a0e6c8cead84c7726795c553a3b54 -size 19773 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_4_en.png deleted file mode 100644 index 07827e9681..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Day_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d6f357222c421d6f02414f3dd9d39c13cfceb36f3f4c6740b91cf18c75eace6e -size 18894 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_0_en.png deleted file mode 100644 index f1e4389d7a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ece0a2814e93bf971c2bfd5309cd239d98795d8ae72041c0a48f0f304250a017 -size 19325 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_1_en.png deleted file mode 100644 index 11d4c7dd65..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_1_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:982df0a262833543c2d4cd56059e48e9020f3bf5b00843acec5b92066dd6ffb3 -size 33522 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_2_en.png deleted file mode 100644 index 97d474cf5a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_2_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6d05adff48c9c6dff7178c556d746f8729e45cb100d074f3bb6a2d783211612a -size 32108 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_3_en.png deleted file mode 100644 index f1e4389d7a..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_3_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ece0a2814e93bf971c2bfd5309cd239d98795d8ae72041c0a48f0f304250a017 -size 19325 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_4_en.png deleted file mode 100644 index 2ccb40dc97..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.send_SendLocationView_Night_4_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0d7578366c6b02b2afe5899195138318bcdda1c408f61740b5fba9084f618a2a -size 18491 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_0_en.png new file mode 100644 index 0000000000..898a010ddf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db105243ef507c209c756015cf41a23150259cbe7ab3be6d233590fa9257b0e5 +size 20528 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_1_en.png new file mode 100644 index 0000000000..8957a012f2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d950639a9cc9961210b449e5c975b6f6bc890a47f4977eeaaa19b837458f66e +size 36475 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_2_en.png new file mode 100644 index 0000000000..bf25a07679 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a52d39cb27572c49ab64b1534ead353687d83870b5d5cfe7158859925b4717f3 +size 34926 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_3_en.png new file mode 100644 index 0000000000..3f99f935c9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ccb0deee31506379cfa76f8d39d37639b6b1f03d7eb2e7d5e8c6ceff300b1978 +size 30397 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_4_en.png new file mode 100644 index 0000000000..898a010ddf --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:db105243ef507c209c756015cf41a23150259cbe7ab3be6d233590fa9257b0e5 +size 20528 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_5_en.png new file mode 100644 index 0000000000..69aea3df2a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ba1418b5d42a56db47e7cc574cedb886c75d9cf22828341bd954a4f1845670e +size 17925 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png new file mode 100644 index 0000000000..cce7a48382 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a97492422d54a6d6666c1ade693dc9b63bc9ca07c17d6c1f787c081984c09f68 +size 42470 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_0_en.png new file mode 100644 index 0000000000..dbd4836467 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3260d344a065f1710aff1450dbdaaa0bd1fbd963d5304167124d845c46be433 +size 19903 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_1_en.png new file mode 100644 index 0000000000..bf0e1594e1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:001bcd1755e837c0c05e493f964ee5c71fba81e811bdaf91effba257743bdacb +size 34836 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_2_en.png new file mode 100644 index 0000000000..11be8ba550 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38d253d8b3aa1c4599aa6b6f5982b7ef0c87ea7346aba13054a7891197b7c000 +size 33268 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_3_en.png new file mode 100644 index 0000000000..2c8c53dc1e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8901e7adb15ff01e9c696729f1abc52c5fc8666bca030f449f8caf5a22615f53 +size 29196 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_4_en.png new file mode 100644 index 0000000000..dbd4836467 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3260d344a065f1710aff1450dbdaaa0bd1fbd963d5304167124d845c46be433 +size 19903 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_5_en.png new file mode 100644 index 0000000000..060f25819e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_5_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d786937d790e13b53a5de8ae3321e5aa8744a979a3a99ecc36def6b7dbf6cf60 +size 17237 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_6_en.png new file mode 100644 index 0000000000..541b2a97e1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.share_ShareLocationView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:038e12f3caeef6ac8d5389b7cdc68138e089dcac335d0a5904adc55c9bcb7b1c +size 40642 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_0_en.png index a7e98b868f..46226555db 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2c0d1a04c5a3096363f636da1d3226c4242936c2430f58a8d08af1b02c5d6808 -size 10890 +oid sha256:cdf7b194a075902ab9434e272865293535ef39370fbb9cb172b3cf8774850c73 +size 19104 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png index d1664bb47d..ff0295d9ac 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:83a49b16c632b680d5c6cd09043c4ca85fdbe8844d519d59377015f4d5657f1f -size 29457 +oid sha256:37ccae030071cc4801538dc5c753a6148ce7e465442edcc89877353b7f5675cb +size 37572 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png index 02b18b4992..6f440d71d6 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c25ef1a184356bb6f7b751bb32abfa6963bf2a1bb4f5f9cdc090d1e573ce04e6 -size 27985 +oid sha256:5f57485d56fd4d02731f797762d931c4d738c8693539da612e33403693cd4b08 +size 35976 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png index a7e98b868f..964ad077b5 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2c0d1a04c5a3096363f636da1d3226c4242936c2430f58a8d08af1b02c5d6808 -size 10890 +oid sha256:c882f3f9ed18a64ecfa253284bf1dbdad5d38f524258b6463521d5185c1c32a7 +size 31530 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png index c8400f7ca7..46226555db 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:651aa76c0a9b6845037c827728d6faf38864135d5a1a421f1acbd29b66aa4122 -size 11019 +oid sha256:cdf7b194a075902ab9434e272865293535ef39370fbb9cb172b3cf8774850c73 +size 19104 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png index c74f159222..ceb1513af6 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fbab6f0cf6e268e16a631183b05b0c463c22d1ebff2358085f12661f98893c02 -size 14598 +oid sha256:8988c700db517eef71d7f42c8e21ac819f51c92fb88c0c25cb400be7a5326c22 +size 19228 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png deleted file mode 100644 index 8338af3af2..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_6_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bb05d4d4481b1177542a7d5f26a0a9a98e718e20848f08132786073594c19d1a -size 23068 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png deleted file mode 100644 index 014429e6b9..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Day_7_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:804cd2bee1d48ec05a248744de906b659e2a6c62c4d3cf4a90ee6cd2689f2081 -size 25758 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_0_en.png index 953e1db0f9..eed60f472d 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d73cdc2be6545d09165a27d77de3204f1a491c04fc6651913ef3b76b2060339 -size 10548 +oid sha256:da601c01dd487f9c66f78ada91954398e1dcdf699f1ba4f6d8f7661f7b8cc4b7 +size 18715 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png index 81fc808eaf..6c424a1ffe 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b9b8967a596dcae62399e953af359caa96a0252ba4d4d63b3eaa5c0afb7f61dd -size 27750 +oid sha256:6a62d7b4716f97f73dddd22fc3ecad30ef159da186ff2f2029772f4574a4f474 +size 36084 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png index 4c0ec571a5..72196c0b11 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8a950a9808be5828a98a0c84a89a6d5fd08a57e22c50f09de8f48f3cb50309d -size 26337 +oid sha256:10d95b146c51895a0e9e816cf56aa216dfbf77e74ae3da10f9c3fa94468ba9ed +size 34500 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png index 953e1db0f9..da90a76ab1 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d73cdc2be6545d09165a27d77de3204f1a491c04fc6651913ef3b76b2060339 -size 10548 +oid sha256:64236eda401891b7a04c9240ed2b9b077b8c08b182b76f454cf0a4376daa740a +size 30345 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png index fd49e1b5a8..eed60f472d 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f258316f364bdf365dd71caa913baa22dce9db599233e02ddca73b9ac4bbc6c7 -size 10666 +oid sha256:da601c01dd487f9c66f78ada91954398e1dcdf699f1ba4f6d8f7661f7b8cc4b7 +size 18715 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png index 1cb2452f75..d3ee3b9e22 100644 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9ac7478f8dbb8195bce194d50008fc9d7dc80d9f40b1d9b2b22e8ddd517c2be5 -size 13941 +oid sha256:ca86fd5eea8c05a52fee801e1ca61c2e4e205ad9874e06d69b1d1f674585f87b +size 18842 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png deleted file mode 100644 index 4bd7b317bd..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_6_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:29c49cbcaf41b41b7924a57e6d6652e7dd5e5bf90b39815f839c9611cb6237eb -size 22205 diff --git a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png deleted file mode 100644 index 12c581dd09..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.location.impl.show_ShowLocationView_Night_7_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bb1255327a2ae18b6dc4117e13b2d1e516eec023ef679034c079cddc9c5a4636 -size 24775 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_AttachmentSourcePickerMenu_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_AttachmentSourcePickerMenu_Day_0_en.png index b92e1ed388..303a32a04d 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_AttachmentSourcePickerMenu_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_AttachmentSourcePickerMenu_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e0bbf92b3132e3653e909f69b4a4baf06e8889e92aaf904883795ce992cd64f -size 23801 +oid sha256:36926eca7b934f7794ddd73e7929861d8837e9ac51341520c883043dc53d1927 +size 25026 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_AttachmentSourcePickerMenu_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_AttachmentSourcePickerMenu_Night_0_en.png index a759c0bc53..32c4015a76 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_AttachmentSourcePickerMenu_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.messagecomposer_AttachmentSourcePickerMenu_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e9f6906366e4af0a2171f287dfc6b3e5b46c1a8b41b9a02600ccf6eb5f44a25d -size 23196 +oid sha256:493ed64dc981851887506ce33cdd063a107c8d5e6376027e7949a248b88e2a42 +size 24167 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png index 9d11f9e61c..57b0b89912 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0731766e54bdee0fcf7b7ca6c5066fc7a2d17f0c18afad3a5b64d5fea9ac95cc -size 148332 +oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553 +size 144841 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png new file mode 100644 index 0000000000..57b0b89912 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b2dea1019d3de891dd47d0d5e3b1deefeb0938be82afb72b0dc77c1f14596553 +size 144841 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png index 4d766b1ee2..74add72a23 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2411192232cdd0f359e446768f052cd1675495afdf1d82526343fc8fa42880f9 -size 62469 +oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573 +size 58482 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png new file mode 100644 index 0000000000..74add72a23 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9305cafe7cca68c9307577533f3b489566c669cbd4fc08c724c8f492bbd75573 +size 58482 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png index 84cabe4409..bf6cbaadcb 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29e50867fd6e64234317766463d3428187a5171215fb5786f6b16d288cc97692 -size 364448 +oid sha256:6d7b628cc474a421869207c8ffd86e46c193da3e1fe8ea115fb12574528da933 +size 364172 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png index 4195637aa5..38499a5838 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:91646548d795c159b94d909276a80ee44ba83b0c95b9d76b2669b6cb4a9f8880 -size 362696 +oid sha256:8b9a9264b6637a35096b2f552a2dbe9f6fa6a078bb631c17bf1d207e10928f07 +size 362463 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_10_en.png index c8bbd3dcd1..b3255ab5b5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ffe8ceb923b63331d4b49bcb4d69793f6ce4f82be6e54b7d3470952b35a2da73 -size 332417 +oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341 +size 374820 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_11_en.png index ce251301d9..b3255ab5b5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:877e252ee1e0c35efef1854bf6c94783add9138148a9e910bc7908ee7228209b -size 88375 +oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341 +size 374820 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_12_en.png index 3907bdf959..ce251301d9 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e157c0f0f115133f36dee9c1f1647fb8f00813a12783b18fa83c6972e92e037 -size 51116 +oid sha256:877e252ee1e0c35efef1854bf6c94783add9138148a9e910bc7908ee7228209b +size 88375 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_13_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_13_en.png index 3ea50dcbe1..3907bdf959 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:00be45a05243c429344206d132d0fdbc6322c25c3c71dcab61f939070dd015d3 -size 63156 +oid sha256:6e157c0f0f115133f36dee9c1f1647fb8f00813a12783b18fa83c6972e92e037 +size 51116 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_14_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_14_en.png index b9da99e5c2..3ea50dcbe1 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a19d67b394c682e329bc232b0a36c5015dd73d09931b7e401c5d306f922f026 -size 47404 +oid sha256:00be45a05243c429344206d132d0fdbc6322c25c3c71dcab61f939070dd015d3 +size 63156 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_15_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_15_en.png index bd22123444..b9da99e5c2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:205d1785959ccc932b6f307bbe8fc86466eeab2be83ce80b265cb54bf75cdfe3 -size 64392 +oid sha256:8a19d67b394c682e329bc232b0a36c5015dd73d09931b7e401c5d306f922f026 +size 47404 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_16_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_16_en.png index cdd4607e18..bd22123444 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4384708192ccd7ef69601ba4a37b491d1959f8ec84c3e70ceba148d716347952 -size 54368 +oid sha256:205d1785959ccc932b6f307bbe8fc86466eeab2be83ce80b265cb54bf75cdfe3 +size 64392 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png index 32a9dd93d2..cdd4607e18 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8c3ebe62e38381e25e85c0be666ab6380811d2fbbea58461b917a0af4c6cb4d8 -size 64322 +oid sha256:4384708192ccd7ef69601ba4a37b491d1959f8ec84c3e70ceba148d716347952 +size 54368 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_18_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_18_en.png new file mode 100644 index 0000000000..32a9dd93d2 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_18_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c3ebe62e38381e25e85c0be666ab6380811d2fbbea58461b917a0af4c6cb4d8 +size 64322 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_10_en.png index 99d8ed3cd9..fc5e6d8e98 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a69c582e4e05d2555ffe5e5aaa190eb216a48aa408623a518b0238dcf70a456 -size 148001 +oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c +size 153022 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_11_en.png index 80dcc6a601..fc5e6d8e98 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e5dddb25a8a201e5334566c90a252111e0d8a65c9949e4f0b7d3d66b3a4103c -size 83890 +oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c +size 153022 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_12_en.png index 7f114a43f2..80dcc6a601 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f225e76a780b6a3eccc66c720f710564b82774250a3f229367b12cdb390928a4 -size 50876 +oid sha256:0e5dddb25a8a201e5334566c90a252111e0d8a65c9949e4f0b7d3d66b3a4103c +size 83890 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_13_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_13_en.png index 161bc26f01..7f114a43f2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed0df44ce25f2a9f7e75d49c48a805f62bbf61f1f63ecee7f29e34f9bcae0e70 -size 61888 +oid sha256:f225e76a780b6a3eccc66c720f710564b82774250a3f229367b12cdb390928a4 +size 50876 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_14_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_14_en.png index d74c89c6aa..161bc26f01 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4eeddbe4e53cdf4453db6a7bd0257a52d46cfcac762da73319cf8735aedce481 -size 47422 +oid sha256:ed0df44ce25f2a9f7e75d49c48a805f62bbf61f1f63ecee7f29e34f9bcae0e70 +size 61888 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_15_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_15_en.png index 6ddd6f5648..d74c89c6aa 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:143ae0c737a60e42370c10cdb39c577b91a151fa69079843e740b9edc7c5fc67 -size 63288 +oid sha256:4eeddbe4e53cdf4453db6a7bd0257a52d46cfcac762da73319cf8735aedce481 +size 47422 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_16_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_16_en.png index 1cff467cbe..6ddd6f5648 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4474b2c524768d2493b7ffd6c05b43f486c595c23f1ea641ba7704fad7181d0f -size 53792 +oid sha256:143ae0c737a60e42370c10cdb39c577b91a151fa69079843e740b9edc7c5fc67 +size 63288 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png index ba24c369e1..1cff467cbe 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f7e7905c6765db72a06c0dba835e34556377fb014476f0ebb2a68a6b93d05314 -size 64889 +oid sha256:4474b2c524768d2493b7ffd6c05b43f486c595c23f1ea641ba7704fad7181d0f +size 53792 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_18_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_18_en.png new file mode 100644 index 0000000000..ba24c369e1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_18_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f7e7905c6765db72a06c0dba835e34556377fb014476f0ebb2a68a6b93d05314 +size 64889 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_1_en.png index 70991f651b..ef8699b324 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:35e9c8ada35ab52308f16683eec27e973d0c66ee3f8ad5cdfffb6ec4ad20b32e -size 38830 +oid sha256:f21b1799dec6cb466378a748adb5227f76bd6cf611be694922ef63a951fdf692 +size 39910 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_1_en.png index 35e473c18f..51a300b462 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl_MessagesView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:761e6215e1554585951626a3ec846526b0dde5ce720e487f379b8c4a074726aa -size 37040 +oid sha256:84a6b1ef879f1a9be16526efdd546aa554e03ea74b0e8ec03178b2a860b752e5 +size 38089 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Day_0_en.png new file mode 100644 index 0000000000..1341961018 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ad77b033de161bcd358e7e3afa15a3d5777a6dde319472be9fbcf07d0f5c800 +size 16358 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Night_0_en.png new file mode 100644 index 0000000000..dac3588ed9 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9767298a096be7fc78210245e6b806a72a6c1ad16d7335e0f89728bbcf08ebd4 +size 15884 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Day_0_en.png deleted file mode 100644 index f758a309b4..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Day_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d3ca357675ca4328041237cb4b9d8a5725c2a37007e242395742fc8f65b4bec9 -size 4732 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Night_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Night_0_en.png deleted file mode 100644 index b174bdbfdd..0000000000 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_PinIcon_Night_0_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:25bb33b880f2ce4eafcb2a34ac1a6cf806da8820caa95c46883a525b50c784fe -size 4731 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Day_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Day_6_en.png index 28bf34a5a4..54ad34decf 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c5a90fc9dc608cc080c473c127289aba3a75c1b04b7353cdf053a625ab45aaf -size 4957 +oid sha256:9a0f68b963d169fead1b012792aeaa890cfa1631370b6f276e39e5c23febebda +size 4749 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Night_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Night_6_en.png index 9316c24d4c..1a2ae3b794 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.components_AttachmentThumbnail_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a56fa04f2d2e98a0a9147fae158e7921ee533290feb6e08b3111fdb4c6d21c43 -size 4939 +oid sha256:6e787d1d35de4d2473ae5b59a8c77c129bb6d8baed05e8dd243edaafb7038639 +size 4795 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Day_8_en.png index c0a170e9de..789e6ad754 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e34ddf667db8e18e9554f476cf120766c3882ea8155818ffae3a313c4b88d433 -size 9048 +oid sha256:936f352e9c70919e2f5d325dee2eb3048b625e77bbdc0ac1685d023c131a7cb2 +size 8977 diff --git a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Night_8_en.png index 2840f32904..8220e6b24a 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.matrix.ui.messages.reply_InReplyToView_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:afcaa0043c17025cbacaf6964ba681a11949ae1241da30aa6a6ab8ebcf3cd19e -size 8839 +oid sha256:8799ed0f6c481130b4f1e3b5ff2c47148e84d5d26aa94559643b82f6db5a3521 +size 8756 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png index 5ef915812c..222210f39f 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d23fdafbd834da43a6fe57d5666fc6a3108359838053961a419a9b90a1f7aa53 -size 67001 +oid sha256:de9d73aa7c19275f9a97d81a99d85f9e03fb38b19a0313e0920034d49f88c0aa +size 66768 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png index 86e8f3d34c..4f13f41ce4 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:df811a1843d3d88ac021d8df892c2b74f61a679317ed40234438459833b90fa9 -size 64134 +oid sha256:5da7140b7701c19dcf429afc0b379c02b4572806fc97a11609b112253270f8ef +size 64018 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png index fc69d0c81c..ae05740f43 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0f924131a517897decc596cf360b7aed621a358a1a58ad7e5e512e5caab6203c -size 67493 +oid sha256:1351eeea2c6bf92f358ceb8ce7021e7f7dc0cfb34525ed3757987b85d2ca10f4 +size 67235 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png index b1ac77f79d..628ab387ab 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7f42650a174ccfee6dbf722045a0147d0ff5e5f3a0d7fcdd14a46a32bbf9ddb6 -size 64628 +oid sha256:c64d685b7710e864b6e0beaa8f77061e07874b8ec15f1f2bb04c484a08d570c1 +size 64416 From 7c6a5638ad104e1dcf61c85b0fa5defdeba308c1 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 13 Mar 2026 10:18:28 +0100 Subject: [PATCH 40/52] Fix some existing tests after changes --- .../MapTilerStaticMapUrlBuilderTest.kt | 24 +++++++++---------- .../location/impl/share/ShareLocationView.kt | 2 ++ ...efaultPinnedMessagesBannerFormatterTest.kt | 2 +- .../DefaultRoomLatestEventFormatterTest.kt | 2 +- .../messages/reply/InReplyToMetadataKtTest.kt | 1 + .../datasource/DefaultEventItemFactoryTest.kt | 2 +- .../DefaultNotifiableEventResolverTest.kt | 2 +- 7 files changed, 19 insertions(+), 16 deletions(-) diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt index 65c0acd88d..3d17107104 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt @@ -47,7 +47,7 @@ class MapTilerStaticMapUrlBuilderTest { height = 600, density = 1f, ) - ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft") + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=topright") } @Test @@ -62,7 +62,7 @@ class MapTilerStaticMapUrlBuilderTest { height = 900, density = 1.5f, ) - ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=bottomleft") + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600.webp?key=anApiKey&attribution=topright") } @Test @@ -77,7 +77,7 @@ class MapTilerStaticMapUrlBuilderTest { height = 1200, density = 2f, ) - ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft") + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=topright") } @Test @@ -92,7 +92,7 @@ class MapTilerStaticMapUrlBuilderTest { height = 1800, density = 3f, ) - ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=bottomleft") + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/800x600@2x.webp?key=anApiKey&attribution=topright") } @Test @@ -107,7 +107,7 @@ class MapTilerStaticMapUrlBuilderTest { height = 2048, density = 1f, ) - ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=bottomleft") + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/2048x1024.webp?key=anApiKey&attribution=topright") assertThat( builder.build( @@ -119,7 +119,7 @@ class MapTilerStaticMapUrlBuilderTest { height = 4096, density = 1f, ) - ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=bottomleft") + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x2048.webp?key=anApiKey&attribution=topright") assertThat( builder.build( @@ -131,7 +131,7 @@ class MapTilerStaticMapUrlBuilderTest { height = 2048, density = 2f, ) - ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=bottomleft") + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x512@2x.webp?key=anApiKey&attribution=topright") assertThat( builder.build( @@ -143,7 +143,7 @@ class MapTilerStaticMapUrlBuilderTest { height = 4096, density = 2f, ) - ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=bottomleft") + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/512x1024@2x.webp?key=anApiKey&attribution=topright") assertThat( builder.build( @@ -155,7 +155,7 @@ class MapTilerStaticMapUrlBuilderTest { height = Int.MAX_VALUE, density = 2f, ) - ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=bottomleft") + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/1024x1024@2x.webp?key=anApiKey&attribution=topright") } @Test @@ -170,7 +170,7 @@ class MapTilerStaticMapUrlBuilderTest { height = 0, density = 1f, ) - ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft") + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=topright") assertThat( builder.build( @@ -182,7 +182,7 @@ class MapTilerStaticMapUrlBuilderTest { height = 0, density = 2f, ) - ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=bottomleft") + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0@2x.webp?key=anApiKey&attribution=topright") assertThat( builder.build( @@ -194,6 +194,6 @@ class MapTilerStaticMapUrlBuilderTest { height = Int.MIN_VALUE, density = 1f, ) - ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=bottomleft") + ).isEqualTo("https://base.url/aLightMapId/static/-4.56,1.23,7.8/0x0.webp?key=anApiKey&attribution=topright") } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index ecb4cf9dd4..b463d494dc 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -6,6 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ +@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH") + package io.element.android.features.location.impl.share import androidx.compose.foundation.layout.Arrangement diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt index b58bbb4b25..e91bed409e 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt @@ -145,7 +145,7 @@ class DefaultPinnedMessagesBannerFormatterTest { ImageMessageType(body, null, null, MediaSource("url"), null), StickerMessageType(body, null, null, MediaSource("url"), null), FileMessageType(body, null, null, MediaSource("url"), null), - LocationMessageType(body, "geo:1,2", null), + LocationMessageType(body, "geo:1,2", null, null), NoticeMessageType(body, null), EmoteMessageType(body, null), OtherMessageType(msgType = "a_type", body = body), diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt index 0da3134098..2345af8a33 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt @@ -190,7 +190,7 @@ class DefaultRoomLatestEventFormatterTest { ImageMessageType(body, null, null, MediaSource("url"), null), StickerMessageType(body, null, null, MediaSource("url"), null), FileMessageType(body, null, null, MediaSource("url"), null), - LocationMessageType(body, "geo:1,2", null), + LocationMessageType(body, "geo:1,2", null, null), NoticeMessageType(body, null), EmoteMessageType(body, null), OtherMessageType(msgType = "a_type", body = body), diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt index 004e622211..5c7fdfaac8 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt @@ -355,6 +355,7 @@ class InReplyToMetadataKtTest { body = "body", geoUri = "geo:3.0,4.0;u=5.0", description = null, + assetType = null ) ) ).metadata(hideImage = false) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt index c70d658418..6c88f1c33f 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt @@ -107,7 +107,7 @@ class DefaultEventItemFactoryTest { EmoteMessageType("", null), NoticeMessageType("", null), OtherMessageType("", ""), - LocationMessageType("", "", null), + LocationMessageType("", "", null, null), TextMessageType("", null) ) messageTypes.forEach { diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index a25e5782ba..63b903a3f7 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -339,7 +339,7 @@ class DefaultNotifiableEventResolverTest { AN_EVENT_ID to Result.success(aNotificationData( content = NotificationContent.MessageLike.RoomMessage( senderId = A_USER_ID_2, - messageType = LocationMessageType("Location", "geo:1,2", null), + messageType = LocationMessageType("Location", "geo:1,2", null, null), ), )) ) From 249d45ce04a95a8cb44fd1f32e43038fb2e3a273 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 13 Mar 2026 15:08:17 +0100 Subject: [PATCH 41/52] Fix wrong dependency --- features/location/impl/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index c7b19fbf59..14dd7a7836 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -45,7 +45,7 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) - implementation(projects.libraries.dateformatter.test) + testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.testtags) testImplementation(projects.services.analytics.test) testImplementation(projects.features.messages.test) From 83832a38f9adeac2726e88f3fba3ca7c5ed1f02a Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 13 Mar 2026 15:48:56 +0100 Subject: [PATCH 42/52] Fix stability --- .../impl/show/ShowLocationPresenter.kt | 14 +-- .../location/impl/show/ShowLocationState.kt | 7 +- .../impl/show/ShowLocationStateProvider.kt | 106 ++++++++---------- .../location/impl/show/ShowLocationView.kt | 20 ++-- 4 files changed, 69 insertions(+), 78 deletions(-) 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 d74d2f36e1..9978c6e1f5 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 @@ -37,6 +37,7 @@ 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 import io.element.android.libraries.matrix.api.room.location.AssetType +import kotlinx.collections.immutable.persistentListOf @AssistedInject class ShowLocationPresenter( @@ -95,7 +96,7 @@ class ShowLocationPresenter( } } - val markers = remember(mode) { + val markers = remember { when (mode) { is ShowLocationMode.Static -> { val pinVariant = if (mode.assetType == AssetType.PIN) { @@ -111,7 +112,7 @@ class ShowLocationPresenter( isLive = false, ) } - listOf( + persistentListOf( LocationMarkerData( id = mode.senderId.value, location = mode.location, @@ -119,16 +120,16 @@ class ShowLocationPresenter( ) ) } - ShowLocationMode.Live -> emptyList() + ShowLocationMode.Live -> persistentListOf() } } - val locationShares = remember(mode) { + val locationShares = remember { when (mode) { is ShowLocationMode.Static -> { val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true) val formattedTimestamp = "Shared $relativeTime" - listOf( + persistentListOf( LocationShareItem( userId = mode.senderId, displayName = mode.senderName, @@ -145,13 +146,12 @@ class ShowLocationPresenter( ) ) } - ShowLocationMode.Live -> emptyList() + ShowLocationMode.Live -> persistentListOf() } } return ShowLocationState( dialogState = dialogState, - mode = mode, markers = markers, locationShares = locationShares, hasLocationPermission = permissionsState.isAnyGranted, 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 85d79f1192..f6c2e6e18f 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 @@ -9,18 +9,17 @@ 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.LocationConstraintsDialogState 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 +import kotlinx.collections.immutable.ImmutableList data class ShowLocationState( val dialogState: LocationConstraintsDialogState, - val mode: ShowLocationMode, - val markers: List, - val locationShares: List, + val markers: ImmutableList, + val locationShares: ImmutableList, val hasLocationPermission: Boolean, val isTrackMyLocation: Boolean, val appName: String, 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 9bbd818f1a..274ccfc8f2 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 @@ -10,7 +10,6 @@ package io.element.android.features.location.impl.show import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationMarkerData import io.element.android.libraries.designsystem.components.PinVariant @@ -18,6 +17,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.location.AssetType +import kotlinx.collections.immutable.toImmutableList private const val APP_NAME = "ApplicationName" @@ -45,62 +45,23 @@ class ShowLocationStateProvider : PreviewParameterProvider { ) } +private val defaultLocation = Location(1.23, 2.34, 4f) +private val defaultSenderId = UserId("@alice:matrix.org") +private const val defaultSenderName = "Alice" + fun aShowLocationState( constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, - mode: ShowLocationMode = aStaticLocationMode(), - markers: List? = null, - locationSharers: List? = null, + markers: List = listOf(aLocationMarkerData()), + locationShares: List = listOf(aLocationShareItem()), hasLocationPermission: Boolean = false, isTrackMyLocation: Boolean = false, appName: String = APP_NAME, eventSink: (ShowLocationEvent) -> Unit = {}, ): ShowLocationState { - val effectiveMarkers = markers ?: when (mode) { - is ShowLocationMode.Static -> listOf( - LocationMarkerData( - id = mode.senderId.value, - location = mode.location, - variant = if (mode.assetType == AssetType.PIN) { - PinVariant.PinnedLocation - } else { - PinVariant.UserLocation( - avatarData = AvatarData( - id = mode.senderId.value, - name = mode.senderName, - url = mode.senderAvatarUrl, - size = AvatarSize.LocationPin, - ), - isLive = true, - ) - } - ) - ) - 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( dialogState = constraintsDialogState, - mode = mode, - markers = effectiveMarkers, - locationShares = effectiveLocationSharers, + markers = markers.toImmutableList(), + locationShares = locationShares.toImmutableList(), hasLocationPermission = hasLocationPermission, isTrackMyLocation = isTrackMyLocation, appName = appName, @@ -108,18 +69,43 @@ fun aShowLocationState( ) } -fun aStaticLocationMode( - location: Location = Location(1.23, 2.34, 4f), - senderName: String = "Alice", - senderId: UserId = UserId("@alice:matrix.org"), - senderAvatarUrl: String? = null, - timestamp: Long = System.currentTimeMillis(), - assetType: AssetType? = null, -) = ShowLocationMode.Static( +fun aLocationMarkerData( + id: String = defaultSenderId.value, + location: Location = defaultLocation, + variant: PinVariant = PinVariant.UserLocation( + avatarData = AvatarData( + id = defaultSenderId.value, + name = defaultSenderName, + url = null, + size = AvatarSize.LocationPin, + ), + isLive = false, + ), +) = LocationMarkerData( + id = id, location = location, - senderName = senderName, - senderId = senderId, - senderAvatarUrl = senderAvatarUrl, - timestamp = timestamp, + variant = variant, +) + +fun aLocationShareItem( + userId: UserId = defaultSenderId, + displayName: String = defaultSenderName, + avatarData: AvatarData = AvatarData( + id = defaultSenderId.value, + name = defaultSenderName, + url = null, + size = AvatarSize.UserListItem, + ), + formattedTimestamp: String = "Shared 1 min ago", + location: Location = defaultLocation, + isLive: Boolean = false, + assetType: AssetType? = null, +) = LocationShareItem( + userId = userId, + displayName = displayName, + avatarData = avatarData, + formattedTimestamp = formattedTimestamp, + location = location, + isLive = isLive, assetType = assetType, ) 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 fdeb027b1e..d97cef81d0 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 @@ -6,6 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ +@file:Suppress("COMPOSE_APPLIER_CALL_MISMATCH") + package io.element.android.features.location.impl.show import androidx.compose.foundation.clickable @@ -19,6 +21,7 @@ import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -26,7 +29,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton @@ -63,12 +65,16 @@ fun ShowLocationView( onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) }, ) - val initialPosition = when (val mode = state.mode) { - is ShowLocationMode.Static -> CameraPosition( - target = Position(latitude = mode.location.lat, longitude = mode.location.lon), - zoom = MapDefaults.DEFAULT_ZOOM - ) - ShowLocationMode.Live -> MapDefaults.defaultCameraPosition + val initialPosition = remember { + if (state.markers.isEmpty()) { + MapDefaults.defaultCameraPosition + } else { + val firstLocation = state.markers.first().location + CameraPosition( + target = Position(latitude = firstLocation.lat, longitude = firstLocation.lon), + zoom = MapDefaults.DEFAULT_ZOOM + ) + } } val cameraState = rememberCameraState(firstPosition = initialPosition) val userLocationState = rememberUserLocationState(state.hasLocationPermission) From 91c46ec971cd88a9a35d3466eaaf52f996c2fdc7 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 13 Mar 2026 15:50:33 +0100 Subject: [PATCH 43/52] Remove duplicate location content and reorder Live mode --- .../timeline/model/event/TimelineItemEventContentProvider.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt index 017fba1902..f3d70f44e7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt @@ -28,15 +28,14 @@ class TimelineItemEventContentProvider : PreviewParameterProvider Date: Fri, 13 Mar 2026 15:10:17 +0000 Subject: [PATCH 44/52] Update screenshots --- ...features.messages.impl.timeline_TimelineView_Day_10_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_11_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_12_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_13_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_14_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_15_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_16_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_17_en.png | 4 ++-- ...features.messages.impl.timeline_TimelineView_Day_18_en.png | 3 --- ...atures.messages.impl.timeline_TimelineView_Night_10_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_11_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_12_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_13_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_14_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_15_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_16_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_17_en.png | 4 ++-- ...atures.messages.impl.timeline_TimelineView_Night_18_en.png | 3 --- ...libraries.designsystem.components_LocationPin_Day_0_en.png | 4 ++-- 19 files changed, 34 insertions(+), 40 deletions(-) delete mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_18_en.png delete mode 100644 tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_18_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_10_en.png index b3255ab5b5..ce251301d9 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341 -size 374820 +oid sha256:877e252ee1e0c35efef1854bf6c94783add9138148a9e910bc7908ee7228209b +size 88375 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_11_en.png index b3255ab5b5..3907bdf959 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341 -size 374820 +oid sha256:6e157c0f0f115133f36dee9c1f1647fb8f00813a12783b18fa83c6972e92e037 +size 51116 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_12_en.png index ce251301d9..3ea50dcbe1 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:877e252ee1e0c35efef1854bf6c94783add9138148a9e910bc7908ee7228209b -size 88375 +oid sha256:00be45a05243c429344206d132d0fdbc6322c25c3c71dcab61f939070dd015d3 +size 63156 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_13_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_13_en.png index 3907bdf959..b9da99e5c2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6e157c0f0f115133f36dee9c1f1647fb8f00813a12783b18fa83c6972e92e037 -size 51116 +oid sha256:8a19d67b394c682e329bc232b0a36c5015dd73d09931b7e401c5d306f922f026 +size 47404 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_14_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_14_en.png index 3ea50dcbe1..bd22123444 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:00be45a05243c429344206d132d0fdbc6322c25c3c71dcab61f939070dd015d3 -size 63156 +oid sha256:205d1785959ccc932b6f307bbe8fc86466eeab2be83ce80b265cb54bf75cdfe3 +size 64392 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_15_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_15_en.png index b9da99e5c2..cdd4607e18 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8a19d67b394c682e329bc232b0a36c5015dd73d09931b7e401c5d306f922f026 -size 47404 +oid sha256:4384708192ccd7ef69601ba4a37b491d1959f8ec84c3e70ceba148d716347952 +size 54368 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_16_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_16_en.png index bd22123444..32a9dd93d2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:205d1785959ccc932b6f307bbe8fc86466eeab2be83ce80b265cb54bf75cdfe3 -size 64392 +oid sha256:8c3ebe62e38381e25e85c0be666ab6380811d2fbbea58461b917a0af4c6cb4d8 +size 64322 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png index cdd4607e18..b3255ab5b5 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4384708192ccd7ef69601ba4a37b491d1959f8ec84c3e70ceba148d716347952 -size 54368 +oid sha256:8a87e01d7a14fa40b73c76af0e80c4f2b43ec8cd2f40cb82ef3e321d54f6f341 +size 374820 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_18_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_18_en.png deleted file mode 100644 index 32a9dd93d2..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Day_18_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8c3ebe62e38381e25e85c0be666ab6380811d2fbbea58461b917a0af4c6cb4d8 -size 64322 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_10_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_10_en.png index fc5e6d8e98..80dcc6a601 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_10_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_10_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c -size 153022 +oid sha256:0e5dddb25a8a201e5334566c90a252111e0d8a65c9949e4f0b7d3d66b3a4103c +size 83890 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_11_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_11_en.png index fc5e6d8e98..7f114a43f2 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_11_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_11_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c -size 153022 +oid sha256:f225e76a780b6a3eccc66c720f710564b82774250a3f229367b12cdb390928a4 +size 50876 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_12_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_12_en.png index 80dcc6a601..161bc26f01 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_12_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_12_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0e5dddb25a8a201e5334566c90a252111e0d8a65c9949e4f0b7d3d66b3a4103c -size 83890 +oid sha256:ed0df44ce25f2a9f7e75d49c48a805f62bbf61f1f63ecee7f29e34f9bcae0e70 +size 61888 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_13_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_13_en.png index 7f114a43f2..d74c89c6aa 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_13_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_13_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f225e76a780b6a3eccc66c720f710564b82774250a3f229367b12cdb390928a4 -size 50876 +oid sha256:4eeddbe4e53cdf4453db6a7bd0257a52d46cfcac762da73319cf8735aedce481 +size 47422 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_14_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_14_en.png index 161bc26f01..6ddd6f5648 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_14_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_14_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ed0df44ce25f2a9f7e75d49c48a805f62bbf61f1f63ecee7f29e34f9bcae0e70 -size 61888 +oid sha256:143ae0c737a60e42370c10cdb39c577b91a151fa69079843e740b9edc7c5fc67 +size 63288 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_15_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_15_en.png index d74c89c6aa..1cff467cbe 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_15_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_15_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4eeddbe4e53cdf4453db6a7bd0257a52d46cfcac762da73319cf8735aedce481 -size 47422 +oid sha256:4474b2c524768d2493b7ffd6c05b43f486c595c23f1ea641ba7704fad7181d0f +size 53792 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_16_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_16_en.png index 6ddd6f5648..ba24c369e1 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_16_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_16_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:143ae0c737a60e42370c10cdb39c577b91a151fa69079843e740b9edc7c5fc67 -size 63288 +oid sha256:f7e7905c6765db72a06c0dba835e34556377fb014476f0ebb2a68a6b93d05314 +size 64889 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png index 1cff467cbe..fc5e6d8e98 100644 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png +++ b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_17_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4474b2c524768d2493b7ffd6c05b43f486c595c23f1ea641ba7704fad7181d0f -size 53792 +oid sha256:3ac7741cfc4ec1d8fd9c8c5bab7dc3dcc0ba79932b5a1ab325573b7dd3622c4c +size 153022 diff --git a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_18_en.png b/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_18_en.png deleted file mode 100644 index ba24c369e1..0000000000 --- a/tests/uitests/src/test/snapshots/images/features.messages.impl.timeline_TimelineView_Night_18_en.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f7e7905c6765db72a06c0dba835e34556377fb014476f0ebb2a68a6b93d05314 -size 64889 diff --git a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Day_0_en.png index 1341961018..8dda833412 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.designsystem.components_LocationPin_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0ad77b033de161bcd358e7e3afa15a3d5777a6dde319472be9fbcf07d0f5c800 -size 16358 +oid sha256:4f3df9c9de000b15692b982c7d69f85ce3d03cab468c342773c88eff3b4fca65 +size 9082 From 9b98a4794143323b0cc784ee1cdc8fe50523ebb4 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 13 Mar 2026 18:41:13 +0100 Subject: [PATCH 45/52] Use localized string instead of hardcoded --- features/location/impl/build.gradle.kts | 2 +- .../features/location/impl/show/ShowLocationPresenter.kt | 8 +++++++- .../impl/show/DefaultShowLocationEntryPointTest.kt | 2 ++ .../location/impl/show/ShowLocationPresenterTest.kt | 2 ++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 14dd7a7836..0da54a1394 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -36,7 +36,6 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.matrixui) - implementation(projects.libraries.androidutils) implementation(projects.services.analytics.api) implementation(libs.accompanist.permission) implementation(projects.libraries.uiStrings) @@ -46,6 +45,7 @@ dependencies { testCommonDependencies(libs, true) testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.dateformatter.test) + testImplementation(projects.services.toolbox.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/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 9978c6e1f5..7ecab4924e 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 @@ -37,6 +37,8 @@ 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 import io.element.android.libraries.matrix.api.room.location.AssetType +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.collections.immutable.persistentListOf @AssistedInject @@ -46,6 +48,7 @@ class ShowLocationPresenter( private val locationActions: LocationActions, private val buildMeta: BuildMeta, private val dateFormatter: DateFormatter, + private val stringProvider: StringProvider, ) : Presenter { @AssistedFactory fun interface Factory { @@ -128,7 +131,10 @@ class ShowLocationPresenter( when (mode) { is ShowLocationMode.Static -> { val relativeTime = dateFormatter.format(timestamp = mode.timestamp, mode = DateFormatterMode.Full, useRelative = true) - val formattedTimestamp = "Shared $relativeTime" + val formattedTimestamp = stringProvider.getString( + CommonStrings.screen_static_location_sheet_timestamp_description, + relativeTime + ) persistentListOf( LocationShareItem( userId = mode.senderId, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt index b8a32e5912..451531fc7e 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.node.TestParentNode import org.junit.Rule import org.junit.Test @@ -42,6 +43,7 @@ class DefaultShowLocationEntryPointTest { locationActions = FakeLocationActions(), buildMeta = aBuildMeta(), dateFormatter = FakeDateFormatter(), + stringProvider = FakeStringProvider() ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index 8a29f0463d..931dd55cea 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -23,6 +23,7 @@ import io.element.android.features.location.impl.common.ui.LocationConstraintsDi import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test import kotlinx.coroutines.delay @@ -56,6 +57,7 @@ class ShowLocationPresenterTest { locationActions = locationActions, buildMeta = fakeBuildMeta, dateFormatter = fakeDateFormatter, + stringProvider = FakeStringProvider() ) @Test From 6414d74c40141140c1d18d12fb8993263165be6c Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 13 Mar 2026 18:41:32 +0100 Subject: [PATCH 46/52] Simplify ShowLocationState --- .../impl/common/ui/LocationShareRow.kt | 10 +++-- .../impl/show/ShowLocationPresenter.kt | 34 +---------------- .../location/impl/show/ShowLocationState.kt | 18 ++++++++- .../impl/show/ShowLocationStateProvider.kt | 38 +++---------------- .../location/impl/show/ShowLocationView.kt | 9 +++-- 5 files changed, 37 insertions(+), 72 deletions(-) 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 index 9e5e35b2ba..b949f55c76 100644 --- 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 @@ -78,7 +78,11 @@ fun LocationShareRow( modifier = Modifier.size(16.dp), ) } else { - val icon = if (item.assetType == AssetType.PIN) CompoundIcons.LocationNavigator() else CompoundIcons.LocationNavigatorCentred() + val icon = if (item.assetType == AssetType.PIN) { + CompoundIcons.LocationNavigator() + } else { + CompoundIcons.LocationNavigatorCentred() + } Icon( imageVector = icon, contentDescription = null, @@ -120,8 +124,8 @@ internal fun LocationShareRowPreview() = ElementPreview { size = AvatarSize.UserListItem, ), formattedTimestamp = "Shared 1 min ago", - assetType = AssetType.SENDER, isLive = true, + assetType = AssetType.SENDER, location = Location(0.0, 0.0) ), onShareClick = {}, @@ -136,9 +140,9 @@ internal fun LocationShareRowPreview() = ElementPreview { url = null, size = AvatarSize.UserListItem, ), + isLive = false, 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/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 7ecab4924e..a2c9a3702d 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 @@ -28,15 +28,12 @@ import io.element.android.features.location.impl.common.permissions.PermissionsP import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.toDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState -import io.element.android.features.location.impl.common.ui.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 -import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.toolbox.api.strings.StringProvider import kotlinx.collections.immutable.persistentListOf @@ -99,34 +96,6 @@ class ShowLocationPresenter( } } - val markers = remember { - when (mode) { - is ShowLocationMode.Static -> { - val pinVariant = if (mode.assetType == AssetType.PIN) { - PinVariant.PinnedLocation - } else { - PinVariant.UserLocation( - avatarData = AvatarData( - id = mode.senderId.value, - name = mode.senderName, - url = mode.senderAvatarUrl, - size = AvatarSize.UserListItem, - ), - isLive = false, - ) - } - persistentListOf( - LocationMarkerData( - id = mode.senderId.value, - location = mode.location, - variant = pinVariant, - ) - ) - } - ShowLocationMode.Live -> persistentListOf() - } - } - val locationShares = remember { when (mode) { is ShowLocationMode.Static -> { @@ -146,9 +115,9 @@ class ShowLocationPresenter( size = AvatarSize.UserListItem, ), formattedTimestamp = formattedTimestamp, + location = mode.location, isLive = false, assetType = mode.assetType, - location = mode.location, ) ) } @@ -158,7 +127,6 @@ class ShowLocationPresenter( return ShowLocationState( dialogState = dialogState, - markers = markers, locationShares = locationShares, hasLocationPermission = permissionsState.isAnyGranted, isTrackMyLocation = isTrackMyLocation, 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 f6c2e6e18f..9494db12ec 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 @@ -11,6 +11,7 @@ package io.element.android.features.location.impl.show import io.element.android.features.location.api.Location import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationMarkerData +import io.element.android.libraries.designsystem.components.PinVariant 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 @@ -18,7 +19,6 @@ import kotlinx.collections.immutable.ImmutableList data class ShowLocationState( val dialogState: LocationConstraintsDialogState, - val markers: ImmutableList, val locationShares: ImmutableList, val hasLocationPermission: Boolean, val isTrackMyLocation: Boolean, @@ -37,3 +37,19 @@ data class LocationShareItem( val isLive: Boolean, val assetType: AssetType?, ) + +fun LocationShareItem.toMarkerData(): LocationMarkerData { + val pinVariant = if (assetType == AssetType.PIN) { + PinVariant.PinnedLocation + } else { + PinVariant.UserLocation( + avatarData = avatarData, + isLive = isLive, + ) + } + return LocationMarkerData( + id = userId.value, + location = location, + variant = pinVariant, + ) +} 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 274ccfc8f2..8bee410715 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 @@ -11,16 +11,12 @@ package io.element.android.features.location.impl.show import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.location.api.Location import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState -import io.element.android.features.location.impl.common.ui.LocationMarkerData -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 import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.location.AssetType import kotlinx.collections.immutable.toImmutableList -private const val APP_NAME = "ApplicationName" - class ShowLocationStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( @@ -45,13 +41,10 @@ class ShowLocationStateProvider : PreviewParameterProvider { ) } -private val defaultLocation = Location(1.23, 2.34, 4f) -private val defaultSenderId = UserId("@alice:matrix.org") -private const val defaultSenderName = "Alice" +private const val APP_NAME = "ApplicationName" fun aShowLocationState( constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, - markers: List = listOf(aLocationMarkerData()), locationShares: List = listOf(aLocationShareItem()), hasLocationPermission: Boolean = false, isTrackMyLocation: Boolean = false, @@ -60,7 +53,6 @@ fun aShowLocationState( ): ShowLocationState { return ShowLocationState( dialogState = constraintsDialogState, - markers = markers.toImmutableList(), locationShares = locationShares.toImmutableList(), hasLocationPermission = hasLocationPermission, isTrackMyLocation = isTrackMyLocation, @@ -69,35 +61,17 @@ fun aShowLocationState( ) } -fun aLocationMarkerData( - id: String = defaultSenderId.value, - location: Location = defaultLocation, - variant: PinVariant = PinVariant.UserLocation( - avatarData = AvatarData( - id = defaultSenderId.value, - name = defaultSenderName, - url = null, - size = AvatarSize.LocationPin, - ), - isLive = false, - ), -) = LocationMarkerData( - id = id, - location = location, - variant = variant, -) - fun aLocationShareItem( - userId: UserId = defaultSenderId, - displayName: String = defaultSenderName, + userId: UserId = UserId("@alice:matrix.org"), + displayName: String = "Alice", avatarData: AvatarData = AvatarData( - id = defaultSenderId.value, - name = defaultSenderName, + id = userId.value, + name = displayName, url = null, size = AvatarSize.UserListItem, ), formattedTimestamp: String = "Shared 1 min ago", - location: Location = defaultLocation, + location: Location = Location(1.23, 2.34, 4f), isLive: Boolean = false, assetType: AssetType? = null, ) = LocationShareItem( 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 d97cef81d0..ad2d4cb8ca 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 @@ -66,10 +66,10 @@ fun ShowLocationView( ) val initialPosition = remember { - if (state.markers.isEmpty()) { + if (state.locationShares.isEmpty()) { MapDefaults.defaultCameraPosition } else { - val firstLocation = state.markers.first().location + val firstLocation = state.locationShares.first().location CameraPosition( target = Position(latitude = firstLocation.lat, longitude = firstLocation.lon), zoom = MapDefaults.DEFAULT_ZOOM @@ -147,7 +147,10 @@ fun ShowLocationView( locationState = userLocationState, trackUserLocation = state.isTrackMyLocation ) - LocationPinMarkers(state.markers) + val markers = remember(state.locationShares) { + state.locationShares.map { it.toMarkerData() } + } + LocationPinMarkers(markers) }, overlayContent = { LocationFloatingActionButton( From abdbc24b474edebc00096007827a63884786788e Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 20 Mar 2026 18:15:44 +0100 Subject: [PATCH 47/52] fix padding on map when gesture navigation is on --- .../location/impl/common/ui/MapBottomSheetScaffold.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 09c5067e1a..fbaed9c854 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 @@ -18,7 +18,7 @@ import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.BottomSheetDefaults @@ -93,7 +93,7 @@ fun MapBottomSheetScaffold( ) { val density = LocalDensity.current - val windowInsets = WindowInsets.safeContent.only(WindowInsetsSides.Horizontal) + val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) { val layoutHeightPx by rememberUpdatedState(constraints.maxHeight) val sheetPadding by remember { From 7581d0ecf21e43244b8449463138b61de2a75ede Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 24 Mar 2026 10:15:25 +0100 Subject: [PATCH 48/52] Use formatter for LLS duration --- .../common/ui/LocationConstraintsDialog.kt | 3 - .../impl/share/LiveLocationDuration.kt | 12 +- .../impl/share/ShareLocationPresenter.kt | 12 +- .../location/impl/share/ShareLocationState.kt | 3 +- .../impl/share/ShareLocationStateProvider.kt | 11 +- .../location/impl/share/ShareLocationView.kt | 11 +- .../DefaultShareLocationEntryPointTest.kt | 2 + .../impl/share/ShareLocationPresenterTest.kt | 11 +- .../dateformatter/api/DurationFormatter.kt | 13 ++ .../impl/DefaultDurationFormatter.kt | 69 +++++++++ .../impl/DefaultDurationFormatterTest.kt | 133 ++++++++++++++++++ .../test/FakeDurationFormatter.kt | 19 +++ 12 files changed, 275 insertions(+), 24 deletions(-) create mode 100644 libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatter.kt create mode 100644 libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt create mode 100644 libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDurationFormatter.kt diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt index 188f8129f8..95f5129f91 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationConstraintsDialog.kt @@ -29,21 +29,18 @@ fun LocationConstraintsDialog( onSubmitClick = onRequestPermissions, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), - cancelText = stringResource(CommonStrings.action_cancel), ) LocationConstraintsDialogState.PermissionDenied -> ConfirmationDialog( content = stringResource(CommonStrings.error_missing_location_auth_android, appName), onSubmitClick = onOpenAppSettings, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), - cancelText = stringResource(CommonStrings.action_cancel), ) LocationConstraintsDialogState.LocationServiceDisabled -> ConfirmationDialog( content = stringResource(CommonStrings.error_location_service_disabled_android), onSubmitClick = onOpenLocationSettings, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), - cancelText = stringResource(CommonStrings.action_cancel), ) } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt index e4ecf331f6..6a45ce3def 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/LiveLocationDuration.kt @@ -8,14 +8,8 @@ package io.element.android.features.location.impl.share import kotlin.time.Duration -import kotlin.time.Duration.Companion.hours -import kotlin.time.Duration.Companion.minutes -enum class LiveLocationDuration( +data class LiveLocationDuration( val duration: Duration, - val label: String, -) { - FifteenMinutes(15.minutes, "15 minutes"), - OneHour(1.hours, "1 hour"), - EightHours(8.hours, "8 hours") -} + val formatted: String +) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index 56b7b073d9..10fddf1e50 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -34,6 +34,7 @@ import io.element.android.features.messages.api.MessageComposerContext import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.dateformatter.api.DurationFormatter import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.MatrixClient @@ -43,7 +44,12 @@ import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +private val LIVE_LOCATION_DURATIONS = listOf(15.minutes, 1.hours, 8.hours) @AssistedInject class ShareLocationPresenter( @@ -56,6 +62,7 @@ class ShareLocationPresenter( private val buildMeta: BuildMeta, private val featureFlagService: FeatureFlagService, private val client: MatrixClient, + private val durationFormatter: DurationFormatter, ) : Presenter { @AssistedFactory fun interface Factory { @@ -105,7 +112,10 @@ class ShareLocationPresenter( ShareLocationEvent.ShowLiveLocationDurationPicker -> { val constraintsResult = checkLocationConstraints(permissionsState, locationActions) dialogState = if (constraintsResult is LocationConstraintsCheck.Success) { - ShareLocationState.Dialog.LiveLocationDuration + val durations = LIVE_LOCATION_DURATIONS.map { + LiveLocationDuration(duration = it, formatted = durationFormatter.format(it)) + } + ShareLocationState.Dialog.LiveLocationDurations(durations.toImmutableList()) } else { Constraints(constraintsResult.toDialogState()) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index 72f94d5b06..8b1f494f1e 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -10,6 +10,7 @@ package io.element.android.features.location.impl.share import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.ImmutableList data class ShareLocationState( val currentUser: MatrixUser, @@ -23,6 +24,6 @@ data class ShareLocationState( sealed interface Dialog { data object None : Dialog data class Constraints(val state: LocationConstraintsDialogState) : Dialog - data object LiveLocationDuration : Dialog + data class LiveLocationDurations(val durations: ImmutableList) : Dialog } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt index efc7fdc8a1..facef74346 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStateProvider.kt @@ -12,6 +12,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.persistentListOf +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes private const val APP_NAME = "ApplicationName" @@ -49,7 +52,13 @@ class ShareLocationStateProvider : PreviewParameterProvider hasLocationPermission = true, ), aShareLocationState( - dialogState = ShareLocationState.Dialog.LiveLocationDuration, + dialogState = ShareLocationState.Dialog.LiveLocationDurations( + persistentListOf( + LiveLocationDuration(15.minutes, "15 minutes"), + LiveLocationDuration(1.hours, "1 hour"), + LiveLocationDuration(8.hours, "8 hours"), + ) + ), trackUserPosition = true, hasLocationPermission = true, canShareLiveLocation = true, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index b463d494dc..4651389212 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -59,6 +59,7 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList import org.maplibre.compose.camera.CameraMoveReason import org.maplibre.compose.camera.CameraState import org.maplibre.compose.camera.rememberCameraState @@ -83,7 +84,8 @@ fun ShareLocationView( onOpenLocationSettings = { state.eventSink(ShareLocationEvent.OpenLocationSettings) }, onDismiss = { state.eventSink(ShareLocationEvent.DismissDialog) }, ) - ShareLocationState.Dialog.LiveLocationDuration -> LiveLocationDurationDialog( + is ShareLocationState.Dialog.LiveLocationDurations -> LiveLocationDurationDialog( + durations = dialogState.durations, onSelectDuration = { duration -> state.eventSink(ShareLocationEvent.StartLiveLocationShare(duration)) context.toast("Not implemented yet!") @@ -252,6 +254,7 @@ private fun ShareLiveLocationItem( @Composable private fun LiveLocationDurationDialog( + durations: ImmutableList, onSelectDuration: (Duration) -> Unit, onDismiss: () -> Unit, ) { @@ -259,14 +262,14 @@ private fun LiveLocationDurationDialog( ListDialog( title = "Choose how long to share your live location.", submitText = stringResource(CommonStrings.action_continue), - onSubmit = { onSelectDuration(LiveLocationDuration.entries[selectedIndex].duration) }, + onSubmit = { onSelectDuration(durations[selectedIndex].duration) }, onDismissRequest = onDismiss, applyPaddingToContents = false, verticalArrangement = Arrangement.Top ) { - itemsIndexed(LiveLocationDuration.entries) { index, duration -> + itemsIndexed(durations) { index, duration -> RadioButtonListItem( - headline = duration.label, + headline = duration.formatted, selected = index == selectedIndex, onSelect = { selectedIndex = index }, compactLayout = true, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt index 100e660820..edd000e02c 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt @@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -46,6 +47,7 @@ class DefaultShareLocationEntryPointTest { buildMeta = aBuildMeta(), featureFlagService = FakeFeatureFlagService(), client = FakeMatrixClient(), + durationFormatter = FakeDurationFormatter(), ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 125d5036fe..92c27d9f21 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -18,10 +18,10 @@ import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsEvents -import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.messages.test.FakeMessageComposerContext +import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.JoinedRoom @@ -54,13 +54,13 @@ class ShareLocationPresenterTest { private val fakeFeatureFlagService = FakeFeatureFlagService() private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID) + private val durationFormatter = FakeDurationFormatter() + private fun createShareLocationPresenter( joinedRoom: JoinedRoom = FakeJoinedRoom(), locationActions: FakeLocationActions = fakeLocationActions, ): ShareLocationPresenter = ShareLocationPresenter( - permissionsPresenterFactory = object : PermissionsPresenter.Factory { - override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter - }, + permissionsPresenterFactory = { fakePermissionsPresenter }, room = joinedRoom, timelineMode = Timeline.Mode.Live, analyticsService = fakeAnalyticsService, @@ -69,6 +69,7 @@ class ShareLocationPresenterTest { buildMeta = fakeBuildMeta, featureFlagService = fakeFeatureFlagService, client = fakeMatrixClient, + durationFormatter = durationFormatter, ) @Test @@ -306,7 +307,7 @@ class ShareLocationPresenterTest { initialState.eventSink(ShareLocationEvent.ShowLiveLocationDurationPicker) val durationDialogState = awaitItem() - assertThat(durationDialogState.dialogState).isEqualTo(ShareLocationState.Dialog.LiveLocationDuration) + assertThat(durationDialogState.dialogState).isInstanceOf(ShareLocationState.Dialog.LiveLocationDurations::class.java) cancelAndIgnoreRemainingEvents() } } diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt index 8ecf01c343..ac297d10e9 100644 --- a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt +++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt @@ -11,6 +11,19 @@ package io.element.android.libraries.dateformatter.api import java.util.Locale import kotlin.time.Duration +/** + * Formats a duration in a localized, human-readable way. + * Uses the largest appropriate unit (hours, minutes, or seconds). + * + * Examples (in English): + * - 2 hours 30 minutes → "3 hours" (rounded) + * - 45 minutes → "45 minutes" + * - 30 seconds → "30 seconds" + */ +interface DurationFormatter { + fun format(duration: Duration): String +} + /** * Convert milliseconds to human readable duration. * Hours in 1 digit or more. diff --git a/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatter.kt b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatter.kt new file mode 100644 index 0000000000..41a4c66481 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatter.kt @@ -0,0 +1,69 @@ +/* + * 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.libraries.dateformatter.impl + +import android.icu.text.MeasureFormat +import android.icu.text.MeasureFormat.FormatWidth +import android.icu.util.Measure +import android.icu.util.MeasureUnit +import android.text.format.DateUtils +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import dev.zacsweers.metro.binding +import io.element.android.libraries.dateformatter.api.DurationFormatter +import java.util.Locale +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +/** + * Formats durations in a localized, human-readable way using Android's MeasureFormat. + * + * Uses WIDE format for readability (e.g., "5 hours", "3 minutes", "10 seconds"). + * Rounds to the nearest unit for cleaner display. + */ +@SingleIn(AppScope::class) +@ContributesBinding(AppScope::class, binding = binding()) +class DefaultDurationFormatter( + localeChangeObserver: LocaleChangeObserver, + locale: Locale, +) : DurationFormatter, LocaleChangeListener { + init { + localeChangeObserver.addListener(this) + } + + // Cache formatter, recreate only on locale change + private var formatter: MeasureFormat = MeasureFormat.getInstance(locale, FormatWidth.WIDE) + + override fun onLocaleChange() { + formatter = MeasureFormat.getInstance(Locale.getDefault(), FormatWidth.WIDE) + } + + override fun format(duration: Duration): String { + val millis = duration.inWholeMilliseconds + + return when { + duration >= 1.hours -> { + // Round to nearest hour (add 30 minutes before dividing) + val hours = ((millis + 30 * DateUtils.MINUTE_IN_MILLIS) / DateUtils.HOUR_IN_MILLIS).toInt() + formatter.format(Measure(hours, MeasureUnit.HOUR)) + } + duration >= 1.minutes -> { + // Round to nearest minute (add 30 seconds before dividing) + val minutes = ((millis + 30 * DateUtils.SECOND_IN_MILLIS) / DateUtils.MINUTE_IN_MILLIS).toInt() + formatter.format(Measure(minutes, MeasureUnit.MINUTE)) + } + else -> { + // Round to nearest second (add 500ms before dividing) + val seconds = ((millis + 500) / DateUtils.SECOND_IN_MILLIS).toInt() + formatter.format(Measure(seconds, MeasureUnit.SECOND)) + } + } + } +} diff --git a/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt new file mode 100644 index 0000000000..3fee5dbb0d --- /dev/null +++ b/libraries/dateformatter/impl/src/test/kotlin/io/element/android/libraries/dateformatter/impl/DefaultDurationFormatterTest.kt @@ -0,0 +1,133 @@ +/* + * 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.libraries.dateformatter.impl + +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.util.Locale +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +@RunWith(AndroidJUnit4::class) +@Config(qualifiers = "en", sdk = [Build.VERSION_CODES.TIRAMISU]) +class DefaultDurationFormatterTest { + private fun createDurationFormatter(): DefaultDurationFormatter { + return DefaultDurationFormatter( + localeChangeObserver = {}, + locale = Locale.US, + ) + } + + @Test + fun `test zero duration`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(0.seconds)).isEqualTo("0 seconds") + } + + @Test + fun `test 1 second`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.seconds)).isEqualTo("1 second") + } + + @Test + fun `test 30 seconds`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(30.seconds)).isEqualTo("30 seconds") + } + + @Test + fun `test 59 seconds`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(59.seconds)).isEqualTo("59 seconds") + } + + @Test + fun `test 1 minute`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.minutes)).isEqualTo("1 minute") + } + + @Test + fun `test 1 minute 29 seconds rounds to 1 minute`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.minutes + 29.seconds)).isEqualTo("1 minute") + } + + @Test + fun `test 1 minute 30 seconds rounds to 2 minutes`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.minutes + 30.seconds)).isEqualTo("2 minutes") + } + + @Test + fun `test 45 minutes`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(45.minutes)).isEqualTo("45 minutes") + } + + @Test + fun `test 59 minutes`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(59.minutes)).isEqualTo("59 minutes") + } + + @Test + fun `test 1 hour`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.hours)).isEqualTo("1 hour") + } + + @Test + fun `test 1 hour 29 minutes rounds to 1 hour`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.hours + 29.minutes)).isEqualTo("1 hour") + } + + @Test + fun `test 1 hour 30 minutes rounds to 2 hours`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(1.hours + 30.minutes)).isEqualTo("2 hours") + } + + @Test + fun `test 2 hours 30 minutes rounds to 3 hours`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(2.hours + 30.minutes)).isEqualTo("3 hours") + } + + @Test + fun `test 5 hours`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(5.hours)).isEqualTo("5 hours") + } + + @Test + fun `test 24 hours`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(24.hours)).isEqualTo("24 hours") + } + + @Test + fun `test rounding at seconds threshold - 499ms rounds to 0 seconds`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(499.milliseconds)).isEqualTo("0 seconds") + } + + @Test + fun `test rounding at seconds threshold - 500ms rounds to 1 second`() { + val formatter = createDurationFormatter() + assertThat(formatter.format(500.milliseconds)).isEqualTo("1 second") + } +} diff --git a/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDurationFormatter.kt b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDurationFormatter.kt new file mode 100644 index 0000000000..7b5cf038ce --- /dev/null +++ b/libraries/dateformatter/test/src/main/kotlin/io/element/android/libraries/dateformatter/test/FakeDurationFormatter.kt @@ -0,0 +1,19 @@ +/* + * 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.libraries.dateformatter.test + +import io.element.android.libraries.dateformatter.api.DurationFormatter +import kotlin.time.Duration + +class FakeDurationFormatter( + private val formatLambda: (Duration) -> String = { it.toString() }, +) : DurationFormatter { + override fun format(duration: Duration): String { + return formatLambda(duration) + } +} From df6e76776d62528300de893f17271d8230637205 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 24 Mar 2026 11:13:40 +0100 Subject: [PATCH 49/52] Add localazy config for location sharing --- .../location/impl/src/main/res/values-fi/translations.xml | 4 ++++ .../location/impl/src/main/res/values-fr/translations.xml | 4 ++++ features/location/impl/src/main/res/values/localazy.xml | 4 ++++ .../ui-strings/src/main/res/values-fi/translations.xml | 1 - .../ui-strings/src/main/res/values-fr/translations.xml | 1 - libraries/ui-strings/src/main/res/values/localazy.xml | 1 - tools/localazy/config.json | 7 +++++++ 7 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 features/location/impl/src/main/res/values-fi/translations.xml create mode 100644 features/location/impl/src/main/res/values-fr/translations.xml create mode 100644 features/location/impl/src/main/res/values/localazy.xml diff --git a/features/location/impl/src/main/res/values-fi/translations.xml b/features/location/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 0000000000..bc7e84e7b0 --- /dev/null +++ b/features/location/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,4 @@ + + + "Valitse, kuinka kauan haluat jakaa reaaliaikaisen sijaintisi." + diff --git a/features/location/impl/src/main/res/values-fr/translations.xml b/features/location/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 0000000000..46689488e1 --- /dev/null +++ b/features/location/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,4 @@ + + + "Choisissez la durée pendant laquelle vous partagerez votre position en direct." + diff --git a/features/location/impl/src/main/res/values/localazy.xml b/features/location/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..04538049db --- /dev/null +++ b/features/location/impl/src/main/res/values/localazy.xml @@ -0,0 +1,4 @@ + + + "Choose how long to share your live location." + diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml index e7606fce3e..182f403140 100644 --- a/libraries/ui-strings/src/main/res/values-fi/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml @@ -496,7 +496,6 @@ Haluatko varmasti jatkaa?" "Viestiä ladataan…" "Näytä kaikki" "Keskustelu" - "Valitse, kuinka kauan haluat jakaa reaaliaikaisen sijaintisi." "Jaa sijainti" "Jaa sijaintini" "Avaa Apple Mapsissa" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index fdc682a67b..1dad21ff65 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -496,7 +496,6 @@ Raison : %1$s." "Chargement du message…" "Voir tout" "Discussion" - "Choisissez la durée pendant laquelle vous partagerez votre position en direct." "Partage de position" "Partager ma position" "Ouvrir dans Apple Maps" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index c5258a23c9..98a8aa1862 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -496,7 +496,6 @@ Are you sure you want to continue?" "Loading message…" "View All" "Chat" - "Choose how long to share your live location." "Share location" "Share my location" "Open in Apple Maps" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index d9c5e088e1..d3e44b7c08 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -409,6 +409,13 @@ "screen\\.security_and_privacy\\..*", "screen\\.manage_authorized_spaces\\..*" ] + }, + { + "name" : ":features:location:impl", + "includeRegex" : [ + "screen\\.share_location\\..*", + "screen\\.view_location\\..*" + ] } ] } From 8910d20ae45c08086b631684f9aa473bdd2d6b7e Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 24 Mar 2026 11:22:51 +0100 Subject: [PATCH 50/52] Remove hardcoded strings --- .../features/location/impl/share/ShareLocationView.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 4651389212..1e163f417d 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -37,6 +37,7 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.location.api.Location import io.element.android.features.location.api.internal.centerBottomEdge +import io.element.android.features.location.impl.R import io.element.android.features.location.impl.common.MapDefaults import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton @@ -242,7 +243,7 @@ private fun ShareLiveLocationItem( ) { ListItem( headlineContent = { - Text("Share live location") + Text(stringResource(CommonStrings.action_share_live_location)) }, onClick = onClick, leadingContent = ListItemContent.Icon( @@ -260,7 +261,7 @@ private fun LiveLocationDurationDialog( ) { var selectedIndex by remember { mutableIntStateOf(0) } ListDialog( - title = "Choose how long to share your live location.", + title = stringResource(R.string.screen_share_location_live_location_duration_picker_title), submitText = stringResource(CommonStrings.action_continue), onSubmit = { onSelectDuration(durations[selectedIndex].duration) }, onDismissRequest = onDismiss, From 7a4641c422279279df2d8eca8cfbb9e20e890d81 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 24 Mar 2026 12:15:14 +0100 Subject: [PATCH 51/52] Fix warning --- .../matrix/impl/timeline/item/event/EventMessageMapper.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt index d89d2766eb..e382bce8db 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/EventMessageMapper.kt @@ -117,7 +117,7 @@ class EventMessageMapper { body = type.content.body, geoUri = type.content.geoUri, description = type.content.description, - assetType = type.content.asset?.into() + assetType = type.content.asset.into() ) } is MessageType.Other -> { From 7f6eec3a8feaf576507de5161dcc6b165a778dad Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 24 Mar 2026 12:54:32 +0000 Subject: [PATCH 52/52] Update screenshots --- ...es.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png | 4 ++-- ....textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Day_8_en.png | 4 ++-- .../libraries.textcomposer_TextComposerReply_Night_8_en.png | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png index 63fee25873..f7398ae396 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:331388711afc99265e651897c04df501f2de777fd0c8e4b084f67fce2a2a1c94 -size 66937 +oid sha256:15256a6b4d1fe8e52080b9726cde401944cc6941f7527db1d18484c86ec88023 +size 66699 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png index 8b189dd1c7..9bfed4fc61 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2bd8f39629ae22d9226f9d759b73a1db9d1d53a80923c835bef6bd403e45e737 -size 64060 +oid sha256:d7c95c91b7c6b04f2851df6a64de1b10c7338a4d46921a90037fe50cd7d49262 +size 63957 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png index fd37f7ae83..8e397b024e 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Day_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de8ffda35666fc34ba5521673af2318ba162a442574005b5062afc17172f4bf5 -size 67414 +oid sha256:27c823be03e6721a4034cdc4827a4d3dd34ac3e9d567651a8ce125cef435b57e +size 67151 diff --git a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png index 203dcc6280..0654617bd7 100644 --- a/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png +++ b/tests/uitests/src/test/snapshots/images/libraries.textcomposer_TextComposerReply_Night_8_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0514f7ceb74b446203f36cf6af6b1da3e8f0a55f7e636a0341b2703201d6390e -size 64529 +oid sha256:947ddd34efb97e8baad91a1c2865105190a7be599327a271db0466f4a86bf092 +size 64326