diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt index cf2ded3a0..3d831e476 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt @@ -9,7 +9,10 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -28,6 +31,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager @@ -43,7 +47,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditLoginTy * Top level composable for the vault add item screen. */ @OptIn(ExperimentalMaterial3Api::class) -@Suppress("LongMethod") +@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun VaultAddEditScreen( onNavigateBack: () -> Unit, @@ -107,6 +111,12 @@ fun VaultAddEditScreen( VaultAddEditCardTypeHandlers.create(viewModel = viewModel) } + val confirmDeleteClickAction = remember(viewModel) { + { viewModel.trySendAction(VaultAddEditAction.Common.ConfirmDeleteClick) } + } + + var pendingDeleteCipher by rememberSaveable { mutableStateOf(false) } + VaultAddEditItemDialogs( dialogState = state.dialog, onDismissRequest = remember(viewModel) { @@ -114,6 +124,21 @@ fun VaultAddEditScreen( }, ) + if (pendingDeleteCipher) { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.delete), + message = stringResource(id = R.string.do_you_really_want_to_soft_delete_cipher), + confirmButtonText = stringResource(id = R.string.ok), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + pendingDeleteCipher = false + confirmDeleteClickAction() + }, + onDismissClick = { pendingDeleteCipher = false }, + onDismissRequest = { pendingDeleteCipher = false }, + ) + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( modifier = Modifier @@ -170,6 +195,11 @@ fun VaultAddEditScreen( }, ) .takeUnless { state.isAddItemMode || !state.isCipherInCollection }, + OverflowMenuItemData( + text = stringResource(id = R.string.delete), + onClick = { pendingDeleteCipher = true }, + ) + .takeUnless { state.isAddItemMode }, ), ) }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 2be74f451..d0a067a14 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModel @@ -147,6 +148,7 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditAction.Common.AttachmentsClick -> handleAttachmentsClick() is VaultAddEditAction.Common.MoveToOrganizationClick -> handleMoveToOrganizationClick() is VaultAddEditAction.Common.CollectionsClick -> handleCollectionsClick() + is VaultAddEditAction.Common.ConfirmDeleteClick -> handleConfirmDeleteClick() is VaultAddEditAction.Common.CloseClick -> handleCloseClick() is VaultAddEditAction.Common.DismissDialog -> handleDismissDialog() is VaultAddEditAction.Common.SaveClick -> handleSaveClick() @@ -276,6 +278,30 @@ class VaultAddEditViewModel @Inject constructor( onEdit { sendEvent(VaultAddEditEvent.NavigateToCollections(it.vaultItemId)) } } + private fun handleConfirmDeleteClick() { + mutableStateFlow.update { + it.copy( + dialog = VaultAddEditState.DialogState.Loading( + R.string.soft_deleting.asText(), + ), + ) + } + onContent { content -> + if (content.common.originalCipher?.id != null) { + viewModelScope.launch { + trySendAction( + VaultAddEditAction.Internal.DeleteCipherReceive( + result = vaultRepository.softDeleteCipher( + cipherId = content.common.originalCipher.id.toString(), + cipherView = content.common.originalCipher, + ), + ), + ) + } + } + } + } + private fun handleCloseClick() { sendEvent( event = VaultAddEditEvent.NavigateBack, @@ -848,7 +874,7 @@ class VaultAddEditViewModel @Inject constructor( is VaultAddEditAction.Internal.UpdateCipherResultReceive -> { handleUpdateCipherResultReceive(action) } - + is VaultAddEditAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action) is VaultAddEditAction.Internal.TotpCodeReceive -> handleVaultTotpCodeReceive(action) is VaultAddEditAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) is VaultAddEditAction.Internal.GeneratorResultReceive -> { @@ -910,6 +936,30 @@ class VaultAddEditViewModel @Inject constructor( } } + private fun handleDeleteCipherReceive(action: VaultAddEditAction.Internal.DeleteCipherReceive) { + when (action.result) { + DeleteCipherResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = VaultAddEditState.DialogState.Generic( + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + DeleteCipherResult.Success -> { + mutableStateFlow.update { it.copy(dialog = null) } + sendEvent( + VaultAddEditEvent.ShowToast( + message = R.string.item_soft_deleted.asText(), + ), + ) + sendEvent(VaultAddEditEvent.NavigateBack) + } + } + } + @Suppress("LongMethod") private fun handleVaultDataReceive(action: VaultAddEditAction.Internal.VaultDataReceive) { when (val vaultDataState = action.vaultDataState) { @@ -1575,6 +1625,11 @@ sealed class VaultAddEditAction { */ data object CollectionsClick : Common() + /** + * The user has confirmed to deleted the cipher. + */ + data object ConfirmDeleteClick : Common() + /** * Represents the action when a type option is selected. * @@ -1960,5 +2015,12 @@ sealed class VaultAddEditAction { data class UpdateCipherResultReceive( val updateCipherResult: UpdateCipherResult, ) : Internal() + + /** + * Indicates that the delete cipher result has been received. + */ + data class DeleteCipherReceive( + val result: DeleteCipherResult, + ) : Internal() } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index 480e9620c..521f7dbb2 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -2225,6 +2225,11 @@ class VaultAddEditScreenTest : BaseComposeTest() { .onAllNodesWithText("Move to Organization") .filterToOne(hasAnyAncestor(isPopup())) .assertDoesNotExist() + + composeTestRule + .onAllNodesWithText("Delete") + .filterToOne(hasAnyAncestor(isPopup())) + .assertIsDisplayed() } @Test @@ -2260,6 +2265,101 @@ class VaultAddEditScreenTest : BaseComposeTest() { .onAllNodesWithText("Collections") .filterToOne(hasAnyAncestor(isPopup())) .assertDoesNotExist() + + composeTestRule + .onAllNodesWithText("Delete") + .filterToOne(hasAnyAncestor(isPopup())) + .assertIsDisplayed() + } + + @Test + fun `Delete dialog ok click should send ConfirmDeleteClick`() { + mutableStateFlow.update { + it.copy( + vaultAddEditType = VaultAddEditType.EditItem(vaultItemId = "mockId-1"), + viewState = VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common( + originalCipher = createMockCipherView(1), + ), + type = VaultAddEditState.ViewState.Content.ItemType.SecureNotes, + ), + ) + } + + composeTestRule.assertNoDialogExists() + + composeTestRule + .onNodeWithContentDescription("More") + .performClick() + + composeTestRule + .onAllNodesWithText("Delete") + .filterToOne(hasAnyAncestor(isPopup())) + .performClick() + + composeTestRule + .onAllNodesWithText("Do you really want to send to the trash?") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule.assertNoDialogExists() + + verify { + viewModel.trySendAction(VaultAddEditAction.Common.ConfirmDeleteClick) + } + } + + @Test + fun `Delete dialog cancel click should dismiss the dialog`() { + mutableStateFlow.update { + it.copy( + vaultAddEditType = VaultAddEditType.EditItem(vaultItemId = "mockId-1"), + viewState = VaultAddEditState.ViewState.Content( + common = VaultAddEditState.ViewState.Content.Common( + originalCipher = createMockCipherView(1), + ), + type = VaultAddEditState.ViewState.Content.ItemType.SecureNotes, + ), + ) + } + + composeTestRule.assertNoDialogExists() + + composeTestRule + .onNodeWithContentDescription("More") + .performClick() + + composeTestRule + .onAllNodesWithText("Delete") + .filterToOne(hasAnyAncestor(isPopup())) + .performClick() + + composeTestRule + .onAllNodesWithText("Do you really want to send to the trash?") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule.assertNoDialogExists() } //region Helper functions diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 0830cb7d7..5b9177810 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRep import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult +import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest @@ -223,6 +224,97 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } } + @Test + @Suppress("MaxLineLength") + fun `ConfirmDeleteClick with DeleteCipherResult Success should emit ShowToast and NavigateBack`() = + runTest { + val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) + val initState = createVaultAddItemState(vaultAddEditType = vaultAddEditType) + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = initState, + vaultAddEditType = vaultAddEditType, + ), + ) + mutableVaultItemFlow.value = DataState.Loaded(data = createMockCipherView(number = 1)) + + coEvery { + vaultRepository.softDeleteCipher( + cipherId = "mockId-1", + cipherView = createMockCipherView(number = 1), + ) + } returns DeleteCipherResult.Success + + viewModel.trySendAction(VaultAddEditAction.Common.ConfirmDeleteClick) + + viewModel.eventFlow.test { + assertEquals( + VaultAddEditEvent.ShowToast(R.string.item_soft_deleted.asText()), + awaitItem(), + ) + assertEquals( + VaultAddEditEvent.NavigateBack, + awaitItem(), + ) + } + } + + @Test + fun `ConfirmDeleteClick with DeleteCipherResult Failure should show generic error`() = + runTest { + val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID) + val initState = createVaultAddItemState(vaultAddEditType = vaultAddEditType) + val viewModel = createAddVaultItemViewModel( + savedStateHandle = createSavedStateHandleWithState( + state = initState, + vaultAddEditType = vaultAddEditType, + ), + ) + mutableVaultItemFlow.value = DataState.Loaded(data = createMockCipherView(number = 1)) + + coEvery { + vaultRepository.softDeleteCipher( + cipherId = "mockId-1", + cipherView = createMockCipherView(number = 1), + ) + } returns DeleteCipherResult.Error + + viewModel.trySendAction(VaultAddEditAction.Common.ConfirmDeleteClick) + + assertEquals( + createVaultAddItemState( + vaultAddEditType = vaultAddEditType, + dialogState = VaultAddEditState.DialogState.Generic( + message = R.string.generic_error_message.asText(), + ), + commonContentViewState = createCommonContentViewState( + name = "mockName-1", + folder = "mockId-1".asText(), + ownership = "", + originalCipher = createMockCipherView(number = 1), + availableFolders = emptyList(), + availableOwners = emptyList(), + notes = "mockNotes-1", + customFieldData = listOf( + VaultAddEditState.Custom.HiddenField( + itemId = "testId", + name = "mockName-1", + value = "mockValue-1", + ), + ), + ), + typeContentViewState = createLoginTypeContentViewState( + username = "mockUsername-1", + password = "mockPassword-1", + uri = "www.mockuri1.com", + totpCode = "mockTotp-1", + canViewPassword = false, + ), + ), + viewModel.stateFlow.value, + ) + } + @Test fun `in add mode, SaveClick should show dialog, and remove it once an item is saved`() = runTest { @@ -1785,6 +1877,13 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { notes: String = "", customFieldData: List = listOf(), ownership: String = "placeholder@email.com", + originalCipher: CipherView? = null, + availableFolders: List = listOf( + "Folder 1".asText(), + "Folder 2".asText(), + "Folder 3".asText(), + ), + availableOwners: List = listOf("a@b.com", "c@d.com"), ): VaultAddEditState.ViewState.Content.Common = VaultAddEditState.ViewState.Content.Common( name = name, @@ -1794,6 +1893,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { masterPasswordReprompt = masterPasswordReprompt, notes = notes, ownership = ownership, + originalCipher = originalCipher, + availableFolders = availableFolders, + availableOwners = availableOwners, ) private fun createLoginTypeContentViewState( @@ -1801,12 +1903,14 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { password: String = "", uri: String = "", totpCode: String? = null, + canViewPassword: Boolean = true, ): VaultAddEditState.ViewState.Content.ItemType.Login = VaultAddEditState.ViewState.Content.ItemType.Login( username = username, password = password, uri = uri, totp = totpCode, + canViewPassword = canViewPassword, ) private fun createSavedStateHandleWithState(