From 636c4c940e6dd95d263fcd7baf0e56df76588aad Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 21 Nov 2025 11:53:28 +0100 Subject: [PATCH] Show not save dialog when exiting user profile edition with unsaved changes --- .../user/editprofile/EditUserProfileEvents.kt | 3 +- .../user/editprofile/EditUserProfileNode.kt | 17 +++++-- .../editprofile/EditUserProfilePresenter.kt | 44 +++++++++++++++---- .../user/editprofile/EditUserProfileView.kt | 28 ++++++++++-- .../EditUserProfilePresenterTest.kt | 6 ++- .../architecture/navigation/BaseNavigator.kt | 12 +++++ 6 files changed, 91 insertions(+), 19 deletions(-) create mode 100644 libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/navigation/BaseNavigator.kt diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt index ba62c6103f..f7f2ffceb4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt @@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction sealed interface EditUserProfileEvents { data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents data class UpdateDisplayName(val name: String) : EditUserProfileEvents + data object Exit : EditUserProfileEvents data object Save : EditUserProfileEvents - data object CancelSaveChanges : EditUserProfileEvents + data object CloseDialog : EditUserProfileEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt index d37ad888ce..3d939cfbd4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt @@ -18,6 +18,7 @@ import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.architecture.navigation.BaseNavigator import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.user.MatrixUser @@ -27,22 +28,30 @@ class EditUserProfileNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenterFactory: EditUserProfilePresenter.Factory, -) : Node(buildContext, plugins = plugins) { +) : Node(buildContext, plugins = plugins), + BaseNavigator { data class Inputs( val matrixUser: MatrixUser ) : NodeInputs val matrixUser = inputs().matrixUser - val presenter = presenterFactory.create(matrixUser) + val presenter = presenterFactory.create( + matrixUser = matrixUser, + navigator = this, + ) @Composable override fun View(modifier: Modifier) { val state = presenter.present() EditUserProfileView( state = state, - onBackClick = ::navigateUp, - onEditProfileSuccess = ::navigateUp, + onEditProfileSuccess = ::close, modifier = modifier ) } + + override fun close() { + // TODO Invoke callback + navigateUp() + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt index 0f46abb918..46298c96a2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt @@ -26,6 +26,7 @@ import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.navigation.BaseNavigator import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.mimetype.MimeTypes @@ -45,6 +46,7 @@ import timber.log.Timber @AssistedInject class EditUserProfilePresenter( @Assisted private val matrixUser: MatrixUser, + @Assisted private val navigator: BaseNavigator, private val matrixClient: MatrixClient, private val mediaPickerProvider: PickerProvider, private val mediaPreProcessor: MediaPreProcessor, @@ -57,7 +59,10 @@ class EditUserProfilePresenter( @AssistedFactory interface Factory { - fun create(matrixUser: MatrixUser): EditUserProfilePresenter + fun create( + matrixUser: MatrixUser, + navigator: BaseNavigator, + ): EditUserProfilePresenter } @Composable @@ -101,6 +106,13 @@ class EditUserProfilePresenter( val saveAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } val localCoroutineScope = rememberCoroutineScope() + + val canSave = remember(userDisplayName, userAvatarUri) { + val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) || + hasAvatarUrlChanged(userAvatarUri, matrixUser) + !userDisplayName.isNullOrBlank() && hasProfileChanged + } + fun handleEvent(event: EditUserProfileEvents) { when (event) { is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges( @@ -124,18 +136,32 @@ class EditUserProfilePresenter( } } } - is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name - EditUserProfileEvents.CancelSaveChanges -> saveAction.value = AsyncAction.Uninitialized + EditUserProfileEvents.Exit -> { + when (saveAction.value) { + is AsyncAction.Confirming -> { + // Close the dialog right now + saveAction.value = AsyncAction.Uninitialized + navigator.close() + } + AsyncAction.Loading -> Unit + is AsyncAction.Failure, + is AsyncAction.Success -> { + // Should not happen + } + AsyncAction.Uninitialized -> { + if (canSave) { + saveAction.value = AsyncAction.ConfirmingCancellation + } else { + navigator.close() + } + } + } + } + EditUserProfileEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized } } - val canSave = remember(userDisplayName, userAvatarUri) { - val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) || - hasAvatarUrlChanged(userAvatarUri, matrixUser) - !userDisplayName.isNullOrBlank() && hasProfileChanged - } - return EditUserProfileState( userId = matrixUser.userId, displayName = userDisplayName.orEmpty(), diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt index 91394d19a6..d6f0fcbd2c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileView.kt @@ -8,6 +8,7 @@ package io.element.android.features.preferences.impl.user.editprofile +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.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.preferences.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.SaveChangesDialog import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -52,7 +55,6 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun EditUserProfileView( state: EditUserProfileState, - onBackClick: () -> Unit, onEditProfileSuccess: () -> Unit, modifier: Modifier = Modifier, ) { @@ -64,12 +66,21 @@ fun EditUserProfileView( isAvatarActionsSheetVisible.value = true } + fun onBackClick() { + focusManager.clearFocus() + state.eventSink(EditUserProfileEvents.Exit) + } + + BackHandler( + enabled = true, + ::onBackClick, + ) Scaffold( modifier = modifier.clearFocusOnTap(focusManager), topBar = { TopAppBar( titleStr = stringResource(R.string.screen_edit_profile_title), - navigationIcon = { BackButton(onClick = onBackClick) }, + navigationIcon = { BackButton(::onBackClick) }, actions = { TextButton( text = stringResource(CommonStrings.action_save), @@ -132,10 +143,20 @@ fun EditUserProfileView( progressText = stringResource(R.string.screen_edit_profile_updating_details), ) }, + confirmationDialog = { confirming -> + when (confirming) { + is AsyncAction.ConfirmingCancellation -> { + SaveChangesDialog( + onSubmitClick = { state.eventSink(EditUserProfileEvents.Exit) }, + onDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) } + ) + } + } + }, onSuccess = { onEditProfileSuccess() }, errorTitle = { stringResource(R.string.screen_edit_profile_error_title) }, errorMessage = { stringResource(R.string.screen_edit_profile_error) }, - onErrorDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) }, + onErrorDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) }, ) } PermissionsView( @@ -148,7 +169,6 @@ fun EditUserProfileView( internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) = ElementPreview { EditUserProfileView( - onBackClick = {}, onEditProfileSuccess = {}, state = state, ) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt index 0840dea2db..7c24cd0492 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt @@ -15,6 +15,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.navigation.BaseNavigator import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL @@ -33,6 +34,7 @@ import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter +import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.mockk.every @@ -79,6 +81,7 @@ class EditUserProfilePresenterTest { private fun createEditUserProfilePresenter( matrixClient: MatrixClient = FakeMatrixClient(), + navigator: BaseNavigator = BaseNavigator { lambdaError() }, matrixUser: MatrixUser = aMatrixUser(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), @@ -86,6 +89,7 @@ class EditUserProfilePresenterTest { ): EditUserProfilePresenter { return EditUserProfilePresenter( matrixClient = matrixClient, + navigator = navigator, matrixUser = matrixUser, mediaPickerProvider = fakePickerProvider, mediaPreProcessor = fakeMediaPreProcessor, @@ -456,7 +460,7 @@ class EditUserProfilePresenterTest { initialState.eventSink(EditUserProfileEvents.Save) skipItems(2) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) - initialState.eventSink(EditUserProfileEvents.CancelSaveChanges) + initialState.eventSink(EditUserProfileEvents.CloseDialog) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) } } diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/navigation/BaseNavigator.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/navigation/BaseNavigator.kt new file mode 100644 index 0000000000..5cc61166fe --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/navigation/BaseNavigator.kt @@ -0,0 +1,12 @@ +/* + * 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.architecture.navigation + +fun interface BaseNavigator { + fun close() +}