diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreen.kt index fe4c8c0e7..15192c7ab 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreen.kt @@ -26,10 +26,12 @@ 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.BitwardenListHeaderText +import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenMultiSelectButton 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 kotlinx.collections.immutable.toImmutableList /** @@ -63,6 +65,16 @@ fun VaultAddItemScreen( VaultAddSecureNotesItemTypeHandlers.create(viewModel = viewModel) } + when (val dialogState = state.dialog) { + is VaultAddItemState.DialogState.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(dialogState.label), + ) + } + + null -> Unit + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( modifier = Modifier diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt index 9ee8dc4d2..eb5a95692 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt @@ -79,9 +79,19 @@ class VaultAddItemViewModel @Inject constructor( //region Top Level Handlers private fun handleSaveClick() { + mutableStateFlow.update { + it.copy( + dialog = VaultAddItemState.DialogState.Loading( + R.string.saving.asText(), + ), + ) + } + viewModelScope.launch { when (state.selectedType) { - is VaultAddItemState.ItemType.Login -> { + is VaultAddItemState.ItemType.Login, + is VaultAddItemState.ItemType.SecureNotes, + -> { sendAction( action = VaultAddItemAction.Internal.CreateCipherResultReceive( createCipherResult = vaultRepository.createCipher( @@ -91,16 +101,12 @@ class VaultAddItemViewModel @Inject constructor( ) } - is VaultAddItemState.ItemType.SecureNotes -> { - // TODO Add Saving of SecureNotes (BIT-509) - } - VaultAddItemState.ItemType.Card -> { - // TODO Add Saving of SecureNotes (BIT-668) + // TODO Add Saving of Card Type (BIT-668) } VaultAddItemState.ItemType.Identity -> { - // TODO Add Saving of SecureNotes (BIT-508) + // TODO Add Saving of Identity type (BIT-508) } } } @@ -502,6 +508,10 @@ class VaultAddItemViewModel @Inject constructor( @Suppress("MaxLineLength") private fun handleCreateCipherResultReceive(action: VaultAddItemAction.Internal.CreateCipherResultReceive) { + mutableStateFlow.update { + it.copy(dialog = null) + } + when (action.createCipherResult) { is CreateCipherResult.Error -> { // TODO Display error dialog BIT-501 @@ -559,6 +569,7 @@ class VaultAddItemViewModel @Inject constructor( companion object { val INITIAL_STATE: VaultAddItemState = VaultAddItemState( selectedType = VaultAddItemState.ItemType.Login(), + dialog = null, ) } } @@ -568,10 +579,12 @@ class VaultAddItemViewModel @Inject constructor( * * @property selectedType The type of the item (e.g., Card, Identity, SecureNotes) * that has been selected to be added to the vault. + * @property dialog the state for the dialogs that can be displayed */ @Parcelize data class VaultAddItemState( val selectedType: ItemType, + val dialog: DialogState?, ) : Parcelable { /** @@ -675,7 +688,14 @@ data class VaultAddItemState( /** * Represents the `SecureNotes` item type. * - * @property displayStringResId Resource ID for the display string of the secure notes type. + * @property name The name associated with the SecureNotes item. + * @property folder The folder used for the SecureNotes item + * @property favorite Indicates whether this SecureNotes item is marked as a favorite. + * @property masterPasswordReprompt Indicates if a master password reprompt is required. + * @property notes Notes or comments associated with the SecureNotes item. + * @property ownership The ownership email associated with the SecureNotes item. + * @property availableFolders A list of available folders. + * @property availableOwners A list of available owners. */ @Parcelize data class SecureNotes( @@ -702,6 +722,18 @@ data class VaultAddItemState( } } } + + /** + * Displays a dialog. + */ + sealed class DialogState : Parcelable { + + /** + * Displays a loading dialog to the user. + */ + @Parcelize + data class Loading(val label: Text) : DialogState() + } } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index 587788392..4708967c6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -5,6 +5,8 @@ import com.bitwarden.core.CipherType import com.bitwarden.core.CipherView import com.bitwarden.core.LoginUriView import com.bitwarden.core.LoginView +import com.bitwarden.core.SecureNoteType +import com.bitwarden.core.SecureNoteView import com.bitwarden.core.UriMatchType import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -92,12 +94,6 @@ fun VaultAddItemState.ItemType.toCipherView(): CipherView = is VaultAddItemState.ItemType.SecureNotes -> toSecureNotesCipherView() } -/** - * Transforms [VaultAddItemState.ItemType.SecureNotes] into [CipherView]. - */ -private fun VaultAddItemState.ItemType.SecureNotes.toSecureNotesCipherView(): CipherView = - TODO("create SecureNotes CipherView BIT-509") - /** * Transforms [VaultAddItemState.ItemType.Login] into [CipherView]. */ @@ -152,6 +148,46 @@ private fun VaultAddItemState.ItemType.Login.toLoginCipherView(): CipherView = revisionDate = Instant.now(), ) +/** + * Transforms [VaultAddItemState.ItemType.SecureNotes] into [CipherView]. + */ +private fun VaultAddItemState.ItemType.SecureNotes.toSecureNotesCipherView(): CipherView = + CipherView( + id = null, + // TODO use real organization id BIT-780 + organizationId = null, + // TODO use real folder id BIT-528 + folderId = null, + collectionIds = emptyList(), + key = null, + name = name, + notes = notes, + type = CipherType.SECURE_NOTE, + secureNote = SecureNoteView(SecureNoteType.GENERIC), + login = null, + identity = null, + card = null, + favorite = favorite, + reprompt = if (masterPasswordReprompt) { + CipherRepromptType.PASSWORD + } else { + CipherRepromptType.NONE + }, + organizationUseTotp = false, + edit = true, + viewPassword = true, + localData = null, + attachments = null, + // TODO implement custom fields BIT-529 + fields = null, + passwordHistory = null, + creationDate = Instant.now(), + deletedDate = null, + // This is a throw away value. + // The SDK will eventually remove revisionDate via encryption. + revisionDate = Instant.now(), + ) + /** * Transforms [VaultAddItemState.ItemType.Identity] into [CipherView]. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreenTest.kt index da9f0ce5b..03e819c02 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreenTest.kt @@ -34,11 +34,8 @@ import org.junit.Test @Suppress("LargeClass") class VaultAddItemScreenTest : BaseComposeTest() { - private val mutableStateFlow = MutableStateFlow( - VaultAddItemState( - selectedType = VaultAddItemState.ItemType.Login(), - ), - ) + + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE_LOGIN) private val viewModel = mockk(relaxed = true) { every { eventFlow } returns emptyFlow() @@ -623,8 +620,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Test fun `in ItemType_SecureNotes state changing Name text field should trigger NameTextChange`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -643,8 +639,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Test fun `in ItemType_SecureNotes the name control should display the text provided by the state`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -665,8 +660,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Test fun `in ItemType_SecureNotes state clicking a Folder Option should send FolderChange action`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -694,8 +688,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Suppress("MaxLineLength") @Test fun `in ItemType_SecureNotes the folder control should display the text provided by the state`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -717,8 +710,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Suppress("MaxLineLength") @Test fun `in ItemType_SecureNotes state, toggling the favorite toggle should send ToggleFavorite action`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -740,8 +732,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Suppress("MaxLineLength") @Test fun `in ItemType_SecureNotes the favorite toggle should be enabled or disabled according to state`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -763,8 +754,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Suppress("MaxLineLength") @Test fun `in ItemType_SecureNotes state, toggling the Master password re-prompt toggle should send ToggleMasterPasswordReprompt action`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -788,8 +778,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Suppress("MaxLineLength") @Test fun `in ItemType_SecureNotes the master password re-prompt toggle should be enabled or disabled according to state`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -811,8 +800,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Suppress("MaxLineLength") @Test fun `in ItemType_SecureNotes state, toggling the Master password re-prompt tooltip button should send TooltipClick action`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -831,8 +819,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Test fun `in ItemType_SecureNotes state changing Notes text field should trigger NotesTextChange`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -853,8 +840,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Suppress("MaxLineLength") @Test fun `in ItemType_SecureNotes the Notes control should display the text provided by the state`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -878,8 +864,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Suppress("MaxLineLength") @Test fun `in ItemType_SecureNotes state clicking New Custom Field button should trigger AddNewCustomFieldClick`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -899,8 +884,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Suppress("MaxLineLength") @Test fun `in ItemType_SecureNotes state clicking a Ownership option should send OwnershipChange action`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -928,8 +912,7 @@ class VaultAddItemScreenTest : BaseComposeTest() { @Suppress("MaxLineLength") @Test fun `in ItemType_SecureNotes the Ownership control should display the text provided by the state`() { - mutableStateFlow.value = - VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + mutableStateFlow.value = DEFAULT_STATE_SECURE_NOTES composeTestRule.setContent { VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) @@ -974,4 +957,16 @@ class VaultAddItemScreenTest : BaseComposeTest() { } //endregion Helper functions + + companion object { + private val DEFAULT_STATE_LOGIN = VaultAddItemState( + selectedType = VaultAddItemState.ItemType.Login(), + dialog = null, + ) + + private val DEFAULT_STATE_SECURE_NOTES = VaultAddItemState( + selectedType = VaultAddItemState.ItemType.SecureNotes(), + dialog = null, + ) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt index 579ca60d6..a23b9966e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.vault.feature.additem import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest @@ -39,7 +40,27 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { } @Test - fun `SaveClick createCipher success should emit NavigateBack`() = runTest { + fun `SaveClick should show dialog, and remove it once an item is saved`() = runTest { + val stateWithDialog = createVaultAddLoginItemState( + dialogState = VaultAddItemState.DialogState.Loading( + R.string.saving.asText(), + ), + ) + + val viewModel = createAddVaultItemViewModel() + coEvery { + vaultRepository.createCipher(any()) + } returns CreateCipherResult.Success + viewModel.stateFlow.test { + viewModel.actionChannel.trySend(VaultAddItemAction.SaveClick) + assertEquals(initialState, awaitItem()) + assertEquals(stateWithDialog, awaitItem()) + assertEquals(initialState, awaitItem()) + } + } + + @Test + fun `SaveClick should update value to loading`() = runTest { val viewModel = createAddVaultItemViewModel() coEvery { vaultRepository.createCipher(any()) @@ -505,6 +526,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { masterPasswordReprompt: Boolean = false, notes: String = "", ownership: String = "placeholder@email.com", + dialogState: VaultAddItemState.DialogState? = null, ): VaultAddItemState = VaultAddItemState( selectedType = VaultAddItemState.ItemType.Login( @@ -518,6 +540,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { notes = notes, ownership = ownership, ), + dialog = dialogState, ) @Suppress("LongParameterList") @@ -538,6 +561,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { notes = notes, ownership = ownership, ), + dialog = null, ) private fun createSavedStateHandleWithState(state: VaultAddItemState) = diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index 094e5e45e..ab7fd93e1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -5,6 +5,8 @@ import com.bitwarden.core.CipherType import com.bitwarden.core.CipherView import com.bitwarden.core.LoginUriView import com.bitwarden.core.LoginView +import com.bitwarden.core.SecureNoteType +import com.bitwarden.core.SecureNoteView import com.bitwarden.core.UriMatchType import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView @@ -163,4 +165,50 @@ class VaultDataExtensionsTest { result, ) } + + @Test + fun `toCipherView should transform SecureNotes ItemType to CipherView`() { + mockkStatic(Instant::class) + every { Instant.now() } returns Instant.MIN + val secureNotesItemType = VaultAddItemState.ItemType.SecureNotes( + name = "mockName-1", + folderName = "mockFolder-1".asText(), + favorite = false, + masterPasswordReprompt = false, + notes = "mockNotes-1", + ownership = "mockOwnership-1", + ) + + val result = secureNotesItemType.toCipherView() + + assertEquals( + CipherView( + id = null, + organizationId = null, + folderId = null, + collectionIds = emptyList(), + key = null, + name = "mockName-1", + notes = "mockNotes-1", + type = CipherType.SECURE_NOTE, + login = null, + identity = null, + card = null, + secureNote = SecureNoteView(SecureNoteType.GENERIC), + favorite = false, + reprompt = CipherRepromptType.NONE, + organizationUseTotp = false, + edit = true, + viewPassword = true, + localData = null, + attachments = null, + fields = null, + passwordHistory = null, + creationDate = Instant.MIN, + deletedDate = null, + revisionDate = Instant.MIN, + ), + result, + ) + } }