Show not save dialog when exiting user profile edition with unsaved changes
This commit is contained in:
parent
42dd6c3544
commit
636c4c940e
6 changed files with 91 additions and 19 deletions
|
|
@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction
|
||||||
sealed interface EditUserProfileEvents {
|
sealed interface EditUserProfileEvents {
|
||||||
data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
|
data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
|
||||||
data class UpdateDisplayName(val name: String) : EditUserProfileEvents
|
data class UpdateDisplayName(val name: String) : EditUserProfileEvents
|
||||||
|
data object Exit : EditUserProfileEvents
|
||||||
data object Save : 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.annotations.ContributesNode
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
import io.element.android.libraries.architecture.inputs
|
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.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
||||||
|
|
@ -27,22 +28,30 @@ class EditUserProfileNode(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
presenterFactory: EditUserProfilePresenter.Factory,
|
presenterFactory: EditUserProfilePresenter.Factory,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins),
|
||||||
|
BaseNavigator {
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
val matrixUser: MatrixUser
|
val matrixUser: MatrixUser
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
val matrixUser = inputs<Inputs>().matrixUser
|
val matrixUser = inputs<Inputs>().matrixUser
|
||||||
val presenter = presenterFactory.create(matrixUser)
|
val presenter = presenterFactory.create(
|
||||||
|
matrixUser = matrixUser,
|
||||||
|
navigator = this,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
EditUserProfileView(
|
EditUserProfileView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = ::navigateUp,
|
onEditProfileSuccess = ::close,
|
||||||
onEditProfileSuccess = ::navigateUp,
|
|
||||||
modifier = modifier
|
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.androidutils.file.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.architecture.Presenter
|
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.architecture.runCatchingUpdatingState
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
|
@ -45,6 +46,7 @@ import timber.log.Timber
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class EditUserProfilePresenter(
|
class EditUserProfilePresenter(
|
||||||
@Assisted private val matrixUser: MatrixUser,
|
@Assisted private val matrixUser: MatrixUser,
|
||||||
|
@Assisted private val navigator: BaseNavigator,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val mediaPickerProvider: PickerProvider,
|
private val mediaPickerProvider: PickerProvider,
|
||||||
private val mediaPreProcessor: MediaPreProcessor,
|
private val mediaPreProcessor: MediaPreProcessor,
|
||||||
|
|
@ -57,7 +59,10 @@ class EditUserProfilePresenter(
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(matrixUser: MatrixUser): EditUserProfilePresenter
|
fun create(
|
||||||
|
matrixUser: MatrixUser,
|
||||||
|
navigator: BaseNavigator,
|
||||||
|
): EditUserProfilePresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -101,6 +106,13 @@ class EditUserProfilePresenter(
|
||||||
|
|
||||||
val saveAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val saveAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val canSave = remember(userDisplayName, userAvatarUri) {
|
||||||
|
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) ||
|
||||||
|
hasAvatarUrlChanged(userAvatarUri, matrixUser)
|
||||||
|
!userDisplayName.isNullOrBlank() && hasProfileChanged
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvent(event: EditUserProfileEvents) {
|
fun handleEvent(event: EditUserProfileEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(
|
is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(
|
||||||
|
|
@ -124,18 +136,32 @@ class EditUserProfilePresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
|
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(
|
return EditUserProfileState(
|
||||||
userId = matrixUser.userId,
|
userId = matrixUser.userId,
|
||||||
displayName = userDisplayName.orEmpty(),
|
displayName = userDisplayName.orEmpty(),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
package io.element.android.features.preferences.impl.user.editprofile
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -30,11 +31,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.features.preferences.impl.R
|
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.AsyncActionView
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
|
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.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
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.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.modifiers.clearFocusOnTap
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -52,7 +55,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
@Composable
|
@Composable
|
||||||
fun EditUserProfileView(
|
fun EditUserProfileView(
|
||||||
state: EditUserProfileState,
|
state: EditUserProfileState,
|
||||||
onBackClick: () -> Unit,
|
|
||||||
onEditProfileSuccess: () -> Unit,
|
onEditProfileSuccess: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -64,12 +66,21 @@ fun EditUserProfileView(
|
||||||
isAvatarActionsSheetVisible.value = true
|
isAvatarActionsSheetVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onBackClick() {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
state.eventSink(EditUserProfileEvents.Exit)
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(
|
||||||
|
enabled = true,
|
||||||
|
::onBackClick,
|
||||||
|
)
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.clearFocusOnTap(focusManager),
|
modifier = modifier.clearFocusOnTap(focusManager),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
titleStr = stringResource(R.string.screen_edit_profile_title),
|
titleStr = stringResource(R.string.screen_edit_profile_title),
|
||||||
navigationIcon = { BackButton(onClick = onBackClick) },
|
navigationIcon = { BackButton(::onBackClick) },
|
||||||
actions = {
|
actions = {
|
||||||
TextButton(
|
TextButton(
|
||||||
text = stringResource(CommonStrings.action_save),
|
text = stringResource(CommonStrings.action_save),
|
||||||
|
|
@ -132,10 +143,20 @@ fun EditUserProfileView(
|
||||||
progressText = stringResource(R.string.screen_edit_profile_updating_details),
|
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() },
|
onSuccess = { onEditProfileSuccess() },
|
||||||
errorTitle = { stringResource(R.string.screen_edit_profile_error_title) },
|
errorTitle = { stringResource(R.string.screen_edit_profile_error_title) },
|
||||||
errorMessage = { stringResource(R.string.screen_edit_profile_error) },
|
errorMessage = { stringResource(R.string.screen_edit_profile_error) },
|
||||||
onErrorDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) },
|
onErrorDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PermissionsView(
|
PermissionsView(
|
||||||
|
|
@ -148,7 +169,6 @@ fun EditUserProfileView(
|
||||||
internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
|
internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
|
||||||
ElementPreview {
|
ElementPreview {
|
||||||
EditUserProfileView(
|
EditUserProfileView(
|
||||||
onBackClick = {},
|
|
||||||
onEditProfileSuccess = {},
|
onEditProfileSuccess = {},
|
||||||
state = state,
|
state = state,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
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.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
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.consumeItemsUntilPredicate
|
||||||
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
import io.element.android.tests.testutils.consumeItemsUntilTimeout
|
||||||
import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter
|
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.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
|
@ -79,6 +81,7 @@ class EditUserProfilePresenterTest {
|
||||||
|
|
||||||
private fun createEditUserProfilePresenter(
|
private fun createEditUserProfilePresenter(
|
||||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||||
|
navigator: BaseNavigator = BaseNavigator { lambdaError() },
|
||||||
matrixUser: MatrixUser = aMatrixUser(),
|
matrixUser: MatrixUser = aMatrixUser(),
|
||||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||||
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||||
|
|
@ -86,6 +89,7 @@ class EditUserProfilePresenterTest {
|
||||||
): EditUserProfilePresenter {
|
): EditUserProfilePresenter {
|
||||||
return EditUserProfilePresenter(
|
return EditUserProfilePresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
|
navigator = navigator,
|
||||||
matrixUser = matrixUser,
|
matrixUser = matrixUser,
|
||||||
mediaPickerProvider = fakePickerProvider,
|
mediaPickerProvider = fakePickerProvider,
|
||||||
mediaPreProcessor = fakeMediaPreProcessor,
|
mediaPreProcessor = fakeMediaPreProcessor,
|
||||||
|
|
@ -456,7 +460,7 @@ class EditUserProfilePresenterTest {
|
||||||
initialState.eventSink(EditUserProfileEvents.Save)
|
initialState.eventSink(EditUserProfileEvents.Save)
|
||||||
skipItems(2)
|
skipItems(2)
|
||||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||||
initialState.eventSink(EditUserProfileEvents.CancelSaveChanges)
|
initialState.eventSink(EditUserProfileEvents.CloseDialog)
|
||||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
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()
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue