From ff23dc3ab2577c75ca3b0877ccf5a2a2743d98f8 Mon Sep 17 00:00:00 2001 From: David Perez Date: Mon, 25 Aug 2025 13:45:12 -0500 Subject: [PATCH] PM-25069: Update VaultAddEditViewModel toasts to snackbars (#5769) --- .../feature/search/SearchViewModel.kt | 4 +- .../manager/snackbar/SnackbarRelay.kt | 3 + .../feature/addedit/VaultAddEditScreen.kt | 8 -- .../feature/addedit/VaultAddEditViewModel.kt | 60 ++++++------ .../vault/feature/item/VaultItemViewModel.kt | 6 +- .../itemlisting/VaultItemListingViewModel.kt | 5 +- .../ui/vault/feature/vault/VaultViewModel.kt | 5 +- .../addedit/VaultAddEditViewModelTest.kt | 92 ++++++++++--------- 8 files changed, 103 insertions(+), 80 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt index 57b5e903c4..87160815ee 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt @@ -160,7 +160,9 @@ class SearchViewModel @Inject constructor( snackbarRelayManager .getSnackbarDataFlow( SnackbarRelay.CIPHER_DELETED, + SnackbarRelay.CIPHER_DELETED_SOFT, SnackbarRelay.CIPHER_RESTORED, + SnackbarRelay.CIPHER_UPDATED, SnackbarRelay.SEND_DELETED, SnackbarRelay.SEND_UPDATED, ) @@ -1329,7 +1331,7 @@ sealed class SearchAction { */ data class SnackbarDataReceived( val data: BitwardenSnackbarData, - ) : Internal() + ) : Internal(), BackgroundEvent /** * Indicates a result for updating a cipher during the autofill-and-save process. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelay.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelay.kt index 83b78ecf1d..a3d3b2e020 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelay.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelay.kt @@ -9,9 +9,12 @@ import kotlinx.serialization.Serializable */ @Serializable enum class SnackbarRelay { + CIPHER_CREATED, CIPHER_DELETED, + CIPHER_DELETED_SOFT, CIPHER_MOVED_TO_ORGANIZATION, CIPHER_RESTORED, + CIPHER_UPDATED, ENVIRONMENT_SAVED, LOGIN_APPROVAL, LOGIN_SUCCESS, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt index fda29bebc0..ed7d478c8b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreen.kt @@ -1,6 +1,5 @@ package com.x8bit.bitwarden.ui.vault.feature.addedit -import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -29,7 +28,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -114,8 +112,6 @@ fun VaultAddEditScreen( onNavigateToMoveToOrganization: (cipherId: String, showOnlyCollections: Boolean) -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val context = LocalContext.current - val resources = context.resources val userVerificationHandlers = remember(viewModel) { VaultAddEditUserVerificationHandlers.create(viewModel = viewModel) } @@ -141,10 +137,6 @@ fun VaultAddEditScreen( onNavigateToGeneratorModal(event.generatorMode) } - is VaultAddEditEvent.ShowToast -> { - Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show() - } - is VaultAddEditEvent.NavigateToAttachments -> onNavigateToAttachments(event.cipherId) is VaultAddEditEvent.NavigateToMoveToOrganization -> { onNavigateToMoveToOrganization(event.cipherId, false) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt index 32afeb938b..f3725a9392 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModel.kt @@ -7,6 +7,7 @@ import androidx.credentials.provider.ProviderCreateCredentialRequest import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.core.DateTime +import com.bitwarden.core.data.manager.toast.ToastManager import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.takeUntilLoaded import com.bitwarden.network.model.PolicyTypeJson @@ -114,7 +115,8 @@ private const val KEY_STATE = "state" class VaultAddEditViewModel @Inject constructor( savedStateHandle: SavedStateHandle, generatorRepository: GeneratorRepository, - snackbarRelayManager: SnackbarRelayManager, + private val snackbarRelayManager: SnackbarRelayManager, + private val toastManager: ToastManager, private val authRepository: AuthRepository, private val clipboardManager: BitwardenClipboardManager, private val policyManager: PolicyManager, @@ -1192,7 +1194,7 @@ class VaultAddEditViewModel @Inject constructor( updateLoginContent { loginType -> loginType.copy(fido2CredentialCreationDateTime = null) } - sendEvent(event = VaultAddEditEvent.ShowToast(BitwardenString.passkey_removed.asText())) + sendEvent(event = VaultAddEditEvent.ShowSnackbar(BitwardenString.passkey_removed.asText())) } private fun handlePasswordVisibilityChange( @@ -1656,10 +1658,9 @@ class VaultAddEditViewModel @Inject constructor( if (state.shouldExitOnSave) { sendEvent(event = VaultAddEditEvent.ExitApp) } else { - sendEvent( - event = VaultAddEditEvent.ShowToast( - BitwardenString.new_item_created.asText(), - ), + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(BitwardenString.new_item_created.asText()), + relay = SnackbarRelay.CIPHER_CREATED, ) sendEvent(event = VaultAddEditEvent.NavigateBack) } @@ -1690,10 +1691,9 @@ class VaultAddEditViewModel @Inject constructor( if (state.shouldExitOnSave) { sendEvent(event = VaultAddEditEvent.ExitApp) } else { - sendEvent( - event = VaultAddEditEvent.ShowToast( - BitwardenString.item_updated.asText(), - ), + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(BitwardenString.item_updated.asText()), + relay = SnackbarRelay.CIPHER_UPDATED, ) sendEvent(event = VaultAddEditEvent.NavigateBack) } @@ -1714,10 +1714,9 @@ class VaultAddEditViewModel @Inject constructor( DeleteCipherResult.Success -> { clearDialogState() - sendEvent( - VaultAddEditEvent.ShowToast( - message = BitwardenString.item_soft_deleted.asText(), - ), + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(BitwardenString.item_soft_deleted.asText()), + relay = SnackbarRelay.CIPHER_DELETED_SOFT, ) sendEvent(VaultAddEditEvent.NavigateBack) } @@ -1887,7 +1886,7 @@ class VaultAddEditViewModel @Inject constructor( when (val result = action.totpResult) { is TotpCodeResult.Success -> { sendEvent( - event = VaultAddEditEvent.ShowToast( + event = VaultAddEditEvent.ShowSnackbar( message = BitwardenString.authenticator_key_added.asText(), ), ) @@ -1962,11 +1961,8 @@ class VaultAddEditViewModel @Inject constructor( clearDialogState() when (action.result) { is Fido2RegisterCredentialResult.Error -> { - sendEvent( - VaultAddEditEvent.ShowToast( - BitwardenString.an_error_has_occurred.asText(), - ), - ) + // Use toast here because we are closing the activity. + toastManager.show(BitwardenString.an_error_has_occurred) sendEvent( VaultAddEditEvent.CompleteFido2Registration( RegisterFido2CredentialResult.Error( @@ -1977,7 +1973,8 @@ class VaultAddEditViewModel @Inject constructor( } is Fido2RegisterCredentialResult.Success -> { - sendEvent(VaultAddEditEvent.ShowToast(BitwardenString.item_updated.asText())) + // Use toast here because we are closing the activity. + toastManager.show(BitwardenString.item_updated) sendEvent( VaultAddEditEvent.CompleteFido2Registration( RegisterFido2CredentialResult.Success(action.result.responseJson), @@ -2804,12 +2801,21 @@ sealed class VaultAddEditEvent { */ data class ShowSnackbar( val data: BitwardenSnackbarData, - ) : VaultAddEditEvent(), BackgroundEvent - - /** - * Shows a toast with the given [message]. - */ - data class ShowToast(val message: Text) : VaultAddEditEvent() + ) : VaultAddEditEvent(), BackgroundEvent { + constructor( + message: Text, + messageHeader: Text? = null, + actionLabel: Text? = null, + withDismissAction: Boolean = false, + ) : this( + data = BitwardenSnackbarData( + message = message, + messageHeader = messageHeader, + actionLabel = actionLabel, + withDismissAction = withDismissAction, + ), + ) + } /** * Leave the application. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index e6757136ca..dcc15d5a69 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -207,7 +207,11 @@ class VaultItemViewModel @Inject constructor( .launchIn(viewModelScope) snackbarRelayManager - .getSnackbarDataFlow(SnackbarRelay.CIPHER_MOVED_TO_ORGANIZATION) + .getSnackbarDataFlow( + SnackbarRelay.CIPHER_DELETED_SOFT, + SnackbarRelay.CIPHER_MOVED_TO_ORGANIZATION, + SnackbarRelay.CIPHER_UPDATED, + ) .map { VaultItemAction.Internal.SnackbarDataReceived(it) } .onEach(::sendAction) .launchIn(viewModelScope) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 3dad532132..c5d25bd2d7 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -218,8 +218,11 @@ class VaultItemListingViewModel @Inject constructor( snackbarRelayManager .getSnackbarDataFlow( + SnackbarRelay.CIPHER_CREATED, SnackbarRelay.CIPHER_DELETED, + SnackbarRelay.CIPHER_DELETED_SOFT, SnackbarRelay.CIPHER_RESTORED, + SnackbarRelay.CIPHER_UPDATED, SnackbarRelay.SEND_DELETED, SnackbarRelay.SEND_UPDATED, ) @@ -3586,7 +3589,7 @@ sealed class VaultItemListingsAction { */ data class SnackbarDataReceived( val data: BitwardenSnackbarData, - ) : Internal() + ) : Internal(), BackgroundEvent /** * Indicates that an error occurred while decrypting a cipher. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 25c8f5be37..920c53dc67 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -173,8 +173,11 @@ class VaultViewModel @Inject constructor( delay(timeMillis = LOGIN_SUCCESS_SNACKBAR_DELAY) }, snackbarRelayManager.getSnackbarDataFlow( + SnackbarRelay.CIPHER_CREATED, SnackbarRelay.CIPHER_DELETED, + SnackbarRelay.CIPHER_DELETED_SOFT, SnackbarRelay.CIPHER_RESTORED, + SnackbarRelay.CIPHER_UPDATED, SnackbarRelay.LOGINS_IMPORTED, ), ) @@ -1713,7 +1716,7 @@ sealed class VaultAction { */ data class SnackbarDataReceive( val data: BitwardenSnackbarData, - ) : Internal() + ) : Internal(), BackgroundEvent /** * Indicates that the flight recorder data was received. diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt index 4604b8df4f..8faf2b96d1 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditViewModelTest.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.collections.CollectionView import com.bitwarden.core.DateTime +import com.bitwarden.core.data.manager.toast.ToastManager import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager @@ -75,6 +76,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorMode import com.x8bit.bitwarden.ui.vault.feature.addedit.model.CustomFieldAction @@ -210,6 +212,11 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { every { getSnackbarDataFlow(relay = any(), relays = anyVararg()) } returns mutableSnackbarDataFlow + every { sendSnackbarData(data = any(), relay = any()) } just runs + } + private val toastManager: ToastManager = mockk { + every { show(messageId = any(), duration = any()) } just runs + every { show(message = any(), duration = any()) } just runs } @BeforeEach @@ -616,15 +623,17 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { viewModel.trySendAction(VaultAddEditAction.Common.ConfirmDeleteClick) viewModel.eventFlow.test { - assertEquals( - VaultAddEditEvent.ShowToast(BitwardenString.item_soft_deleted.asText()), - awaitItem(), - ) assertEquals( VaultAddEditEvent.NavigateBack, awaitItem(), ) } + verify(exactly = 1) { + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(BitwardenString.item_soft_deleted.asText()), + relay = SnackbarRelay.CIPHER_DELETED_SOFT, + ) + } } @Test @@ -734,17 +743,17 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { assertEquals(stateWithDialog, stateFlow.awaitItem()) assertEquals(stateWithName, stateFlow.awaitItem()) - assertEquals( - VaultAddEditEvent.ShowToast( - BitwardenString.new_item_created.asText(), - ), - eventFlow.awaitItem(), - ) assertEquals( VaultAddEditEvent.NavigateBack, eventFlow.awaitItem(), ) } + verify(exactly = 1) { + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(BitwardenString.new_item_created.asText()), + relay = SnackbarRelay.CIPHER_CREATED, + ) + } coVerify(exactly = 1) { vaultRepository.createCipherInOrganization(any(), any()) } @@ -906,13 +915,15 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { assertEquals(stateWithName, stateTurbine.awaitItem()) assertEquals(stateWithDialog, stateTurbine.awaitItem()) assertEquals(stateWithName, stateTurbine.awaitItem()) - assertEquals( - VaultAddEditEvent.ShowToast(BitwardenString.new_item_created.asText()), - eventTurbine.awaitItem(), - ) assertEquals(VaultAddEditEvent.NavigateBack, eventTurbine.awaitItem()) } assertNotNull(specialCircumstanceManager.specialCircumstance) + verify(exactly = 1) { + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(BitwardenString.new_item_created.asText()), + relay = SnackbarRelay.CIPHER_CREATED, + ) + } coVerify(exactly = 1) { vaultRepository.createCipherInOrganization(any(), any()) } @@ -1070,10 +1081,6 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { assertEquals(stateWithName, stateFlow.awaitItem()) assertEquals(stateWithSavingDialog, stateFlow.awaitItem()) - assertEquals( - VaultAddEditEvent.ShowToast(BitwardenString.item_updated.asText()), - eventFlow.awaitItem(), - ) assertEquals(stateWithName, stateFlow.awaitItem()) assertEquals( VaultAddEditEvent.CompleteFido2Registration( @@ -1083,6 +1090,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ), eventFlow.awaitItem(), ) + verify(exactly = 1) { + toastManager.show(messageId = BitwardenString.item_updated) + } coVerify(exactly = 1) { bitwardenCredentialManager.registerFido2Credential( userId = mockUserId, @@ -1337,14 +1347,14 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } returns CreateCipherResult.Success viewModel.eventFlow.test { viewModel.trySendAction(VaultAddEditAction.Common.SaveClick) - assertEquals( - VaultAddEditEvent.ShowToast( - BitwardenString.new_item_created.asText(), - ), - awaitItem(), - ) assertEquals(VaultAddEditEvent.NavigateBack, awaitItem()) } + verify(exactly = 1) { + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(BitwardenString.new_item_created.asText()), + relay = SnackbarRelay.CIPHER_CREATED, + ) + } } @Test @@ -1668,14 +1678,14 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { } returns UpdateCipherResult.Success viewModel.eventFlow.test { viewModel.trySendAction(VaultAddEditAction.Common.SaveClick) - assertEquals( - VaultAddEditEvent.ShowToast( - BitwardenString.item_updated.asText(), - ), - awaitItem(), - ) assertEquals(VaultAddEditEvent.NavigateBack, awaitItem()) } + verify(exactly = 1) { + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(BitwardenString.item_updated.asText()), + relay = SnackbarRelay.CIPHER_UPDATED, + ) + } } @Test @@ -2579,7 +2589,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) assertEquals( - VaultAddEditEvent.ShowToast(BitwardenString.authenticator_key_added.asText()), + VaultAddEditEvent.ShowSnackbar( + message = BitwardenString.authenticator_key_added.asText(), + ), awaitItem(), ) @@ -3306,6 +3318,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { generatorRepository = generatorRepository, settingsRepository = settingsRepository, snackbarRelayManager = snackbarRelayManager, + toastManager = toastManager, specialCircumstanceManager = specialCircumstanceManager, resourceManager = resourceManager, clock = fixedClock, @@ -4508,11 +4521,6 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) viewModel.eventFlow.test { - assertEquals( - VaultAddEditEvent.ShowToast(BitwardenString.an_error_has_occurred.asText()), - awaitItem(), - ) - assertEquals( VaultAddEditEvent.CompleteFido2Registration( RegisterFido2CredentialResult.Error( @@ -4523,6 +4531,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { awaitItem(), ) } + verify(exactly = 1) { + toastManager.show(messageId = BitwardenString.an_error_has_occurred) + } } @Suppress("MaxLineLength") @@ -4555,11 +4566,6 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { ) viewModel.eventFlow.test { - assertEquals( - VaultAddEditEvent.ShowToast(BitwardenString.item_updated.asText()), - awaitItem(), - ) - assertEquals( VaultAddEditEvent.CompleteFido2Registration( RegisterFido2CredentialResult.Success( @@ -4569,6 +4575,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { awaitItem(), ) } + verify(exactly = 1) { + toastManager.show(messageId = BitwardenString.item_updated) + } } } @@ -4709,6 +4718,7 @@ class VaultAddEditViewModelTest : BaseViewModelTest() { generatorRepository = generatorRepo, settingsRepository = settingsRepository, snackbarRelayManager = snackbarRelayManager, + toastManager = toastManager, specialCircumstanceManager = specialCircumstanceManager, resourceManager = bitwardenResourceManager, clock = clock,