[PM-33517] feat: Add Plan row to Settings and premium upgrade flow (#6794)

This commit is contained in:
Patrick Honkonen
2026-04-22 14:42:13 +00:00
committed by GitHub
parent 83f8fca0d1
commit 72c6310f95
12 changed files with 369 additions and 63 deletions
@@ -9,6 +9,7 @@ import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import androidx.navigation.toRoute
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import com.bitwarden.ui.platform.util.ParcelableRouteSerializer
import kotlinx.parcelize.Parcelize
@@ -71,6 +72,17 @@ fun SavedStateHandle.toPlanArgs(): PlanArgs {
)
}
/**
* Register inside settingsGraph — bottom nav visible.
*/
fun NavGraphBuilder.planDestination(
onNavigateBack: () -> Unit,
) {
composableWithPushTransitions<PlanRoute.Standard> {
PlanScreen(onNavigateBack = onNavigateBack)
}
}
/**
* Register at parent vaultUnlockedGraph level — bottom nav hidden.
*/
@@ -82,6 +94,13 @@ fun NavGraphBuilder.planModalDestination(
}
}
/**
* Navigate to the plan screen (standard, within settings graph).
*/
fun NavController.navigateToPlan(navOptions: NavOptions? = null) {
navigate(route = PlanRoute.Standard, navOptions = navOptions)
}
/**
* Navigate to the plan screen (modal, at parent level).
*/
@@ -54,7 +54,7 @@ import com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers.PlanHandler
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
/**
* The screen for the plan -- shows upgrade flow for free users.
* The screen for the plan shows the upgrade flow for free users.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -98,7 +98,7 @@ fun PlanScreen(
},
topBar = {
BitwardenTopAppBar(
title = stringResource(id = BitwardenString.upgrade_to_premium),
title = stringResource(id = state.title),
scrollBehavior = scrollBehavior,
navigationIcon = rememberVectorPainter(id = state.navigationIcon),
navigationIconContentDescription = stringResource(
@@ -116,10 +116,25 @@ fun PlanScreen(
handlers = handlers,
)
}
PlanState.ViewState.Premium -> {
PremiumContent(modifier = Modifier.fillMaxSize())
}
}
}
}
@Composable
private fun PremiumContent(
modifier: Modifier = Modifier,
) {
// TODO(PM-35455): Render the premium subscription management UI —
// status badge, next-charge summary, billing / storage / discount /
// tax line items, and manage plan / cancel actions — once the
// subscription fetch path is wired up.
Spacer(modifier = modifier)
}
@Composable
private fun FreeDialogs(
dialogState: PlanState.DialogState?,
@@ -43,7 +43,9 @@ private const val PLACEHOLDER_RATE = "--"
const val PREMIUM_CHECKOUT_CALLBACK_URL = "bitwarden://premium-checkout-result"
/**
* View model for the plan screen, handling the free-user upgrade flow.
* View model for the plan screen, driving the upgrade flow for free users and a
* placeholder surface for premium users until PM-35455 wires in subscription
* management.
*/
@Suppress("TooManyFunctions")
@HiltViewModel
@@ -54,18 +56,27 @@ class PlanViewModel @Inject constructor(
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val vaultRepository: VaultRepository,
) : BaseViewModel<PlanState, PlanEvent, PlanAction>(
initialState = savedStateHandle[KEY_STATE]
?: PlanState(
planMode = savedStateHandle.toPlanArgs().planMode,
viewState = PlanState.ViewState.Free(
rate = PLACEHOLDER_RATE,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
),
initialState = savedStateHandle[KEY_STATE] ?: run {
val planMode = savedStateHandle.toPlanArgs().planMode
val isPremium = authRepository
.userStateFlow
.value
?.activeAccount
?.isPremium == true
PlanState(
planMode = planMode,
viewState = if (isPremium) {
PlanState.ViewState.Premium
} else {
PlanState.ViewState.Free(
rate = PLACEHOLDER_RATE,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
)
},
dialogState = null,
)
},
) {
init {
stateFlow
@@ -84,22 +95,21 @@ class PlanViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
viewModelScope.launch {
sendAction(
PlanAction.Internal.PricingResultReceive(
result = billingRepository.getPremiumPlanPricing(),
),
)
onFreeContent {
viewModelScope.launch {
sendAction(
PlanAction.Internal.PricingResultReceive(
result = billingRepository.getPremiumPlanPricing(),
),
)
}
}
}
override fun handleAction(action: PlanAction) {
when (action) {
is PlanAction.BackClick -> handleBackClick()
is PlanAction.UpgradeNowClick -> {
handleUpgradeNowClick()
}
is PlanAction.UpgradeNowClick -> handleUpgradeNowClick()
is PlanAction.DismissError -> handleDismissError()
is PlanAction.ClosePricingErrorClick -> {
handleClosePricingErrorClick()
@@ -346,7 +356,6 @@ class PlanViewModel @Inject constructor(
),
),
)
// TODO: transition to Premium ViewState (PM-33517)
}
private inline fun onFreeContent(
@@ -359,30 +368,32 @@ class PlanViewModel @Inject constructor(
private fun handlePricingResultReceive(
action: PlanAction.Internal.PricingResultReceive,
) {
onFreeContent { freeState ->
when (val result = action.result) {
is PremiumPlanPricingResult.Success -> {
val formattedRate = NumberFormat
.getCurrencyInstance(Locale.US)
.format(result.annualPrice / MONTHS_PER_YEAR)
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(rate = formattedRate),
dialogState = null,
)
when (val result = action.result) {
is PremiumPlanPricingResult.Success -> {
val formattedRate = NumberFormat
.getCurrencyInstance(Locale.US)
.format(result.annualPrice / MONTHS_PER_YEAR)
mutableStateFlow.update { currentState ->
val updatedViewState = when (val vs = currentState.viewState) {
is PlanState.ViewState.Free -> vs.copy(rate = formattedRate)
PlanState.ViewState.Premium -> vs
}
currentState.copy(
viewState = updatedViewState,
dialogState = null,
)
}
}
is PremiumPlanPricingResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.GetPricingError(
title = BitwardenString.pricing_unavailable.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.generic_error_message.asText(),
),
)
}
is PremiumPlanPricingResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.GetPricingError(
title = BitwardenString.pricing_unavailable.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.generic_error_message.asText(),
),
)
}
}
}
@@ -447,25 +458,38 @@ data class PlanState(
PlanMode.Modal -> BitwardenString.close
}
/**
* The title string resource for the top app bar.
*/
@get:StringRes
val title: Int
get() = when (viewState) {
is ViewState.Free -> BitwardenString.upgrade_to_premium
ViewState.Premium -> BitwardenString.plan
}
/**
* Models the content state of the plan screen.
*/
sealed class ViewState : Parcelable {
/**
* The monthly billing rate for the plan.
*/
abstract val rate: String
/**
* Free user view — shows upgrade pricing and feature list.
*/
@Parcelize
data class Free(
override val rate: String,
val rate: String,
val checkoutUrl: String?,
val isAwaitingPremiumStatus: Boolean,
) : ViewState()
/**
* Premium user view. Empty placeholder until PM-35455 wires
* subscription management (status, billing amount, next charge,
* manage plan / cancel actions).
*/
@Parcelize
data object Premium : ViewState()
}
/**
@@ -32,6 +32,8 @@ 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.feature.premium.plan.navigateToPlan
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.planDestination
import com.x8bit.bitwarden.ui.vault.feature.importitems.importItemsDestination
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@@ -103,7 +105,7 @@ fun SavedStateHandle.toSettingsArgs(): SettingsArgs {
/**
* Add settings destinations to the nav graph.
*/
@Suppress("LongParameterList")
@Suppress("LongMethod", "LongParameterList")
fun NavGraphBuilder.settingsGraph(
navController: NavController,
onNavigateToDeleteAccount: () -> Unit,
@@ -131,6 +133,7 @@ fun NavGraphBuilder.settingsGraph(
onNavigateToAutoFill = { navController.navigateToAutoFill() },
onNavigateToOther = { navController.navigateToOther(isPreAuth = false) },
onNavigateToVault = { navController.navigateToVaultSettings() },
onNavigateToPlan = { navController.navigateToPlan() },
)
}
aboutDestination(
@@ -174,6 +177,7 @@ fun NavGraphBuilder.settingsGraph(
)
blockAutoFillDestination(onNavigateBack = { navController.popBackStack() })
privilegedAppsListDestination(onNavigateBack = { navController.popBackStack() })
planDestination(onNavigateBack = { navController.popBackStack() })
}
}
@@ -192,6 +196,7 @@ fun NavGraphBuilder.preAuthSettingsDestinations(
onNavigateToAccountSecurity = { /* no-op */ },
onNavigateToAutoFill = { /* no-op */ },
onNavigateToVault = { /* no-op */ },
onNavigateToPlan = { /* no-op */ },
)
}
appearanceDestination(
@@ -46,6 +46,7 @@ fun SettingsScreen(
onNavigateToAutoFill: () -> Unit,
onNavigateToOther: () -> Unit,
onNavigateToVault: () -> Unit,
onNavigateToPlan: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
@@ -53,12 +54,13 @@ fun SettingsScreen(
when (event) {
SettingsEvent.NavigateBack -> onNavigateBack()
SettingsEvent.NavigateAbout -> onNavigateToAbout()
SettingsEvent.NavigateAccountSecurity -> onNavigateToAccountSecurity.invoke()
SettingsEvent.NavigateAccountSecurity -> onNavigateToAccountSecurity()
SettingsEvent.NavigateAppearance -> onNavigateToAppearance()
SettingsEvent.NavigateAutoFill -> onNavigateToAutoFill()
SettingsEvent.NavigateOther -> onNavigateToOther()
SettingsEvent.NavigateVault -> onNavigateToVault()
SettingsEvent.NavigateAccountSecurityShortcut -> onNavigateToAccountSecurity()
SettingsEvent.NavigatePlan -> onNavigateToPlan()
}
}
@@ -4,12 +4,14 @@ import androidx.annotation.DrawableRes
import androidx.compose.material3.Text
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.DeferredBackgroundEvent
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@@ -18,6 +20,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import javax.inject.Inject
@@ -29,6 +32,7 @@ import javax.inject.Inject
class SettingsViewModel @Inject constructor(
specialCircumstanceManager: SpecialCircumstanceManager,
firstTimeActionManager: FirstTimeActionManager,
featureFlagManager: FeatureFlagManager,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<SettingsState, SettingsEvent, SettingsAction>(
initialState = SettingsState(
@@ -36,6 +40,8 @@ class SettingsViewModel @Inject constructor(
securityCount = firstTimeActionManager.allSecuritySettingsBadgeCountFlow.value,
autoFillCount = firstTimeActionManager.allAutofillSettingsBadgeCountFlow.value,
vaultCount = firstTimeActionManager.allVaultSettingsBadgeCountFlow.value,
isMobilePremiumUpgradeEnabled = featureFlagManager
.getFeatureFlag(FlagKey.MobilePremiumUpgrade),
),
) {
@@ -54,6 +60,16 @@ class SettingsViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
featureFlagManager
.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade)
.map {
SettingsAction.Internal.MobilePremiumUpgradeFlagUpdate(
isMobilePremiumUpgradeEnabled = it,
)
}
.onEach(::sendAction)
.launchIn(viewModelScope)
when (specialCircumstanceManager.specialCircumstance) {
SpecialCircumstance.AccountSecurityShortcut -> {
sendEvent(SettingsEvent.NavigateAccountSecurityShortcut)
@@ -66,10 +82,14 @@ class SettingsViewModel @Inject constructor(
override fun handleAction(action: SettingsAction): Unit = when (action) {
is SettingsAction.CloseClick -> handleCloseClick()
is SettingsAction.SettingsClick -> handleAccountSecurityClick(action)
is SettingsAction.SettingsClick -> handleSettingsClick(action)
is SettingsAction.Internal.SettingsNotificationCountUpdate -> {
handleSettingsNotificationCountUpdate(action)
}
is SettingsAction.Internal.MobilePremiumUpgradeFlagUpdate -> {
handleMobilePremiumUpgradeFlagUpdate(action)
}
}
private fun handleCloseClick() {
@@ -88,7 +108,18 @@ class SettingsViewModel @Inject constructor(
}
}
private fun handleAccountSecurityClick(action: SettingsAction.SettingsClick) {
private fun handleMobilePremiumUpgradeFlagUpdate(
action: SettingsAction.Internal.MobilePremiumUpgradeFlagUpdate,
) {
mutableStateFlow.update {
it.copy(
isMobilePremiumUpgradeEnabled =
action.isMobilePremiumUpgradeEnabled,
)
}
}
private fun handleSettingsClick(action: SettingsAction.SettingsClick) {
when (action.settings) {
Settings.ACCOUNT_SECURITY -> {
sendEvent(SettingsEvent.NavigateAccountSecurity)
@@ -106,6 +137,10 @@ class SettingsViewModel @Inject constructor(
sendEvent(SettingsEvent.NavigateAppearance)
}
Settings.PLAN -> {
sendEvent(SettingsEvent.NavigatePlan)
}
Settings.OTHER -> {
sendEvent(SettingsEvent.NavigateOther)
}
@@ -125,8 +160,18 @@ data class SettingsState(
private val autoFillCount: Int,
private val securityCount: Int,
private val vaultCount: Int,
private val isMobilePremiumUpgradeEnabled: Boolean = false,
) {
val shouldShowCloseButton: Boolean = isPreAuth
/**
* Whether the plan row should be shown. The row is visible when the
* mobile premium upgrade feature flag is enabled and the user is
* authenticated.
*/
private val shouldShowPlanRow: Boolean =
!isPreAuth && isMobilePremiumUpgradeEnabled
val settingRows: ImmutableList<Settings> = Settings
.entries
.filter { setting ->
@@ -135,6 +180,7 @@ data class SettingsState(
Settings.AUTO_FILL -> !isPreAuth
Settings.VAULT -> !isPreAuth
Settings.APPEARANCE -> true
Settings.PLAN -> shouldShowPlanRow
Settings.OTHER -> true
Settings.ABOUT -> true
}
@@ -168,7 +214,7 @@ sealed class SettingsEvent {
data object NavigateAccountSecurity : SettingsEvent()
/**
* Navigate to the account security screen.
* Navigate to the account security screen via shortcut.
*/
data object NavigateAccountSecurityShortcut : SettingsEvent(), DeferredBackgroundEvent
@@ -191,6 +237,11 @@ sealed class SettingsEvent {
* Navigate to the vault screen.
*/
data object NavigateVault : SettingsEvent()
/**
* Navigate to the plan screen.
*/
data object NavigatePlan : SettingsEvent()
}
/**
@@ -198,7 +249,7 @@ sealed class SettingsEvent {
*/
sealed class SettingsAction {
/**
* THe user has clicked the close button
* The user has clicked the close button.
*/
data object CloseClick : SettingsAction()
@@ -221,6 +272,13 @@ sealed class SettingsAction {
val securityCount: Int,
val vaultCount: Int,
) : Internal()
/**
* Update the mobile premium upgrade feature flag state.
*/
data class MobilePremiumUpgradeFlagUpdate(
val isMobilePremiumUpgradeEnabled: Boolean,
) : Internal()
}
}
@@ -255,6 +313,11 @@ enum class Settings(
vectorIconRes = BitwardenDrawable.ic_paintbrush,
testTag = "AppearanceSettingsButton",
),
PLAN(
text = BitwardenString.plan.asText(),
vectorIconRes = BitwardenDrawable.ic_plan,
testTag = "PlanSettingsButton",
),
OTHER(
text = BitwardenString.other.asText(),
vectorIconRes = BitwardenDrawable.ic_filter,
@@ -49,6 +49,7 @@ class PlanScreenTest : BitwardenComposeTest() {
every {
startAuthTab(uri = any(), authTabData = any(), launcher = any())
} just runs
every { launchUri(any()) } just runs
}
@Before
@@ -599,7 +599,7 @@ class PlanViewModelTest : BaseViewModelTest() {
// region Pricing fetch
@Test
fun `initial state before pricing fetch resolves should show Loading dialog`() =
fun `initial state before pricing fetch resolves should show placeholder rate`() =
runTest {
val viewModel = createViewModel(pricingResult = null)
@@ -612,9 +612,7 @@ class PlanViewModelTest : BaseViewModelTest() {
checkoutUrl = null,
isAwaitingPremiumStatus = false,
),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
dialogState = null,
),
awaitItem(),
)
@@ -750,8 +748,55 @@ class PlanViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `init should fetch pricing for Free viewstate`() = runTest {
createViewModel()
coVerify(exactly = 1) {
mockBillingRepository.getPremiumPlanPricing()
}
}
@Test
fun `init should not fetch pricing for Premium viewstate`() = runTest {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isPremium = true)),
)
createViewModel()
coVerify(exactly = 0) {
mockBillingRepository.getPremiumPlanPricing()
}
}
// endregion Pricing fetch
// region Premium user path
@Test
fun `initial state should be Premium ViewState for premium user`() =
runTest {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isPremium = true)),
)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Premium,
dialogState = null,
),
awaitItem(),
)
}
}
// endregion Premium user path
private fun createViewModel(
initialState: PlanState? = null,
planMode: PlanMode = PlanMode.Modal,
@@ -25,6 +25,7 @@ class SettingsScreenTest : BitwardenComposeTest() {
private var haveCalledNavigateToOther = false
private var haveCalledNavigateToVault = false
private var haveCalledNavigateBack = false
private var haveCalledNavigateToPlan = false
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<SettingsEvent>()
@@ -45,6 +46,9 @@ class SettingsScreenTest : BitwardenComposeTest() {
onNavigateToOther = { haveCalledNavigateToOther = true },
onNavigateToVault = { haveCalledNavigateToVault = true },
onNavigateBack = { haveCalledNavigateBack = true },
onNavigateToPlan = {
haveCalledNavigateToPlan = true
},
)
}
}
@@ -145,6 +149,12 @@ class SettingsScreenTest : BitwardenComposeTest() {
assertTrue(haveCalledNavigateBack)
}
@Test
fun `on NavigatePlan should call onNavigateToPlan`() {
mutableEventFlow.tryEmit(SettingsEvent.NavigatePlan)
assertTrue(haveCalledNavigateToPlan)
}
@Test
fun `should display correct items according to state`() {
mutableStateFlow.update { it.copy(isPreAuth = false) }
@@ -2,7 +2,9 @@ package com.x8bit.bitwarden.ui.platform.feature.settings
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@@ -18,6 +20,8 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -26,6 +30,16 @@ class SettingsViewModelTest : BaseViewModelTest() {
private val mutableAutofillBadgeCountFlow = MutableStateFlow(0)
private val mutableVaultBadgeCountFlow = MutableStateFlow(0)
private val mutableSecurityBadgeCountFlow = MutableStateFlow(0)
private val mutableMobilePremiumUpgradeFlagFlow = MutableStateFlow(false)
private val featureFlagManager = mockk<FeatureFlagManager> {
every {
getFeatureFlag(FlagKey.MobilePremiumUpgrade)
} answers { mutableMobilePremiumUpgradeFlagFlow.value }
every {
getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade)
} returns mutableMobilePremiumUpgradeFlagFlow
}
private val firstTimeManager = mockk<FirstTimeActionManager> {
every { allSecuritySettingsBadgeCountFlow } returns mutableSecurityBadgeCountFlow
every { allAutofillSettingsBadgeCountFlow } returns mutableAutofillBadgeCountFlow
@@ -108,6 +122,18 @@ class SettingsViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `on SettingsClick with PLAN should emit NavigatePlan`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
SettingsAction.SettingsClick(Settings.PLAN),
)
assertEquals(SettingsEvent.NavigatePlan, awaitItem())
}
}
@Test
fun `initial state reflects the current state of the repository`() {
mutableAutofillBadgeCountFlow.update { 1 }
@@ -185,8 +211,84 @@ class SettingsViewModelTest : BaseViewModelTest() {
verify { specialCircumstanceManager.specialCircumstance = null }
}
@Test
fun `Plan row should appear when feature flag is enabled and not preAuth`() {
every {
featureFlagManager.getFeatureFlag(FlagKey.MobilePremiumUpgrade)
} returns true
mutableMobilePremiumUpgradeFlagFlow.value = true
val viewModel = createViewModel()
assertTrue(
viewModel.stateFlow.value.settingRows
.contains(Settings.PLAN),
)
}
@Test
fun `Plan row should be hidden when feature flag is disabled`() {
every {
featureFlagManager.getFeatureFlag(FlagKey.MobilePremiumUpgrade)
} returns false
val viewModel = createViewModel()
assertFalse(
viewModel.stateFlow.value.settingRows
.contains(Settings.PLAN),
)
}
@Test
fun `Plan row should be hidden in preAuth mode`() {
every {
featureFlagManager.getFeatureFlag(FlagKey.MobilePremiumUpgrade)
} returns true
mutableMobilePremiumUpgradeFlagFlow.value = true
val viewModel = createViewModel(isPreAuth = true)
assertFalse(
viewModel.stateFlow.value.settingRows
.contains(Settings.PLAN),
)
}
@Test
fun `Plan row should appear between Appearance and Other in settings rows`() {
every {
featureFlagManager.getFeatureFlag(FlagKey.MobilePremiumUpgrade)
} returns true
mutableMobilePremiumUpgradeFlagFlow.value = true
val viewModel = createViewModel()
val rows = viewModel.stateFlow.value.settingRows
val planIndex = rows.indexOf(Settings.PLAN)
val appearanceIndex = rows.indexOf(Settings.APPEARANCE)
val otherIndex = rows.indexOf(Settings.OTHER)
assertTrue(planIndex > appearanceIndex)
assertTrue(planIndex < otherIndex)
}
@Test
fun `Plan row should update when feature flag changes to enabled`() =
runTest {
every {
featureFlagManager.getFeatureFlag(
FlagKey.MobilePremiumUpgrade,
)
} returns false
val viewModel = createViewModel()
assertFalse(
viewModel.stateFlow.value.settingRows
.contains(Settings.PLAN),
)
mutableMobilePremiumUpgradeFlagFlow.value = true
viewModel.stateFlow.test {
assertTrue(
awaitItem().settingRows.contains(Settings.PLAN),
)
}
}
private fun createViewModel(isPreAuth: Boolean = false) = SettingsViewModel(
firstTimeActionManager = firstTimeManager,
featureFlagManager = featureFlagManager,
specialCircumstanceManager = specialCircumstanceManager,
savedStateHandle = SavedStateHandle().apply {
every { toSettingsArgs() } returns SettingsArgs(isPreAuth = isPreAuth)
+19
View File
@@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M8.464,6.956C8.05,6.956 7.714,7.292 7.714,7.706C7.714,8.121 8.05,8.456 8.464,8.456H14.54C14.954,8.456 15.29,8.121 15.29,7.706C15.29,7.292 14.954,6.956 14.54,6.956H8.464Z"
android:fillColor="#5A6D91"/>
<path
android:pathData="M8.464,10.26C8.05,10.26 7.714,10.596 7.714,11.01C7.714,11.425 8.05,11.76 8.464,11.76H14.54C14.954,11.76 15.29,11.425 15.29,11.01C15.29,10.596 14.954,10.26 14.54,10.26H8.464Z"
android:fillColor="#5A6D91"/>
<path
android:pathData="M12.515,13.564C12.101,13.564 11.765,13.9 11.765,14.314C11.765,14.729 12.101,15.064 12.515,15.064H14.54C14.954,15.064 15.29,14.729 15.29,14.314C15.29,13.9 14.954,13.564 14.54,13.564H12.515Z"
android:fillColor="#5A6D91"/>
<path
android:pathData="M18.253,22.002C18.667,22.002 19.003,21.666 19.003,21.252V4.05C19.003,2.918 18.085,2 16.953,2H6.05C4.918,2 4,2.918 4,4.05V21.252C4,21.666 4.336,22.002 4.75,22.002H6.036C6.184,22.002 6.329,21.958 6.452,21.876L8.631,20.424L11.119,21.897C11.355,22.037 11.648,22.037 11.884,21.897L14.372,20.424L16.551,21.876C16.674,21.958 16.819,22.002 16.967,22.002H18.253ZM17.503,4.05V20.502H17.194L14.418,18.653L11.502,20.381L8.584,18.653L5.809,20.502H5.5V4.05C5.5,3.746 5.746,3.5 6.05,3.5H16.953C17.257,3.5 17.503,3.746 17.503,4.05Z"
android:fillColor="#5A6D91"
android:fillType="evenOdd"/>
</vector>
+1
View File
@@ -1211,6 +1211,7 @@ Do you want to switch to this account?</string>
<string name="archive_unavailable">Archive unavailable</string>
<string name="archiving_items_is_a_premium_feature">Archiving items is a Premium feature. Your current plan does not include access to this feature.</string>
<string name="upgrade_to_premium">Upgrade to Premium</string>
<string name="plan">Plan</string>
<string name="unlock_advanced_security_features">Unlock advanced security features</string>
<string name="a_premium_plan_gives_you_more_tools_to_stay_secure_and_in_control">A Premium plan gives you more tools to stay secure and in control.</string>
<string name="this_item_is_archived">This item is archived.</string>