diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanNavigation.kt index 478571fa24..5afb7d5734 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanNavigation.kt @@ -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 { + 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). */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt index e3d72c02fb..1bd2185c73 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt @@ -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?, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt index be9ab5859e..0e0b9c9916 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt @@ -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( - 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() } /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt index 47b308ef5e..bf36fc5a86 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt @@ -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( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt index ac3a9f9d8f..9a6103fffe 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt @@ -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() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt index d57984a730..00da8164c8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt @@ -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( 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 .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, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt index 372c63978f..6f6c2a1971 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt @@ -49,6 +49,7 @@ class PlanScreenTest : BitwardenComposeTest() { every { startAuthTab(uri = any(), authTabData = any(), launcher = any()) } just runs + every { launchUri(any()) } just runs } @Before diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt index c33e819a2e..73626c43b6 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt @@ -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, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt index 93d91ea7f5..7588e321a7 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt @@ -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() @@ -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) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt index cda476c63f..7811d6bd1d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -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 { + every { + getFeatureFlag(FlagKey.MobilePremiumUpgrade) + } answers { mutableMobilePremiumUpgradeFlagFlow.value } + every { + getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade) + } returns mutableMobilePremiumUpgradeFlagFlow + } private val firstTimeManager = mockk { 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) diff --git a/ui/src/main/res/drawable/ic_plan.xml b/ui/src/main/res/drawable/ic_plan.xml new file mode 100644 index 0000000000..0ab2e67fbb --- /dev/null +++ b/ui/src/main/res/drawable/ic_plan.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 7837b25db5..d191d98ad3 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1211,6 +1211,7 @@ Do you want to switch to this account? Archive unavailable Archiving items is a Premium feature. Your current plan does not include access to this feature. Upgrade to Premium + Plan Unlock advanced security features A Premium plan gives you more tools to stay secure and in control. This item is archived.