PM-39821: feat: Vault empty state can display action cards (#7126)

This commit is contained in:
David Perez authored and GitHub committed 2026-07-01 14:04:10 +00:00
1 parent 0d2fd2d184
commit 18036bb620
7 files changed
+245 -161

No files matched your search

@@ -0,0 +1,103 @@
package com.x8bit.bitwarden.ui.vault.feature.vault
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.bitwarden.ui.platform.components.card.BitwardenActionCard
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.vault.handlers.VaultHandlers
/**
* The action card for the vault screen.
*/
@Suppress("LongMethod")
@Composable
fun VaultActionCard(
actionCardState: VaultState.ActionCardState,
vaultHandlers: VaultHandlers,
modifier: Modifier = Modifier,
) {
when (actionCardState) {
VaultState.ActionCardState.UpgradedToPremium -> {
BitwardenActionCard(
cardTitle = stringResource(id = BitwardenString.upgraded_to_premium),
cardSubtitle = stringResource(
id = BitwardenString.you_now_have_access_to_all_advanced_security_features,
),
actionText = stringResource(id = BitwardenString.learn_more),
isExternalLink = true,
leadingContent = {
Icon(
painter = rememberVectorPainter(id = BitwardenDrawable.ic_star),
contentDescription = null,
tint = BitwardenTheme.colorScheme.icon.secondary,
)
},
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
onDismissClick = { vaultHandlers.dismissActionCardClick(actionCardState) },
modifier = modifier,
)
}
VaultState.ActionCardState.UpgradePremium -> {
BitwardenActionCard(
cardTitle = stringResource(
id = BitwardenString.unlock_advanced_security_features,
),
cardSubtitle = stringResource(
id = BitwardenString
.a_premium_plan_gives_you_more_tools_to_stay_secure_and_in_control,
),
actionText = stringResource(id = BitwardenString.learn_more),
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
onDismissClick = { vaultHandlers.dismissActionCardClick(actionCardState) },
modifier = modifier,
)
}
VaultState.ActionCardState.PremiumNeedsAttention -> {
BitwardenActionCard(
cardTitle = stringResource(id = BitwardenString.your_subscription_needs_attention),
cardSubtitle = stringResource(id = BitwardenString.check_your_plan_for_details),
actionText = stringResource(id = BitwardenString.view_plan),
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
modifier = modifier,
)
}
VaultState.ActionCardState.IntroducingArchive -> {
BitwardenActionCard(
cardTitle = stringResource(id = BitwardenString.introducing_archive),
cardSubtitle = stringResource(
id = BitwardenString.keep_items_you_dont_need_right_now_safe_but_out_sight,
),
actionText = stringResource(id = BitwardenString.go_to_archive),
leadingContent = {
Icon(
painter = rememberVectorPainter(id = BitwardenDrawable.ic_archive),
contentDescription = null,
tint = BitwardenTheme.colorScheme.icon.secondary,
)
},
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
onDismissClick = { vaultHandlers.dismissActionCardClick(actionCardState) },
modifier = modifier,
)
}
VaultState.ActionCardState.ImportItems -> {
BitwardenActionCard(
cardTitle = stringResource(id = BitwardenString.import_saved_logins),
cardSubtitle = stringResource(id = BitwardenString.use_a_computer_to_import_logins),
actionText = stringResource(id = BitwardenString.get_started),
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
onDismissClick = { vaultHandlers.dismissActionCardClick(actionCardState) },
modifier = modifier,
)
}
}
}
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -19,15 +18,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.bitwarden.ui.platform.components.card.BitwardenActionCard
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenMasterPasswordDialog
import com.x8bit.bitwarden.ui.platform.components.listitem.BitwardenGroupItem
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflowAction
@@ -107,7 +103,7 @@ fun VaultContent(
actionCardState?.let {
item(key = "action_card") {
ActionCard(
VaultActionCard(
actionCardState = it,
vaultHandlers = vaultHandlers,
modifier = Modifier
@@ -561,80 +557,3 @@ fun VaultContent(
}
}
}
@Suppress("LongMethod")
@Composable
private fun ActionCard(
actionCardState: VaultState.ActionCardState,
vaultHandlers: VaultHandlers,
modifier: Modifier = Modifier,
) {
when (actionCardState) {
VaultState.ActionCardState.UpgradedToPremium -> {
BitwardenActionCard(
cardTitle = stringResource(id = BitwardenString.upgraded_to_premium),
cardSubtitle = stringResource(
id = BitwardenString.you_now_have_access_to_all_advanced_security_features,
),
actionText = stringResource(id = BitwardenString.learn_more),
isExternalLink = true,
leadingContent = {
Icon(
painter = rememberVectorPainter(id = BitwardenDrawable.ic_star),
contentDescription = null,
tint = BitwardenTheme.colorScheme.icon.secondary,
)
},
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
onDismissClick = { vaultHandlers.dismissActionCardClick(actionCardState) },
modifier = modifier,
)
}
VaultState.ActionCardState.UpgradePremium -> {
BitwardenActionCard(
cardTitle = stringResource(
id = BitwardenString.unlock_advanced_security_features,
),
cardSubtitle = stringResource(
id = BitwardenString
.a_premium_plan_gives_you_more_tools_to_stay_secure_and_in_control,
),
actionText = stringResource(id = BitwardenString.learn_more),
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
onDismissClick = { vaultHandlers.dismissActionCardClick(actionCardState) },
modifier = modifier,
)
}
VaultState.ActionCardState.PremiumNeedsAttention -> {
BitwardenActionCard(
cardTitle = stringResource(id = BitwardenString.your_subscription_needs_attention),
cardSubtitle = stringResource(id = BitwardenString.check_your_plan_for_details),
actionText = stringResource(id = BitwardenString.view_plan),
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
modifier = modifier,
)
}
VaultState.ActionCardState.IntroducingArchive -> {
BitwardenActionCard(
cardTitle = stringResource(id = BitwardenString.introducing_archive),
cardSubtitle = stringResource(
id = BitwardenString.keep_items_you_dont_need_right_now_safe_but_out_sight,
),
actionText = stringResource(id = BitwardenString.go_to_archive),
leadingContent = {
Icon(
painter = rememberVectorPainter(id = BitwardenDrawable.ic_archive),
contentDescription = null,
tint = BitwardenTheme.colorScheme.icon.secondary,
)
},
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
onDismissClick = { vaultHandlers.dismissActionCardClick(actionCardState) },
modifier = modifier,
)
}
}
}
@@ -39,7 +39,6 @@ import com.bitwarden.ui.platform.components.appbar.action.BitwardenSearchActionI
import com.bitwarden.ui.platform.components.appbar.model.OverflowMenuItemData
import com.bitwarden.ui.platform.components.appbar.model.TopAppBarDividerStyle
import com.bitwarden.ui.platform.components.button.model.BitwardenButtonData
import com.bitwarden.ui.platform.components.card.BitwardenActionCard
import com.bitwarden.ui.platform.components.card.actionCardExitAnimation
import com.bitwarden.ui.platform.components.content.BitwardenErrorContent
import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent
@@ -340,19 +339,14 @@ private fun VaultScreenScaffold(
)
is VaultState.ViewState.NoItems -> {
AnimatedVisibility(
visible = state.showImportActionCard,
AnimateNullableContentVisibility(
targetState = state.actionCard,
exit = actionCardExitAnimation(),
label = "VaultNoItemsActionCard",
) {
BitwardenActionCard(
cardTitle = stringResource(BitwardenString.import_saved_logins),
cardSubtitle = stringResource(
BitwardenString.use_a_computer_to_import_logins,
),
actionText = stringResource(BitwardenString.get_started),
onActionClick = vaultHandlers.importActionCardClick,
onDismissClick = vaultHandlers.dismissImportActionCard,
) { actionCard ->
VaultActionCard(
actionCardState = actionCard,
vaultHandlers = vaultHandlers,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
@@ -358,8 +358,6 @@ class VaultViewModel @Inject constructor(
}
is VaultAction.Internal -> handleInternalAction(action)
VaultAction.DismissImportActionCard -> handleDismissImportActionCard()
VaultAction.ImportActionCardClick -> handleImportActionCardClick()
VaultAction.LifecycleResumed -> handleLifecycleResumed()
VaultAction.SelectAddItemType -> handleSelectAddItemType()
VaultAction.DismissFlightRecorderSnackbar -> handleDismissFlightRecorderSnackbar()
@@ -457,6 +455,12 @@ class VaultViewModel @Inject constructor(
VaultState.ActionCardState.IntroducingArchive -> {
settingsRepository.dismissIntroducingArchiveActionCard()
}
VaultState.ActionCardState.ImportItems -> {
firstTimeActionManager.storeShowImportLoginsSettingsBadge(showBadge = true)
if (!state.showImportActionCard) return
firstTimeActionManager.storeShowImportLogins(showImportLogins = false)
}
}
}
@@ -477,9 +481,11 @@ class VaultViewModel @Inject constructor(
VaultState.ActionCardState.IntroducingArchive -> {
settingsRepository.dismissIntroducingArchiveActionCard()
sendEvent(
VaultEvent.NavigateToItemListing(VaultItemListingType.Archive),
)
sendEvent(VaultEvent.NavigateToItemListing(VaultItemListingType.Archive))
}
VaultState.ActionCardState.ImportItems -> {
sendEvent(VaultEvent.NavigateToImportLogins)
}
}
}
@@ -526,16 +532,6 @@ class VaultViewModel @Inject constructor(
}
}
private fun handleImportActionCardClick() {
sendEvent(VaultEvent.NavigateToImportLogins)
}
private fun handleDismissImportActionCard() {
firstTimeActionManager.storeShowImportLoginsSettingsBadge(true)
if (!state.showImportActionCard) return
firstTimeActionManager.storeShowImportLogins(false)
}
private fun handleIconLoadingSettingReceive(
action: VaultAction.Internal.IconLoadingSettingReceive,
) {
@@ -1789,16 +1785,30 @@ data class VaultState(
* Indicates what action card to display.
*/
val actionCard: ActionCardState?
get() = (viewState as? ViewState.Content)?.let {
ActionCardState.UpgradedToPremium
.takeIf { isUpgradedToPremiumCardEligible }
?: ActionCardState.UpgradePremium.takeIf { premiumCard == PremiumCard.UPGRADE }
?: ActionCardState.PremiumNeedsAttention.takeIf {
premiumCard == PremiumCard.NEEDS_ATTENTION
}
?: ActionCardState.IntroducingArchive.takeIf {
isPremium && !isIntroducingArchiveActionCardDismissed
}
get() = when (viewState) {
is ViewState.Content -> {
ActionCardState.UpgradedToPremium
.takeIf { isUpgradedToPremiumCardEligible }
?: ActionCardState.UpgradePremium.takeIf { premiumCard == PremiumCard.UPGRADE }
?: ActionCardState.PremiumNeedsAttention.takeIf {
premiumCard == PremiumCard.NEEDS_ATTENTION
}
?: ActionCardState.IntroducingArchive.takeIf {
isPremium && !isIntroducingArchiveActionCardDismissed
}
}
ViewState.NoItems -> {
ActionCardState.UpgradePremium.takeIf { premiumCard == PremiumCard.UPGRADE }
?: ActionCardState.PremiumNeedsAttention.takeIf {
premiumCard == PremiumCard.NEEDS_ATTENTION
}
?: ActionCardState.ImportItems.takeIf { showImportActionCard }
}
is ViewState.Error,
ViewState.Loading,
-> null
}
/**
@@ -2211,6 +2221,11 @@ data class VaultState(
* Indicates that the archive feature is ready for use.
*/
data object IntroducingArchive : ActionCardState()
/**
* Indicates that the import items card should be displayed.
*/
data object ImportItems : ActionCardState()
}
/**
@@ -2599,16 +2614,6 @@ sealed class VaultAction {
*/
data object TryAgainClick : VaultAction()
/**
* The user has dismissed the import action card.
*/
data object DismissImportActionCard : VaultAction()
/**
* The user has clicked the import action card.
*/
data object ImportActionCardClick : VaultAction()
/**
* User clicked an overflow action.
*/
@@ -44,8 +44,6 @@ data class VaultHandlers(
String,
) -> Unit,
val masterPasswordRepromptSubmit: (VaultState.ViewState.VaultItem, String) -> Unit,
val dismissImportActionCard: () -> Unit,
val importActionCardClick: () -> Unit,
val flightRecorderGoToSettingsClick: () -> Unit,
val dismissFlightRecorderSnackbar: () -> Unit,
val onShareCipherDecryptionErrorClick: (selectedCipherId: String) -> Unit,
@@ -131,12 +129,6 @@ data class VaultHandlers(
),
)
},
dismissImportActionCard = {
viewModel.trySendAction(VaultAction.DismissImportActionCard)
},
importActionCardClick = {
viewModel.trySendAction(VaultAction.ImportActionCardClick)
},
flightRecorderGoToSettingsClick = {
viewModel.trySendAction(VaultAction.FlightRecorderGoToSettingsClick)
},
@@ -2498,7 +2498,7 @@ class VaultScreenTest : BitwardenComposeTest() {
}
@Test
fun `when import action card is showing, clicking it should send ImportLoginsClick action`() {
fun `when import action card is showing, clicking it should send ActionCardClick action`() {
mutableStateFlow.update {
it.copy(
viewState = VaultState.ViewState.NoItems,
@@ -2509,12 +2509,16 @@ class VaultScreenTest : BitwardenComposeTest() {
.onNodeWithText("Get started")
.performClick()
verify { viewModel.trySendAction(VaultAction.ImportActionCardClick) }
verify {
viewModel.trySendAction(
VaultAction.ActionCardClick(VaultState.ActionCardState.ImportItems),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `when import action card is showing, dismissing it should send DismissImportActionCard action`() {
fun `when import action card is showing, dismissing it should send DismissActionCardClick action`() {
mutableStateFlow.update {
it.copy(
viewState = VaultState.ViewState.NoItems,
@@ -2524,7 +2528,11 @@ class VaultScreenTest : BitwardenComposeTest() {
composeTestRule
.onNodeWithContentDescription("Close")
.performClick()
verify { viewModel.trySendAction(VaultAction.DismissImportActionCard) }
verify {
viewModel.trySendAction(
VaultAction.DismissActionCardClick(VaultState.ActionCardState.ImportItems),
)
}
}
@Test
@@ -3950,9 +3950,11 @@ class VaultViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `when DismissImportActionCard is sent, repository called to showImportLogins to false and storeShowImportLoginsBadge to true`() {
fun `DismissActionCardClick with ImportItems should call storeShowImportLogins false and storeShowImportLoginsSettingsBadge true`() {
val viewModel = createViewModel()
viewModel.trySendAction(VaultAction.DismissImportActionCard)
viewModel.trySendAction(
VaultAction.DismissActionCardClick(VaultState.ActionCardState.ImportItems),
)
verify(exactly = 1) {
firstTimeActionManager.storeShowImportLogins(false)
firstTimeActionManager.storeShowImportLoginsSettingsBadge(true)
@@ -3961,7 +3963,7 @@ class VaultViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength")
@Test
fun `when DismissImportActionCard is sent, repository is not called if value is already false`() {
fun `DismissActionCardClick with ImportItems should not call storeShowImportLogins if value is already false`() {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = DEFAULT_USER_STATE.accounts.map {
it.copy(
@@ -3972,18 +3974,25 @@ class VaultViewModelTest : BaseViewModelTest() {
},
)
val viewModel = createViewModel()
viewModel.trySendAction(VaultAction.DismissImportActionCard)
viewModel.trySendAction(
VaultAction.DismissActionCardClick(VaultState.ActionCardState.ImportItems),
)
verify(exactly = 1) {
firstTimeActionManager.storeShowImportLoginsSettingsBadge(true)
}
verify(exactly = 0) {
firstTimeActionManager.storeShowImportLogins(false)
}
}
@Test
fun `when ImportActionCardClick is sent, NavigateToImportLogins event is sent`() =
fun `ActionCardClick with ImportItems should emit NavigateToImportLogins`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(VaultAction.ImportActionCardClick)
viewModel.trySendAction(
VaultAction.ActionCardClick(VaultState.ActionCardState.ImportItems),
)
assertEquals(VaultEvent.NavigateToImportLogins, awaitItem())
}
verify(exactly = 0) {
@@ -3991,22 +4000,76 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `when ImportActionCardClick is sent, repository is not called if value is already false`() {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = DEFAULT_USER_STATE.accounts.map {
it.copy(
firstTimeState = DEFAULT_FIRST_TIME_STATE.copy(
showImportLoginsCard = false,
),
)
},
fun `actionCard should return ImportItems when NoItems is showing and showImportActionCard is true`() {
val state = createMockVaultState(viewState = VaultState.ViewState.NoItems).copy(
premiumCard = PremiumCard.NONE,
showImportActionCard = true,
)
val viewModel = createViewModel()
viewModel.trySendAction(VaultAction.ImportActionCardClick)
verify(exactly = 0) {
firstTimeActionManager.storeShowImportLogins(false)
}
assertEquals(
VaultState.ActionCardState.ImportItems,
state.actionCard,
)
}
@Test
fun `actionCard should return UpgradePremium when NoItems is showing and eligible`() {
val state = createMockVaultState(viewState = VaultState.ViewState.NoItems).copy(
premiumCard = PremiumCard.UPGRADE,
showImportActionCard = true,
)
assertEquals(
VaultState.ActionCardState.UpgradePremium,
state.actionCard,
)
}
@Test
fun `actionCard should return PremiumNeedsAttention when NoItems is showing and eligible`() {
val state = createMockVaultState(viewState = VaultState.ViewState.NoItems).copy(
premiumCard = PremiumCard.NEEDS_ATTENTION,
showImportActionCard = true,
)
assertEquals(
VaultState.ActionCardState.PremiumNeedsAttention,
state.actionCard,
)
}
@Test
fun `actionCard should return null when NoItems is showing and not eligible for any card`() {
val state = createMockVaultState(viewState = VaultState.ViewState.NoItems).copy(
premiumCard = PremiumCard.NONE,
showImportActionCard = false,
)
assertNull(state.actionCard)
}
@Test
fun `actionCard should return null when Loading is showing`() {
val state = createMockVaultState(viewState = VaultState.ViewState.Loading).copy(
premiumCard = PremiumCard.UPGRADE,
showImportActionCard = true,
)
assertNull(state.actionCard)
}
@Test
fun `actionCard should return null when Error is showing`() {
val state = createMockVaultState(
viewState = VaultState.ViewState.Error(message = "error".asText()),
).copy(
premiumCard = PremiumCard.UPGRADE,
showImportActionCard = true,
)
assertNull(state.actionCard)
}
@Test