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:
commit
df7640a0a3
20 changed files with 374 additions and 76 deletions
|
|
@ -57,6 +57,7 @@ class NotLoggedInFlowNode(
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun navigateToBugReport()
|
fun navigateToBugReport()
|
||||||
|
fun onDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callback: Callback = callback()
|
private val callback: Callback = callback()
|
||||||
|
|
@ -83,6 +84,10 @@ class NotLoggedInFlowNode(
|
||||||
override fun navigateToBugReport() {
|
override fun navigateToBugReport() {
|
||||||
callback.navigateToBugReport()
|
callback.navigateToBugReport()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDone() {
|
||||||
|
callback.onDone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
loginEntryPoint.createNode(
|
loginEntryPoint.createNode(
|
||||||
parentNode = this,
|
parentNode = this,
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,10 @@ class RootFlowNode(
|
||||||
override fun navigateToBugReport() {
|
override fun navigateToBugReport() {
|
||||||
backstack.push(NavTarget.BugReport)
|
backstack.push(NavTarget.BugReport)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDone() {
|
||||||
|
backstack.pop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val params = NotLoggedInFlowNode.Params(
|
val params = NotLoggedInFlowNode.Params(
|
||||||
loginParams = navTarget.params,
|
loginParams = navTarget.params,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ interface LoginEntryPoint : FeatureEntryPoint {
|
||||||
|
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun navigateToBugReport()
|
fun navigateToBugReport()
|
||||||
|
fun onDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createNode(
|
fun createNode(
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,10 @@ class LoginFlowNode(
|
||||||
override fun navigateToLoginPassword() {
|
override fun navigateToLoginPassword() {
|
||||||
backstack.push(NavTarget.LoginPassword)
|
backstack.push(NavTarget.LoginPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDone() {
|
||||||
|
callback.onDone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val params = inputs<Params>()
|
val params = inputs<Params>()
|
||||||
val inputs = OnBoardingNode.Params(
|
val inputs = OnBoardingNode.Params(
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ class OnBoardingNode(
|
||||||
fun navigateToLoginPassword()
|
fun navigateToLoginPassword()
|
||||||
fun navigateToOidc(oidcDetails: OidcDetails)
|
fun navigateToOidc(oidcDetails: OidcDetails)
|
||||||
fun navigateToCreateAccount(url: String)
|
fun navigateToCreateAccount(url: String)
|
||||||
|
fun onDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Params(
|
data class Params(
|
||||||
|
|
@ -71,7 +72,7 @@ class OnBoardingNode(
|
||||||
onNeedLoginPassword = callback::navigateToLoginPassword,
|
onNeedLoginPassword = callback::navigateToLoginPassword,
|
||||||
onLearnMoreClick = { openLearnMorePage(context) },
|
onLearnMoreClick = { openLearnMorePage(context) },
|
||||||
onCreateAccountContinue = callback::navigateToCreateAccount,
|
onCreateAccountContinue = callback::navigateToCreateAccount,
|
||||||
onBackClick = ::navigateUp,
|
onBackClick = callback::onDone,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ class DefaultLoginEntryPointTest {
|
||||||
}
|
}
|
||||||
val callback = object : LoginEntryPoint.Callback {
|
val callback = object : LoginEntryPoint.Callback {
|
||||||
override fun navigateToBugReport() = lambdaError()
|
override fun navigateToBugReport() = lambdaError()
|
||||||
|
override fun onDone() = lambdaError()
|
||||||
}
|
}
|
||||||
val params = LoginEntryPoint.Params(
|
val params = LoginEntryPoint.Params(
|
||||||
accountProvider = "ac",
|
accountProvider = "ac",
|
||||||
|
|
|
||||||
|
|
@ -186,11 +186,20 @@ class PreferencesFlowNode(
|
||||||
override fun navigateToPushHistory() {
|
override fun navigateToPushHistory() {
|
||||||
backstack.push(NavTarget.PushHistory)
|
backstack.push(NavTarget.PushHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDone() {
|
||||||
|
backstack.pop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))
|
createNode<DeveloperSettingsNode>(buildContext, listOf(developerSettingsCallback))
|
||||||
}
|
}
|
||||||
NavTarget.Labs -> {
|
NavTarget.Labs -> {
|
||||||
createNode<LabsNode>(buildContext)
|
val callback = object : LabsNode.Callback {
|
||||||
|
override fun onDone() {
|
||||||
|
backstack.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createNode<LabsNode>(buildContext, listOf(callback))
|
||||||
}
|
}
|
||||||
NavTarget.About -> {
|
NavTarget.About -> {
|
||||||
val callback = object : AboutNode.Callback {
|
val callback = object : AboutNode.Callback {
|
||||||
|
|
@ -267,7 +276,12 @@ class PreferencesFlowNode(
|
||||||
}
|
}
|
||||||
is NavTarget.UserProfile -> {
|
is NavTarget.UserProfile -> {
|
||||||
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
|
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 -> {
|
NavTarget.LockScreenSettings -> {
|
||||||
lockScreenEntryPoint.createNode(
|
lockScreenEntryPoint.createNode(
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ class DeveloperSettingsNode(
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
interface Callback : Plugin {
|
interface Callback : Plugin {
|
||||||
fun navigateToPushHistory()
|
fun navigateToPushHistory()
|
||||||
|
fun onDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callback: Callback = callback()
|
private val callback: Callback = callback()
|
||||||
|
|
@ -49,7 +50,7 @@ class DeveloperSettingsNode(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onOpenShowkase = ::openShowkase,
|
onOpenShowkase = ::openShowkase,
|
||||||
onPushHistoryClick = callback::navigateToPushHistory,
|
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.Assisted
|
||||||
import dev.zacsweers.metro.AssistedInject
|
import dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.annotations.ContributesNode
|
import io.element.android.annotations.ContributesNode
|
||||||
|
import io.element.android.libraries.architecture.callback
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
|
||||||
@ContributesNode(SessionScope::class)
|
@ContributesNode(SessionScope::class)
|
||||||
|
|
@ -25,9 +26,18 @@ class LabsNode(
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenter: LabsPresenter,
|
private val presenter: LabsPresenter,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
val callback: Callback = callback()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
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 {
|
sealed interface EditUserProfileEvents {
|
||||||
data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
|
data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
|
||||||
data class UpdateDisplayName(val name: String) : EditUserProfileEvents
|
data class UpdateDisplayName(val name: String) : EditUserProfileEvents
|
||||||
|
data object Exit : EditUserProfileEvents
|
||||||
data object Save : EditUserProfileEvents
|
data object Save : EditUserProfileEvents
|
||||||
data object CancelSaveChanges : EditUserProfileEvents
|
data object CloseDialog : EditUserProfileEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 dev.zacsweers.metro.AssistedInject
|
||||||
import io.element.android.annotations.ContributesNode
|
import io.element.android.annotations.ContributesNode
|
||||||
import io.element.android.libraries.architecture.NodeInputs
|
import io.element.android.libraries.architecture.NodeInputs
|
||||||
|
import io.element.android.libraries.architecture.callback
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
|
@ -27,22 +28,32 @@ class EditUserProfileNode(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
presenterFactory: EditUserProfilePresenter.Factory,
|
presenterFactory: EditUserProfilePresenter.Factory,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins),
|
||||||
|
EditUserProfileNavigator {
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
val matrixUser: MatrixUser
|
val matrixUser: MatrixUser
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onDone()
|
||||||
|
}
|
||||||
|
|
||||||
val matrixUser = inputs<Inputs>().matrixUser
|
val matrixUser = inputs<Inputs>().matrixUser
|
||||||
val presenter = presenterFactory.create(matrixUser)
|
val callback: Callback = callback()
|
||||||
|
val presenter = presenterFactory.create(
|
||||||
|
matrixUser = matrixUser,
|
||||||
|
navigator = this,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
EditUserProfileView(
|
EditUserProfileView(
|
||||||
state = state,
|
state = state,
|
||||||
onBackClick = ::navigateUp,
|
onEditProfileSuccess = ::close,
|
||||||
onEditProfileSuccess = ::navigateUp,
|
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun close() = callback.onDone()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import timber.log.Timber
|
||||||
@AssistedInject
|
@AssistedInject
|
||||||
class EditUserProfilePresenter(
|
class EditUserProfilePresenter(
|
||||||
@Assisted private val matrixUser: MatrixUser,
|
@Assisted private val matrixUser: MatrixUser,
|
||||||
|
@Assisted private val navigator: EditUserProfileNavigator,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val mediaPickerProvider: PickerProvider,
|
private val mediaPickerProvider: PickerProvider,
|
||||||
private val mediaPreProcessor: MediaPreProcessor,
|
private val mediaPreProcessor: MediaPreProcessor,
|
||||||
|
|
@ -57,7 +58,10 @@ class EditUserProfilePresenter(
|
||||||
|
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(matrixUser: MatrixUser): EditUserProfilePresenter
|
fun create(
|
||||||
|
matrixUser: MatrixUser,
|
||||||
|
navigator: EditUserProfileNavigator,
|
||||||
|
): EditUserProfilePresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -101,6 +105,13 @@ class EditUserProfilePresenter(
|
||||||
|
|
||||||
val saveAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
val saveAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
|
||||||
val localCoroutineScope = rememberCoroutineScope()
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val canSave = remember(userDisplayName, userAvatarUri) {
|
||||||
|
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) ||
|
||||||
|
hasAvatarUrlChanged(userAvatarUri, matrixUser)
|
||||||
|
!userDisplayName.isNullOrBlank() && hasProfileChanged
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvent(event: EditUserProfileEvents) {
|
fun handleEvent(event: EditUserProfileEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(
|
is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(
|
||||||
|
|
@ -124,18 +135,32 @@ class EditUserProfilePresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
|
is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
|
||||||
EditUserProfileEvents.CancelSaveChanges -> saveAction.value = AsyncAction.Uninitialized
|
EditUserProfileEvents.Exit -> {
|
||||||
|
when (saveAction.value) {
|
||||||
|
is AsyncAction.Confirming -> {
|
||||||
|
// Close the dialog right now
|
||||||
|
saveAction.value = AsyncAction.Uninitialized
|
||||||
|
navigator.close()
|
||||||
|
}
|
||||||
|
AsyncAction.Loading -> Unit
|
||||||
|
is AsyncAction.Failure,
|
||||||
|
is AsyncAction.Success -> {
|
||||||
|
// Should not happen
|
||||||
|
}
|
||||||
|
AsyncAction.Uninitialized -> {
|
||||||
|
if (canSave) {
|
||||||
|
saveAction.value = AsyncAction.ConfirmingCancellation
|
||||||
|
} else {
|
||||||
|
navigator.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EditUserProfileEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val canSave = remember(userDisplayName, userAvatarUri) {
|
|
||||||
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) ||
|
|
||||||
hasAvatarUrlChanged(userAvatarUri, matrixUser)
|
|
||||||
!userDisplayName.isNullOrBlank() && hasProfileChanged
|
|
||||||
}
|
|
||||||
|
|
||||||
return EditUserProfileState(
|
return EditUserProfileState(
|
||||||
userId = matrixUser.userId,
|
userId = matrixUser.userId,
|
||||||
displayName = userDisplayName.orEmpty(),
|
displayName = userDisplayName.orEmpty(),
|
||||||
|
|
|
||||||
|
|
@ -11,27 +11,36 @@ package io.element.android.features.preferences.impl.user.editprofile
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.matrix.api.core.UserId
|
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 io.element.android.libraries.permissions.api.aPermissionsState
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> {
|
open class EditUserProfileStateProvider : PreviewParameterProvider<EditUserProfileState> {
|
||||||
override val values: Sequence<EditUserProfileState>
|
override val values: Sequence<EditUserProfileState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aEditUserProfileState(),
|
aEditUserProfileState(),
|
||||||
aEditUserProfileState(userAvatarUrl = "example://uri"),
|
aEditUserProfileState(userAvatarUrl = "example://uri"),
|
||||||
// Add other states here
|
aEditUserProfileState(saveAction = AsyncAction.ConfirmingCancellation),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aEditUserProfileState(
|
fun aEditUserProfileState(
|
||||||
|
userId: UserId = UserId("@john.doe:matrix.org"),
|
||||||
|
displayName: String = "John Doe",
|
||||||
userAvatarUrl: String? = null,
|
userAvatarUrl: String? = null,
|
||||||
|
avatarActions: List<AvatarAction> = emptyList(),
|
||||||
|
saveButtonEnabled: Boolean = true,
|
||||||
|
saveAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||||
|
cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false),
|
||||||
|
eventSink: (EditUserProfileEvents) -> Unit = {},
|
||||||
) = EditUserProfileState(
|
) = EditUserProfileState(
|
||||||
userId = UserId("@john.doe:matrix.org"),
|
userId = userId,
|
||||||
displayName = "John Doe",
|
displayName = displayName,
|
||||||
userAvatarUrl = userAvatarUrl,
|
userAvatarUrl = userAvatarUrl,
|
||||||
avatarActions = persistentListOf(),
|
avatarActions = avatarActions.toImmutableList(),
|
||||||
saveAction = AsyncAction.Uninitialized,
|
saveButtonEnabled = saveButtonEnabled,
|
||||||
saveButtonEnabled = true,
|
saveAction = saveAction,
|
||||||
cameraPermissionState = aPermissionsState(showDialog = false),
|
cameraPermissionState = cameraPermissionState,
|
||||||
eventSink = {}
|
eventSink = eventSink,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
package io.element.android.features.preferences.impl.user.editprofile
|
package io.element.android.features.preferences.impl.user.editprofile
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
|
@ -30,11 +31,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.features.preferences.impl.R
|
import io.element.android.features.preferences.impl.R
|
||||||
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
|
import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
import io.element.android.libraries.designsystem.components.avatar.AvatarType
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
|
import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog
|
||||||
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
|
import io.element.android.libraries.designsystem.modifiers.clearFocusOnTap
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -52,7 +55,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
@Composable
|
@Composable
|
||||||
fun EditUserProfileView(
|
fun EditUserProfileView(
|
||||||
state: EditUserProfileState,
|
state: EditUserProfileState,
|
||||||
onBackClick: () -> Unit,
|
|
||||||
onEditProfileSuccess: () -> Unit,
|
onEditProfileSuccess: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -64,12 +66,21 @@ fun EditUserProfileView(
|
||||||
isAvatarActionsSheetVisible.value = true
|
isAvatarActionsSheetVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onBackClick() {
|
||||||
|
focusManager.clearFocus()
|
||||||
|
state.eventSink(EditUserProfileEvents.Exit)
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(
|
||||||
|
enabled = true,
|
||||||
|
::onBackClick,
|
||||||
|
)
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.clearFocusOnTap(focusManager),
|
modifier = modifier.clearFocusOnTap(focusManager),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
titleStr = stringResource(R.string.screen_edit_profile_title),
|
titleStr = stringResource(R.string.screen_edit_profile_title),
|
||||||
navigationIcon = { BackButton(onClick = onBackClick) },
|
navigationIcon = { BackButton(::onBackClick) },
|
||||||
actions = {
|
actions = {
|
||||||
TextButton(
|
TextButton(
|
||||||
text = stringResource(CommonStrings.action_save),
|
text = stringResource(CommonStrings.action_save),
|
||||||
|
|
@ -132,10 +143,20 @@ fun EditUserProfileView(
|
||||||
progressText = stringResource(R.string.screen_edit_profile_updating_details),
|
progressText = stringResource(R.string.screen_edit_profile_updating_details),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
confirmationDialog = { confirming ->
|
||||||
|
when (confirming) {
|
||||||
|
is AsyncAction.ConfirmingCancellation -> {
|
||||||
|
SaveChangesDialog(
|
||||||
|
onSubmitClick = { state.eventSink(EditUserProfileEvents.Exit) },
|
||||||
|
onDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onSuccess = { onEditProfileSuccess() },
|
onSuccess = { onEditProfileSuccess() },
|
||||||
errorTitle = { stringResource(R.string.screen_edit_profile_error_title) },
|
errorTitle = { stringResource(R.string.screen_edit_profile_error_title) },
|
||||||
errorMessage = { stringResource(R.string.screen_edit_profile_error) },
|
errorMessage = { stringResource(R.string.screen_edit_profile_error) },
|
||||||
onErrorDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) },
|
onErrorDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PermissionsView(
|
PermissionsView(
|
||||||
|
|
@ -148,7 +169,6 @@ fun EditUserProfileView(
|
||||||
internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
|
internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) =
|
||||||
ElementPreview {
|
ElementPreview {
|
||||||
EditUserProfileView(
|
EditUserProfileView(
|
||||||
onBackClick = {},
|
|
||||||
onEditProfileSuccess = {},
|
onEditProfileSuccess = {},
|
||||||
state = state,
|
state = state,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@
|
||||||
package io.element.android.features.preferences.impl.user.editprofile
|
package io.element.android.features.preferences.impl.user.editprofile
|
||||||
|
|
||||||
import android.net.Uri
|
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 com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
import io.element.android.libraries.androidutils.file.TemporaryUriDeleter
|
||||||
import io.element.android.libraries.architecture.AsyncAction
|
import io.element.android.libraries.architecture.AsyncAction
|
||||||
|
|
@ -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.fake.FakeTemporaryUriDeleter
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
|
|
@ -79,6 +77,7 @@ class EditUserProfilePresenterTest {
|
||||||
|
|
||||||
private fun createEditUserProfilePresenter(
|
private fun createEditUserProfilePresenter(
|
||||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||||
|
navigator: EditUserProfileNavigator = FakeEditUserProfileNavigator(),
|
||||||
matrixUser: MatrixUser = aMatrixUser(),
|
matrixUser: MatrixUser = aMatrixUser(),
|
||||||
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
|
||||||
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
temporaryUriDeleter: TemporaryUriDeleter = FakeTemporaryUriDeleter(),
|
||||||
|
|
@ -86,6 +85,7 @@ class EditUserProfilePresenterTest {
|
||||||
): EditUserProfilePresenter {
|
): EditUserProfilePresenter {
|
||||||
return EditUserProfilePresenter(
|
return EditUserProfilePresenter(
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
|
navigator = navigator,
|
||||||
matrixUser = matrixUser,
|
matrixUser = matrixUser,
|
||||||
mediaPickerProvider = fakePickerProvider,
|
mediaPickerProvider = fakePickerProvider,
|
||||||
mediaPreProcessor = fakeMediaPreProcessor,
|
mediaPreProcessor = fakeMediaPreProcessor,
|
||||||
|
|
@ -99,9 +99,7 @@ class EditUserProfilePresenterTest {
|
||||||
fun `present - initial state is created from user info`() = runTest {
|
fun `present - initial state is created from user info`() = runTest {
|
||||||
val user = aMatrixUser(avatarUrl = AN_AVATAR_URL)
|
val user = aMatrixUser(avatarUrl = AN_AVATAR_URL)
|
||||||
val presenter = createEditUserProfilePresenter(matrixUser = user)
|
val presenter = createEditUserProfilePresenter(matrixUser = user)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.userId).isEqualTo(user.userId)
|
assertThat(initialState.userId).isEqualTo(user.userId)
|
||||||
assertThat(initialState.displayName).isEqualTo(user.displayName)
|
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
|
@Test
|
||||||
fun `present - updates state in response to changes`() = runTest {
|
fun `present - updates state in response to changes`() = runTest {
|
||||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
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) }
|
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.displayName).isEqualTo("Name")
|
assertThat(initialState.displayName).isEqualTo("Name")
|
||||||
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||||
|
|
@ -159,9 +202,7 @@ class EditUserProfilePresenterTest {
|
||||||
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||||
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||||
|
|
@ -184,9 +225,7 @@ class EditUserProfilePresenterTest {
|
||||||
deleteLambda = deleteCallback,
|
deleteLambda = deleteCallback,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL)
|
||||||
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
|
assertThat(initialState.cameraPermissionState.permissionGranted).isFalse()
|
||||||
|
|
@ -221,9 +260,7 @@ class EditUserProfilePresenterTest {
|
||||||
deleteLambda = deleteCallback
|
deleteLambda = deleteCallback
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||||
// Once a change is made, the save button is enabled
|
// Once a change is made, the save button is enabled
|
||||||
|
|
@ -264,9 +301,7 @@ class EditUserProfilePresenterTest {
|
||||||
deleteLambda = deleteCallback
|
deleteLambda = deleteCallback
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
assertThat(initialState.saveButtonEnabled).isFalse()
|
assertThat(initialState.saveButtonEnabled).isFalse()
|
||||||
// Once a change is made, the save button is enabled
|
// Once a change is made, the save button is enabled
|
||||||
|
|
@ -307,9 +342,7 @@ class EditUserProfilePresenterTest {
|
||||||
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name"))
|
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name"))
|
||||||
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
|
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove))
|
||||||
|
|
@ -330,9 +363,7 @@ class EditUserProfilePresenterTest {
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
matrixUser = user
|
matrixUser = user
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name "))
|
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name "))
|
||||||
initialState.eventSink(EditUserProfileEvents.Save)
|
initialState.eventSink(EditUserProfileEvents.Save)
|
||||||
|
|
@ -351,9 +382,7 @@ class EditUserProfilePresenterTest {
|
||||||
matrixClient = matrixClient,
|
matrixClient = matrixClient,
|
||||||
matrixUser = user
|
matrixUser = user
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(""))
|
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(""))
|
||||||
initialState.eventSink(EditUserProfileEvents.Save)
|
initialState.eventSink(EditUserProfileEvents.Save)
|
||||||
|
|
@ -376,9 +405,7 @@ class EditUserProfilePresenterTest {
|
||||||
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||||
initialState.eventSink(EditUserProfileEvents.Save)
|
initialState.eventSink(EditUserProfileEvents.Save)
|
||||||
|
|
@ -400,9 +427,7 @@ class EditUserProfilePresenterTest {
|
||||||
)
|
)
|
||||||
fakePickerProvider.givenResult(anotherAvatarUri)
|
fakePickerProvider.givenResult(anotherAvatarUri)
|
||||||
fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no")))
|
fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no")))
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto))
|
||||||
initialState.eventSink(EditUserProfileEvents.Save)
|
initialState.eventSink(EditUserProfileEvents.Save)
|
||||||
|
|
@ -441,22 +466,20 @@ class EditUserProfilePresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - CancelSaveChanges resets save action state`() = runTest {
|
fun `present - CloseDialog resets save action state`() = runTest {
|
||||||
givenPickerReturnsFile()
|
givenPickerReturnsFile()
|
||||||
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL)
|
||||||
val matrixClient = FakeMatrixClient().apply {
|
val matrixClient = FakeMatrixClient().apply {
|
||||||
givenSetDisplayNameResult(Result.failure(RuntimeException("!")))
|
givenSetDisplayNameResult(Result.failure(RuntimeException("!")))
|
||||||
}
|
}
|
||||||
val presenter = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient)
|
val presenter = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo"))
|
initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo"))
|
||||||
initialState.eventSink(EditUserProfileEvents.Save)
|
initialState.eventSink(EditUserProfileEvents.Save)
|
||||||
skipItems(2)
|
skipItems(2)
|
||||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java)
|
||||||
initialState.eventSink(EditUserProfileEvents.CancelSaveChanges)
|
initialState.eventSink(EditUserProfileEvents.CloseDialog)
|
||||||
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -469,9 +492,7 @@ class EditUserProfilePresenterTest {
|
||||||
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) }
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem()
|
val initialState = awaitItem()
|
||||||
initialState.eventSink(event)
|
initialState.eventSink(event)
|
||||||
initialState.eventSink(EditUserProfileEvents.Save)
|
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()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:da49d0867fdaa28fbe7db7dc21c5de09268e7bb3afdb0b2289df372d537c2abb
|
||||||
|
size 33570
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:60fe1926cf8d66a8086268f3e4e1b6b1616f860668e1e5f4dc670d5c57ec4d24
|
||||||
|
size 31943
|
||||||
Loading…
Add table
Add a link
Reference in a new issue