diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt index 516df9de6d..c57eec77f8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt @@ -27,6 +27,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler +import com.x8bit.bitwarden.ui.platform.components.BasicDialogState +import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog @@ -77,6 +79,9 @@ fun SendScreen( SendDialogs( dialogState = state.dialogState, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(SendAction.DismissDialog) } + }, ) val sendHandlers = remember(viewModel) { SendHandlers.create(viewModel) } @@ -176,8 +181,17 @@ fun SendScreen( @Composable private fun SendDialogs( dialogState: SendState.DialogState?, + onDismissRequest: () -> Unit, ) { when (dialogState) { + is SendState.DialogState.Error -> BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialogState.title, + message = dialogState.message, + ), + onDismissRequest = onDismissRequest, + ) + is SendState.DialogState.Loading -> BitwardenLoadingDialog( visibilityState = LoadingDialogState.Shown(dialogState.message), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt index 12e6a72de5..2d02704a1c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt @@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult +import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text @@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -69,13 +72,64 @@ class SendViewModel @Inject constructor( SendAction.TextTypeClick -> handleTextTypeClick() is SendAction.DeleteSendClick -> handleDeleteSendClick(action) is SendAction.RemovePasswordClick -> handleRemovePasswordClick(action) + SendAction.DismissDialog -> handleDismissDialog() is SendAction.Internal -> handleInternalAction(action) } private fun handleInternalAction(action: SendAction.Internal): Unit = when (action) { + is SendAction.Internal.DeleteSendResultReceive -> handleDeleteSendResultReceive(action) + is SendAction.Internal.RemovePasswordSendResultReceive -> { + handleRemovePasswordSendResultReceive(action) + } + is SendAction.Internal.SendDataReceive -> handleSendDataReceive(action) } + private fun handleDeleteSendResultReceive(action: SendAction.Internal.DeleteSendResultReceive) { + when (action.result) { + DeleteSendResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = SendState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + DeleteSendResult.Success -> { + mutableStateFlow.update { it.copy(dialogState = null) } + sendEvent(SendEvent.ShowToast(R.string.send_deleted.asText())) + } + } + } + + private fun handleRemovePasswordSendResultReceive( + action: SendAction.Internal.RemovePasswordSendResultReceive, + ) { + when (val result = action.result) { + is RemovePasswordSendResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = SendState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = result + .errorMessage + ?.asText() + ?: R.string.generic_error_message.asText(), + ), + ) + } + } + + is RemovePasswordSendResult.Success -> { + mutableStateFlow.update { it.copy(dialogState = null) } + sendEvent(SendEvent.ShowToast(message = R.string.send_password_removed.asText())) + } + } + } + private fun handleSendDataReceive(action: SendAction.Internal.SendDataReceive) { when (val dataState = action.sendDataState) { is DataState.Error -> { @@ -189,13 +243,31 @@ class SendViewModel @Inject constructor( } private fun handleDeleteSendClick(action: SendAction.DeleteSendClick) { - // TODO: Navigate to the text type send list screen (BIT-1388) - sendEvent(SendEvent.ShowToast("Not yet implemented".asText())) + mutableStateFlow.update { + it.copy(dialogState = SendState.DialogState.Loading(R.string.deleting.asText())) + } + viewModelScope.launch { + val result = vaultRepo.deleteSend(action.sendItem.id) + sendAction(SendAction.Internal.DeleteSendResultReceive(result)) + } } private fun handleRemovePasswordClick(action: SendAction.RemovePasswordClick) { - // TODO: Navigate to the text type send list screen (BIT-1388) - sendEvent(SendEvent.ShowToast("Not yet implemented".asText())) + mutableStateFlow.update { + it.copy( + dialogState = SendState.DialogState.Loading( + message = R.string.removing_send_password.asText(), + ), + ) + } + viewModelScope.launch { + val result = vaultRepo.removePasswordSend(action.sendItem.id) + sendAction(SendAction.Internal.RemovePasswordSendResultReceive(result)) + } + } + + private fun handleDismissDialog() { + mutableStateFlow.update { it.copy(dialogState = null) } } } @@ -283,6 +355,15 @@ data class SendState( */ sealed class DialogState : Parcelable { + /** + * Represents a dismissible dialog with the given error [message]. + */ + @Parcelize + data class Error( + val title: Text?, + val message: Text, + ) : DialogState() + /** * Represents a loading dialog with the given [message]. */ @@ -372,10 +453,27 @@ sealed class SendAction { val sendItem: SendState.ViewState.Content.SendItem, ) : SendAction() + /** + * Dismiss the currently displayed dialog. + */ + data object DismissDialog : SendAction() + /** * Models actions that the [SendViewModel] itself will send. */ sealed class Internal : SendAction() { + /** + * Indicates a result for deleting the send has been received. + */ + data class DeleteSendResultReceive(val result: DeleteSendResult) : Internal() + + /** + * Indicates a result for removing the password protection from a send has been received. + */ + data class RemovePasswordSendResultReceive( + val result: RemovePasswordSendResult, + ) : Internal() + /** * Indicates that the send data has been received. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt index a0e231f291..a51a2ed356 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt @@ -589,6 +589,36 @@ class SendScreenTest : BaseComposeTest() { composeTestRule.assertNoDialogExists() } + @Test + fun `error dialog should be displayed according to state`() { + val errorMessage = "Failure" + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = SendState.DialogState.Error( + title = null, + message = errorMessage.asText(), + ), + ) + } + + composeTestRule + .onNodeWithText(errorMessage) + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + + composeTestRule + .onNodeWithText("Ok") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(SendAction.DismissDialog) + } + } + @Test fun `loading dialog should be displayed according to state`() { val loadingMessage = "syncing" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt index d77bd6d9f9..8348874eec 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt @@ -9,11 +9,14 @@ import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult +import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.concat import com.x8bit.bitwarden.ui.tools.feature.send.util.toViewState +import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -108,22 +111,99 @@ class SendViewModelTest : BaseViewModelTest() { } @Test - fun `DeleteSendClick should emit ShowToast`() = runTest { - val sendItem = mockk() + fun `DeleteSendClick with deleteSend error should display error dialog`() = runTest { + val sendId = "sendId1234" + val sendItem = mockk { + every { id } returns sendId + } + coEvery { vaultRepo.deleteSend(sendId) } returns DeleteSendResult.Error + val viewModel = createViewModel() - viewModel.eventFlow.test { + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) viewModel.trySendAction(SendAction.DeleteSendClick(sendItem)) - assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem()) + assertEquals( + DEFAULT_STATE.copy( + dialogState = SendState.DialogState.Loading(R.string.deleting.asText()), + ), + awaitItem(), + ) + assertEquals( + DEFAULT_STATE.copy( + dialogState = SendState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ), + awaitItem(), + ) } } @Test - fun `RemovePasswordClick should emit ShowToast`() = runTest { - val sendItem = mockk() + fun `DeleteSendClick with deleteSend success should emit ShowToast`() = runTest { + val sendId = "sendId1234" + val sendItem = mockk { + every { id } returns sendId + } + coEvery { vaultRepo.deleteSend(sendId) } returns DeleteSendResult.Success + + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(SendAction.DeleteSendClick(sendItem)) + assertEquals(SendEvent.ShowToast(R.string.send_deleted.asText()), awaitItem()) + } + } + + @Test + fun `RemovePasswordClick with removePasswordSend error should display error dialog`() = + runTest { + val sendId = "sendId1234" + val sendItem = mockk { + every { id } returns sendId + } + coEvery { + vaultRepo.removePasswordSend(sendId) + } returns RemovePasswordSendResult.Error(errorMessage = null) + + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + viewModel.trySendAction(SendAction.RemovePasswordClick(sendItem)) + assertEquals( + DEFAULT_STATE.copy( + dialogState = SendState.DialogState.Loading( + message = R.string.removing_send_password.asText(), + ), + ), + awaitItem(), + ) + assertEquals( + DEFAULT_STATE.copy( + dialogState = SendState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + } + } + + @Test + fun `RemovePasswordClick with removePasswordSend success should emit ShowToast`() = runTest { + val sendId = "sendId1234" + val sendItem = mockk { + every { id } returns sendId + } + coEvery { + vaultRepo.removePasswordSend(sendId) + } returns RemovePasswordSendResult.Success(mockk()) + val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(SendAction.RemovePasswordClick(sendItem)) - assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem()) + assertEquals(SendEvent.ShowToast(R.string.send_password_removed.asText()), awaitItem()) } } @@ -206,6 +286,20 @@ class SendViewModelTest : BaseViewModelTest() { } } + @Test + fun `DismissDialog should clear the dialogState`() = runTest { + val initialState = DEFAULT_STATE.copy( + dialogState = SendState.DialogState.Error( + title = null, + message = "Test".asText(), + ), + ) + val viewModel = createViewModel(initialState) + + viewModel.trySendAction(SendAction.DismissDialog) + assertEquals(initialState.copy(dialogState = null), viewModel.stateFlow.value) + } + @Test fun `VaultRepository SendData Error should update view state to Error`() { val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())