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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,6 +21,7 @@ interface LoginEntryPoint : FeatureEntryPoint {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun navigateToBugReport()
|
||||
fun onDone()
|
||||
}
|
||||
|
||||
fun createNode(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user