diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt index 559a4737f..98d069691 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/completeregistration/CompleteRegistrationScreen.kt @@ -48,7 +48,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton -import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard +import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCardSmall 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.dialog.BitwardenTwoButtonDialog @@ -213,7 +213,7 @@ private fun CompleteRegistrationContent( .standardHorizontalMargin(), ) Spacer(modifier = Modifier.height(24.dp)) - BitwardenActionCard( + BitwardenActionCardSmall( actionIcon = rememberVectorPainter(id = R.drawable.ic_tooltip), actionText = stringResource(id = R.string.what_makes_a_password_strong), callToActionText = stringResource(id = R.string.learn_more), diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordguidance/MasterPasswordGuidanceScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordguidance/MasterPasswordGuidanceScreen.kt index 1ad1b2f2b..c6708a307 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordguidance/MasterPasswordGuidanceScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/masterpasswordguidance/MasterPasswordGuidanceScreen.kt @@ -35,7 +35,7 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar -import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard +import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCardSmall import com.x8bit.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter @@ -158,7 +158,7 @@ private fun TryGeneratorCard( onCardClicked: () -> Unit, modifier: Modifier = Modifier, ) { - BitwardenActionCard( + BitwardenActionCardSmall( actionIcon = rememberVectorPainter(id = R.drawable.ic_generator), actionText = stringResource( R.string.use_the_generator_to_create_a_strong_unique_password, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCard.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCard.kt index 3ff97a779..3029d8070 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCard.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCard.kt @@ -1,117 +1,123 @@ package com.x8bit.bitwarden.ui.platform.components.card -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope +import android.content.res.Configuration +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.components.badge.NotificationBadge +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenStandardIconButton import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme /** - * A reusable card for displaying actions to the user. + * A design component action card, which contains a title, action button, and a dismiss button + * by default, with optional leading icon content. + * + * @param cardTitle The title of the card. + * @param actionText The text content on the CTA button. + * @param onActionClick The action to perform when the CTA button is clicked. + * @param onDismissClick The action to perform when the dismiss button is clicked. + * @param leadingContent Optional content to display on the leading side of the + * [cardTitle] [Text]. */ @Composable fun BitwardenActionCard( - actionIcon: VectorPainter, + cardTitle: String, actionText: String, - callToActionText: String, - onCardClicked: () -> Unit, + onActionClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier, - trailingContent: (@Composable BoxScope.() -> Unit)? = null, + leadingContent: @Composable (() -> Unit)? = null, ) { Card( - onClick = onCardClicked, - shape = RoundedCornerShape(size = 16.dp), modifier = modifier, + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLowest, + containerColor = BitwardenTheme.colorScheme.background.tertiary, ), elevation = CardDefaults.elevatedCardElevation(), + border = BorderStroke(width = 1.dp, color = BitwardenTheme.colorScheme.stroke.border), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), + Column( + modifier = Modifier.padding(16.dp), ) { - Icon( - painter = actionIcon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + leadingContent?.let { + it() + Spacer(Modifier.width(12.dp)) + } + Text( + text = cardTitle, + style = BitwardenTheme.typography.titleMedium, + color = BitwardenTheme.colorScheme.text.primary, + ) + Spacer(Modifier.weight(1f)) + BitwardenStandardIconButton( + painter = rememberVectorPainter(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.close), + onClick = onDismissClick, + contentColor = BitwardenTheme.colorScheme.icon.primary, + modifier = Modifier.offset(x = 8.dp), + ) + } + Spacer(Modifier.height(16.dp)) + BitwardenFilledButton( + actionText, + onClick = onActionClick, + modifier = Modifier.fillMaxWidth(), ) - Spacer(modifier = Modifier.width(16.dp)) - Column( - modifier = Modifier.weight(weight = 1f), - ) { - Text( - text = actionText, - style = BitwardenTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = callToActionText, - style = BitwardenTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - ) - } - Spacer(modifier = Modifier.width(16.dp)) - Box( - modifier = Modifier - .align(Alignment.CenterVertically), - ) { - trailingContent?.invoke(this) - } } } } @Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun ActionCardPreview() { +private fun BitwardenActionCard_preview() { BitwardenTheme { BitwardenActionCard( - actionIcon = rememberVectorPainter(id = R.drawable.ic_generator), - actionText = "This is an action.", - callToActionText = "Take action", - onCardClicked = { }, + cardTitle = "Title", + actionText = "Action", + onActionClick = {}, + onDismissClick = {}, ) } } @Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -private fun ActionCardWithTrailingPreview() { +private fun BitwardenActionCardWithLeadingContent_preview() { BitwardenTheme { BitwardenActionCard( - actionIcon = rememberVectorPainter(id = R.drawable.ic_generator), - actionText = "An action with trailing content", - callToActionText = "Take action", - onCardClicked = {}, - trailingContent = { - Icon( - painter = rememberVectorPainter(id = R.drawable.ic_navigate_next), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, + cardTitle = "Title", + actionText = "Action", + onActionClick = {}, + onDismissClick = {}, + leadingContent = { + NotificationBadge( + notificationCount = 1, ) }, ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCardSmall.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCardSmall.kt new file mode 100644 index 000000000..f207c417b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/card/BitwardenActionCardSmall.kt @@ -0,0 +1,119 @@ +package com.x8bit.bitwarden.ui.platform.components.card + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.VectorPainter +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme + +/** + * A reusable card for displaying actions to the user. + */ +@Composable +fun BitwardenActionCardSmall( + actionIcon: VectorPainter, + actionText: String, + callToActionText: String, + onCardClicked: () -> Unit, + modifier: Modifier = Modifier, + trailingContent: (@Composable BoxScope.() -> Unit)? = null, +) { + Card( + onClick = onCardClicked, + shape = RoundedCornerShape(size = 16.dp), + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = BitwardenTheme.colorScheme.background.tertiary, + ), + elevation = CardDefaults.elevatedCardElevation(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Icon( + painter = actionIcon, + contentDescription = null, + tint = BitwardenTheme.colorScheme.icon.secondary, + modifier = Modifier.size(24.dp), + ) + Spacer(modifier = Modifier.width(16.dp)) + Column( + modifier = Modifier.weight(weight = 1f), + ) { + Text( + text = actionText, + style = BitwardenTheme.typography.bodyLarge, + color = BitwardenTheme.colorScheme.text.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = callToActionText, + style = BitwardenTheme.typography.labelLarge, + color = BitwardenTheme.colorScheme.text.primary, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Box( + modifier = Modifier + .align(Alignment.CenterVertically), + ) { + trailingContent?.invoke(this) + } + } + } +} + +@Preview +@Composable +private fun ActionCardSmall_preview() { + BitwardenTheme { + BitwardenActionCardSmall( + actionIcon = rememberVectorPainter(id = R.drawable.ic_generator), + actionText = "This is an action.", + callToActionText = "Take action", + onCardClicked = { }, + ) + } +} + +@Preview +@Composable +private fun ActionCardSmallWithTrailingIcon_preview() { + BitwardenTheme { + BitwardenActionCardSmall( + actionIcon = rememberVectorPainter(id = R.drawable.ic_generator), + actionText = "An action with trailing content", + callToActionText = "Take action", + onCardClicked = {}, + trailingContent = { + Icon( + painter = rememberVectorPainter(id = R.drawable.ic_navigate_next), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt index 90765b7be..394f808ad 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt @@ -96,8 +96,8 @@ data class SettingsState( private val securityCount: Int, ) { val notificationBadgeCountMap: Map = mapOf( - Settings.ACCOUNT_SECURITY to autoFillCount, - Settings.AUTO_FILL to securityCount, + Settings.ACCOUNT_SECURITY to securityCount, + Settings.AUTO_FILL to autoFillCount, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt index 729ba5a56..948950695 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt @@ -1,6 +1,9 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -21,6 +24,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -36,8 +40,11 @@ import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect 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.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.badge.NotificationBadge import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton +import com.x8bit.bitwarden.ui.platform.components.card.BitwardenActionCard import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog @@ -175,6 +182,33 @@ fun AccountSecurityScreen( .fillMaxSize() .verticalScroll(rememberScrollState()), ) { + AnimatedVisibility( + visible = state.shouldShowUnlockActionCard, + label = "UnlockActionCard", + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top), + ) { + BitwardenActionCard( + cardTitle = stringResource(id = R.string.set_up_unlock), + actionText = stringResource(R.string.get_started), + onActionClick = remember(viewModel) { + { + viewModel.trySendAction(AccountSecurityAction.UnlockActionCardCtaClick) + } + }, + onDismissClick = remember(viewModel) { + { + viewModel.trySendAction(AccountSecurityAction.UnlockActionCardDismiss) + } + }, + leadingContent = { + NotificationBadge(notificationCount = 1) + }, + modifier = Modifier + .standardHorizontalMargin() + .padding(top = 12.dp, bottom = 16.dp), + ) + } + BitwardenListHeaderText( label = stringResource(id = R.string.approve_login_requests), modifier = Modifier diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt index f95e9c3e6..8d160dd48 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt @@ -77,6 +77,7 @@ class AccountSecurityViewModel @Inject constructor( vaultTimeoutAction = settingsRepository.vaultTimeoutAction, vaultTimeoutPolicyMinutes = null, vaultTimeoutPolicyAction = null, + shouldShowUnlockActionCard = false, ) }, ) { @@ -113,6 +114,14 @@ class AccountSecurityViewModel @Inject constructor( } .launchIn(viewModelScope) + settingsRepository + .getShowUnlockBadgeFlow(state.userId) + .map { + AccountSecurityAction.Internal.ShowUnlockBadgeUpdated(it) + } + .onEach(::sendAction) + .launchIn(viewModelScope) + viewModelScope.launch { trySendAction( AccountSecurityAction.Internal.FingerprintResultReceive( @@ -146,6 +155,17 @@ class AccountSecurityViewModel @Inject constructor( is AccountSecurityAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action) is AccountSecurityAction.PushNotificationConfirm -> handlePushNotificationConfirm() is AccountSecurityAction.Internal -> handleInternalAction(action) + AccountSecurityAction.UnlockActionCardCtaClick -> handleUnlockCardCtaClick() + AccountSecurityAction.UnlockActionCardDismiss -> handleUnlockCardDismiss() + } + + private fun handleUnlockCardDismiss() { + dismissUnlockNotificationBadge() + } + + private fun handleUnlockCardCtaClick() { + dismissUnlockNotificationBadge() + // TODO: Navigate to unlock set up screen PM-13067 } private fun handleAccountFingerprintPhraseClick() { @@ -199,6 +219,7 @@ class AccountSecurityViewModel @Inject constructor( ) } } + dismissUnlockNotificationBadge() } private fun handleFingerPrintLearnMoreClick() { @@ -310,6 +331,7 @@ class AccountSecurityViewModel @Inject constructor( ) } } + dismissUnlockNotificationBadge() } private fun handleInternalAction(action: AccountSecurityAction.Internal) { @@ -329,6 +351,20 @@ class AccountSecurityViewModel @Inject constructor( is AccountSecurityAction.Internal.PolicyUpdateReceive -> { handlePolicyUpdateReceive(action) } + + is AccountSecurityAction.Internal.ShowUnlockBadgeUpdated -> { + handleShowUnlockBadgeUpdated(action) + } + } + } + + private fun handleShowUnlockBadgeUpdated( + action: AccountSecurityAction.Internal.ShowUnlockBadgeUpdated, + ) { + mutableStateFlow.update { + it.copy( + shouldShowUnlockActionCard = action.showUnlockBadge, + ) } } @@ -404,6 +440,14 @@ class AccountSecurityViewModel @Inject constructor( mutableStateFlow.update { it.copy(vaultTimeoutAction = vaultTimeoutAction) } settingsRepository.vaultTimeoutAction = vaultTimeoutAction } + + private fun dismissUnlockNotificationBadge() { + if (!state.shouldShowUnlockActionCard) return + settingsRepository.storeShowUnlockSettingBadge( + userId = state.userId, + showBadge = false, + ) + } } /** @@ -423,6 +467,7 @@ data class AccountSecurityState( val vaultTimeoutAction: VaultTimeoutAction, val vaultTimeoutPolicyMinutes: Int?, val vaultTimeoutPolicyAction: String?, + val shouldShowUnlockActionCard: Boolean, ) : Parcelable { /** * Indicates that there is a mechanism for unlocking your vault in place. @@ -633,6 +678,16 @@ sealed class AccountSecurityAction { val unlockWithPinState: UnlockWithPinState, ) : AccountSecurityAction() + /** + * User has dismissed the unlock action card. + */ + data object UnlockActionCardDismiss : AccountSecurityAction() + + /** + * User has clicked the CTA on the unlock action card. + */ + data object UnlockActionCardCtaClick : AccountSecurityAction() + /** * Models actions that can be sent by the view model itself. */ @@ -665,5 +720,10 @@ sealed class AccountSecurityAction { data class PolicyUpdateReceive( val vaultTimeoutPolicies: List?, ) : Internal() + + /** + * The show unlock badge update has been received. + */ + data class ShowUnlockBadgeUpdated(val showUnlockBadge: Boolean) : Internal() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2cb1c8aae..96d9c234b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1009,4 +1009,5 @@ Do you want to switch to this account? Error connecting with the Duo service. Use a different two-step login method or contact Duo for assistance. Master password hint Important: Your master password cannot be recovered if you forget it! 12 characters minimum. + Get started diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt index 692f647de..232048e45 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt @@ -1488,6 +1488,42 @@ class AccountSecurityScreenTest : BaseComposeTest() { .performClick() verify { viewModel.trySendAction(AccountSecurityAction.AuthenticatorSyncToggle(true)) } } + + @Test + fun `unlock action card should show when state is true and hide when false`() { + composeTestRule + .onNodeWithText("Get started") + .assertDoesNotExist() + mutableStateFlow.update { DEFAULT_STATE.copy(shouldShowUnlockActionCard = true) } + composeTestRule + .onNodeWithText("Get started") + .assertIsDisplayed() + mutableStateFlow.update { DEFAULT_STATE.copy(shouldShowUnlockActionCard = false) } + composeTestRule + .onNodeWithText("Get started") + .assertDoesNotExist() + } + + @Test + fun `when unlock action card is visible clicking the cta button should send correct action`() { + mutableStateFlow.update { DEFAULT_STATE.copy(shouldShowUnlockActionCard = true) } + composeTestRule + .onNodeWithText("Get started") + .performScrollTo() + .performClick() + + verify { viewModel.trySendAction(AccountSecurityAction.UnlockActionCardCtaClick) } + } + + @Test + fun `when unlock action card is visible clicking dismissing should send correct action`() { + mutableStateFlow.update { DEFAULT_STATE.copy(shouldShowUnlockActionCard = true) } + composeTestRule + .onNodeWithContentDescription("Close") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AccountSecurityAction.UnlockActionCardDismiss) } + } } private val CIPHER = mockk() @@ -1505,4 +1541,5 @@ private val DEFAULT_STATE = AccountSecurityState( vaultTimeoutAction = VaultTimeoutAction.LOCK, vaultTimeoutPolicyMinutes = null, vaultTimeoutPolicyAction = null, + shouldShowUnlockActionCard = false, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt index c8a4a56cf..3da2ab488 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt @@ -37,6 +37,7 @@ import io.mockk.runs import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToJsonElement @@ -45,6 +46,7 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import javax.crypto.Cipher +@Suppress("LargeClass") class AccountSecurityViewModelTest : BaseViewModelTest() { private val fakeEnvironmentRepository = FakeEnvironmentRepository() @@ -53,6 +55,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { every { userStateFlow } returns mutableUserStateFlow } private val vaultRepository: VaultRepository = mockk(relaxed = true) + private val mutableShowUnlockBadgeFlow = MutableStateFlow(false) private val settingsRepository: SettingsRepository = mockk { every { isAuthenticatorSyncEnabled } returns false every { isUnlockWithBiometricsEnabled } returns false @@ -60,6 +63,8 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { every { vaultTimeout } returns VaultTimeout.ThirtyMinutes every { vaultTimeoutAction } returns VaultTimeoutAction.LOCK coEvery { getUserFingerprint() } returns UserFingerprintResult.Success(FINGERPRINT) + every { getShowUnlockBadgeFlow(any()) } returns mutableShowUnlockBadgeFlow + every { storeShowUnlockSettingBadge(any(), false) } just runs } private val mutableActivePolicyFlow = bufferedMutableSharedFlow>() private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk { @@ -359,6 +364,21 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { awaitItem(), ) } + + verify(exactly = 0) { + settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false) + } + } + + @Test + fun `on EnableBiometricsClick should update user show unlock badge status if shown`() { + mutableShowUnlockBadgeFlow.update { true } + val viewModel = createViewModel() + viewModel.trySendAction(AccountSecurityAction.EnableBiometricsClick) + + verify(exactly = 1) { + settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false) + } } @Test @@ -576,6 +596,41 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { shouldRequireMasterPasswordOnRestart = true, ) } + + verify(exactly = 0) { + settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on UnlockWithPinToggle Enabled should update show unlock badge state if card is visible`() { + mutableShowUnlockBadgeFlow.update { true } + val initialState = DEFAULT_STATE.copy( + isUnlockWithPinEnabled = false, + ) + every { settingsRepository.storeUnlockPin(any(), any()) } just runs + + val viewModel = createViewModel(initialState = initialState) + viewModel.trySendAction( + AccountSecurityAction.UnlockWithPinToggle( + UnlockWithPinState.Enabled( + pin = "1234", + shouldRequireMasterPasswordOnRestart = true, + ), + ), + ) + assertEquals( + initialState.copy(isUnlockWithPinEnabled = true, shouldShowUnlockActionCard = true), + viewModel.stateFlow.value, + ) + verify { + settingsRepository.storeUnlockPin( + pin = "1234", + shouldRequireMasterPasswordOnRestart = true, + ) + settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false) + } } @Test @@ -652,6 +707,36 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { } } + @Test + fun `when showUnlockBadgeFlow updates value, should update state`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + mutableShowUnlockBadgeFlow.update { true } + assertEquals(DEFAULT_STATE.copy(shouldShowUnlockActionCard = true), awaitItem()) + } + } + + @Test + fun `when UnlockActionCardDismiss action received, should dismiss unlock action card`() { + mutableShowUnlockBadgeFlow.update { true } + val viewModel = createViewModel() + viewModel.trySendAction(AccountSecurityAction.UnlockActionCardDismiss) + verify { + settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false) + } + } + + @Test + fun `when UnlockActionCardCtaClick action received, should dismiss unlock action card`() { + mutableShowUnlockBadgeFlow.update { true } + val viewModel = createViewModel() + viewModel.trySendAction(AccountSecurityAction.UnlockActionCardCtaClick) + verify { + settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false) + } + } + @Suppress("LongParameterList") private fun createViewModel( initialState: AccountSecurityState? = DEFAULT_STATE, @@ -716,4 +801,5 @@ private val DEFAULT_STATE: AccountSecurityState = AccountSecurityState( vaultTimeoutPolicyMinutes = null, vaultTimeoutPolicyAction = null, shouldShowEnableAuthenticatorSync = false, + shouldShowUnlockActionCard = false, )