Show not save dialog when exiting user profile edition with unsaved changes
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user