PM-11188 show snackbar after import success. PM-13943 add relay for snackbar events across screen contexts. (#4152)

This commit is contained in:
Dave Severns 2024-10-29 14:23:00 -04:00 committed by GitHub
parent a1108889cb
commit 8b16135955
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 505 additions and 59 deletions

View file

@ -19,6 +19,7 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.other.navigateToOther
import com.x8bit.bitwarden.ui.platform.feature.settings.other.otherDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.vault.navigateToVaultSettings
import com.x8bit.bitwarden.ui.platform.feature.settings.vault.vaultSettingsDestination
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
const val SETTINGS_GRAPH_ROUTE: String = "settings_graph"
private const val SETTINGS_ROUTE: String = "settings"
@ -35,7 +36,7 @@ fun NavGraphBuilder.settingsGraph(
onNavigateToPendingRequests: () -> Unit,
onNavigateToSetupUnlockScreen: () -> Unit,
onNavigateToSetupAutoFillScreen: () -> Unit,
onNavigateToImportLogins: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
) {
navigation(
startDestination = SETTINGS_ROUTE,

View file

@ -4,6 +4,7 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithPushTransitions
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
private const val VAULT_SETTINGS_ROUTE = "vault_settings"
@ -14,7 +15,7 @@ fun NavGraphBuilder.vaultSettingsDestination(
onNavigateBack: () -> Unit,
onNavigateToExportVault: () -> Unit,
onNavigateToFolders: () -> Unit,
onNavigateToImportLogins: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
) {
composableWithPushTransitions(
route = VAULT_SETTINGS_ROUTE,

View file

@ -33,9 +33,12 @@ import com.x8bit.bitwarden.ui.platform.components.card.actionCardExitAnimation
import com.x8bit.bitwarden.ui.platform.components.row.BitwardenExternalLinkRow
import com.x8bit.bitwarden.ui.platform.components.row.BitwardenTextRow
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.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
/**
* Displays the vault settings screen.
@ -47,12 +50,13 @@ fun VaultSettingsScreen(
onNavigateBack: () -> Unit,
onNavigateToExportVault: () -> Unit,
onNavigateToFolders: () -> Unit,
onNavigateToImportLogins: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
viewModel: VaultSettingsViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val snackbarHostState = rememberBitwardenSnackbarHostState()
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
@ -65,11 +69,13 @@ fun VaultSettingsScreen(
is VaultSettingsEvent.NavigateToImportVault -> {
if (state.isNewImportLoginsFlowEnabled) {
onNavigateToImportLogins()
onNavigateToImportLogins(SnackbarRelay.VAULT_SETTINGS_RELAY)
} else {
intentManager.launchUri(event.url.toUri())
}
}
is VaultSettingsEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data)
}
}
@ -89,6 +95,11 @@ fun VaultSettingsScreen(
},
)
},
snackbarHost = {
BitwardenSnackbarHost(
bitwardenHostState = snackbarHostState,
)
},
) { innerPadding ->
Column(
Modifier

View file

@ -7,6 +7,10 @@ import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.util.toBaseWebVaultImportUrl
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@ -22,6 +26,7 @@ import javax.inject.Inject
class VaultSettingsViewModel @Inject constructor(
environmentRepository: EnvironmentRepository,
featureFlagManager: FeatureFlagManager,
snackbarRelayManager: SnackbarRelayManager,
private val firstTimeActionManager: FirstTimeActionManager,
) : BaseViewModel<VaultSettingsState, VaultSettingsEvent, VaultSettingsAction>(
initialState = run {
@ -53,6 +58,14 @@ class VaultSettingsViewModel @Inject constructor(
}
.onEach(::sendAction)
.launchIn(viewModelScope)
snackbarRelayManager
.getSnackbarDataFlow(SnackbarRelay.VAULT_SETTINGS_RELAY)
.map {
VaultSettingsAction.Internal.SnackbarDataReceived(it)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultSettingsAction): Unit = when (action) {
@ -74,9 +87,19 @@ class VaultSettingsViewModel @Inject constructor(
is VaultSettingsAction.Internal.UserFirstTimeStateChanged -> {
handleUserFirstTimeStateChanged(action)
}
is VaultSettingsAction.Internal.SnackbarDataReceived -> {
handleSnackbarDataReceived(action)
}
}
}
private fun handleSnackbarDataReceived(
action: VaultSettingsAction.Internal.SnackbarDataReceived,
) {
sendEvent(VaultSettingsEvent.ShowSnackbar(action.data))
}
private fun handleImportLoginsCardDismissClicked() {
if (!state.shouldShowImportCard) return
firstTimeActionManager.storeShowImportLoginsSettingsBadge(showBadge = false)
@ -166,6 +189,11 @@ sealed class VaultSettingsEvent {
data class ShowToast(
val message: String,
) : VaultSettingsEvent()
/**
* Shows a snackbar with the given [data].
*/
data class ShowSnackbar(val data: BitwardenSnackbarData) : VaultSettingsEvent(), BackgroundEvent
}
/**
@ -220,5 +248,10 @@ sealed class VaultSettingsAction {
data class UserFirstTimeStateChanged(
val showImportLoginsCard: Boolean,
) : Internal()
/**
* Indicates that the snackbar data has been received.
*/
data class SnackbarDataReceived(val data: BitwardenSnackbarData) : Internal()
}
}

View file

@ -106,7 +106,9 @@ fun NavGraphBuilder.vaultUnlockedGraph(
},
onNavigateToSetupUnlockScreen = { navController.navigateToSetupUnlockScreen() },
onNavigateToSetupAutoFillScreen = { navController.navigateToSetupAutoFillScreen() },
onNavigateToImportLogins = { navController.navigateToImportLoginsScreen() },
onNavigateToImportLogins = {
navController.navigateToImportLoginsScreen(snackbarRelay = it)
},
)
deleteAccountDestination(
onNavigateBack = { navController.popBackStack() },

View file

@ -5,6 +5,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithStayTransitions
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType
/**
@ -38,7 +39,7 @@ fun NavGraphBuilder.vaultUnlockedNavBarDestination(
onNavigateToPasswordHistory: () -> Unit,
onNavigateToSetupUnlockScreen: () -> Unit,
onNavigateToSetupAutoFillScreen: () -> Unit,
onNavigateToImportLogins: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
) {
composableWithStayTransitions(
route = VAULT_UNLOCKED_NAV_BAR_ROUTE,

View file

@ -44,6 +44,7 @@ import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettingsGraph
import com.x8bit.bitwarden.ui.platform.feature.settings.settingsGraph
import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.model.VaultUnlockedNavBarTab
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.theme.RootTransitionProviders
import com.x8bit.bitwarden.ui.tools.feature.generator.generatorGraph
@ -77,7 +78,7 @@ fun VaultUnlockedNavBarScreen(
onNavigateToPasswordHistory: () -> Unit,
onNavigateToSetupUnlockScreen: () -> Unit,
onNavigateToSetupAutoFillScreen: () -> Unit,
onNavigateToImportLogins: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@ -171,7 +172,7 @@ private fun VaultUnlockedNavBarScaffold(
navigateToPasswordHistory: () -> Unit,
onNavigateToSetupUnlockScreen: () -> Unit,
onNavigateToSetupAutoFillScreen: () -> Unit,
onNavigateToImportLogins: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
) {
var shouldDimNavBar by remember { mutableStateOf(false) }

View file

@ -5,6 +5,8 @@ import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManagerImpl
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManager
import com.x8bit.bitwarden.ui.platform.manager.resource.ResourceManagerImpl
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManagerImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -32,4 +34,8 @@ class PlatformUiManagerModule {
@Singleton
fun provideResourceManager(@ApplicationContext context: Context): ResourceManager =
ResourceManagerImpl(context = context)
@Provides
@Singleton
fun provideSnackbarRelayManager(): SnackbarRelayManager = SnackbarRelayManagerImpl()
}

View file

@ -0,0 +1,12 @@
package com.x8bit.bitwarden.ui.platform.manager.snackbar
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
/**
* Models a relay key to be mapped to an instance of [BitwardenSnackbarData] being sent
* between producers and consumers of the data.
*/
enum class SnackbarRelay {
VAULT_SETTINGS_RELAY,
MY_VAULT_RELAY,
}

View file

@ -0,0 +1,22 @@
package com.x8bit.bitwarden.ui.platform.manager.snackbar
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import kotlinx.coroutines.flow.Flow
/**
* Manager responsible for relaying snackbar data between a producer and consumer who may
* communicate with reference to a specific [SnackbarRelay].
*/
interface SnackbarRelayManager {
/**
* Called from a producer to send snackbar data to a consumer, the producer must
* specify the [relay] to send the data to.
*/
fun sendSnackbarData(data: BitwardenSnackbarData, relay: SnackbarRelay)
/**
* Called from a consumer to receive snackbar data from a producer, the consumer must specify
* the [relay] to receive the data from.
*/
fun getSnackbarDataFlow(relay: SnackbarRelay): Flow<BitwardenSnackbarData>
}

View file

@ -0,0 +1,35 @@
package com.x8bit.bitwarden.ui.platform.manager.snackbar
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.onCompletion
/**
* The default implementation of the [SnackbarRelayManager] interface.
*/
class SnackbarRelayManagerImpl : SnackbarRelayManager {
private val mutableSnackbarRelayMap =
mutableMapOf<SnackbarRelay, MutableSharedFlow<BitwardenSnackbarData?>>()
override fun sendSnackbarData(data: BitwardenSnackbarData, relay: SnackbarRelay) {
getSnackbarDataFlowInternal(relay).tryEmit(data)
}
override fun getSnackbarDataFlow(relay: SnackbarRelay): Flow<BitwardenSnackbarData> =
getSnackbarDataFlowInternal(relay)
.onCompletion {
// when the subscription is ended, remove the relay from the map.
mutableSnackbarRelayMap.remove(relay)
}
.filterNotNull()
private fun getSnackbarDataFlowInternal(
relay: SnackbarRelay,
): MutableSharedFlow<BitwardenSnackbarData?> =
mutableSnackbarRelayMap.getOrPut(relay) {
bufferedMutableSharedFlow(replay = 1)
}
}

View file

@ -1,17 +1,39 @@
package com.x8bit.bitwarden.ui.vault.feature.importlogins
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
private const val IMPORT_LOGINS_ROUTE = "import-logins"
private const val IMPORT_LOGINS_PREFIX = "import-logins"
private const val IMPORT_LOGINS_NAV_ARG = "snackbarRelay"
private const val IMPORT_LOGINS_ROUTE = "$IMPORT_LOGINS_PREFIX/{$IMPORT_LOGINS_NAV_ARG}"
/**
* Arguments for the [ImportLoginsScreen] using [SavedStateHandle].
*/
@OmitFromCoverage
data class ImportLoginsArgs(val snackBarRelay: SnackbarRelay) {
constructor(savedStateHandle: SavedStateHandle) : this(
snackBarRelay = SnackbarRelay.valueOf(
requireNotNull(savedStateHandle[IMPORT_LOGINS_NAV_ARG]),
),
)
}
/**
* Helper function to navigate to the import logins screen.
*/
fun NavController.navigateToImportLoginsScreen(navOptions: NavOptions? = null) {
navigate(route = IMPORT_LOGINS_ROUTE, navOptions = navOptions)
fun NavController.navigateToImportLoginsScreen(
snackbarRelay: SnackbarRelay,
navOptions: NavOptions? = null,
) {
navigate(route = "$IMPORT_LOGINS_PREFIX/$snackbarRelay", navOptions = navOptions)
}
/**
@ -22,6 +44,12 @@ fun NavGraphBuilder.importLoginsScreenDestination(
) {
composableWithSlideTransitions(
route = IMPORT_LOGINS_ROUTE,
arguments = listOf(
navArgument(IMPORT_LOGINS_NAV_ARG) {
type = NavType.StringType
nullable = false
},
),
) {
ImportLoginsScreen(
onNavigateBack = onNavigateBack,

View file

@ -58,6 +58,7 @@ import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.importlogins.components.ImportLoginsInstructionStep
import com.x8bit.bitwarden.ui.vault.feature.importlogins.handlers.ImportLoginHandler
@ -627,6 +628,7 @@ private class ImportLoginsDialogContentPreviewProvider :
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = "vault.bitwarden.com",
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
ImportLoginsState(
dialogState = ImportLoginsState.DialogState.ImportLater,
@ -634,6 +636,7 @@ private class ImportLoginsDialogContentPreviewProvider :
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = "vault.bitwarden.com",
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
)
}

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.vault.feature.importlogins
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
@ -10,6 +11,9 @@ import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelayManager
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -21,9 +25,11 @@ import javax.inject.Inject
@Suppress("TooManyFunctions")
@HiltViewModel
class ImportLoginsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val vaultRepository: VaultRepository,
private val firstTimeActionManager: FirstTimeActionManager,
private val environmentRepository: EnvironmentRepository,
private val snackbarRelayManager: SnackbarRelayManager,
) :
BaseViewModel<ImportLoginsState, ImportLoginsEvent, ImportLoginsAction>(
initialState = run {
@ -36,6 +42,7 @@ class ImportLoginsViewModel @Inject constructor(
showBottomSheet = false,
// attempt to trim the scheme of the vault url
currentWebVaultUrl = vaultUrl.toUriOrNull()?.host ?: vaultUrl,
snackbarRelay = ImportLoginsArgs(savedStateHandle).snackBarRelay,
)
},
) {
@ -70,6 +77,13 @@ class ImportLoginsViewModel @Inject constructor(
showBottomSheet = false,
)
}
// instead of doing inline, this approach to avoid "MaxLineLength" suppression.
val snackbarData = BitwardenSnackbarData(
messageHeader = R.string.logins_imported.asText(),
message = R.string.remember_to_delete_your_imported_password_file_from_your_computer
.asText(),
)
snackbarRelayManager.sendSnackbarData(data = snackbarData, relay = state.snackbarRelay)
sendEvent(ImportLoginsEvent.NavigateBack)
}
@ -213,6 +227,7 @@ data class ImportLoginsState(
val isVaultSyncing: Boolean,
val showBottomSheet: Boolean,
val currentWebVaultUrl: String,
val snackbarRelay: SnackbarRelay,
) {
/**
* Dialog states for the [ImportLoginsViewModel].

View file

@ -5,6 +5,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListing
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.vaultItemListingDestination
import com.x8bit.bitwarden.ui.vault.feature.verificationcode.navigateToVerificationCodeScreen
@ -24,7 +25,7 @@ fun NavGraphBuilder.vaultGraph(
onNavigateToVaultEditItemScreen: (vaultItemId: String) -> Unit,
onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit,
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
onNavigateToImportLogins: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
) {
navigation(
route = VAULT_GRAPH_ROUTE,

View file

@ -5,6 +5,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithRootPushTransitions
import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
const val VAULT_ROUTE: String = "vault"
@ -21,7 +22,7 @@ fun NavGraphBuilder.vaultDestination(
onNavigateToVaultItemListingScreen: (vaultItemType: VaultItemListingType) -> Unit,
onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit,
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
onNavigateToImportLogins: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
) {
composableWithRootPushTransitions(
route = VAULT_ROUTE,

View file

@ -55,6 +55,9 @@ import com.x8bit.bitwarden.ui.platform.components.model.TopAppBarDividerStyle
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenPullToRefreshState
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.scaffold.rememberBitwardenPullToRefreshState
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.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalExitManager
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
@ -63,6 +66,7 @@ import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType
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.PermissionsManager
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
@ -83,7 +87,7 @@ fun VaultScreen(
onNavigateToVaultItemListingScreen: (vaultItemType: VaultItemListingType) -> Unit,
onNavigateToSearchVault: (searchType: SearchType.Vault) -> Unit,
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
onNavigateToImportLogins: () -> Unit,
onNavigateToImportLogins: (SnackbarRelay) -> Unit,
exitManager: ExitManager = LocalExitManager.current,
intentManager: IntentManager = LocalIntentManager.current,
permissionsManager: PermissionsManager = LocalPermissionsManager.current,
@ -97,6 +101,7 @@ fun VaultScreen(
{ viewModel.trySendAction(VaultAction.RefreshPull) }
},
)
val snackbarHostState = rememberBitwardenSnackbarHostState()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
VaultEvent.NavigateToAddItemScreen -> onNavigateToVaultAddItemScreen()
@ -124,7 +129,11 @@ fun VaultScreen(
.show()
}
VaultEvent.NavigateToImportLogins -> onNavigateToImportLogins()
VaultEvent.NavigateToImportLogins -> {
onNavigateToImportLogins(SnackbarRelay.MY_VAULT_RELAY)
}
is VaultEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data)
}
}
val vaultHandlers = remember(viewModel) { VaultHandlers.create(viewModel) }
@ -137,6 +146,7 @@ fun VaultScreen(
pullToRefreshState = pullToRefreshState,
vaultHandlers = vaultHandlers,
onDimBottomNavBarRequest = onDimBottomNavBarRequest,
snackbarHostState = snackbarHostState,
)
}
@ -173,6 +183,7 @@ private fun VaultScreenScaffold(
pullToRefreshState: BitwardenPullToRefreshState,
vaultHandlers: VaultHandlers,
onDimBottomNavBarRequest: (shouldDim: Boolean) -> Unit,
snackbarHostState: BitwardenSnackbarHostState,
) {
var accountMenuVisible by rememberSaveable {
mutableStateOf(false)
@ -261,6 +272,11 @@ private fun VaultScreenScaffold(
},
)
},
snackbarHost = {
BitwardenSnackbarHost(
bitwardenHostState = snackbarHostState,
)
},
floatingActionButton = {
AnimatedVisibility(
visible = state.viewState.hasFab && !accountMenuVisible,

View file

@ -26,6 +26,7 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
@ -33,6 +34,9 @@ import com.x8bit.bitwarden.ui.platform.base.util.hexToColor
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.model.IconRes
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.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
@ -71,8 +75,9 @@ class VaultViewModel @Inject constructor(
private val policyManager: PolicyManager,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
private val featureFlagManager: FeatureFlagManager,
private val firstTimeActionManager: FirstTimeActionManager,
featureFlagManager: FeatureFlagManager,
snackbarRelayManager: SnackbarRelayManager,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
initialState = run {
val userState = requireNotNull(authRepository.userStateFlow.value)
@ -143,6 +148,14 @@ class VaultViewModel @Inject constructor(
}
.onEach(::sendAction)
.launchIn(viewModelScope)
snackbarRelayManager
.getSnackbarDataFlow(SnackbarRelay.MY_VAULT_RELAY)
.map {
VaultAction.Internal.SnackbarDataReceive(it)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: VaultAction) {
@ -458,9 +471,15 @@ class VaultViewModel @Inject constructor(
is VaultAction.Internal.ValidatePasswordResultReceive -> {
handleValidatePasswordResultReceive(action)
}
is VaultAction.Internal.SnackbarDataReceive -> handleSnackbarDataReceive(action)
}
}
private fun handleSnackbarDataReceive(action: VaultAction.Internal.SnackbarDataReceive) {
sendEvent(VaultEvent.ShowSnackbar(action.data))
}
private fun handleGenerateTotpResultReceive(
action: VaultAction.Internal.GenerateTotpResultReceive,
) {
@ -1011,6 +1030,11 @@ sealed class VaultEvent {
* Show a toast with the given [message].
*/
data class ShowToast(val message: Text) : VaultEvent()
/**
* Show a snackbar with the given [data].
*/
data class ShowSnackbar(val data: BitwardenSnackbarData) : VaultEvent(), BackgroundEvent
}
/**
@ -1217,6 +1241,13 @@ sealed class VaultAction {
val overflowAction: ListingItemOverflowAction.VaultAction,
val result: ValidatePasswordResult,
) : Internal()
/**
* Indicates that a snackbar data was received.
*/
data class SnackbarDataReceive(
val data: BitwardenSnackbarData,
) : Internal()
}
}

View file

@ -1073,4 +1073,6 @@ Do you want to switch to this account?</string>
<string name="bitwarden_tools">Bitwarden Tools</string>
<string name="got_it">Got it</string>
<string name="no_logins_were_imported">No logins were imported</string>
<string name="logins_imported">Logins imported</string>
<string name="remember_to_delete_your_imported_password_file_from_your_computer">Remember to delete your imported password file from your computer</string>
</resources>

View file

@ -11,7 +11,10 @@ import androidx.compose.ui.test.performScrollTo
import androidx.core.net.toUri
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
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 io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -19,6 +22,7 @@ import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
@ -56,7 +60,10 @@ class VaultSettingsScreenTest : BaseComposeTest() {
onNavigateToExportVault = { onNavigateToExportVaultCalled = true },
onNavigateToFolders = { onNavigateToFoldersCalled = true },
intentManager = intentManager,
onNavigateToImportLogins = { onNavigateToImportLoginsCalled = true },
onNavigateToImportLogins = {
onNavigateToImportLoginsCalled = true
assertEquals(SnackbarRelay.VAULT_SETTINGS_RELAY, it)
},
)
}
}
@ -213,4 +220,11 @@ class VaultSettingsScreenTest : BaseComposeTest() {
viewModel.trySendAction(VaultSettingsAction.ImportLoginsCardCtaClick)
}
}
@Test
fun `when ShowSnackbar is sent snackbar should be displayed`() {
val data = BitwardenSnackbarData("message".asText())
mutableEventFlow.tryEmit(VaultSettingsEvent.ShowSnackbar(data))
composeTestRule.onNodeWithText("message").assertIsDisplayed()
}
}

View file

@ -7,6 +7,10 @@ import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
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.SnackbarRelayManagerImpl
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
@ -34,6 +38,8 @@ class VaultSettingsViewModelTest : BaseViewModelTest() {
every { storeShowImportLoginsSettingsBadge(any()) } just runs
}
private val snackbarRelayManager = SnackbarRelayManagerImpl()
@Test
fun `BackClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
@ -136,10 +142,24 @@ class VaultSettingsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `SnackbarDataReceived action should send snackbar event`() = runTest {
val viewModel = createViewModel()
val expectedSnackbarData = BitwardenSnackbarData(message = "test message".asText())
viewModel.eventFlow.test {
snackbarRelayManager.sendSnackbarData(
data = expectedSnackbarData,
relay = SnackbarRelay.VAULT_SETTINGS_RELAY,
)
assertEquals(VaultSettingsEvent.ShowSnackbar(expectedSnackbarData), awaitItem())
}
}
private fun createViewModel(): VaultSettingsViewModel = VaultSettingsViewModel(
environmentRepository = environmentRepository,
featureFlagManager = featureFlagManager,
firstTimeActionManager = firstTimeActionManager,
snackbarRelayManager = snackbarRelayManager,
)
}

View file

@ -0,0 +1,105 @@
package com.x8bit.bitwarden.ui.platform.manager.snackbar
import app.cash.turbine.test
import app.cash.turbine.turbineScope
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarData
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
class SnackbarRelayManagerTest {
@Test
fun `Relay is completed successfully when consumer registers first and event is sent`() =
runTest {
val relayManager = SnackbarRelayManagerImpl()
val relay = SnackbarRelay.MY_VAULT_RELAY
val expectedData = BitwardenSnackbarData(message = "Test message".asText())
val consumer = relayManager.getSnackbarDataFlow(relay)
consumer.test {
relayManager.sendSnackbarData(data = expectedData, relay = relay)
assertEquals(
expectedData,
awaitItem(),
)
}
}
@Test
fun `Relay is completed successfully when consumer registers second and event is sent`() =
runTest {
val relayManager = SnackbarRelayManagerImpl()
val relay = SnackbarRelay.MY_VAULT_RELAY
val expectedData = BitwardenSnackbarData(message = "Test message".asText())
// producer code
relayManager.sendSnackbarData(data = expectedData, relay = relay)
relayManager.getSnackbarDataFlow(relay).test {
assertEquals(
expectedData,
awaitItem(),
)
}
}
@Test
fun `When relay is specified by producer only send data to that relay`() =
runTest {
val relayManager = SnackbarRelayManagerImpl()
val relay1 = SnackbarRelay.MY_VAULT_RELAY
val relay2 = SnackbarRelay.VAULT_SETTINGS_RELAY
val expectedData = BitwardenSnackbarData(message = "Test message".asText())
turbineScope {
val consumer1 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope)
val consumer2 = relayManager.getSnackbarDataFlow(relay2).testIn(backgroundScope)
relayManager.sendSnackbarData(data = expectedData, relay = relay1)
consumer2.expectNoEvents()
assertEquals(
expectedData,
consumer1.awaitItem(),
)
}
}
@Test
fun `When multiple consumers are registered to the same relay, send data to all consumers`() =
runTest {
val relayManager = SnackbarRelayManagerImpl()
val relay1 = SnackbarRelay.MY_VAULT_RELAY
val expectedData = BitwardenSnackbarData(message = "Test message".asText())
turbineScope {
val consumer1 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope)
relayManager.sendSnackbarData(data = expectedData, relay = relay1)
assertEquals(
expectedData,
consumer1.awaitItem(),
)
val consumer2 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope)
assertEquals(
expectedData,
consumer2.awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `When multiple consumers are register to the same relay, and one is completed before the other the second consumer registers should not receive any emissions`() =
runTest {
val relayManager = SnackbarRelayManagerImpl()
val relay1 = SnackbarRelay.MY_VAULT_RELAY
val expectedData = BitwardenSnackbarData(message = "Test message".asText())
turbineScope {
val consumer1 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope)
relayManager.sendSnackbarData(data = expectedData, relay = relay1)
assertEquals(
expectedData,
consumer1.awaitItem(),
)
consumer1.cancel()
val consumer2 = relayManager.getSnackbarDataFlow(relay1).testIn(backgroundScope)
consumer2.expectNoEvents()
}
}
}

View file

@ -23,6 +23,7 @@ import com.x8bit.bitwarden.data.util.advanceTimeByAndRunCurrent
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import io.mockk.every
import io.mockk.just
@ -491,4 +492,5 @@ private val DEFAULT_STATE = ImportLoginsState(
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = "vault.bitwarden.com",
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
)

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.vault.feature.importlogins
import android.net.Uri
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
@ -10,15 +11,18 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.SyncVaultDataResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
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.SnackbarRelayManagerImpl
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
@ -55,6 +59,10 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
unmockkStatic(Uri::parse)
}
private val snackbarRelayManager: SnackbarRelayManagerImpl = mockk() {
coEvery { sendSnackbarData(any(), any()) } just runs
}
@Test
fun `initial state is correct`() {
val viewModel = createViewModel()
@ -75,6 +83,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
viewModel.stateFlow.value,
)
@ -91,6 +100,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
viewModel.stateFlow.value,
)
@ -112,6 +122,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
awaitItem(),
)
@ -123,6 +134,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
awaitItem(),
)
@ -146,6 +158,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
stateFlow.awaitItem(),
)
@ -157,6 +170,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
stateFlow.awaitItem(),
)
@ -187,6 +201,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
awaitItem(),
)
@ -198,6 +213,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
awaitItem(),
)
@ -239,6 +255,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
viewModel.stateFlow.value,
)
@ -255,6 +272,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
viewModel.stateFlow.value,
)
@ -271,6 +289,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
viewModel.stateFlow.value,
)
@ -291,6 +310,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
viewModel.stateFlow.value,
)
@ -312,6 +332,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = true,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
awaitItem(),
)
@ -340,6 +361,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = true,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
awaitItem(),
)
@ -360,6 +382,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = true,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
viewModel.stateFlow.value,
)
@ -390,6 +413,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = true,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
awaitItem(),
)
@ -400,6 +424,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
awaitItem(),
)
@ -418,6 +443,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
viewModel.stateFlow.value,
)
@ -441,6 +467,7 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
viewModel.stateFlow.value,
)
@ -451,52 +478,76 @@ class ImportLoginsViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `SuccessfulSyncAcknowledged should hide bottom sheet and send NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope = backgroundScope) { stateFlow, eventFlow ->
// Initial state
assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress)
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.InitialContent,
isVaultSyncing = true,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
),
stateFlow.awaitItem(),
fun `SuccessfulSyncAcknowledged should hide bottom sheet and send NavigateBack event and send Snackbar data through snackbar manager`() =
runTest {
val viewModel = createViewModel()
viewModel.stateEventFlow(backgroundScope = backgroundScope) { stateFlow, eventFlow ->
// Initial state
assertEquals(DEFAULT_STATE, stateFlow.awaitItem())
viewModel.trySendAction(ImportLoginsAction.MoveToSyncInProgress)
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.InitialContent,
isVaultSyncing = true,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
stateFlow.awaitItem(),
)
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.InitialContent,
isVaultSyncing = false,
showBottomSheet = true,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
stateFlow.awaitItem(),
)
viewModel.trySendAction(ImportLoginsAction.SuccessfulSyncAcknowledged)
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.InitialContent,
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
),
stateFlow.awaitItem(),
)
assertEquals(ImportLoginsEvent.NavigateBack, eventFlow.awaitItem())
}
val expectedSnackbarData = BitwardenSnackbarData(
messageHeader = R.string.logins_imported.asText(),
message = R.string.remember_to_delete_your_imported_password_file_from_your_computer
.asText(),
)
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.InitialContent,
isVaultSyncing = false,
showBottomSheet = true,
currentWebVaultUrl = DEFAULT_VAULT_URL,
),
stateFlow.awaitItem(),
)
viewModel.trySendAction(ImportLoginsAction.SuccessfulSyncAcknowledged)
assertEquals(
ImportLoginsState(
dialogState = null,
viewState = ImportLoginsState.ViewState.InitialContent,
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
),
stateFlow.awaitItem(),
)
assertEquals(ImportLoginsEvent.NavigateBack, eventFlow.awaitItem())
verify {
snackbarRelayManager.sendSnackbarData(
data = expectedSnackbarData,
relay = SnackbarRelay.MY_VAULT_RELAY,
)
}
}
}
private fun createViewModel(): ImportLoginsViewModel = ImportLoginsViewModel(
private fun createViewModel(
snackbarRelay: SnackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
): ImportLoginsViewModel = ImportLoginsViewModel(
savedStateHandle = SavedStateHandle(
mapOf(
"snackbarRelay" to snackbarRelay.name,
),
),
vaultRepository = vaultRepository,
firstTimeActionManager = firstTimeActionManager,
environmentRepository = environmentRepository,
snackbarRelayManager = snackbarRelayManager,
)
}
@ -508,4 +559,5 @@ private val DEFAULT_STATE = ImportLoginsState(
isVaultSyncing = false,
showBottomSheet = false,
currentWebVaultUrl = DEFAULT_VAULT_URL,
snackbarRelay = SnackbarRelay.MY_VAULT_RELAY,
)

View file

@ -24,9 +24,11 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
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
import com.x8bit.bitwarden.ui.platform.manager.snackbar.SnackbarRelay
import com.x8bit.bitwarden.ui.util.assertLockOrLogoutDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertLogoutConfirmationDialogIsDisplayed
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
@ -91,7 +93,10 @@ class VaultScreenTest : BaseComposeTest() {
onDimBottomNavBarRequest = { onDimBottomNavBarRequestCalled = true },
onNavigateToVerificationCodeScreen = { onNavigateToVerificationCodeScreen = true },
onNavigateToSearchVault = { onNavigateToSearchScreen = true },
onNavigateToImportLogins = { onNavigateToImportLoginsCalled = true },
onNavigateToImportLogins = {
onNavigateToImportLoginsCalled = true
assertEquals(SnackbarRelay.MY_VAULT_RELAY, it)
},
exitManager = exitManager,
intentManager = intentManager,
permissionsManager = permissionsManager,
@ -1195,6 +1200,13 @@ class VaultScreenTest : BaseComposeTest() {
mutableEventFlow.tryEmit(VaultEvent.NavigateToImportLogins)
assertTrue(onNavigateToImportLoginsCalled)
}
@Test
fun `when ShowSnackbar is sent snackbar should be displayed`() {
val data = BitwardenSnackbarData("message".asText())
mutableEventFlow.tryEmit(VaultEvent.ShowSnackbar(data))
composeTestRule.onNodeWithText("message").assertIsDisplayed()
}
}
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(

View file

@ -33,6 +33,9 @@ import com.x8bit.bitwarden.data.vault.repository.model.VaultData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
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.SnackbarRelayManagerImpl
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
@ -62,6 +65,8 @@ class VaultViewModelTest : BaseViewModelTest() {
ZoneOffset.UTC,
)
private val snackbarRelayManager = SnackbarRelayManagerImpl()
private val clipboardManager: BitwardenClipboardManager = mockk {
every { setText(any<String>()) } just runs
}
@ -1619,6 +1624,19 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `when SnackbarRelay flow updates, snackbar is shown`() = runTest {
val viewModel = createViewModel()
val expectedSnackbarData = BitwardenSnackbarData(message = "test message".asText())
viewModel.eventFlow.test {
snackbarRelayManager.sendSnackbarData(
data = expectedSnackbarData,
relay = SnackbarRelay.MY_VAULT_RELAY,
)
assertEquals(VaultEvent.ShowSnackbar(expectedSnackbarData), awaitItem())
}
}
private fun createViewModel(): VaultViewModel =
VaultViewModel(
authRepository = authRepository,
@ -1630,6 +1648,7 @@ class VaultViewModelTest : BaseViewModelTest() {
organizationEventManager = organizationEventManager,
featureFlagManager = featureFlagManager,
firstTimeActionManager = firstTimeActionManager,
snackbarRelayManager = snackbarRelayManager,
)
}