mirror of
https://github.com/bitwarden/android.git
synced 2026-08-25 17:09:55 -05:00
[PM-33517] feat: Add Plan row to Settings and premium upgrade flow (#6794)
This commit is contained in:
+19
@@ -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).
|
||||
*/
|
||||
|
||||
+17
-2
@@ -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?,
|
||||
|
||||
+75
-51
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+6
-1
@@ -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(
|
||||
|
||||
+3
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+67
-4
@@ -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,
|
||||
|
||||
+1
@@ -49,6 +49,7 @@ class PlanScreenTest : BitwardenComposeTest() {
|
||||
every {
|
||||
startAuthTab(uri = any(), authTabData = any(), launcher = any())
|
||||
} just runs
|
||||
every { launchUri(any()) } just runs
|
||||
}
|
||||
|
||||
@Before
|
||||
|
||||
+49
-4
@@ -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,
|
||||
|
||||
+10
@@ -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) }
|
||||
|
||||
+102
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user