mirror of
https://github.com/bitwarden/android.git
synced 2024-12-18 07:11:51 +03:00
PM-12773 show autofill card when user skipped this step in onboarding (#4021)
This commit is contained in:
parent
a5cf4f49d7
commit
83652c9699
6 changed files with 237 additions and 16 deletions
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,11 +26,14 @@ 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(
|
||||
?: run {
|
||||
val userId = requireNotNull(authRepository.userStateFlow.value).activeUserId
|
||||
AutoFillState(
|
||||
isAskToAddLoginEnabled = !settingsRepository.isAutofillSavePromptDisabled,
|
||||
isAccessibilityAutofillEnabled = settingsRepository
|
||||
.isAccessibilityEnabledStateFlow
|
||||
|
@ -38,9 +42,14 @@ class AutoFillViewModel @Inject constructor(
|
|||
isCopyTotpAutomaticallyEnabled = !settingsRepository.isAutoCopyTotpDisabled,
|
||||
isUseInlineAutoFillEnabled = settingsRepository.isInlineAutofillEnabled,
|
||||
showInlineAutofillOption = !isBuildVersionBelow(Build.VERSION_CODES.R),
|
||||
showPasskeyManagementRow = !isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE),
|
||||
defaultUriMatchType = settingsRepository.defaultUriMatchType,
|
||||
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,8 +105,27 @@ 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
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue