diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreen.kt index 7376ba6b0b..72387f7381 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreen.kt @@ -19,11 +19,15 @@ import androidx.hilt.navigation.compose.hiltViewModel 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.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 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.LoadingDialogState import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandlers /** @@ -49,6 +53,13 @@ fun AddSendScreen( } } + AddSendDialogs( + dialogState = state.dialogState, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(AddSendAction.DismissDialogClick) } + }, + ) + BitwardenScaffold( modifier = Modifier .fillMaxSize() @@ -96,3 +107,25 @@ fun AddSendScreen( } } } + +@Composable +private fun AddSendDialogs( + dialogState: AddSendState.DialogState?, + onDismissRequest: () -> Unit, +) { + when (dialogState) { + is AddSendState.DialogState.Error -> BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialogState.title, + message = dialogState.message, + ), + onDismissRequest = onDismissRequest, + ) + + is AddSendState.DialogState.Loading -> BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(dialogState.message), + ) + + null -> Unit + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt index e9dd19e94d..2ca761ed1f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModel.kt @@ -3,12 +3,18 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addsend import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult 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 com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toSendView import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -21,6 +27,7 @@ private const val KEY_STATE = "state" @HiltViewModel class AddSendViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val vaultRepo: VaultRepository, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: AddSendState( viewState = AddSendState.ViewState.Content( @@ -37,6 +44,7 @@ class AddSendViewModel @Inject constructor( isHideByDefaultChecked = false, ), ), + dialogState = null, ), ) { @@ -48,6 +56,7 @@ class AddSendViewModel @Inject constructor( override fun handleAction(action: AddSendAction): Unit = when (action) { is AddSendAction.CloseClick -> handleCloseClick() + AddSendAction.DismissDialogClick -> handleDismissDialogClick() is AddSendAction.SaveClick -> handleSaveClick() is AddSendAction.FileTypeClick -> handleFileTypeClick() is AddSendAction.TextTypeClick -> handleTextTypeClick() @@ -60,6 +69,33 @@ class AddSendViewModel @Inject constructor( is AddSendAction.HideByDefaultToggle -> handleHideByDefaultToggle(action) is AddSendAction.DeactivateThisSendToggle -> handleDeactivateThisSendToggle(action) is AddSendAction.HideMyEmailToggle -> handleHideMyEmailToggle(action) + is AddSendAction.Internal -> handleInternalAction(action) + } + + private fun handleInternalAction(action: AddSendAction.Internal): Unit = when (action) { + is AddSendAction.Internal.CreateSendResultReceive -> handleCreateSendResultReceive(action) + } + + private fun handleCreateSendResultReceive( + action: AddSendAction.Internal.CreateSendResultReceive, + ) { + when (action.result) { + CreateSendResult.Error -> { + mutableStateFlow.update { + it.copy( + dialogState = AddSendState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ) + } + } + + CreateSendResult.Success -> { + mutableStateFlow.update { it.copy(dialogState = null) } + sendEvent(AddSendEvent.NavigateBack) + } + } } private fun handlePasswordChange(action: AddSendAction.PasswordChange) { @@ -88,7 +124,38 @@ class AddSendViewModel @Inject constructor( private fun handleCloseClick() = sendEvent(AddSendEvent.NavigateBack) - private fun handleSaveClick() = sendEvent(AddSendEvent.ShowToast("Save Not Implemented")) + private fun handleSaveClick() { + onContent { content -> + if (content.common.name.isBlank()) { + mutableStateFlow.update { + it.copy( + dialogState = AddSendState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.validation_field_required.asText( + R.string.name.asText(), + ), + ), + ) + } + return@onContent + } + mutableStateFlow.update { + it.copy( + dialogState = AddSendState.DialogState.Loading( + message = R.string.saving.asText(), + ), + ) + } + viewModelScope.launch { + val result = vaultRepo.createSend(content.toSendView()) + sendAction(AddSendAction.Internal.CreateSendResultReceive(result)) + } + } + } + + private fun handleDismissDialogClick() { + mutableStateFlow.update { it.copy(dialogState = null) } + } private fun handleNameChange(action: AddSendAction.NameChange) { updateCommonContent { @@ -188,6 +255,7 @@ class AddSendViewModel @Inject constructor( */ @Parcelize data class AddSendState( + val dialogState: DialogState?, val viewState: ViewState, ) : Parcelable { @@ -251,6 +319,29 @@ data class AddSendState( } } } + + /** + * Represents the current state of any dialogs on the screen. + */ + 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]. + */ + @Parcelize + data class Loading( + val message: Text, + ) : DialogState() + } } /** @@ -278,6 +369,11 @@ sealed class AddSendAction { */ data object CloseClick : AddSendAction() + /** + * User clicked to dismiss the current dialog. + */ + data object DismissDialogClick : AddSendAction() + /** * User clicked the save button. */ @@ -337,4 +433,14 @@ sealed class AddSendAction { * User toggled the "deactivate this send" toggle. */ data class DeactivateThisSendToggle(val isChecked: Boolean) : AddSendAction() + + /** + * Models actions that the [AddSendViewModel] itself might send. + */ + sealed class Internal : AddSendAction() { + /** + * Indicates a result for creating a send has been received. + */ + data class CreateSendResultReceive(val result: CreateSendResult) : Internal() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/AddSendStateExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/AddSendStateExtensions.kt new file mode 100644 index 0000000000..869f8faff5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/AddSendStateExtensions.kt @@ -0,0 +1,57 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.addsend.util + +import com.bitwarden.core.SendFileView +import com.bitwarden.core.SendTextView +import com.bitwarden.core.SendType +import com.bitwarden.core.SendView +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState +import java.time.Instant + +/** + * Transforms [AddSendState] into [SendView]. + */ +// TODO: The 'key' needs to be updated in order to get the save operation to work (BIT-480) +fun AddSendState.ViewState.Content.toSendView(): SendView = + SendView( + id = null, + accessId = null, + name = common.name, + notes = common.noteInput, + key = "", + password = common.passwordInput.takeUnless { it.isBlank() }, + type = selectedType.toSendType(), + file = toSendFileView(), + text = toSendTextView(), + maxAccessCount = common.maxAccessCount?.toUInt(), + accessCount = 0U, + disabled = common.isDeactivateChecked, + hideEmail = common.isHideEmailChecked, + revisionDate = Instant.now(), + deletionDate = Instant.now(), + expirationDate = null, + ) + +private fun AddSendState.ViewState.Content.SendType.toSendType(): SendType = + when (this) { + AddSendState.ViewState.Content.SendType.File -> SendType.FILE + is AddSendState.ViewState.Content.SendType.Text -> SendType.TEXT + } + +private fun AddSendState.ViewState.Content.toSendFileView(): SendFileView? = + (this.selectedType as? AddSendState.ViewState.Content.SendType.File)?.let { + // TODO: Add support for these properties in order to save a file (BIT-480) + SendFileView( + id = "", + fileName = "", + size = "", + sizeName = "", + ) + } + +private fun AddSendState.ViewState.Content.toSendTextView(): SendTextView? = + (this.selectedType as? AddSendState.ViewState.Content.SendType.Text)?.let { + SendTextView( + text = it.input, + hidden = it.isHideByDefaultChecked, + ) + } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreenTest.kt index d0c11af3ae..14bb96bd0f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendScreenTest.kt @@ -1,11 +1,14 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addsend +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText @@ -521,6 +524,60 @@ class AddSendScreenTest : BaseComposeTest() { composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist() } + @Test + fun `error dialog should be displayed according to state`() { + val errorTitle = "Fail Title" + val errorMessage = "Fail Message" + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialogState = AddSendState.DialogState.Error( + title = errorTitle.asText(), + message = errorMessage.asText(), + ), + ) + } + + composeTestRule + .onNodeWithText(errorMessage) + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + } + + @Test + fun `error dialog Ok click should send DismissDialogClick`() { + mutableStateFlow.update { + it.copy( + dialogState = AddSendState.DialogState.Error( + title = "Fail Title".asText(), + message = "Fail Message".asText(), + ), + ) + } + composeTestRule + .onNodeWithText("Ok") + .performClick() + verify { viewModel.trySendAction(AddSendAction.DismissDialogClick) } + } + + @Test + fun `loading dialog should be displayed according to state`() { + val loadingMessage = "syncing" + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText(loadingMessage).assertDoesNotExist() + + mutableStateFlow.update { + it.copy(dialogState = AddSendState.DialogState.Loading(loadingMessage.asText())) + } + + composeTestRule + .onNodeWithText(loadingMessage) + .assertIsDisplayed() + .assert(hasAnyAncestor(isDialog())) + } + companion object { private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common( name = "", @@ -543,6 +600,7 @@ class AddSendScreenTest : BaseComposeTest() { private val DEFAULT_STATE = AddSendState( viewState = DEFAULT_VIEW_STATE, + dialogState = null, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt index 53f0e2bb8b..aeec66e56f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/AddSendViewModelTest.kt @@ -2,14 +2,39 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addsend import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.bitwarden.core.SendView +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toSendView +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test class AddSendViewModelTest : BaseViewModelTest() { + private val vaultRepository: VaultRepository = mockk() + + @BeforeEach + fun setup() { + mockkStatic(ADD_SEND_STATE_EXTENSIONS_PATH) + } + + @AfterEach + fun tearDown() { + unmockkStatic(ADD_SEND_STATE_EXTENSIONS_PATH) + } + @Test fun `initial state should be correct`() { val viewModel = createViewModel() @@ -33,12 +58,94 @@ class AddSendViewModelTest : BaseViewModelTest() { } @Test - fun `SaveClick should emit ShowToast`() = runTest { - val viewModel = createViewModel() + fun `SaveClick with createSend success should emit NavigateBack`() = runTest { + val viewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON_STATE.copy(name = "input"), + ) + val initialState = DEFAULT_STATE.copy(viewState = viewState) + val mockSendView = mockk() + every { viewState.toSendView() } returns mockSendView + coEvery { vaultRepository.createSend(mockSendView) } returns CreateSendResult.Success + val viewModel = createViewModel(initialState) + viewModel.eventFlow.test { viewModel.trySendAction(AddSendAction.SaveClick) - assertEquals(AddSendEvent.ShowToast("Save Not Implemented"), awaitItem()) + assertEquals(AddSendEvent.NavigateBack, awaitItem()) } + assertEquals(initialState, viewModel.stateFlow.value) + coVerify(exactly = 1) { + vaultRepository.createSend(mockSendView) + } + } + + @Test + fun `SaveClick with createSend failure should show error dialog`() = runTest { + val viewState = DEFAULT_VIEW_STATE.copy( + common = DEFAULT_COMMON_STATE.copy(name = "input"), + ) + val initialState = DEFAULT_STATE.copy(viewState = viewState) + val mockSendView = mockk() + every { viewState.toSendView() } returns mockSendView + coEvery { vaultRepository.createSend(mockSendView) } returns CreateSendResult.Error + val viewModel = createViewModel(initialState) + + viewModel.stateFlow.test { + assertEquals(initialState, awaitItem()) + viewModel.trySendAction(AddSendAction.SaveClick) + assertEquals( + initialState.copy( + dialogState = AddSendState.DialogState.Loading( + message = R.string.saving.asText(), + ), + ), + awaitItem(), + ) + assertEquals( + initialState.copy( + dialogState = AddSendState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.generic_error_message.asText(), + ), + ), + awaitItem(), + ) + } + coVerify(exactly = 1) { + vaultRepository.createSend(mockSendView) + } + } + + @Test + fun `SaveClick with blank name should show error dialog`() { + val viewModel = createViewModel(DEFAULT_STATE) + + viewModel.trySendAction(AddSendAction.SaveClick) + + assertEquals( + DEFAULT_STATE.copy( + dialogState = AddSendState.DialogState.Error( + title = R.string.an_error_has_occurred.asText(), + message = R.string.validation_field_required.asText( + R.string.name.asText(), + ), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `DismissDialogClick should clear the dialog state`() { + val viewModel = createViewModel( + DEFAULT_STATE.copy( + dialogState = AddSendState.DialogState.Error( + title = "Fail Title".asText(), + message = "Fail Message".asText(), + ), + ), + ) + viewModel.trySendAction(AddSendAction.DismissDialogClick) + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) } @Test @@ -171,9 +278,13 @@ class AddSendViewModelTest : BaseViewModelTest() { state: AddSendState? = null, ): AddSendViewModel = AddSendViewModel( savedStateHandle = SavedStateHandle().apply { set("state", state) }, + vaultRepo = vaultRepository, ) companion object { + private const val ADD_SEND_STATE_EXTENSIONS_PATH: String = + "com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.AddSendStateExtensionsKt" + private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common( name = "", maxAccessCount = null, @@ -195,6 +306,7 @@ class AddSendViewModelTest : BaseViewModelTest() { private val DEFAULT_STATE = AddSendState( viewState = DEFAULT_VIEW_STATE, + dialogState = null, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/AddSendStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/AddSendStateExtensionsTest.kt new file mode 100644 index 0000000000..b19acb2451 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/tools/feature/send/addsend/util/AddSendStateExtensionsTest.kt @@ -0,0 +1,87 @@ +package com.x8bit.bitwarden.ui.tools.feature.send.addsend.util + +import com.bitwarden.core.SendFileView +import com.bitwarden.core.SendType +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView +import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Instant + +class AddSendStateExtensionsTest { + + private val fixedInstant: Instant = Instant.parse("2023-10-27T12:00:00Z") + + @AfterEach + fun tearDown() { + // Some individual tests call mockkStatic so we will make sure this is always undone. + unmockkStatic(Instant::class) + } + + @Test + fun `toSendView should create an appropriate SendView with file type`() { + val sendView = createMockSendView(number = 1, type = SendType.FILE).copy( + id = null, + accessId = null, + key = "", + accessCount = 0U, + expirationDate = null, + text = null, + file = SendFileView( + id = "", + fileName = "", + size = "", + sizeName = "", + ), + ) + mockkStatic(Instant::class) + every { Instant.now() } returns fixedInstant + + val result = DEFAULT_VIEW_STATE + .copy(selectedType = AddSendState.ViewState.Content.SendType.File) + .toSendView() + + assertEquals(sendView, result) + } + + @Test + fun `toSendView should create an appropriate SendView with text type`() { + val sendView = createMockSendView(number = 1, type = SendType.TEXT).copy( + id = null, + accessId = null, + key = "", + accessCount = 0U, + expirationDate = null, + file = null, + ) + mockkStatic(Instant::class) + every { Instant.now() } returns fixedInstant + + val result = DEFAULT_VIEW_STATE.toSendView() + + assertEquals(sendView, result) + } +} + +private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common( + name = "mockName-1", + maxAccessCount = 1, + passwordInput = "mockPassword-1", + noteInput = "mockNotes-1", + isHideEmailChecked = false, + isDeactivateChecked = false, +) + +private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text( + input = "mockText-1", + isHideByDefaultChecked = false, +) + +private val DEFAULT_VIEW_STATE = AddSendState.ViewState.Content( + common = DEFAULT_COMMON_STATE, + selectedType = DEFAULT_SELECTED_TYPE_STATE, +)