Show not save dialog when exiting user profile edition with unsaved changes

This commit is contained in:
Benoit Marty
2025-11-21 11:53:28 +01:00
parent 42dd6c3544
commit 636c4c940e
6 changed files with 91 additions and 19 deletions

View File

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

View File

@@ -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<Plugin>,
presenterFactory: EditUserProfilePresenter.Factory,
) : Node(buildContext, plugins = plugins) {
) : Node(buildContext, plugins = plugins),
BaseNavigator {
data class Inputs(
val matrixUser: MatrixUser
) : NodeInputs
val matrixUser = inputs<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()
}
}

View File

@@ -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<AsyncAction<Unit>> = 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(),

View File

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

View File

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

View File

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