Confirm exit without saving change in room details edit screen (#5618)

* Room details edit screen: add confirmation dialog when leaving without saving pending changes.

* Improve preview coverage.

* Update screenshots

* Introduce AsyncAction.ConfirmingCancellation and use it for leaving room edition without saving change.

* Fix issue in comment

* Use new `ConfirmingCancellation` object in Change Roles screen

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty 2025-10-28 18:25:51 +01:00 committed by GitHub
parent 4d9d684f2a
commit 08f75dda4c
20 changed files with 270 additions and 153 deletions

View file

@ -13,6 +13,7 @@ sealed interface RoomDetailsEditEvents {
data class HandleAvatarAction(val action: AvatarAction) : RoomDetailsEditEvents
data class UpdateRoomName(val name: String) : RoomDetailsEditEvents
data class UpdateRoomTopic(val topic: String) : RoomDetailsEditEvents
data object OnBackPress : RoomDetailsEditEvents
data object Save : RoomDetailsEditEvents
data object CancelSaveChanges : RoomDetailsEditEvents
data object CloseDialog : RoomDetailsEditEvents
}

View file

@ -41,8 +41,7 @@ class RoomDetailsEditNode(
val state = presenter.present()
RoomDetailsEditView(
state = state,
onBackClick = ::navigateUp,
onRoomEditSuccess = ::navigateUp,
onDone = ::navigateUp,
modifier = modifier,
)
}

View file

@ -169,7 +169,13 @@ class RoomDetailsEditPresenter(
is RoomDetailsEditEvents.UpdateRoomName -> roomRawNameEdited = event.name
is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopicEdited = event.topic
RoomDetailsEditEvents.CancelSaveChanges -> saveAction.value = AsyncAction.Uninitialized
RoomDetailsEditEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized
RoomDetailsEditEvents.OnBackPress -> if (saveButtonEnabled.not() || saveAction.value == AsyncAction.ConfirmingCancellation) {
// No changes to save or already confirming exit without saving
saveAction.value = AsyncAction.Success(Unit)
} else {
saveAction.value = AsyncAction.ConfirmingCancellation
}
}
}

View file

@ -26,6 +26,7 @@ open class RoomDetailsEditStateProvider : PreviewParameterProvider<RoomDetailsEd
aRoomDetailsEditState(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false),
aRoomDetailsEditState(saveAction = AsyncAction.Loading),
aRoomDetailsEditState(saveAction = AsyncAction.Failure(RuntimeException("Whelp"))),
aRoomDetailsEditState(saveAction = AsyncAction.ConfirmingCancellation),
)
}

View file

@ -9,6 +9,7 @@
package io.element.android.features.roomdetails.impl.edit
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -30,11 +31,13 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
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.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -50,8 +53,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun RoomDetailsEditView(
state: RoomDetailsEditState,
onBackClick: () -> Unit,
onRoomEditSuccess: () -> Unit,
onDone: () -> Unit,
modifier: Modifier = Modifier,
) {
val focusManager = LocalFocusManager.current
@ -62,12 +64,21 @@ fun RoomDetailsEditView(
isAvatarActionsSheetVisible.value = true
}
BackHandler {
state.eventSink(RoomDetailsEditEvents.OnBackPress)
}
Scaffold(
modifier = modifier.clearFocusOnTap(focusManager),
topBar = {
TopAppBar(
titleStr = stringResource(id = R.string.screen_room_details_edit_room_title),
navigationIcon = { BackButton(onClick = onBackClick) },
navigationIcon = {
BackButton(
onClick = {
state.eventSink(RoomDetailsEditEvents.OnBackPress)
}
)
},
actions = {
TextButton(
text = stringResource(CommonStrings.action_save),
@ -126,14 +137,12 @@ fun RoomDetailsEditView(
)
}
}
AvatarActionBottomSheet(
actions = state.avatarActions,
isVisible = isAvatarActionsSheetVisible.value,
onDismiss = { isAvatarActionsSheetVisible.value = false },
onSelectAction = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) }
)
AsyncActionView(
async = state.saveAction,
progressDialog = {
@ -141,9 +150,19 @@ fun RoomDetailsEditView(
progressText = stringResource(R.string.screen_room_details_updating_room),
)
},
onSuccess = { onRoomEditSuccess() },
confirmationDialog = {
if (state.saveAction == AsyncAction.ConfirmingCancellation) {
ConfirmationDialog(
title = stringResource(CommonStrings.dialog_unsaved_changes_title),
content = stringResource(CommonStrings.dialog_unsaved_changes_description_android),
onSubmitClick = { state.eventSink(RoomDetailsEditEvents.OnBackPress) },
onDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) }
)
}
},
onSuccess = { onDone() },
errorMessage = { stringResource(R.string.screen_room_details_edition_error) },
onErrorDismiss = { state.eventSink(RoomDetailsEditEvents.CancelSaveChanges) }
onErrorDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) }
)
PermissionsView(
@ -156,7 +175,6 @@ fun RoomDetailsEditView(
internal fun RoomDetailsEditViewPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = ElementPreview {
RoomDetailsEditView(
state = state,
onBackClick = {},
onRoomEditSuccess = {},
onDone = {},
)
}

View file

@ -41,7 +41,8 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
),
banned = persistentListOf(),
)
)
),
moderationState = aRoomMemberModerationState(canBan = true)
),
aRoomMemberListState(roomMembers = AsyncData.Loading()),
aRoomMemberListState().copy(canInvite = true),

View file

@ -649,11 +649,88 @@ class RoomDetailsEditPresenterTest {
initialState.eventSink(RoomDetailsEditEvents.Save)
skipItems(3)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
initialState.eventSink(RoomDetailsEditEvents.CancelSaveChanges)
initialState.eventSink(RoomDetailsEditEvents.CloseDialog)
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
}
}
@Test
fun `present - leave without saving - cancel`() = runTest {
val room = aJoinedRoom(
displayName = "Name",
canSendStateResult = { _, _ -> Result.success(true) }
)
val deleteCallback = lambdaRecorder<Uri?, Unit> {}
val presenter = createRoomDetailsEditPresenter(
room = room,
temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback),
)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name edited"))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
eventSink(RoomDetailsEditEvents.OnBackPress)
}
awaitItem().apply {
assertThat(saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
eventSink(RoomDetailsEditEvents.CloseDialog)
}
awaitItem().apply {
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
}
}
}
@Test
fun `present - leave no changes, no confirmation`() = runTest {
val room = aJoinedRoom(
displayName = "Name",
canSendStateResult = { _, _ -> Result.success(true) }
)
val presenter = createRoomDetailsEditPresenter(
room = room,
temporaryUriDeleter = FakeTemporaryUriDeleter {},
)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.saveButtonEnabled).isFalse()
initialState.eventSink(RoomDetailsEditEvents.OnBackPress)
assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Success(Unit))
}
}
@Test
fun `present - leave without saving - confirm`() = runTest {
val room = aJoinedRoom(
displayName = "Name",
canSendStateResult = { _, _ -> Result.success(true) }
)
val presenter = createRoomDetailsEditPresenter(
room = room,
temporaryUriDeleter = FakeTemporaryUriDeleter({}),
)
presenter.test {
val initialState = awaitFirstItem()
assertThat(initialState.saveButtonEnabled).isFalse()
// Once a change is made, the save button is enabled
initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name edited"))
awaitItem().apply {
assertThat(saveButtonEnabled).isTrue()
eventSink(RoomDetailsEditEvents.OnBackPress)
}
awaitItem().apply {
assertThat(saveAction).isEqualTo(AsyncAction.ConfirmingCancellation)
eventSink(RoomDetailsEditEvents.OnBackPress)
}
awaitItem().apply {
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
}
}
}
private suspend fun saveAndAssertFailure(
room: JoinedRoom,
event: RoomDetailsEditEvents,

View file

@ -38,17 +38,41 @@ class RoomDetailsEditViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invoke back callback`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder
),
onBackClick = callback,
)
rule.pressBack()
}
fun `clicking on back emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
eventSink = eventsRecorder
),
)
rule.pressBack()
eventsRecorder.assertSingle(RoomDetailsEditEvents.OnBackPress)
}
@Test
fun `clicking on OK when confirming exit emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
saveAction = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(RoomDetailsEditEvents.OnBackPress)
}
@Test
fun `clicking on cancel when confirming exit emits the expected Event`() {
val eventsRecorder = EventsRecorder<RoomDetailsEditEvents>()
rule.setRoomDetailsEditView(
aRoomDetailsEditState(
saveAction = AsyncAction.ConfirmingCancellation,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_cancel)
eventsRecorder.assertSingle(RoomDetailsEditEvents.CloseDialog)
}
@Test
@ -60,7 +84,7 @@ class RoomDetailsEditViewTest {
eventSink = eventsRecorder,
saveAction = AsyncAction.Success(Unit)
),
onRoomEdited = callback,
onDone = callback,
)
}
}
@ -209,20 +233,18 @@ class RoomDetailsEditViewTest {
),
)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(RoomDetailsEditEvents.CancelSaveChanges)
eventsRecorder.assertSingle(RoomDetailsEditEvents.CloseDialog)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setRoomDetailsEditView(
state: RoomDetailsEditState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onRoomEdited: () -> Unit = EnsureNeverCalled(),
onDone: () -> Unit = EnsureNeverCalled(),
) {
setContent {
RoomDetailsEditView(
state = state,
onBackClick = onBackClick,
onRoomEditSuccess = onRoomEdited,
onDone = onDone,
)
}
}