diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHostState.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHostState.kt index e931c584b1..c39636e51a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHostState.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/components/snackbar/BitwardenSnackbarHostState.kt @@ -84,6 +84,6 @@ data class BitwardenSnackbarData( fun rememberBitwardenSnackbarHostState( snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, scope: CoroutineScope = rememberCoroutineScope(), -) = remember { +): BitwardenSnackbarHostState = remember { BitwardenSnackbarHostState(snackbarHostState = snackbarHostState, scope = scope) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt index 1f410aa477..57767facf0 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt @@ -31,6 +31,8 @@ import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingConten import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost +import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState import com.x8bit.bitwarden.ui.platform.composition.LocalAppResumeStateManager import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.feature.search.handlers.SearchHandlers @@ -72,6 +74,7 @@ fun SearchScreen( ) } + val snackbarHostState = rememberBitwardenSnackbarHostState() EventsEffect(viewModel = viewModel) { event -> when (event) { SearchEvent.NavigateBack -> onNavigateBack() @@ -111,6 +114,7 @@ fun SearchScreen( is SearchEvent.NavigateToUrl -> intentManager.launchUri(event.url.toUri()) is SearchEvent.ShowShareSheet -> intentManager.shareText(event.content) + is SearchEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) is SearchEvent.ShowToast -> { Toast .makeText(context, event.message(context.resources), Toast.LENGTH_SHORT) @@ -159,6 +163,7 @@ fun SearchScreen( ) } }, + snackbarHost = { BitwardenSnackbarHost(bitwardenHostState = snackbarHostState) }, modifier = Modifier .nestedScroll(scrollBehavior.nestedScrollConnection), ) { 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 8e5cb7c893..bd6616d662 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 @@ -9,6 +9,7 @@ import com.bitwarden.data.repository.util.baseIconUrl import com.bitwarden.data.repository.util.baseWebSendUrl import com.bitwarden.network.model.PolicyTypeJson import com.bitwarden.send.SendType +import com.bitwarden.ui.platform.base.BackgroundEvent import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText @@ -39,12 +40,15 @@ import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOption import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.feature.search.util.filterAndOrganize import com.x8bit.bitwarden.ui.platform.feature.search.util.toSearchTypeData import com.x8bit.bitwarden.ui.platform.feature.search.util.toViewState import com.x8bit.bitwarden.ui.platform.feature.search.util.updateWithAdditionalDataIfNecessary +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.send.model.SendItemType import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendItemType import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction @@ -81,6 +85,7 @@ class SearchViewModel @Inject constructor( private val accessibilitySelectionManager: AccessibilitySelectionManager, private val autofillSelectionManager: AutofillSelectionManager, private val organizationEventManager: OrganizationEventManager, + private val snackbarRelayManager: SnackbarRelayManager, private val vaultRepo: VaultRepository, private val authRepo: AuthRepository, environmentRepo: EnvironmentRepository, @@ -135,6 +140,11 @@ class SearchViewModel @Inject constructor( .map { SearchAction.Internal.VaultDataReceive(it) } .onEach(::sendAction) .launchIn(viewModelScope) + snackbarRelayManager + .getSnackbarDataFlow(SnackbarRelay.SEND_DELETED, SnackbarRelay.SEND_UPDATED) + .map { SearchAction.Internal.SnackbarDataReceived(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: SearchAction) { @@ -473,6 +483,8 @@ class SearchViewModel @Inject constructor( handleRemovePasswordSendResultReceive(action) } + is SearchAction.Internal.SnackbarDataReceived -> handleSnackbarDataReceived(action) + is SearchAction.Internal.UpdateCipherResultReceive -> { handleUpdateCipherResultReceive(action) } @@ -554,6 +566,10 @@ class SearchViewModel @Inject constructor( } } + private fun handleSnackbarDataReceived(action: SearchAction.Internal.SnackbarDataReceived) { + sendEvent(SearchEvent.ShowSnackbar(action.data)) + } + private fun handleUpdateCipherResultReceive( action: SearchAction.Internal.UpdateCipherResultReceive, ) { @@ -1184,6 +1200,13 @@ sealed class SearchAction { val result: RemovePasswordSendResult, ) : Internal() + /** + * Indicates that snackbar data has been received. + */ + data class SnackbarDataReceived( + val data: BitwardenSnackbarData, + ) : Internal() + /** * Indicates a result for updating a cipher during the autofill-and-save process. */ @@ -1265,6 +1288,13 @@ sealed class SearchEvent { val content: String, ) : SearchEvent() + /** + * Show a snackbar to the user. + */ + data class ShowSnackbar( + val data: BitwardenSnackbarData, + ) : SearchEvent(), BackgroundEvent + /** * Show a toast with the given [message]. */ 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 cdda9094c9..864a4a8284 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 @@ -11,4 +11,5 @@ import kotlinx.serialization.Serializable enum class SnackbarRelay { LOGINS_IMPORTED, SEND_DELETED, + SEND_UPDATED, } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManager.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManager.kt index cf341c3c42..b5c58d1b8d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManager.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManager.kt @@ -16,7 +16,10 @@ interface SnackbarRelayManager { /** * Called from a consumer to receive snackbar data from a producer, the consumer must specify - * the [relay] to receive the data from. + * the [relay] or [relays] to receive the data from. */ - fun getSnackbarDataFlow(relay: SnackbarRelay): Flow + fun getSnackbarDataFlow( + relay: SnackbarRelay, + vararg relays: SnackbarRelay, + ): Flow } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerImpl.kt index 3ee0fff1ff..cad2701896 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/manager/snackbar/SnackbarRelayManagerImpl.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.launch @@ -34,9 +35,14 @@ class SnackbarRelayManagerImpl( } } - override fun getSnackbarDataFlow(relay: SnackbarRelay): Flow = - snackbarSharedFlow - .generateFlowFor(relay = relay) + override fun getSnackbarDataFlow( + relay: SnackbarRelay, + vararg relays: SnackbarRelay, + ): Flow = + merge( + snackbarSharedFlow.generateFlowFor(relay = relay), + *relays.map { snackbarSharedFlow.generateFlowFor(relay = it) }.toTypedArray(), + ) .map { it.data } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt index 89fd8f2f2d..0aa2a7fb0e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreen.kt @@ -38,6 +38,8 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenSelectionDialo import com.x8bit.bitwarden.ui.platform.components.dialog.row.BitwardenBasicDialogRow import com.x8bit.bitwarden.ui.platform.components.model.rememberBitwardenPullToRefreshState import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost +import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState import com.x8bit.bitwarden.ui.platform.composition.LocalAppResumeStateManager import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType @@ -82,6 +84,7 @@ fun SendScreen( AppResumeScreenData.SendScreen } + val snackbarHostState = rememberBitwardenSnackbarHostState() EventsEffect(viewModel = viewModel) { event -> when (event) { is SendEvent.NavigateToSearch -> onNavigateToSearchSend(SearchType.Sends.All) @@ -116,6 +119,8 @@ fun SendScreen( intentManager.shareText(event.url) } + is SendEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) + is SendEvent.ShowToast -> { Toast .makeText(context, event.message(context.resources), Toast.LENGTH_SHORT) @@ -197,6 +202,7 @@ fun SendScreen( } }, pullToRefreshState = pullToRefreshState, + snackbarHost = { BitwardenSnackbarHost(bitwardenHostState = snackbarHostState) }, ) { val modifier = Modifier .fillMaxSize() diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt index 3db1972821..2ce2799911 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import com.bitwarden.core.data.repository.model.DataState import com.bitwarden.data.repository.util.baseWebSendUrl import com.bitwarden.network.model.PolicyTypeJson +import com.bitwarden.ui.platform.base.BackgroundEvent import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText @@ -23,6 +24,9 @@ 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.components.model.IconData +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +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.send.model.SendItemType import com.x8bit.bitwarden.ui.tools.feature.send.util.toViewState import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemScreen @@ -49,6 +53,7 @@ class SendViewModel @Inject constructor( authRepo: AuthRepository, settingsRepo: SettingsRepository, policyManager: PolicyManager, + snackbarRelayManager: SnackbarRelayManager, private val clipboardManager: BitwardenClipboardManager, private val environmentRepo: EnvironmentRepository, private val vaultRepo: VaultRepository, @@ -89,6 +94,11 @@ class SendViewModel @Inject constructor( .map { SendAction.Internal.UserStateReceive(it) } .onEach(::sendAction) .launchIn(viewModelScope) + snackbarRelayManager + .getSnackbarDataFlow(SnackbarRelay.SEND_DELETED, SnackbarRelay.SEND_UPDATED) + .map { SendAction.Internal.SnackbarDataReceived(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: SendAction): Unit = when (action) { @@ -132,6 +142,7 @@ class SendViewModel @Inject constructor( } is SendAction.Internal.UserStateReceive -> handleUserStateReceive(action) + is SendAction.Internal.SnackbarDataReceived -> handleSnackbarDataReceived(action) } private fun handleInternetConnectionErrorReceived() { @@ -152,6 +163,10 @@ class SendViewModel @Inject constructor( } } + private fun handleSnackbarDataReceived(action: SendAction.Internal.SnackbarDataReceived) { + sendEvent(SendEvent.ShowSnackbar(action.data)) + } + private fun handlePullToRefreshEnableReceive( action: SendAction.Internal.PullToRefreshEnableReceive, ) { @@ -701,6 +716,13 @@ sealed class SendAction { val result: RemovePasswordSendResult, ) : Internal() + /** + * Indicates that snackbar data has been received from a relay to be displayed. + */ + data class SnackbarDataReceived( + val data: BitwardenSnackbarData, + ) : Internal() + /** * Indicates what the current [userState] is. */ @@ -781,6 +803,13 @@ sealed class SendEvent { */ data class ShowShareSheet(val url: String) : SendEvent() + /** + * Show a snackbar to the user. + */ + data class ShowSnackbar( + val data: BitwardenSnackbarData, + ) : SendEvent(), BackgroundEvent + /** * Show a toast to the user. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt index 981df79727..13e4419316 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreen.kt @@ -1,6 +1,5 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addedit -import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api @@ -11,7 +10,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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.stringResource import androidx.hilt.navigation.compose.hiltViewModel @@ -31,6 +29,8 @@ import com.x8bit.bitwarden.ui.platform.components.content.BitwardenLoadingConten import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost +import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.composition.LocalPermissionsManager @@ -57,15 +57,13 @@ fun AddEditSendScreen( val state by viewModel.stateFlow.collectAsStateWithLifecycle() val addSendHandlers = remember(viewModel) { AddEditSendHandlers.create(viewModel) } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) - val context = LocalContext.current - val resources = context.resources - val fileChooserLauncher = intentManager.getActivityResultLauncher { activityResult -> intentManager.getFileDataFromActivityResult(activityResult)?.let { addSendHandlers.onFileChoose(it) } } + val snackbarHostState = rememberBitwardenSnackbarHostState() BackHandler( onBack = remember(viewModel) { { viewModel.trySendAction(AddEditSendAction.CloseClick) } @@ -89,9 +87,7 @@ fun AddEditSendScreen( intentManager.shareText(event.message) } - is AddEditSendEvent.ShowToast -> { - Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show() - } + is AddEditSendEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) } } @@ -166,6 +162,7 @@ fun AddEditSendScreen( }, ) }, + snackbarHost = { BitwardenSnackbarHost(bitwardenHostState = snackbarHostState) }, ) { val modifier = Modifier .fillMaxSize() diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt index 8278b9917b..f16a7d1c07 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt @@ -27,7 +27,10 @@ import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult 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.UpdateSendResult +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +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.send.addedit.model.AddEditSendType import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.shouldFinishOnComplete import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toSendName @@ -70,6 +73,7 @@ class AddEditSendViewModel @Inject constructor( private val vaultRepo: VaultRepository, private val policyManager: PolicyManager, private val networkConnectionManager: NetworkConnectionManager, + private val snackbarRelayManager: SnackbarRelayManager, ) : BaseViewModel( // We load the state from the savedStateHandle for testing purposes. initialState = savedStateHandle[KEY_STATE] ?: run { @@ -250,6 +254,10 @@ class AddEditSendViewModel @Inject constructor( message = result.sendView.toSendUrl(state.baseWebSendUrl), ), ) + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(message = R.string.send_updated.asText()), + relay = SnackbarRelay.SEND_UPDATED, + ) } } } @@ -273,7 +281,10 @@ class AddEditSendViewModel @Inject constructor( is DeleteSendResult.Success -> { mutableStateFlow.update { it.copy(dialogState = null) } navigateBack(isDeleted = true) - sendEvent(AddEditSendEvent.ShowToast(message = R.string.send_deleted.asText())) + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(message = R.string.send_deleted.asText()), + relay = SnackbarRelay.SEND_DELETED, + ) } } } @@ -301,7 +312,11 @@ class AddEditSendViewModel @Inject constructor( updateCommonContent { it.copy(hasPassword = false) } mutableStateFlow.update { it.copy(dialogState = null) } sendEvent( - AddEditSendEvent.ShowToast(message = R.string.send_password_removed.asText()), + AddEditSendEvent.ShowSnackbar( + data = BitwardenSnackbarData( + message = R.string.send_password_removed.asText(), + ), + ), ) } } @@ -845,9 +860,11 @@ sealed class AddEditSendEvent { ) : BackgroundEvent, AddEditSendEvent() /** - * Show Toast. + * Show a snackbar. */ - data class ShowToast(val message: Text) : AddEditSendEvent() + data class ShowSnackbar( + val data: BitwardenSnackbarData, + ) : AddEditSendEvent() } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt index bc2a7b0eaa..3cc64b3074 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt @@ -1,6 +1,5 @@ package com.x8bit.bitwarden.ui.tools.feature.send.viewsend -import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn @@ -68,6 +67,8 @@ import com.x8bit.bitwarden.ui.platform.components.field.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.header.BitwardenExpandingHeader import com.x8bit.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost +import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState import com.x8bit.bitwarden.ui.platform.components.stepper.BitwardenStepper import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager @@ -89,6 +90,7 @@ fun ViewSendScreen( val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current val resources = context.resources + val snackbarHostState = rememberBitwardenSnackbarHostState() EventsEffect(viewModel = viewModel) { event -> when (event) { is ViewSendEvent.NavigateBack -> onNavigateBack() @@ -106,9 +108,7 @@ fun ViewSendScreen( intentManager.shareText(text = event.text(resources).toString()) } - is ViewSendEvent.ShowToast -> { - Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show() - } + is ViewSendEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) } } @@ -153,6 +153,7 @@ fun ViewSendScreen( ) } }, + snackbarHost = { BitwardenSnackbarHost(bitwardenHostState = snackbarHostState) }, ) { ViewSendScreenContent( state = state, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt index 30714deacf..eb345d5dbf 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt @@ -16,6 +16,9 @@ import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardMan import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +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.send.model.SendItemType import com.x8bit.bitwarden.ui.tools.feature.send.viewsend.util.toViewSendViewStateContent import dagger.hilt.android.lifecycle.HiltViewModel @@ -37,6 +40,7 @@ private const val KEY_STATE = "state" @HiltViewModel class ViewSendViewModel @Inject constructor( private val clipboardManager: BitwardenClipboardManager, + private val snackbarRelayManager: SnackbarRelayManager, private val clock: Clock, private val vaultRepository: VaultRepository, environmentRepository: EnvironmentRepository, @@ -60,6 +64,11 @@ class ViewSendViewModel @Inject constructor( .map { ViewSendAction.Internal.SendDataReceive(it) } .onEach(::sendAction) .launchIn(viewModelScope) + snackbarRelayManager + .getSnackbarDataFlow(SnackbarRelay.SEND_UPDATED) + .map { ViewSendAction.Internal.SnackbarDataReceived(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) } override fun handleAction(action: ViewSendAction) { @@ -79,6 +88,7 @@ class ViewSendViewModel @Inject constructor( when (action) { is ViewSendAction.Internal.SendDataReceive -> handleSendDataReceive(action) is ViewSendAction.Internal.DeleteResultReceive -> handleDeleteResultReceive(action) + is ViewSendAction.Internal.SnackbarDataReceived -> handleSnackbarDataReceived(action) } } @@ -134,12 +144,19 @@ class ViewSendViewModel @Inject constructor( is DeleteSendResult.Success -> { mutableStateFlow.update { it.copy(dialogState = null) } - sendEvent(ViewSendEvent.ShowToast(message = R.string.send_deleted.asText())) + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(message = R.string.send_deleted.asText()), + relay = SnackbarRelay.SEND_DELETED, + ) sendEvent(ViewSendEvent.NavigateBack) } } } + private fun handleSnackbarDataReceived(action: ViewSendAction.Internal.SnackbarDataReceived) { + sendEvent(ViewSendEvent.ShowSnackbar(action.data)) + } + private fun handleSendDataReceive(action: ViewSendAction.Internal.SendDataReceive) { when (val dataState = action.sendDataState) { is DataState.Error -> sendErrorReceive(dataState = dataState) @@ -340,10 +357,10 @@ sealed class ViewSendEvent { ) : ViewSendEvent() /** - * Shows the [message] via a toast. + * Show a snackbar to the user. */ - data class ShowToast( - val message: Text, + data class ShowSnackbar( + val data: BitwardenSnackbarData, ) : ViewSendEvent(), BackgroundEvent } @@ -399,5 +416,12 @@ sealed class ViewSendAction { * Indicates that the send item data has been received. */ data class SendDataReceive(val sendDataState: DataState) : Internal() + + /** + * Indicates that snackbar data has been received. + */ + data class SnackbarDataReceived( + val data: BitwardenSnackbarData, + ) : Internal() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index aa04365bc4..2120667850 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -45,6 +45,9 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialo import com.x8bit.bitwarden.ui.platform.components.model.BitwardenPullToRefreshState import com.x8bit.bitwarden.ui.platform.components.model.rememberBitwardenPullToRefreshState import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHostState +import com.x8bit.bitwarden.ui.platform.components.snackbar.rememberBitwardenSnackbarHostState import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager import com.x8bit.bitwarden.ui.platform.composition.LocalCredentialProviderCompletionManager import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager @@ -105,6 +108,7 @@ fun VaultItemListingScreen( { viewModel.trySendAction(VaultItemListingsAction.RefreshPull) } }, ) + val snackbarHostState = rememberBitwardenSnackbarHostState() EventsEffect(viewModel = viewModel) { event -> when (event) { is VaultItemListingEvent.NavigateBack -> onNavigateBack() @@ -217,6 +221,8 @@ fun VaultItemListingScreen( is VaultItemListingEvent.NavigateToAddFolder -> { onNavigateToAddFolder(event.parentFolderName) } + + is VaultItemListingEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) } } @@ -325,6 +331,7 @@ fun VaultItemListingScreen( BackHandler(onBack = vaultItemListingHandlers.backClick) VaultItemListingScaffold( state = state, + snackbarHostState = snackbarHostState, pullToRefreshState = pullToRefreshState, vaultItemListingHandlers = vaultItemListingHandlers, ) @@ -478,6 +485,7 @@ private fun VaultItemListingDialogs( private fun VaultItemListingScaffold( state: VaultItemListingState, pullToRefreshState: BitwardenPullToRefreshState, + snackbarHostState: BitwardenSnackbarHostState, vaultItemListingHandlers: VaultItemListingHandlers, ) { var isAccountMenuVisible by rememberSaveable { mutableStateOf(false) } @@ -553,6 +561,7 @@ private fun VaultItemListingScaffold( ) }, pullToRefreshState = pullToRefreshState, + snackbarHost = { BitwardenSnackbarHost(bitwardenHostState = snackbarHostState) }, ) { when (state.viewState) { is VaultItemListingState.ViewState.Content -> { 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 28ccfd512f..af58547803 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 @@ -72,9 +72,12 @@ import com.x8bit.bitwarden.ui.credentials.manager.model.GetCredentialsResult import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.feature.search.SearchTypeData import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.feature.search.util.filterAndOrganize +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import com.x8bit.bitwarden.ui.platform.util.persistentListOfNotNull import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendItemType @@ -132,6 +135,7 @@ class VaultItemListingViewModel @Inject constructor( private val bitwardenCredentialManager: BitwardenCredentialManager, private val organizationEventManager: OrganizationEventManager, private val networkConnectionManager: NetworkConnectionManager, + private val snackbarRelayManager: SnackbarRelayManager, ) : BaseViewModel( initialState = run { val userState = requireNotNull(authRepository.userStateFlow.value) @@ -189,6 +193,12 @@ class VaultItemListingViewModel @Inject constructor( .onEach(::sendAction) .launchIn(viewModelScope) + snackbarRelayManager + .getSnackbarDataFlow(SnackbarRelay.SEND_DELETED, SnackbarRelay.SEND_UPDATED) + .map { VaultItemListingsAction.Internal.SnackbarDataReceived(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + specialCircumstanceManager.specialCircumstance ?.toCreateCredentialRequestOrNull() ?.let { request -> @@ -1403,6 +1413,10 @@ class VaultItemListingViewModel @Inject constructor( is VaultItemListingsAction.Internal.GetCredentialEntriesResultReceive -> { handleGetCredentialEntriesResultReceive(action) } + + is VaultItemListingsAction.Internal.SnackbarDataReceived -> { + handleSnackbarDataReceived(action) + } } } @@ -2038,6 +2052,12 @@ class VaultItemListingViewModel @Inject constructor( } } + private fun handleSnackbarDataReceived( + action: VaultItemListingsAction.Internal.SnackbarDataReceived, + ) { + sendEvent(VaultItemListingEvent.ShowSnackbar(action.data)) + } + private fun updateStateWithVaultData(vaultData: VaultData, clearDialogState: Boolean) { mutableStateFlow.update { currentState -> currentState.copy( @@ -2769,6 +2789,13 @@ sealed class VaultItemListingEvent { */ data class ShowToast(val text: Text) : VaultItemListingEvent() + /** + * Show a snackbar to the user. + */ + data class ShowSnackbar( + val data: BitwardenSnackbarData, + ) : VaultItemListingEvent(), BackgroundEvent + /** * Complete the current FIDO 2 credential registration process. * @@ -3138,6 +3165,13 @@ sealed class VaultItemListingsAction { val userId: String, val result: Result>, ) : Internal() + + /** + * Indicates that snackbar data has been received. + */ + data class SnackbarDataReceived( + val data: BitwardenSnackbarData, + ) : Internal() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dc416abb5b..aa30d48eaf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -466,6 +466,7 @@ Scanning will happen automatically. New text Send Are you sure you want to delete this Send? Send deleted + Send updated 1 day 2 days 3 days diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt index 12cfa89122..a873d2c42d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreenTest.kt @@ -26,6 +26,7 @@ import com.bitwarden.ui.util.isProgressBar import com.bitwarden.vault.CipherType import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.feature.search.model.AutofillSelectionOption import com.x8bit.bitwarden.ui.platform.feature.search.util.createMockDisplayItemForCipher import com.x8bit.bitwarden.ui.platform.feature.search.util.createMockDisplayItemForSend @@ -151,6 +152,16 @@ class SearchScreenTest : BitwardenComposeTest() { } } + @Test + fun `on ShowSnackbar event should display the snackbar`() { + val message = "message" + val data = BitwardenSnackbarData(message = message.asText()) + mutableEventFlow.tryEmit(SearchEvent.ShowSnackbar(data = data)) + composeTestRule + .onNodeWithText(text = message) + .assertIsDisplayed() + } + @Test fun `ShowShareSheet should call onNavigateBack`() { val sendUrl = "www.test.com" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt index 376c3fb661..96ce37ee5c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModelTest.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import app.cash.turbine.turbineScope import com.bitwarden.core.data.repository.model.DataState +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager import com.bitwarden.data.repository.model.Environment import com.bitwarden.network.model.PolicyTypeJson @@ -50,10 +51,12 @@ import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.VaultData +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.feature.search.util.createMockDisplayItemForCipher import com.x8bit.bitwarden.ui.platform.feature.search.util.filterAndOrganize import com.x8bit.bitwarden.ui.platform.feature.search.util.toViewState +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType @@ -69,6 +72,7 @@ import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkStatic import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach @@ -129,6 +133,13 @@ class SearchViewModelTest : BaseViewModelTest() { private val organizationEventManager = mockk { every { trackEvent(event = any()) } just runs } + private val mutableSnackbarDataFlow: MutableSharedFlow = + bufferedMutableSharedFlow() + private val snackbarRelayManager: SnackbarRelayManager = mockk { + every { + getSnackbarDataFlow(relay = any(), relays = anyVararg()) + } returns mutableSnackbarDataFlow + } @BeforeEach fun setup() { @@ -1554,6 +1565,16 @@ class SearchViewModelTest : BaseViewModelTest() { assertTrue(viewModel.stateFlow.value.isIconLoadingDisabled) } + @Test + fun `SnackbarDataReceive should update emit ShowSnackbar`() = runTest { + val viewModel = createViewModel() + val snackbarData = BitwardenSnackbarData(message = "Test".asText()) + viewModel.eventFlow.test { + mutableSnackbarDataFlow.tryEmit(snackbarData) + assertEquals(SearchEvent.ShowSnackbar(data = snackbarData), awaitItem()) + } + } + @Suppress("CyclomaticComplexMethod") private fun createViewModel( initialState: SearchState? = null, @@ -1599,6 +1620,7 @@ class SearchViewModelTest : BaseViewModelTest() { accessibilitySelectionManager = accessibilitySelectionManager, autofillSelectionManager = autofillSelectionManager, organizationEventManager = organizationEventManager, + snackbarRelayManager = snackbarRelayManager, ) /** diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt index f302985dae..82e4583cad 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendScreenTest.kt @@ -28,6 +28,7 @@ import com.bitwarden.ui.util.assertNoDialogExists import com.bitwarden.ui.util.isProgressBar import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendRoute import com.x8bit.bitwarden.ui.tools.feature.send.addedit.ModeType @@ -139,6 +140,16 @@ class SendScreenTest : BitwardenComposeTest() { } } + @Test + fun `on ShowSnackbar event should display the snackbar`() { + val message = "message" + val data = BitwardenSnackbarData(message = message.asText()) + mutableEventFlow.tryEmit(SendEvent.ShowSnackbar(data = data)) + composeTestRule + .onNodeWithText(text = message) + .assertIsDisplayed() + } + @Test fun `on ShowShareSheet should call shareText on IntentManager`() { val text = "sharable stuff" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt index d13221b58b..8fe2e1332f 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/SendViewModelTest.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.tools.feature.send import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.data.repository.model.DataState +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.repository.model.Environment import com.bitwarden.data.repository.util.baseWebSendUrl import com.bitwarden.network.model.PolicyTypeJson @@ -23,6 +24,8 @@ 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.components.snackbar.BitwardenSnackbarData +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType import com.x8bit.bitwarden.ui.tools.feature.send.util.toViewState import io.mockk.coEvery @@ -34,6 +37,7 @@ import io.mockk.runs import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.advanceTimeBy @@ -72,6 +76,13 @@ class SendViewModelTest : BaseViewModelTest() { private val networkConnectionManager: NetworkConnectionManager = mockk { every { isNetworkConnected } returns true } + private val mutableSnackbarDataFlow: MutableSharedFlow = + bufferedMutableSharedFlow() + private val snackbarRelayManager: SnackbarRelayManager = mockk { + every { + getSnackbarDataFlow(relay = any(), relays = anyVararg()) + } returns mutableSnackbarDataFlow + } @BeforeEach fun setup() { @@ -629,6 +640,16 @@ class SendViewModelTest : BaseViewModelTest() { } } + @Test + fun `SnackbarDataReceive should update emit ShowSnackbar`() = runTest { + val viewModel = createViewModel() + val snackbarData = BitwardenSnackbarData(message = "Test".asText()) + viewModel.eventFlow.test { + mutableSnackbarDataFlow.tryEmit(snackbarData) + assertEquals(SendEvent.ShowSnackbar(data = snackbarData), awaitItem()) + } + } + @Suppress("LongParameterList") private fun createViewModel( state: SendState? = null, @@ -649,6 +670,7 @@ class SendViewModelTest : BaseViewModelTest() { vaultRepo = vaultRepository, policyManager = policyManager, networkConnectionManager = networkConnectionManager, + snackbarRelayManager = snackbarRelayManager, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt index c51a073056..b63e539323 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendScreenTest.kt @@ -27,6 +27,7 @@ import com.bitwarden.ui.util.assertNoDialogExists import com.bitwarden.ui.util.isEditableText import com.bitwarden.ui.util.isProgressBar import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.manager.permissions.FakePermissionManager @@ -100,6 +101,16 @@ class AddEditSendScreenTest : BitwardenComposeTest() { } } + @Test + fun `on ShowSnackbar event should display the snackbar`() { + val message = "message" + val data = BitwardenSnackbarData(message = message.asText()) + mutableEventFlow.tryEmit(AddEditSendEvent.ShowSnackbar(data = data)) + composeTestRule + .onNodeWithText(text = message) + .assertIsDisplayed() + } + @Test fun `on ShowShareSheet should call shareText on IntentManager`() { val text = "sharable stuff" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt index 0fa01fcab5..2cbd77debd 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt @@ -24,7 +24,10 @@ import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult 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.UpdateSendResult +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +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.send.addedit.model.AddEditSendType import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toSendView import com.x8bit.bitwarden.ui.tools.feature.send.addedit.util.toViewState @@ -81,6 +84,9 @@ class AddEditSendViewModelTest : BaseViewModelTest() { private val networkConnectionManager = mockk { every { isNetworkConnected } returns true } + private val snackbarRelayManager: SnackbarRelayManager = mockk { + every { sendSnackbarData(data = any(), relay = any()) } just runs + } @BeforeEach fun setup() { @@ -594,7 +600,11 @@ class AddEditSendViewModelTest : BaseViewModelTest() { viewModel.eventFlow.test { viewModel.trySendAction(AddEditSendAction.RemovePasswordClick) assertEquals( - AddEditSendEvent.ShowToast(R.string.send_password_removed.asText()), + AddEditSendEvent.ShowSnackbar( + data = BitwardenSnackbarData( + message = R.string.send_password_removed.asText(), + ), + ), awaitItem(), ) } @@ -649,22 +659,28 @@ class AddEditSendViewModelTest : BaseViewModelTest() { } @Test - fun `DeleteClick vaultRepository deleteSend Success should show toast`() = runTest { - val sendId = "mockId-1" - coEvery { vaultRepository.deleteSend(sendId) } returns DeleteSendResult.Success - val viewModel = createViewModel( - state = DEFAULT_STATE.copy( + fun `DeleteClick vaultRepository deleteSend Success should emit NavigateUpToSearchOrRoot`() = + runTest { + val sendId = "mockId-1" + coEvery { vaultRepository.deleteSend(sendId) } returns DeleteSendResult.Success + val viewModel = createViewModel( + state = DEFAULT_STATE.copy( + addEditSendType = AddEditSendType.EditItem(sendItemId = sendId), + ), addEditSendType = AddEditSendType.EditItem(sendItemId = sendId), - ), - addEditSendType = AddEditSendType.EditItem(sendItemId = sendId), - ) + ) - viewModel.eventFlow.test { - viewModel.trySendAction(AddEditSendAction.DeleteClick) - assertEquals(AddEditSendEvent.NavigateUpToSearchOrRoot, awaitItem()) - assertEquals(AddEditSendEvent.ShowToast(R.string.send_deleted.asText()), awaitItem()) + viewModel.eventFlow.test { + viewModel.trySendAction(AddEditSendAction.DeleteClick) + assertEquals(AddEditSendEvent.NavigateUpToSearchOrRoot, awaitItem()) + } + verify(exactly = 1) { + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(message = R.string.send_deleted.asText()), + relay = SnackbarRelay.SEND_DELETED, + ) + } } - } @Test fun `ShareLinkClick with nonnull sendUrl should launch share sheet`() = runTest { @@ -959,6 +975,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() { vaultRepo = vaultRepository, policyManager = policyManager, networkConnectionManager = networkConnectionManager, + snackbarRelayManager = snackbarRelayManager, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt index 46165c5b1d..b63999cde9 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt @@ -1,6 +1,5 @@ package com.x8bit.bitwarden.ui.tools.feature.send.viewsend -import android.widget.Toast import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed @@ -16,6 +15,7 @@ import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.assertNoDialogExists import com.bitwarden.ui.util.isProgressBar import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.tools.feature.send.addedit.AddEditSendRoute import com.x8bit.bitwarden.ui.tools.feature.send.addedit.ModeType @@ -23,13 +23,10 @@ import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType import io.mockk.every import io.mockk.just import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.runs -import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -52,7 +49,6 @@ class ViewSendScreenTest : BitwardenComposeTest() { @Before fun setup() { - mockkStatic(Toast::class) setContent( intentManager = intentManager, ) { @@ -64,11 +60,6 @@ class ViewSendScreenTest : BitwardenComposeTest() { } } - @After - fun tearDown() { - unmockkStatic(Toast::class) - } - @Test fun `on NavigateBack event should call onNavigateBack`() { mutableEventFlow.tryEmit(ViewSendEvent.NavigateBack) @@ -106,16 +97,13 @@ class ViewSendScreenTest : BitwardenComposeTest() { } @Test - fun `on ShowToast event should call onNavigateToEdit`() { + fun `on ShowSnackbar event should display the snackbar`() { val message = "message" - val toast = mockk { - every { show() } just runs - } - every { Toast.makeText(any(), message, Toast.LENGTH_SHORT) } returns toast - mutableEventFlow.tryEmit(ViewSendEvent.ShowToast(message = message.asText())) - verify(exactly = 1) { - toast.show() - } + val data = BitwardenSnackbarData(message = message.asText()) + mutableEventFlow.tryEmit(ViewSendEvent.ShowSnackbar(data = data)) + composeTestRule + .onNodeWithText(text = message) + .assertIsDisplayed() } @Test diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt index 6bf5ae8ae9..cc98019d22 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt @@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.tools.feature.send.viewsend import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.data.repository.model.DataState +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.data.repository.model.Environment import com.bitwarden.send.SendView import com.bitwarden.ui.platform.base.BaseViewModelTest @@ -14,6 +15,9 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData +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.send.model.SendItemType import com.x8bit.bitwarden.ui.tools.feature.send.viewsend.util.toViewSendViewStateContent import io.mockk.coEvery @@ -24,6 +28,7 @@ import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkStatic import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach @@ -46,6 +51,14 @@ class ViewSendViewModelTest : BaseViewModelTest() { private val environmentRepository = mockk { every { environment } returns Environment.Us } + private val mutableSnackbarDataFlow: MutableSharedFlow = + bufferedMutableSharedFlow() + private val snackbarRelayManager: SnackbarRelayManager = mockk { + every { sendSnackbarData(data = any(), relay = any()) } just runs + every { + getSnackbarDataFlow(relay = any(), relays = anyVararg()) + } returns mutableSnackbarDataFlow + } @BeforeEach fun setup() { @@ -149,7 +162,7 @@ class ViewSendViewModelTest : BaseViewModelTest() { } @Test - fun `on DeleteClick with success should display toast`() = runTest { + fun `on DeleteClick with success should navigate back`() = runTest { val initialState = DEFAULT_STATE.copy(viewState = DEFAULT_CONTENT_VIEW_STATE) val sendView = createMockSendView(number = 1) every { @@ -175,15 +188,17 @@ class ViewSendViewModelTest : BaseViewModelTest() { initialState.copy(dialogState = null), stateFlow.awaitItem(), ) - assertEquals( - ViewSendEvent.ShowToast(message = R.string.send_deleted.asText()), - eventFLow.awaitItem(), - ) assertEquals( ViewSendEvent.NavigateBack, eventFLow.awaitItem(), ) } + verify(exactly = 1) { + snackbarRelayManager.sendSnackbarData( + data = BitwardenSnackbarData(message = R.string.send_deleted.asText()), + relay = SnackbarRelay.SEND_DELETED, + ) + } } @Test @@ -390,6 +405,16 @@ class ViewSendViewModelTest : BaseViewModelTest() { } } + @Test + fun `SnackbarDataReceive should update emit ShowSnackbar`() = runTest { + val viewModel = createViewModel() + val snackbarData = BitwardenSnackbarData(message = "Test".asText()) + viewModel.eventFlow.test { + mutableSnackbarDataFlow.tryEmit(snackbarData) + assertEquals(ViewSendEvent.ShowSnackbar(data = snackbarData), awaitItem()) + } + } + private fun createViewModel( state: ViewSendState? = null, ): ViewSendViewModel = ViewSendViewModel( @@ -397,8 +422,8 @@ class ViewSendViewModelTest : BaseViewModelTest() { clock = FIXED_CLOCK, vaultRepository = vaultRepository, environmentRepository = environmentRepository, - savedStateHandle = SavedStateHandle().apply - { + snackbarRelayManager = snackbarRelayManager, + savedStateHandle = SavedStateHandle().apply { set(key = "state", value = state) every { toViewSendArgs() } returns ViewSendArgs( sendId = (state ?: DEFAULT_STATE).sendId, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 8a3b52dba3..874c3504ae 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -37,6 +37,7 @@ import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialR import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.manager.exit.ExitManager @@ -368,6 +369,16 @@ class VaultItemListingScreenTest : BitwardenComposeTest() { } } + @Test + fun `on ShowSnackbar event should display the snackbar`() { + val message = "message" + val data = BitwardenSnackbarData(message = message.asText()) + mutableEventFlow.tryEmit(VaultItemListingEvent.ShowSnackbar(data = data)) + composeTestRule + .onNodeWithText(text = message) + .assertIsDisplayed() + } + @Test fun `back gesture should send BackClick action`() { backDispatcher?.onBackPressed() diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 586a1969c9..6b90d656e1 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -15,6 +15,7 @@ import androidx.credentials.provider.PublicKeyCredentialEntry import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.data.repository.model.DataState +import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.core.data.util.asFailure import com.bitwarden.core.data.util.asSuccess import com.bitwarden.data.datasource.disk.base.FakeDispatcherManager @@ -86,7 +87,9 @@ import com.x8bit.bitwarden.ui.credentials.manager.model.GetCredentialsResult import com.x8bit.bitwarden.ui.credentials.manager.model.RegisterFido2CredentialResult import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.components.model.IconData +import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType +import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager import com.x8bit.bitwarden.ui.tools.feature.send.model.SendItemType import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType import com.x8bit.bitwarden.ui.vault.feature.addedit.util.createMockPasskeyAttestationOptions @@ -112,6 +115,7 @@ import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.advanceTimeBy @@ -244,6 +248,13 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { every { callingRequest } returns mockk(relaxed = true) every { callingAppInfo } returns mockCallingAppInfo } + private val mutableSnackbarDataFlow: MutableSharedFlow = + bufferedMutableSharedFlow() + private val snackbarRelayManager: SnackbarRelayManager = mockk { + every { + getSnackbarDataFlow(relay = any(), relays = anyVararg()) + } returns mutableSnackbarDataFlow + } @BeforeEach fun setUp() { @@ -4949,6 +4960,16 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Test + fun `SnackbarDataReceive should update emit ShowSnackbar`() = runTest { + val viewModel = createVaultItemListingViewModel() + val snackbarData = BitwardenSnackbarData(message = "Test".asText()) + viewModel.eventFlow.test { + mutableSnackbarDataFlow.tryEmit(snackbarData) + assertEquals(VaultItemListingEvent.ShowSnackbar(data = snackbarData), awaitItem()) + } + } + private fun createSavedStateHandleWithVaultItemListingType( vaultItemListingType: VaultItemListingType, ): SavedStateHandle = SavedStateHandle().apply { @@ -4985,6 +5006,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { originManager = originManager, networkConnectionManager = networkConnectionManager, privilegedAppRepository = privilegedAppRepository, + snackbarRelayManager = snackbarRelayManager, ) @Suppress("MaxLineLength")