Edit user profile cancel confirmation (#5788)

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

* Use test extension

* Add unit tests

* Add preview

* Add unit test on EditUserProfileView

* Avoid using navigateUp.

* Update screenshots

* Remove BaseCallback, it's actually not ideal when looking for usage.

* Remove BaseNavigator, it's actually not ideal when looking for usage.

* Fix crash when clicking fast on back key on the Add account screen.

* Fix crash when clicking fast on back key on the Labs screen.

* Fix crash when clicking fast on back key on the Developer settings screen.

* Fix compilation issue in test

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2025-11-24 09:38:15 +01:00
committed by GitHub
20 changed files with 374 additions and 76 deletions

View File

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

View File

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

View File

@@ -21,6 +21,7 @@ interface LoginEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToBugReport()
fun onDone()
}
fun createNode(

View File

@@ -167,6 +167,10 @@ class LoginFlowNode(
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword)
}
override fun onDone() {
callback.onDone()
}
}
val params = inputs<Params>()
val inputs = OnBoardingNode.Params(

View File

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

View File

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

View File

@@ -186,11 +186,20 @@ class PreferencesFlowNode(
override fun navigateToPushHistory() {
backstack.push(NavTarget.PushHistory)
}
override fun onDone() {
backstack.pop()
}
}
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))
}
NavTarget.Labs -> {
createNode<LabsNode>(buildContext)
val callback = object : LabsNode.Callback {
override fun onDone() {
backstack.pop()
}
}
createNode<LabsNode>(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<EditUserProfileNode>(buildContext, listOf(inputs))
val callback = object : EditUserProfileNode.Callback {
override fun onDone() {
backstack.pop()
}
}
createNode<EditUserProfileNode>(buildContext, listOf(inputs, callback))
}
NavTarget.LockScreenSettings -> {
lockScreenEntryPoint.createNode(

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -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<EditUserProfileState> {
override val values: Sequence<EditUserProfileState>
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<AvatarAction> = emptyList(),
saveButtonEnabled: Boolean = true,
saveAction: AsyncAction<Unit> = 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,
)

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

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

View File

@@ -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<ComponentActivity>()
@Test
fun `clicking on back emits the expected event`() {
val eventsRecorder = EventsRecorder<EditUserProfileEvents>()
rule.setEditUserProfileView(
aEditUserProfileState(
eventSink = eventsRecorder,
),
)
rule.pressBack()
eventsRecorder.assertSingle(EditUserProfileEvents.Exit)
}
@Test
fun `clicking on cancel exit emits the expected event`() {
val eventsRecorder = EventsRecorder<EditUserProfileEvents>()
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<EditUserProfileEvents>()
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<EditUserProfileEvents>()
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<EditUserProfileEvents>()
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<EditUserProfileEvents>(expectEvents = false)
ensureCalledOnce { callback ->
rule.setEditUserProfileView(
aEditUserProfileState(
saveAction = AsyncAction.Success(Unit),
eventSink = eventsRecorder,
),
onEditProfileSuccess = callback,
)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setEditUserProfileView(
state: EditUserProfileState,
onEditProfileSuccess: () -> Unit = EnsureNeverCalled(),
) {
setContent {
EditUserProfileView(
state = state,
onEditProfileSuccess = onEditProfileSuccess,
)
}
}

View File

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