Allow picking duration for the live location share

This commit is contained in:
ganfra
2026-02-18 19:13:47 +01:00
parent a129d1f9ca
commit 7472f889cf
8 changed files with 113 additions and 28 deletions

View File

@@ -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");
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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
}
}

View File

@@ -40,6 +40,12 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
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,

View File

@@ -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(

View File

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

View File

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