diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountNavigation.kt index 2cd81f880..2fcb188d1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountNavigation.kt @@ -12,11 +12,15 @@ private const val DELETE_ACCOUNT_ROUTE = "delete_account" */ fun NavGraphBuilder.deleteAccountDestination( onNavigateBack: () -> Unit, + onNavigateToDeleteAccountConfirmation: () -> Unit, ) { composableWithSlideTransitions( route = DELETE_ACCOUNT_ROUTE, ) { - DeleteAccountScreen(onNavigateBack = onNavigateBack) + DeleteAccountScreen( + onNavigateBack = onNavigateBack, + onNavigateToDeleteAccountConfirmation = onNavigateToDeleteAccountConfirmation, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreen.kt index 946c9ab91..dcab92d08 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreen.kt @@ -54,6 +54,7 @@ import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold fun DeleteAccountScreen( viewModel: DeleteAccountViewModel = hiltViewModel(), onNavigateBack: () -> Unit, + onNavigateToDeleteAccountConfirmation: () -> Unit, ) { val state by viewModel.stateFlow.collectAsState() val context = LocalContext.current @@ -65,6 +66,10 @@ fun DeleteAccountScreen( is DeleteAccountEvent.ShowToast -> { Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show() } + + DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen -> { + onNavigateToDeleteAccountConfirmation() + } } } @@ -149,9 +154,17 @@ fun DeleteAccountScreen( ) Spacer(modifier = Modifier.height(24.dp)) DeleteAccountButton( - onConfirmationClick = remember(viewModel) { - { viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(it)) } + onDeleteAccountConfirmDialogClick = remember(viewModel) { + { + viewModel.trySendAction( + DeleteAccountAction.DeleteAccountConfirmDialogClick(it), + ) + } }, + onDeleteAccountClick = remember(viewModel) { + { viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick) } + }, + isUnlockWithPasswordEnabled = state.isUnlockWithPasswordEnabled, modifier = Modifier .semantics { testTag = "DELETE ACCOUNT" } .fillMaxWidth() @@ -175,7 +188,9 @@ fun DeleteAccountScreen( @Composable private fun DeleteAccountButton( - onConfirmationClick: (masterPassword: String) -> Unit, + onDeleteAccountConfirmDialogClick: (masterPassword: String) -> Unit, + onDeleteAccountClick: () -> Unit, + isUnlockWithPasswordEnabled: Boolean, modifier: Modifier = Modifier, ) { var showPasswordDialog by remember { mutableStateOf(false) } @@ -183,7 +198,7 @@ private fun DeleteAccountButton( BitwardenMasterPasswordDialog( onConfirmClick = { showPasswordDialog = false - onConfirmationClick(it) + onDeleteAccountConfirmDialogClick(it) }, onDismissRequest = { showPasswordDialog = false }, ) @@ -191,7 +206,13 @@ private fun DeleteAccountButton( BitwardenErrorButton( label = stringResource(id = R.string.delete_account), - onClick = { showPasswordDialog = true }, + onClick = { + if (isUnlockWithPasswordEnabled) { + showPasswordDialog = true + } else { + onDeleteAccountClick() + } + }, modifier = modifier, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModel.kt index 691a1483b..724925cbf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModel.kt @@ -20,7 +20,7 @@ import javax.inject.Inject private const val KEY_STATE = "state" /** - * View model for the account security screen. + * View model for the [DeleteAccountScreen]. */ @HiltViewModel class DeleteAccountViewModel @Inject constructor( @@ -29,6 +29,10 @@ class DeleteAccountViewModel @Inject constructor( ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountState( dialog = null, + isUnlockWithPasswordEnabled = requireNotNull(authRepository.userStateFlow.value) + .activeAccount + .trustedDevice + ?.hasMasterPassword != false, ), ) { @@ -42,15 +46,23 @@ class DeleteAccountViewModel @Inject constructor( when (action) { DeleteAccountAction.CancelClick -> handleCancelClick() DeleteAccountAction.CloseClick -> handleCloseClick() - is DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick(action) + is DeleteAccountAction.DeleteAccountClick -> handleDeleteAccountClick() DeleteAccountAction.AccountDeletionConfirm -> handleAccountDeletionConfirm() DeleteAccountAction.DismissDialog -> handleDismissDialog() is DeleteAccountAction.Internal.DeleteAccountComplete -> { handleDeleteAccountComplete(action) } + + is DeleteAccountAction.DeleteAccountConfirmDialogClick -> { + handleDeleteAccountConfirmDialogClick(action) + } } } + private fun handleDeleteAccountClick() { + sendEvent(DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen) + } + private fun handleCancelClick() { sendEvent(DeleteAccountEvent.NavigateBack) } @@ -59,7 +71,9 @@ class DeleteAccountViewModel @Inject constructor( sendEvent(DeleteAccountEvent.NavigateBack) } - private fun handleDeleteAccountClick(action: DeleteAccountAction.DeleteAccountClick) { + private fun handleDeleteAccountConfirmDialogClick( + action: DeleteAccountAction.DeleteAccountConfirmDialogClick, + ) { mutableStateFlow.update { it.copy(dialog = DeleteAccountState.DeleteAccountDialog.Loading) } @@ -103,10 +117,15 @@ class DeleteAccountViewModel @Inject constructor( /** * Models state for the Delete Account screen. + * + * @param dialog The dialog for the [DeleteAccountScreen]. + * @param isUnlockWithPasswordEnabled Whether or not the user is able to unlock the vault with + * their master password. */ @Parcelize data class DeleteAccountState( val dialog: DeleteAccountDialog?, + val isUnlockWithPasswordEnabled: Boolean, ) : Parcelable { /** @@ -144,6 +163,11 @@ sealed class DeleteAccountEvent { */ data object NavigateBack : DeleteAccountEvent() + /** + * Navigates to the [DeleteAccountConfirmationScreen]. + */ + data object NavigateToDeleteAccountConfirmationScreen : DeleteAccountEvent() + /** * Displays the [message] in a toast. */ @@ -169,7 +193,14 @@ sealed class DeleteAccountAction { /** * The user has clicked the delete account button. */ - data class DeleteAccountClick( + data object DeleteAccountClick : DeleteAccountAction() + + /** + * The user has clicked the delete account confirmation dialog button. + * + * @param masterPassword The master password of the user. + */ + data class DeleteAccountConfirmDialogClick( val masterPassword: String, ) : DeleteAccountAction() diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationNavigation.kt new file mode 100644 index 000000000..170565ab1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationNavigation.kt @@ -0,0 +1,30 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions + +private const val DELETE_ACCOUNT_CONFIRMATION_ROUTE = "delete_account_confirmation" + +/** + * Add delete account confirmation destinations to the nav graph. + */ +fun NavGraphBuilder.deleteAccountConfirmationDestination( + onNavigateBack: () -> Unit, +) { + composableWithSlideTransitions( + route = DELETE_ACCOUNT_CONFIRMATION_ROUTE, + ) { + DeleteAccountConfirmationScreen( + onNavigateBack = onNavigateBack, + ) + } +} + +/** + * Navigate to the [DeleteAccountConfirmationScreen]. + */ +fun NavController.navigateToDeleteAccountConfirmation(navOptions: NavOptions? = null) { + navigate(DELETE_ACCOUNT_CONFIRMATION_ROUTE, navOptions) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreen.kt new file mode 100644 index 000000000..846ef1720 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreen.kt @@ -0,0 +1,129 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation + +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog +import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold + +/** + * Displays the delete account confirmation screen. + */ +@Composable +fun DeleteAccountConfirmationScreen( + viewModel: DeleteAccountConfirmationViewModel = hiltViewModel(), + onNavigateBack: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsState() + val context = LocalContext.current + val resources = context.resources + EventsEffect(viewModel = viewModel) { event -> + when (event) { + DeleteAccountConfirmationEvent.NavigateBack -> onNavigateBack() + + is DeleteAccountConfirmationEvent.ShowToast -> { + Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show() + } + } + } + + DeleteAccountConfirmationDialogs( + dialogState = state.dialog, + onDeleteAccountAcknowledge = remember(viewModel) { + { viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountAcknowledge) } + }, + onDismissDialog = remember(viewModel) { + { viewModel.trySendAction(DeleteAccountConfirmationAction.DismissDialog) } + }, + ) + + DeleteAccountConfirmationScaffold( + state = state, + onCloseClick = remember(viewModel) { + { viewModel.trySendAction(DeleteAccountConfirmationAction.CloseClick) } + }, + ) +} + +@Composable +private fun DeleteAccountConfirmationDialogs( + dialogState: DeleteAccountConfirmationState.DeleteAccountConfirmationDialog?, + onDismissDialog: () -> Unit, + onDeleteAccountAcknowledge: () -> Unit, +) { + when (dialogState) { + is DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = null, + message = dialogState.message, + ), + onDismissRequest = onDeleteAccountAcknowledge, + ) + } + + is DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialogState.title, + message = dialogState.message, + ), + onDismissRequest = onDismissDialog, + ) + } + + is DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(dialogState.title), + ) + } + null -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DeleteAccountConfirmationScaffold( + state: DeleteAccountConfirmationState, + onCloseClick: () -> Unit, +) { + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.verification_code), + scrollBehavior = scrollBehavior, + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = onCloseClick, + ) + }, + ) { innerPadding -> + Column(modifier = Modifier.padding(innerPadding)) { + // TODO finish UI in BIT-2234 + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModel.kt new file mode 100644 index 000000000..4333058c5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModel.kt @@ -0,0 +1,149 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * View model for the [DeleteAccountConfirmationScreen]. + */ +@HiltViewModel +class DeleteAccountConfirmationViewModel @Inject constructor( + private val authRepository: AuthRepository, + savedStateHandle: SavedStateHandle, +) : BaseViewModel< + DeleteAccountConfirmationState, + DeleteAccountConfirmationEvent, + DeleteAccountConfirmationAction,>( + initialState = savedStateHandle[KEY_STATE] ?: DeleteAccountConfirmationState( + dialog = null, + ), +) { + + init { + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + } + + override fun handleAction(action: DeleteAccountConfirmationAction) { + when (action) { + DeleteAccountConfirmationAction.CloseClick -> handleCloseClick() + DeleteAccountConfirmationAction.DeleteAccountAcknowledge -> { + handleDeleteAccountAcknowledge() + } + + DeleteAccountConfirmationAction.DismissDialog -> handleDismissDialog() + } + } + + private fun handleCloseClick() { + sendEvent(DeleteAccountConfirmationEvent.NavigateBack) + } + + private fun handleDeleteAccountAcknowledge() { + authRepository.clearPendingAccountDeletion() + mutableStateFlow.update { it.copy(dialog = null) } + } + + private fun handleDismissDialog() { + mutableStateFlow.update { it.copy(dialog = null) } + } +} + +/** + * Models state for the [DeleteAccountConfirmationScreen]. + */ +@Parcelize +data class DeleteAccountConfirmationState( + val dialog: DeleteAccountConfirmationDialog?, +) : Parcelable { + + /** + * Displays a dialog. + */ + sealed class DeleteAccountConfirmationDialog : Parcelable { + /** + * Dialog to confirm to the user that the account has been deleted. + * + * @param message The message for the dialog. + */ + @Parcelize + data class DeleteSuccess( + val message: Text = + R.string.your_account_has_been_permanently_deleted.asText(), + ) : DeleteAccountConfirmationDialog() + + /** + * Displays the error dialog when deleting an account fails. + * + * @param title The title for the dialog. + * @param message The message for the dialog. + */ + @Parcelize + data class Error( + val title: Text = R.string.an_error_has_occurred.asText(), + val message: Text, + ) : DeleteAccountConfirmationDialog() + + /** + * Displays the loading dialog when deleting an account. + * + * @param title The title for the dialog. + */ + @Parcelize + data class Loading( + val title: Text = R.string.loading.asText(), + ) : DeleteAccountConfirmationDialog() + } +} + +/** + * Models events for the [DeleteAccountConfirmationScreen]. + */ +sealed class DeleteAccountConfirmationEvent { + /** + * Navigates back. + */ + data object NavigateBack : DeleteAccountConfirmationEvent() + + /** + * Displays the [message] in a toast. + */ + data class ShowToast( + val message: Text, + ) : DeleteAccountConfirmationEvent() +} + +/** + * Models actions for the [DeleteAccountConfirmationScreen]. + */ +sealed class DeleteAccountConfirmationAction { + /** + * The user has clicked the close button. + */ + data object CloseClick : DeleteAccountConfirmationAction() + + /** + * The user has dismissed the dialog. + */ + data object DismissDialog : DeleteAccountConfirmationAction() + + /** + * The user has acknowledged the account deletion. + */ + data object DeleteAccountAcknowledge : DeleteAccountConfirmationAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index 94028600d..b71da4aff 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -8,6 +8,8 @@ import com.x8bit.bitwarden.ui.platform.feature.search.navigateToSearch import com.x8bit.bitwarden.ui.platform.feature.search.searchDestination import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.deleteAccountDestination import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccount.navigateToDeleteAccount +import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation.deleteAccountConfirmationDestination +import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation.navigateToDeleteAccountConfirmation import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.loginApprovalDestination import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.pendingrequests.navigateToPendingRequests @@ -97,7 +99,15 @@ fun NavGraphBuilder.vaultUnlockedGraph( ) }, ) - deleteAccountDestination(onNavigateBack = { navController.popBackStack() }) + deleteAccountDestination( + onNavigateBack = { navController.popBackStack() }, + onNavigateToDeleteAccountConfirmation = { + navController.navigateToDeleteAccountConfirmation() + }, + ) + deleteAccountConfirmationDestination( + onNavigateBack = { navController.popBackStack() }, + ) loginApprovalDestination(onNavigateBack = { navController.popBackStack() }) pendingRequestsDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreenTest.kt index 24410652b..b4195e30f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountScreenTest.kt @@ -26,6 +26,7 @@ import org.junit.Test class DeleteAccountScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false + private var onNavigateToDeleteAccountConfirmationScreenCalled = false private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) @@ -39,6 +40,9 @@ class DeleteAccountScreenTest : BaseComposeTest() { composeTestRule.setContent { DeleteAccountScreen( onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToDeleteAccountConfirmation = { + onNavigateToDeleteAccountConfirmationScreenCalled = true + }, viewModel = viewModel, ) } @@ -50,6 +54,13 @@ class DeleteAccountScreenTest : BaseComposeTest() { assertTrue(onNavigateBackCalled) } + @Test + @Suppress("MaxLineLength") + fun `NavigateToDeleteAccountConfirmationScreen should call onNavigateToDeleteAccountConfirmationScreenCalled`() { + mutableEventFlow.tryEmit(DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen) + assertTrue(onNavigateToDeleteAccountConfirmationScreenCalled) + } + @Test fun `cancel click should emit CancelClick`() { composeTestRule.onNodeWithText("Cancel").performScrollTo().performClick() @@ -195,11 +206,16 @@ class DeleteAccountScreenTest : BaseComposeTest() { .assertDoesNotExist() verify { - viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(password)) + viewModel.trySendAction( + DeleteAccountAction.DeleteAccountConfirmDialogClick( + password, + ), + ) } } } private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState( dialog = null, + isUnlockWithPasswordEnabled = true, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModelTest.kt index 6a4ad9a2e..b83dedae3 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccount/DeleteAccountViewModelTest.kt @@ -5,6 +5,8 @@ import app.cash.turbine.test import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.DeleteAccountResult +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import io.mockk.coEvery @@ -14,18 +16,36 @@ import io.mockk.just import io.mockk.mockk import io.mockk.runs import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class DeleteAccountViewModelTest : BaseViewModelTest() { - - private val authRepo: AuthRepository = mockk(relaxed = true) + private val mutableUserStateFlow = MutableStateFlow(DEFAULT_USER_STATE) + private val authRepo: AuthRepository = mockk { + every { userStateFlow } returns mutableUserStateFlow + } @Test fun `initial state should be correct when not set`() { + mutableUserStateFlow.update { currentState -> + currentState.copy( + accounts = currentState.accounts.map { account -> + account.copy( + trustedDevice = account.trustedDevice?.copy( + hasMasterPassword = false, + ), + ) + }, + ) + } val viewModel = createViewModel(state = null) - assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + assertEquals( + DEFAULT_STATE.copy(isUnlockWithPasswordEnabled = false), + viewModel.stateFlow.value, + ) } @Test @@ -56,13 +76,18 @@ class DeleteAccountViewModelTest : BaseViewModelTest() { } @Test - fun `on DeleteAccountClick should update dialog state when delete account succeeds`() = + @Suppress("MaxLineLength") + fun `on DeleteAccountConfirmDialogClick should update dialog state when delete account succeeds`() = runTest { val viewModel = createViewModel() val masterPassword = "ckasb kcs ja" coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Success - viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword)) + viewModel.trySendAction( + DeleteAccountAction.DeleteAccountConfirmDialogClick( + masterPassword, + ), + ) assertEquals( DEFAULT_STATE.copy(dialog = DeleteAccountState.DeleteAccountDialog.DeleteSuccess), @@ -74,13 +99,32 @@ class DeleteAccountViewModelTest : BaseViewModelTest() { } } + @Test + @Suppress("MaxLineLength") + fun `on DeleteAccountClick should emit NavigateToDeleteAccountConfirmationScreen`() = + runTest { + val viewModel = createViewModel( + state = DEFAULT_STATE.copy( + isUnlockWithPasswordEnabled = false, + ), + ) + + viewModel.eventFlow.test { + viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick) + assertEquals( + DeleteAccountEvent.NavigateToDeleteAccountConfirmationScreen, + awaitItem(), + ) + } + } + @Test fun `on DeleteAccountClick should update dialog state when deleteAccount fails`() = runTest { val viewModel = createViewModel() val masterPassword = "ckasb kcs ja" coEvery { authRepo.deleteAccount(masterPassword) } returns DeleteAccountResult.Error - viewModel.trySendAction(DeleteAccountAction.DeleteAccountClick(masterPassword)) + viewModel.trySendAction(DeleteAccountAction.DeleteAccountConfirmDialogClick(masterPassword)) assertEquals( DEFAULT_STATE.copy( @@ -135,6 +179,34 @@ class DeleteAccountViewModelTest : BaseViewModelTest() { ) } +private val DEFAULT_USER_STATE: UserState = UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = UserState.TrustedDevice( + isDeviceTrusted = true, + hasMasterPassword = true, + hasAdminApproval = true, + hasLoginApprovingDevice = true, + hasResetPasswordPermission = true, + ), + ), + ), +) + private val DEFAULT_STATE: DeleteAccountState = DeleteAccountState( dialog = null, + isUnlockWithPasswordEnabled = true, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreenTest.kt new file mode 100644 index 000000000..c085a1130 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationScreenTest.kt @@ -0,0 +1,129 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation + +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.performClick +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class DeleteAccountConfirmationScreenTest : BaseComposeTest() { + private var onNavigateBackCalled = false + + private val mutableEventFlow = bufferedMutableSharedFlow() + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setUp() { + composeTestRule.setContent { + DeleteAccountConfirmationScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `on NavigateBack should call onNavigateBack`() { + mutableEventFlow.tryEmit(DeleteAccountConfirmationEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `loading dialog presence should update with dialog state`() { + composeTestRule + .onAllNodesWithText("Loading") + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + mutableStateFlow.update { + DEFAULT_STATE.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Loading(), + ) + } + + composeTestRule + .onAllNodesWithText("Loading") + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `error dialog presence should update with dialog state`() { + val message = "hello world" + composeTestRule + .onAllNodesWithText(message) + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + mutableStateFlow.update { + DEFAULT_STATE.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error( + message = message.asText(), + ), + ) + } + + composeTestRule + .onAllNodesWithText(message) + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `delete success dialog presence should update with dialog state`() { + val message = "Your account has been permanently deleted" + composeTestRule + .onAllNodesWithText(message) + .filterToOne(hasAnyAncestor(isDialog())) + .assertDoesNotExist() + + mutableStateFlow.update { + DEFAULT_STATE.copy( + dialog = + DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess(), + ) + } + + composeTestRule + .onAllNodesWithText(message) + .filterToOne(hasAnyAncestor(isDialog())) + .assertExists() + } + + @Test + fun `delete success dialog dismiss should emit DeleteAccountAction`() { + mutableStateFlow.update { + DEFAULT_STATE.copy( + dialog = + DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess(), + ) + } + + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountAcknowledge) + } + } +} + +private val DEFAULT_STATE: DeleteAccountConfirmationState = + DeleteAccountConfirmationState(dialog = null) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModelTest.kt new file mode 100644 index 000000000..5e927976f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/deleteaccountconfirmation/DeleteAccountConfirmationViewModelTest.kt @@ -0,0 +1,93 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.deleteaccountconfirmation + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DeleteAccountConfirmationViewModelTest : BaseViewModelTest() { + + private val authRepo: AuthRepository = mockk(relaxed = true) + + @Test + fun `initial state should be correct when not set`() { + val viewModel = createViewModel(state = null) + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + } + + @Test + fun `initial state should be correct when set`() { + val state = DEFAULT_STATE.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error( + message = "Hello".asText(), + ), + ) + val viewModel = createViewModel(state = state) + assertEquals(state, viewModel.stateFlow.value) + } + + @Test + fun `on CloseClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(DeleteAccountConfirmationAction.CloseClick) + assertEquals(DeleteAccountConfirmationEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `DeleteAccountAcknowledge should clear dialog and call clearPendingAccountDeletion`() = + runTest { + every { authRepo.clearPendingAccountDeletion() } just runs + val state = DEFAULT_STATE.copy( + dialog = + DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.DeleteSuccess(), + ) + val viewModel = createViewModel(state = state) + + viewModel.trySendAction(DeleteAccountConfirmationAction.DeleteAccountAcknowledge) + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + verify { + authRepo.clearPendingAccountDeletion() + } + } + + @Test + fun `on DismissDialog should clear dialog state`() = runTest { + val state = DEFAULT_STATE.copy( + dialog = DeleteAccountConfirmationState.DeleteAccountConfirmationDialog.Error( + message = "Hello".asText(), + ), + ) + val viewModel = createViewModel(state = state) + + viewModel.trySendAction(DeleteAccountConfirmationAction.DismissDialog) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + private fun createViewModel( + authenticationRepository: AuthRepository = authRepo, + state: DeleteAccountConfirmationState? = null, + ): DeleteAccountConfirmationViewModel = DeleteAccountConfirmationViewModel( + authRepository = authenticationRepository, + savedStateHandle = SavedStateHandle().apply { set("state", state) }, + ) +} + +private val DEFAULT_STATE: DeleteAccountConfirmationState = + DeleteAccountConfirmationState(dialog = null)