PM-12773 show autofill card when user skipped this step in onboarding (#4021)

This commit is contained in:
Dave Severns 2024-10-04 14:21:40 -04:00 committed by GitHub
parent a5cf4f49d7
commit 83652c9699
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 237 additions and 16 deletions

View file

@ -1,6 +1,8 @@
package com.x8bit.bitwarden.ui.platform.components.card
import android.content.res.Configuration
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -89,6 +91,12 @@ fun BitwardenActionCard(
}
}
/**
* A default exit animation for [BitwardenActionCard] when using an animation wrapper like
* [AnimatedVisibility].
*/
fun actionCardExitAnimation() = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top)
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable

View file

@ -2,8 +2,6 @@ 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
@ -23,7 +21,6 @@ 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
@ -44,6 +41,7 @@ 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.card.actionCardExitAnimation
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
@ -183,7 +181,7 @@ fun AccountSecurityScreen(
AnimatedVisibility(
visible = state.shouldShowUnlockActionCard,
label = "UnlockActionCard",
exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Top),
exit = actionCardExitAnimation(),
) {
BitwardenActionCard(
cardTitle = stringResource(id = R.string.set_up_unlock),

View file

@ -1,6 +1,7 @@
package com.x8bit.bitwarden.ui.platform.feature.settings.autofill
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@ -31,7 +32,11 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
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.card.BitwardenActionCard
import com.x8bit.bitwarden.ui.platform.components.card.actionCardExitAnimation
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.BitwardenSelectionDialog
@ -125,6 +130,32 @@ fun AutoFillScreen(
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
AnimatedVisibility(
visible = state.showAutofillActionCard,
label = "AutofillActionCard",
exit = actionCardExitAnimation(),
) {
BitwardenActionCard(
cardTitle = stringResource(R.string.turn_on_autofill),
actionText = stringResource(R.string.get_started),
onActionClick = remember(viewModel) {
{
viewModel.trySendAction(AutoFillAction.AutoFillActionCardCtaClick)
}
},
onDismissClick = remember(viewModel) {
{
viewModel.trySendAction(AutoFillAction.DismissShowAutofillActionCard)
}
},
leadingContent = {
NotificationBadge(notificationCount = 1)
},
modifier = Modifier
.standardHorizontalMargin()
.padding(top = 12.dp, bottom = 16.dp),
)
}
BitwardenListHeaderText(
label = stringResource(id = R.string.autofill),
modifier = Modifier

View file

@ -4,6 +4,7 @@ import android.os.Build
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
@ -25,22 +26,30 @@ private const val KEY_STATE = "state"
@Suppress("TooManyFunctions")
@HiltViewModel
class AutoFillViewModel @Inject constructor(
authRepository: AuthRepository,
private val savedStateHandle: SavedStateHandle,
private val settingsRepository: SettingsRepository,
) : BaseViewModel<AutoFillState, AutoFillEvent, AutoFillAction>(
initialState = savedStateHandle[KEY_STATE]
?: AutoFillState(
isAskToAddLoginEnabled = !settingsRepository.isAutofillSavePromptDisabled,
isAccessibilityAutofillEnabled = settingsRepository
.isAccessibilityEnabledStateFlow
.value,
isAutoFillServicesEnabled = settingsRepository.isAutofillEnabledStateFlow.value,
isCopyTotpAutomaticallyEnabled = !settingsRepository.isAutoCopyTotpDisabled,
isUseInlineAutoFillEnabled = settingsRepository.isInlineAutofillEnabled,
showInlineAutofillOption = !isBuildVersionBelow(Build.VERSION_CODES.R),
showPasskeyManagementRow = !isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
defaultUriMatchType = settingsRepository.defaultUriMatchType,
),
?: run {
val userId = requireNotNull(authRepository.userStateFlow.value).activeUserId
AutoFillState(
isAskToAddLoginEnabled = !settingsRepository.isAutofillSavePromptDisabled,
isAccessibilityAutofillEnabled = settingsRepository
.isAccessibilityEnabledStateFlow
.value,
isAutoFillServicesEnabled = settingsRepository.isAutofillEnabledStateFlow.value,
isCopyTotpAutomaticallyEnabled = !settingsRepository.isAutoCopyTotpDisabled,
isUseInlineAutoFillEnabled = settingsRepository.isInlineAutofillEnabled,
showInlineAutofillOption = !isBuildVersionBelow(Build.VERSION_CODES.R),
showPasskeyManagementRow = !isBuildVersionBelow(
Build.VERSION_CODES.UPSIDE_DOWN_CAKE,
),
defaultUriMatchType = settingsRepository.defaultUriMatchType,
showAutofillActionCard = false,
activeUserId = userId,
)
},
) {
init {
@ -64,6 +73,12 @@ class AutoFillViewModel @Inject constructor(
}
.onEach(::sendAction)
.launchIn(viewModelScope)
settingsRepository
.getShowAutofillBadgeFlow(userId = state.activeUserId)
.map { AutoFillAction.Internal.UpdateShowAutofillActionCard(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: AutoFillAction) = when (action) {
@ -77,6 +92,8 @@ class AutoFillViewModel @Inject constructor(
is AutoFillAction.UseInlineAutofillClick -> handleUseInlineAutofillClick(action)
AutoFillAction.PasskeyManagementClick -> handlePasskeyManagementClick()
is AutoFillAction.Internal -> handleInternalAction(action)
AutoFillAction.AutoFillActionCardCtaClick -> handleAutoFillActionCardCtClick()
AutoFillAction.DismissShowAutofillActionCard -> handleDismissShowAutofillActionCard()
}
private fun handleInternalAction(action: AutoFillAction.Internal) {
@ -88,9 +105,28 @@ class AutoFillViewModel @Inject constructor(
is AutoFillAction.Internal.AutofillEnabledUpdateReceive -> {
handleAutofillEnabledUpdateReceive(action)
}
is AutoFillAction.Internal.UpdateShowAutofillActionCard -> {
handleUpdateShowAutofillActionCard(action)
}
}
}
private fun handleDismissShowAutofillActionCard() {
dismissShowAutofillActionCard()
}
private fun handleAutoFillActionCardCtClick() {
dismissShowAutofillActionCard()
// TODO PM-13068 navigate to auto fill setup screen
}
private fun handleUpdateShowAutofillActionCard(
action: AutoFillAction.Internal.UpdateShowAutofillActionCard,
) {
mutableStateFlow.update { it.copy(showAutofillActionCard = action.showAutofillActionCard) }
}
private fun handleAskToAddLoginClick(action: AutoFillAction.AskToAddLoginClick) {
settingsRepository.isAutofillSavePromptDisabled = !action.isEnabled
mutableStateFlow.update { it.copy(isAskToAddLoginEnabled = action.isEnabled) }
@ -102,6 +138,7 @@ class AutoFillViewModel @Inject constructor(
} else {
settingsRepository.disableAutofill()
}
dismissShowAutofillActionCard()
}
private fun handleBackClick() {
@ -154,6 +191,14 @@ class AutoFillViewModel @Inject constructor(
private fun handleBlockAutoFillClick() {
sendEvent(AutoFillEvent.NavigateToBlockAutoFill)
}
private fun dismissShowAutofillActionCard() {
if (!state.showAutofillActionCard) return
settingsRepository.storeShowAutoFillSettingBadge(
userId = state.activeUserId,
showBadge = false,
)
}
}
/**
@ -169,6 +214,8 @@ data class AutoFillState(
val showInlineAutofillOption: Boolean,
val showPasskeyManagementRow: Boolean,
val defaultUriMatchType: UriMatchType,
val showAutofillActionCard: Boolean,
val activeUserId: String,
) : Parcelable {
/**
@ -275,6 +322,16 @@ sealed class AutoFillAction {
*/
data object PasskeyManagementClick : AutoFillAction()
/**
* User has clicked the "X" to dismiss the autofill action card.
*/
data object DismissShowAutofillActionCard : AutoFillAction()
/**
* User has clicked the CTA on the autofill action card.
*/
data object AutoFillActionCardCtaClick : AutoFillAction()
/**
* Internal actions.
*/
@ -292,5 +349,10 @@ sealed class AutoFillAction {
data class AutofillEnabledUpdateReceive(
val isAutofillEnabled: Boolean,
) : Internal()
/**
* An update for changes in the [showAutofillActionCard] value from the settings repository.
*/
data class UpdateShowAutofillActionCard(val showAutofillActionCard: Boolean) : Internal()
}
}

View file

@ -462,6 +462,42 @@ class AutoFillScreenTest : BaseComposeTest() {
mutableEventFlow.tryEmit(AutoFillEvent.NavigateToBlockAutoFill)
assertTrue(onNavigateToBlockAutoFillScreenCalled)
}
@Test
fun `autofill action card should show when state is true and hide when false`() {
composeTestRule
.onNodeWithText("Get started")
.assertDoesNotExist()
mutableStateFlow.update { DEFAULT_STATE.copy(showAutofillActionCard = true) }
composeTestRule
.onNodeWithText("Get started")
.assertIsDisplayed()
mutableStateFlow.update { DEFAULT_STATE.copy(showAutofillActionCard = false) }
composeTestRule
.onNodeWithText("Get started")
.assertDoesNotExist()
}
@Test
fun `when autofill card is visible clicking the cta button should send correct action`() {
mutableStateFlow.update { DEFAULT_STATE.copy(showAutofillActionCard = true) }
composeTestRule
.onNodeWithText("Get started")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(AutoFillAction.AutoFillActionCardCtaClick) }
}
@Test
fun `when autofill action card is visible clicking dismissing should send correct action`() {
mutableStateFlow.update { DEFAULT_STATE.copy(showAutofillActionCard = true) }
composeTestRule
.onNodeWithContentDescription("Close")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(AutoFillAction.DismissShowAutofillActionCard) }
}
}
private val DEFAULT_STATE: AutoFillState = AutoFillState(
@ -473,4 +509,6 @@ private val DEFAULT_STATE: AutoFillState = AutoFillState(
showInlineAutofillOption = true,
showPasskeyManagementRow = true,
defaultUriMatchType = UriMatchType.DOMAIN,
showAutofillActionCard = false,
activeUserId = "activeUserId",
)

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.autofill
import android.os.Build
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.UriMatchType
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
@ -15,6 +16,7 @@ import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
@ -25,6 +27,11 @@ class AutoFillViewModelTest : BaseViewModelTest() {
private val mutableIsAccessibilityEnabledStateFlow = MutableStateFlow(false)
private val mutableIsAutofillEnabledStateFlow = MutableStateFlow(false)
private val authRepository: AuthRepository = mockk {
every { userStateFlow.value?.activeUserId } returns "activeUserId"
}
private val mutableShowAutofillActionCardFlow = MutableStateFlow(false)
private val settingsRepository: SettingsRepository = mockk {
every { isInlineAutofillEnabled } returns true
every { isInlineAutofillEnabled = any() } just runs
@ -37,6 +44,8 @@ class AutoFillViewModelTest : BaseViewModelTest() {
every { isAccessibilityEnabledStateFlow } returns mutableIsAccessibilityEnabledStateFlow
every { isAutofillEnabledStateFlow } returns mutableIsAutofillEnabledStateFlow
every { disableAutofill() } just runs
every { getShowAutofillBadgeFlow(any()) } returns mutableShowAutofillActionCardFlow
every { storeShowAutoFillSettingBadge(any(), any()) } just runs
}
@BeforeEach
@ -196,6 +205,40 @@ class AutoFillViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `on AutoFillServicesClick should update show autofill in repository if card shown`() {
mutableShowAutofillActionCardFlow.update { true }
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(showAutofillActionCard = true),
viewModel.stateFlow.value,
)
viewModel.trySendAction(AutoFillAction.AutoFillServicesClick(true))
verify(exactly = 1) {
settingsRepository.storeShowAutoFillSettingBadge(
DEFAULT_STATE.activeUserId,
false,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `on AutoFillServicesClick should not update show autofill in repository if card not shown`() {
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(showAutofillActionCard = false),
viewModel.stateFlow.value,
)
viewModel.trySendAction(AutoFillAction.AutoFillServicesClick(true))
verify(exactly = 0) {
settingsRepository.storeShowAutoFillSettingBadge(
DEFAULT_STATE.activeUserId,
false,
)
}
}
@Test
fun `on BackClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
@ -266,11 +309,50 @@ class AutoFillViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `when showAutofillBadgeFlow updates value, should update state`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutableShowAutofillActionCardFlow.emit(true)
assertEquals(DEFAULT_STATE.copy(showAutofillActionCard = true), awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `when AutoFillActionCardCtaClick action is sent should update show autofill in repository`() {
mutableShowAutofillActionCardFlow.update { true }
val viewModel = createViewModel()
viewModel.trySendAction(AutoFillAction.AutoFillActionCardCtaClick)
verify {
settingsRepository.storeShowAutoFillSettingBadge(
DEFAULT_STATE.activeUserId,
false,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `when DismissShowAutofillActionCard action is sent should update show autofill in repository`() {
mutableShowAutofillActionCardFlow.update { true }
val viewModel = createViewModel()
viewModel.trySendAction(AutoFillAction.DismissShowAutofillActionCard)
verify {
settingsRepository.storeShowAutoFillSettingBadge(
DEFAULT_STATE.activeUserId,
false,
)
}
}
private fun createViewModel(
state: AutoFillState? = DEFAULT_STATE,
): AutoFillViewModel = AutoFillViewModel(
savedStateHandle = SavedStateHandle().apply { set("state", state) },
settingsRepository = settingsRepository,
authRepository = authRepository,
)
}
@ -283,4 +365,6 @@ private val DEFAULT_STATE: AutoFillState = AutoFillState(
showInlineAutofillOption = false,
showPasskeyManagementRow = true,
defaultUriMatchType = UriMatchType.DOMAIN,
showAutofillActionCard = false,
activeUserId = "activeUserId",
)