diff --git a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt index 2544abbfb3..d888ac3b15 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/NotLoggedInFlowNode.kt @@ -57,6 +57,7 @@ class NotLoggedInFlowNode( interface Callback : Plugin { fun navigateToBugReport() + fun onDone() } private val callback: Callback = callback() @@ -83,6 +84,10 @@ class NotLoggedInFlowNode( override fun navigateToBugReport() { callback.navigateToBugReport() } + + override fun onDone() { + callback.onDone() + } } loginEntryPoint.createNode( parentNode = this, diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index a0d2f44640..1a23bce132 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -243,6 +243,10 @@ class RootFlowNode( override fun navigateToBugReport() { backstack.push(NavTarget.BugReport) } + + override fun onDone() { + backstack.pop() + } } val params = NotLoggedInFlowNode.Params( loginParams = navTarget.params, diff --git a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt index 5256bcb695..830a63b8f1 100644 --- a/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt +++ b/features/login/api/src/main/kotlin/io/element/android/features/login/api/LoginEntryPoint.kt @@ -21,6 +21,7 @@ interface LoginEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun navigateToBugReport() + fun onDone() } fun createNode( diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index 4a7275e2da..a19bb12d86 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -167,6 +167,10 @@ class LoginFlowNode( override fun navigateToLoginPassword() { backstack.push(NavTarget.LoginPassword) } + + override fun onDone() { + callback.onDone() + } } val params = inputs() val inputs = OnBoardingNode.Params( diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt index 7e0901470a..1ded677c13 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingNode.kt @@ -42,6 +42,7 @@ class OnBoardingNode( fun navigateToLoginPassword() fun navigateToOidc(oidcDetails: OidcDetails) fun navigateToCreateAccount(url: String) + fun onDone() } data class Params( @@ -71,7 +72,7 @@ class OnBoardingNode( onNeedLoginPassword = callback::navigateToLoginPassword, onLearnMoreClick = { openLearnMorePage(context) }, onCreateAccountContinue = callback::navigateToCreateAccount, - onBackClick = ::navigateUp, + onBackClick = callback::onDone, ) } } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt index 9d72edebc9..953693b40d 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/DefaultLoginEntryPointTest.kt @@ -43,6 +43,7 @@ class DefaultLoginEntryPointTest { } val callback = object : LoginEntryPoint.Callback { override fun navigateToBugReport() = lambdaError() + override fun onDone() = lambdaError() } val params = LoginEntryPoint.Params( accountProvider = "ac", diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index bd43736cbd..c7328fb6ed 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -186,11 +186,20 @@ class PreferencesFlowNode( override fun navigateToPushHistory() { backstack.push(NavTarget.PushHistory) } + + override fun onDone() { + backstack.pop() + } } createNode(buildContext, listOf(developerSettingsCallback)) } NavTarget.Labs -> { - createNode(buildContext) + val callback = object : LabsNode.Callback { + override fun onDone() { + backstack.pop() + } + } + createNode(buildContext, listOf(callback)) } NavTarget.About -> { val callback = object : AboutNode.Callback { @@ -267,7 +276,12 @@ class PreferencesFlowNode( } is NavTarget.UserProfile -> { val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser) - createNode(buildContext, listOf(inputs)) + val callback = object : EditUserProfileNode.Callback { + override fun onDone() { + backstack.pop() + } + } + createNode(buildContext, listOf(inputs, callback)) } NavTarget.LockScreenSettings -> { lockScreenEntryPoint.createNode( diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt index 71077fe405..98c7d89633 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt @@ -31,6 +31,7 @@ class DeveloperSettingsNode( ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun navigateToPushHistory() + fun onDone() } private val callback: Callback = callback() @@ -49,7 +50,7 @@ class DeveloperSettingsNode( modifier = modifier, onOpenShowkase = ::openShowkase, onPushHistoryClick = callback::navigateToPushHistory, - onBackClick = ::navigateUp + onBackClick = callback::onDone, ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsNode.kt index 343b4d2795..b7ba73c9af 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/labs/LabsNode.kt @@ -16,6 +16,7 @@ import com.bumble.appyx.core.plugin.Plugin import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) @@ -25,9 +26,18 @@ class LabsNode( @Assisted plugins: List, private val presenter: LabsPresenter, ) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onDone() + } + + val callback: Callback = callback() + @Composable override fun View(modifier: Modifier) { val state = presenter.present() - LabsView(state = state, onBack = ::navigateUp) + LabsView( + state = state, + onBack = callback::onDone, + ) } } 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/EditUserProfileNavigator.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNavigator.kt new file mode 100644 index 0000000000..2935bce747 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNavigator.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.features.preferences.impl.user.editprofile + +interface EditUserProfileNavigator { + fun close() +} 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..2303abb06e 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 @@ -17,6 +17,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.user.MatrixUser @@ -27,22 +28,32 @@ class EditUserProfileNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenterFactory: EditUserProfilePresenter.Factory, -) : Node(buildContext, plugins = plugins) { +) : Node(buildContext, plugins = plugins), + EditUserProfileNavigator { data class Inputs( val matrixUser: MatrixUser ) : NodeInputs + interface Callback : Plugin { + fun onDone() + } + val matrixUser = inputs().matrixUser - val presenter = presenterFactory.create(matrixUser) + val callback: Callback = callback() + 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() = callback.onDone() } 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..59607139d7 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 @@ -45,6 +45,7 @@ import timber.log.Timber @AssistedInject class EditUserProfilePresenter( @Assisted private val matrixUser: MatrixUser, + @Assisted private val navigator: EditUserProfileNavigator, private val matrixClient: MatrixClient, private val mediaPickerProvider: PickerProvider, private val mediaPreProcessor: MediaPreProcessor, @@ -57,7 +58,10 @@ class EditUserProfilePresenter( @AssistedFactory interface Factory { - fun create(matrixUser: MatrixUser): EditUserProfilePresenter + fun create( + matrixUser: MatrixUser, + navigator: EditUserProfileNavigator, + ): EditUserProfilePresenter } @Composable @@ -101,6 +105,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 +135,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/EditUserProfileStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt index 7b373293c4..56b734a342 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt @@ -11,27 +11,36 @@ package io.element.android.features.preferences.impl.user.editprofile import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.permissions.api.PermissionsState import io.element.android.libraries.permissions.api.aPermissionsState -import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList open class EditUserProfileStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aEditUserProfileState(), aEditUserProfileState(userAvatarUrl = "example://uri"), - // Add other states here + aEditUserProfileState(saveAction = AsyncAction.ConfirmingCancellation), ) } fun aEditUserProfileState( + userId: UserId = UserId("@john.doe:matrix.org"), + displayName: String = "John Doe", userAvatarUrl: String? = null, + avatarActions: List = emptyList(), + saveButtonEnabled: Boolean = true, + saveAction: AsyncAction = AsyncAction.Uninitialized, + cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false), + eventSink: (EditUserProfileEvents) -> Unit = {}, ) = EditUserProfileState( - userId = UserId("@john.doe:matrix.org"), - displayName = "John Doe", + userId = userId, + displayName = displayName, userAvatarUrl = userAvatarUrl, - avatarActions = persistentListOf(), - saveAction = AsyncAction.Uninitialized, - saveButtonEnabled = true, - cameraPermissionState = aPermissionsState(showDialog = false), - eventSink = {} + avatarActions = avatarActions.toImmutableList(), + saveButtonEnabled = saveButtonEnabled, + saveAction = saveAction, + cameraPermissionState = cameraPermissionState, + eventSink = eventSink, ) 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..3432bac29f 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 @@ -9,9 +9,6 @@ package io.element.android.features.preferences.impl.user.editprofile import android.net.Uri -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -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 @@ -35,6 +32,7 @@ import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.test import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -79,6 +77,7 @@ class EditUserProfilePresenterTest { private fun createEditUserProfilePresenter( matrixClient: MatrixClient = FakeMatrixClient(), + navigator: EditUserProfileNavigator = FakeEditUserProfileNavigator(), matrixUser: MatrixUser = aMatrixUser(), permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(), @@ -86,6 +85,7 @@ class EditUserProfilePresenterTest { ): EditUserProfilePresenter { return EditUserProfilePresenter( matrixClient = matrixClient, + navigator = navigator, matrixUser = matrixUser, mediaPickerProvider = fakePickerProvider, mediaPreProcessor = fakeMediaPreProcessor, @@ -99,9 +99,7 @@ class EditUserProfilePresenterTest { fun `present - initial state is created from user info`() = runTest { val user = aMatrixUser(avatarUrl = AN_AVATAR_URL) val presenter = createEditUserProfilePresenter(matrixUser = user) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() assertThat(initialState.userId).isEqualTo(user.userId) assertThat(initialState.displayName).isEqualTo(user.displayName) @@ -116,6 +114,53 @@ class EditUserProfilePresenterTest { } } + @Test + fun `present - exit invokes the expected callback`() = runTest { + val user = aMatrixUser(avatarUrl = AN_AVATAR_URL) + val closeLambda = lambdaRecorder {} + val presenter = createEditUserProfilePresenter( + matrixUser = user, + navigator = FakeEditUserProfileNavigator(closeLambda), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(EditUserProfileEvents.Exit) + closeLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - exit without unsaved changes`() = runTest { + val user = aMatrixUser(avatarUrl = AN_AVATAR_URL) + val closeLambda = lambdaRecorder {} + val presenter = createEditUserProfilePresenter( + matrixUser = user, + navigator = FakeEditUserProfileNavigator(closeLambda), + ) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name")) + val withUpdatedName = awaitItem() + withUpdatedName.eventSink(EditUserProfileEvents.Exit) + val withConfirmation = awaitItem() + assertThat(withConfirmation.saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) + // Cancel + withConfirmation.eventSink(EditUserProfileEvents.CloseDialog) + val afterCancel = awaitItem() + assertThat(afterCancel.saveAction).isEqualTo(AsyncAction.Uninitialized) + // Try again and confirm + afterCancel.eventSink(EditUserProfileEvents.Exit) + val withConfirmation2 = awaitItem() + assertThat(withConfirmation2.saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) + closeLambda.assertions().isNeverCalled() + withConfirmation2.eventSink(EditUserProfileEvents.Exit) + // Dialog is closed + val finalState = awaitItem() + assertThat(finalState.saveAction).isEqualTo(AsyncAction.Uninitialized) + closeLambda.assertions().isCalledOnce() + } + } + @Test fun `present - updates state in response to changes`() = runTest { val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) @@ -125,9 +170,7 @@ class EditUserProfilePresenterTest { deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } ), ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() assertThat(initialState.displayName).isEqualTo("Name") assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) @@ -159,9 +202,7 @@ class EditUserProfilePresenterTest { deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } ), ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) @@ -184,9 +225,7 @@ class EditUserProfilePresenterTest { deleteLambda = deleteCallback, ), ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) assertThat(initialState.cameraPermissionState.permissionGranted).isFalse() @@ -221,9 +260,7 @@ class EditUserProfilePresenterTest { deleteLambda = deleteCallback ), ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() assertThat(initialState.saveButtonEnabled).isFalse() // Once a change is made, the save button is enabled @@ -264,9 +301,7 @@ class EditUserProfilePresenterTest { deleteLambda = deleteCallback ), ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() assertThat(initialState.saveButtonEnabled).isFalse() // Once a change is made, the save button is enabled @@ -307,9 +342,7 @@ class EditUserProfilePresenterTest { deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } ), ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name")) initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) @@ -330,9 +363,7 @@ class EditUserProfilePresenterTest { matrixClient = matrixClient, matrixUser = user ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name ")) initialState.eventSink(EditUserProfileEvents.Save) @@ -351,9 +382,7 @@ class EditUserProfilePresenterTest { matrixClient = matrixClient, matrixUser = user ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("")) initialState.eventSink(EditUserProfileEvents.Save) @@ -376,9 +405,7 @@ class EditUserProfilePresenterTest { deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } ), ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(EditUserProfileEvents.Save) @@ -400,9 +427,7 @@ class EditUserProfilePresenterTest { ) fakePickerProvider.givenResult(anotherAvatarUri) fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no"))) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(EditUserProfileEvents.Save) @@ -441,22 +466,20 @@ class EditUserProfilePresenterTest { } @Test - fun `present - CancelSaveChanges resets save action state`() = runTest { + fun `present - CloseDialog resets save action state`() = runTest { givenPickerReturnsFile() val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val matrixClient = FakeMatrixClient().apply { givenSetDisplayNameResult(Result.failure(RuntimeException("!"))) } val presenter = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo")) 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) } } @@ -469,9 +492,7 @@ class EditUserProfilePresenterTest { deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } ), ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(event) initialState.eventSink(EditUserProfileEvents.Save) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt new file mode 100644 index 0000000000..f4c7144350 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector 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.features.preferences.impl.user.editprofile + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EditUserProfileViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setEditUserProfileView( + aEditUserProfileState( + eventSink = eventsRecorder, + ), + ) + rule.pressBack() + eventsRecorder.assertSingle(EditUserProfileEvents.Exit) + } + + @Test + fun `clicking on cancel exit emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setEditUserProfileView( + aEditUserProfileState( + saveAction = AsyncAction.ConfirmingCancellation, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(EditUserProfileEvents.CloseDialog) + } + + @Test + fun `clicking on OK exit emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setEditUserProfileView( + aEditUserProfileState( + saveAction = AsyncAction.ConfirmingCancellation, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(EditUserProfileEvents.Exit) + } + + @Test + fun `clicking on save emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setEditUserProfileView( + aEditUserProfileState( + saveButtonEnabled = true, + saveAction = AsyncAction.Uninitialized, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_save) + eventsRecorder.assertSingle(EditUserProfileEvents.Save) + } + + @Test + fun `clicking on avatar opens the bottom sheet dialog`() { + val eventsRecorder = EventsRecorder() + val actions = listOf( + AvatarAction.TakePhoto, + AvatarAction.ChoosePhoto, + AvatarAction.Remove, + ) + rule.setEditUserProfileView( + aEditUserProfileState( + saveAction = AsyncAction.Uninitialized, + avatarActions = actions, + eventSink = eventsRecorder, + ), + ) + val contentDescription = rule.activity.getString(CommonStrings.a11y_avatar) + rule.onNodeWithContentDescription(contentDescription).performClick() + // Assert that the actions are displayed + actions.forEach { action -> + val text = rule.activity.getString(action.titleResId) + rule.onNodeWithText(text).assertExists() + } + } + + @Test + fun `success invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setEditUserProfileView( + aEditUserProfileState( + saveAction = AsyncAction.Success(Unit), + eventSink = eventsRecorder, + ), + onEditProfileSuccess = callback, + ) + } + } +} + +private fun AndroidComposeTestRule.setEditUserProfileView( + state: EditUserProfileState, + onEditProfileSuccess: () -> Unit = EnsureNeverCalled(), +) { + setContent { + EditUserProfileView( + state = state, + onEditProfileSuccess = onEditProfileSuccess, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/FakeEditUserProfileNavigator.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/FakeEditUserProfileNavigator.kt new file mode 100644 index 0000000000..7b34e904a1 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/FakeEditUserProfileNavigator.kt @@ -0,0 +1,16 @@ +/* + * 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.features.preferences.impl.user.editprofile + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeEditUserProfileNavigator( + val closeLambda: () -> Unit = { lambdaError() } +) : EditUserProfileNavigator { + override fun close() = closeLambda() +} diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Day_2_en.png new file mode 100644 index 0000000000..1e81e0e58a --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da49d0867fdaa28fbe7db7dc21c5de09268e7bb3afdb0b2289df372d537c2abb +size 33570 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Night_2_en.png new file mode 100644 index 0000000000..73a68c1db0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.user.editprofile_EditUserProfileView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:60fe1926cf8d66a8086268f3e4e1b6b1616f860668e1e5f4dc670d5c57ec4d24 +size 31943