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), ) } }