[PM-35455] feat: Wire premium subscription data into Plan screen (#6819)

This commit is contained in:
Patrick Honkonen
2026-04-28 16:04:31 -04:00
committed by GitHub
parent be1dabb9dc
commit 32b704cfde
7 changed files with 1790 additions and 107 deletions

View File

@@ -1,5 +1,8 @@
@file:Suppress("TooManyFunctions")
package com.x8bit.bitwarden.ui.platform.feature.premium.plan
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -18,7 +21,9 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -33,7 +38,9 @@ import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.cardStyle
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.badge.BitwardenStatusBadge
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.content.BitwardenContentBlock
import com.bitwarden.ui.platform.components.content.model.ContentBlockData
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
@@ -49,13 +56,20 @@ import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers.PlanHandlers
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.badgeColors
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.labelRes
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
/**
* The screen for the plan — shows the upgrade flow for free users.
* The screen for the plan — shows the upgrade flow for free users and the
* subscription-management surface for premium users.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlanScreen(
@@ -78,12 +92,13 @@ fun PlanScreen(
)
}
is PlanEvent.LaunchPortal -> intentManager.launchUri(event.url.toUri())
PlanEvent.NavigateBack -> onNavigateBack()
is PlanEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data)
}
}
FreeDialogs(
PlanDialogs(
dialogState = state.dialogState,
handlers = handlers,
)
@@ -112,31 +127,23 @@ fun PlanScreen(
is PlanState.ViewState.Free -> {
FreeContent(
viewState = viewState,
isDialogShowing = state.dialogState != null,
handlers = handlers,
)
}
PlanState.ViewState.Premium -> {
PremiumContent(modifier = Modifier.fillMaxSize())
is PlanState.ViewState.Premium -> {
PremiumContent(
viewState = viewState,
handlers = handlers,
)
}
}
}
}
@Suppress("LongMethod")
@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(
private fun PlanDialogs(
dialogState: PlanState.DialogState?,
handlers: PlanHandlers,
) {
@@ -191,6 +198,51 @@ private fun FreeDialogs(
)
}
is PlanState.DialogState.CancelConfirmation -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.cancel_premium),
message = stringResource(
id = BitwardenString.cancel_premium_confirmation,
dialogState.nextRenewalDate,
),
confirmButtonText = stringResource(id = BitwardenString.cancel_now),
dismissButtonText = stringResource(id = BitwardenString.close),
onConfirmClick = handlers.onConfirmCancelClick,
onDismissClick = handlers.onDismissCancelConfirmation,
onDismissRequest = handlers.onDismissCancelConfirmation,
)
}
is PlanState.DialogState.PortalError -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.portal_error),
message = stringResource(id = BitwardenString.trouble_loading_portal),
confirmButtonText = stringResource(id = BitwardenString.try_again),
dismissButtonText = stringResource(id = BitwardenString.close),
onConfirmClick = handlers.onManagePlanClick,
onDismissClick = handlers.onDismissPortalError,
onDismissRequest = handlers.onDismissPortalError,
)
}
is PlanState.DialogState.SubscriptionError -> {
BitwardenTwoButtonDialog(
title = dialogState.title(),
message = dialogState.message(),
confirmButtonText = stringResource(id = BitwardenString.try_again),
dismissButtonText = stringResource(id = BitwardenString.close),
onConfirmClick = handlers.onRetrySubscriptionClick,
onDismissClick = handlers.onBackClick,
onDismissRequest = handlers.onBackClick,
)
}
PlanState.DialogState.LoadingPortal -> {
BitwardenLoadingDialog(
text = stringResource(id = BitwardenString.loading_portal),
)
}
is PlanState.DialogState.Loading -> {
BitwardenLoadingDialog(text = dialogState.message())
}
@@ -202,7 +254,6 @@ private fun FreeDialogs(
@Composable
private fun FreeContent(
viewState: PlanState.ViewState.Free,
isDialogShowing: Boolean,
handlers: PlanHandlers,
modifier: Modifier = Modifier,
) {
@@ -223,7 +274,6 @@ private fun FreeContent(
BitwardenFilledButton(
label = stringResource(id = BitwardenString.upgrade_now),
onClick = handlers.onUpgradeNowClick,
isEnabled = !isDialogShowing,
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
modifier = Modifier
.standardHorizontalMargin()
@@ -329,6 +379,183 @@ private fun PriceRow(
}
}
@Composable
private fun PremiumContent(
viewState: PlanState.ViewState.Premium,
handlers: PlanHandlers,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(12.dp))
SubscriptionCard(
viewState = viewState,
modifier = Modifier.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.manage_plan),
onClick = handlers.onManagePlanClick,
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.testTag("ManagePlanButton"),
)
if (viewState.showCancelButton) {
Spacer(modifier = Modifier.height(12.dp))
BitwardenOutlinedButton(
label = stringResource(id = BitwardenString.cancel_premium),
onClick = handlers.onCancelPremiumClick,
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.testTag("CancelPremiumButton"),
)
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Suppress("LongMethod")
@Composable
private fun SubscriptionCard(
viewState: PlanState.ViewState.Premium,
modifier: Modifier = Modifier,
) {
val rowModifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
Column(
modifier = modifier
.fillMaxWidth()
.cardStyle(
cardStyle = CardStyle.Full,
// Override bottom padding; the final row owns its own spacing.
paddingBottom = 0.dp,
),
) {
SubscriptionHeader(
status = viewState.status,
descriptionText = viewState.descriptionText,
modifier = Modifier
.padding(bottom = 16.dp)
.standardHorizontalMargin(),
)
BitwardenHorizontalDivider()
SubscriptionLineItem(
label = stringResource(id = BitwardenString.billing_amount),
value = viewState.billingAmountText(),
testTag = "BillingAmountRow",
modifier = rowModifier,
)
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.storage_cost),
value = viewState.storageCostText,
testTag = "StorageCostRow",
modifier = rowModifier,
)
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.discount),
value = viewState.discountAmountText,
valueColor = if (viewState.discountAmountText == "--") {
BitwardenTheme.colorScheme.text.primary
} else {
BitwardenTheme.colorScheme.statusBadge.success.text
},
testTag = "DiscountRow",
modifier = rowModifier,
)
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.estimated_tax),
value = viewState.estimatedTaxText,
testTag = "EstimatedTaxRow",
modifier = rowModifier,
)
}
}
@Composable
private fun SubscriptionHeader(
status: PremiumSubscriptionStatus?,
descriptionText: Text?,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(id = BitwardenString.premium_plan_name),
style = BitwardenTheme.typography.titleLarge,
color = BitwardenTheme.colorScheme.text.primary,
)
status?.let {
Spacer(modifier = Modifier.width(8.dp))
BitwardenStatusBadge(
label = stringResource(id = it.labelRes()),
colors = it.badgeColors(),
)
}
}
descriptionText?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = it(),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
)
}
}
}
@Composable
private fun SubscriptionLineItem(
label: String,
value: String,
testTag: String,
modifier: Modifier = Modifier,
valueColor: Color = BitwardenTheme.colorScheme.text.primary,
) {
Row(
modifier = modifier
.padding(vertical = 16.dp)
.testTag(testTag),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = BitwardenTheme.typography.bodyLarge,
color = BitwardenTheme.colorScheme.text.secondary,
)
Text(
text = value,
style = BitwardenTheme.typography.bodyLarge,
color = valueColor,
)
}
}
@Preview
@OmitFromCoverage
@Composable
@@ -341,7 +568,6 @@ private fun PlanScreenFreeAccount_preview() {
checkoutUrl = null,
isAwaitingPremiumStatus = false,
),
isDialogShowing = false,
handlers = PlanHandlers(
onBackClick = {},
onUpgradeNowClick = {},
@@ -353,6 +579,55 @@ private fun PlanScreenFreeAccount_preview() {
onGoBackClick = {},
onSyncClick = {},
onContinueClick = {},
onManagePlanClick = {},
onCancelPremiumClick = {},
onConfirmCancelClick = {},
onDismissCancelConfirmation = {},
onDismissPortalError = {},
onRetrySubscriptionClick = {},
),
)
}
}
}
@Preview
@OmitFromCoverage
@Composable
private fun PlanScreenPremiumAccount_preview() {
BitwardenTheme {
BitwardenScaffold {
PremiumContent(
viewState = PlanState.ViewState.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
descriptionText = BitwardenString.premium_next_charge_summary.asText(
"$45.55",
"April 2, 2026",
),
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = "$24.00",
discountAmountText = "-$2.10",
estimatedTaxText = "$3.85",
nextChargeDateText = "April 2, 2026",
showCancelButton = true,
),
handlers = PlanHandlers(
onBackClick = {},
onUpgradeNowClick = {},
onDismissError = {},
onRetryClick = {},
onRetryPricingClick = {},
onClosePricingErrorClick = {},
onCancelWaiting = {},
onGoBackClick = {},
onSyncClick = {},
onContinueClick = {},
onManagePlanClick = {},
onCancelPremiumClick = {},
onConfirmCancelClick = {},
onDismissCancelConfirmation = {},
onDismissPortalError = {},
onRetrySubscriptionClick = {},
),
)
}

View File

@@ -5,6 +5,7 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.util.toFormattedDateStyle
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
@@ -16,7 +17,12 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@@ -29,13 +35,17 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import java.math.BigDecimal
import java.text.NumberFormat
import java.time.Clock
import java.time.Instant
import java.time.format.FormatStyle
import java.util.Locale
import javax.inject.Inject
private const val KEY_STATE = "state"
private const val MONTHS_PER_YEAR = 12
private const val PLACEHOLDER_RATE = "--"
private const val PLACEHOLDER_TEXT = "--"
/**
* The callback URL for the premium checkout custom tab.
@@ -43,11 +53,10 @@ private const val PLACEHOLDER_RATE = "--"
const val PREMIUM_CHECKOUT_CALLBACK_URL = "bitwarden://premium-checkout-result"
/**
* 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.
* View model for the plan screen, driving the upgrade flow for free users and
* the subscription management surface for premium users.
*/
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LargeClass")
@HiltViewModel
class PlanViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
@@ -55,6 +64,7 @@ class PlanViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val vaultRepository: VaultRepository,
private val clock: Clock,
) : BaseViewModel<PlanState, PlanEvent, PlanAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val planMode = savedStateHandle.toPlanArgs().planMode
@@ -66,10 +76,10 @@ class PlanViewModel @Inject constructor(
PlanState(
planMode = planMode,
viewState = if (isPremium) {
PlanState.ViewState.Premium
PlanState.ViewState.Premium()
} else {
PlanState.ViewState.Free(
rate = PLACEHOLDER_RATE,
rate = PLACEHOLDER_TEXT,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
)
@@ -78,6 +88,10 @@ class PlanViewModel @Inject constructor(
)
},
) {
private val currencyFormatter: NumberFormat =
NumberFormat.getCurrencyInstance(Locale.US)
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
@@ -104,6 +118,23 @@ class PlanViewModel @Inject constructor(
)
}
}
onPremiumContent {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
}
viewModelScope.launch {
sendAction(
PlanAction.Internal.SubscriptionResultReceive(
result = billingRepository.getSubscription(),
),
)
}
}
}
override fun handleAction(action: PlanAction) {
@@ -111,37 +142,30 @@ class PlanViewModel @Inject constructor(
is PlanAction.BackClick -> handleBackClick()
is PlanAction.UpgradeNowClick -> handleUpgradeNowClick()
is PlanAction.DismissError -> handleDismissError()
is PlanAction.ClosePricingErrorClick -> {
handleClosePricingErrorClick()
}
is PlanAction.ClosePricingErrorClick -> handleClosePricingErrorClick()
is PlanAction.RetryClick -> handleRetryClick()
is PlanAction.RetryPricingClick -> {
handleRetryPricingClick()
}
is PlanAction.RetryPricingClick -> handleRetryPricingClick()
is PlanAction.CancelWaiting -> handleCancelWaiting()
is PlanAction.GoBackClick -> handleGoBackClick()
is PlanAction.SyncClick -> handleSyncClick()
is PlanAction.ContinueClick -> handleContinueClick()
is PlanAction.Internal.CheckoutUrlReceive -> {
handleCheckoutUrlReceive(action)
}
is PlanAction.Internal.UserStateUpdateReceive -> {
handleUserStateUpdateReceive(action)
}
is PlanAction.ManagePlanClick -> handleManagePlanClick()
is PlanAction.CancelPremiumClick -> handleCancelPremiumClick()
is PlanAction.ConfirmCancelClick -> handleConfirmCancelClick()
is PlanAction.DismissCancelConfirmation -> handleDismissCancelConfirmation()
is PlanAction.DismissPortalError -> handleDismissPortalError()
is PlanAction.RetrySubscriptionClick -> handleRetrySubscriptionClick()
is PlanAction.Internal.CheckoutUrlReceive -> handleCheckoutUrlReceive(action)
is PlanAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action)
is PlanAction.Internal.SpecialCircumstanceReceive -> {
handleSpecialCircumstanceReceive(action)
}
is PlanAction.Internal.SyncCompleteReceive -> {
handleSyncCompleteReceive()
}
is PlanAction.Internal.PricingResultReceive -> {
handlePricingResultReceive(action)
is PlanAction.Internal.SyncCompleteReceive -> handleSyncCompleteReceive()
is PlanAction.Internal.PricingResultReceive -> handlePricingResultReceive(action)
is PlanAction.Internal.PortalUrlReceive -> handlePortalUrlReceive(action)
is PlanAction.Internal.SubscriptionResultReceive -> {
handleSubscriptionResultReceive(action)
}
}
}
@@ -150,6 +174,8 @@ class PlanViewModel @Inject constructor(
sendEvent(PlanEvent.NavigateBack)
}
// region Free user handlers
private fun handleUpgradeNowClick() {
mutableStateFlow.update {
it.copy(
@@ -248,6 +274,119 @@ class PlanViewModel @Inject constructor(
}
}
// endregion Free user handlers
// region Premium user handlers
private fun handleManagePlanClick() {
launchPortalFetch()
}
private fun handleCancelPremiumClick() {
onPremiumContent { premiumState ->
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.CancelConfirmation(
nextRenewalDate = premiumState.nextChargeDateText
?: PLACEHOLDER_TEXT,
),
)
}
}
}
private fun handleConfirmCancelClick() {
launchPortalFetch()
}
private fun handleDismissCancelConfirmation() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun handleDismissPortalError() {
mutableStateFlow.update { it.copy(dialogState = null) }
}
private fun launchPortalFetch() {
mutableStateFlow.update {
it.copy(dialogState = PlanState.DialogState.LoadingPortal)
}
viewModelScope.launch {
sendAction(
PlanAction.Internal.PortalUrlReceive(
result = billingRepository.getPortalUrl(),
),
)
}
}
private fun handlePortalUrlReceive(
action: PlanAction.Internal.PortalUrlReceive,
) {
when (val result = action.result) {
is CustomerPortalResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(PlanEvent.LaunchPortal(url = result.url))
}
is CustomerPortalResult.Error -> {
mutableStateFlow.update {
it.copy(dialogState = PlanState.DialogState.PortalError)
}
}
}
}
private fun handleRetrySubscriptionClick() {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
}
viewModelScope.launch {
sendAction(
PlanAction.Internal.SubscriptionResultReceive(
result = billingRepository.getSubscription(),
),
)
}
}
private fun handleSubscriptionResultReceive(
action: PlanAction.Internal.SubscriptionResultReceive,
) {
when (val result = action.result) {
is SubscriptionResult.Success -> {
val info = result.subscription
mutableStateFlow.update {
it.copy(
viewState = info.toPremiumViewState(),
dialogState = null,
)
}
}
is SubscriptionResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.SubscriptionError(
title = BitwardenString.subscription_error.asText(),
message = BitwardenString
.trouble_loading_subscription
.asText(),
),
)
}
}
}
}
// endregion Premium user handlers
// region Shared handlers
private fun handleUserStateUpdateReceive(
action: PlanAction.Internal.UserStateUpdateReceive,
) {
@@ -269,7 +408,6 @@ class PlanViewModel @Inject constructor(
specialCircumstanceManager.specialCircumstance = null
if (checkoutResult.callbackResult is PremiumCheckoutCallbackResult.Canceled) {
// User canceled checkout — show "Payment not received yet" dialog.
onFreeContent { freeState ->
mutableStateFlow.update {
it.copy(
@@ -283,7 +421,6 @@ class PlanViewModel @Inject constructor(
return
}
// Success — check if already premium, otherwise trigger background sync.
val isPremium = authRepository
.userStateFlow
.value
@@ -327,8 +464,6 @@ class PlanViewModel @Inject constructor(
if (isPremium) {
onPremiumUpgradeSuccess()
} else {
// Sync completed but premium not yet provisioned —
// prompt the user to retry or continue as free.
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.PendingUpgrade,
@@ -358,25 +493,17 @@ class PlanViewModel @Inject constructor(
)
}
private inline fun onFreeContent(
block: (PlanState.ViewState.Free) -> Unit,
) {
(state.viewState as? PlanState.ViewState.Free)
?.let(block)
}
private fun handlePricingResultReceive(
action: PlanAction.Internal.PricingResultReceive,
) {
when (val result = action.result) {
is PremiumPlanPricingResult.Success -> {
val formattedRate = NumberFormat
.getCurrencyInstance(Locale.US)
val formattedRate = currencyFormatter
.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
is PlanState.ViewState.Premium -> vs
}
currentState.copy(
viewState = updatedViewState,
@@ -415,6 +542,100 @@ class PlanViewModel @Inject constructor(
)
}
}
private inline fun onFreeContent(
block: (PlanState.ViewState.Free) -> Unit,
) {
(state.viewState as? PlanState.ViewState.Free)?.let(block)
}
private inline fun onPremiumContent(
block: (PlanState.ViewState.Premium) -> Unit,
) {
(state.viewState as? PlanState.ViewState.Premium)?.let(block)
}
private fun SubscriptionInfo.toPremiumViewState(): PlanState.ViewState.Premium {
val formattedTotal = currencyFormatter.format(nextChargeTotal)
val formattedDate = nextCharge?.toLocalizedDate()
val formattedCanceled = canceledDate?.toLocalizedDate()
val formattedSuspension = suspensionDate?.toLocalizedDate()
return PlanState.ViewState.Premium(
status = status,
descriptionText = toDescriptionText(
formattedTotal = formattedTotal,
nextChargeDate = formattedDate,
canceledDate = formattedCanceled,
suspensionDate = formattedSuspension,
),
billingAmountText = seatsCost.toBillingAmountText(cadence),
storageCostText = storageCost.toMoneyText(),
discountAmountText = discountAmount.toMoneyText(negative = true),
estimatedTaxText = estimatedTax.toMoneyText(),
nextChargeDateText = formattedDate,
showCancelButton = status != PremiumSubscriptionStatus.CANCELED,
)
}
private fun BigDecimal.toBillingAmountText(cadence: PlanCadence): Text {
if (this.signum() == 0) return PLACEHOLDER_TEXT.asText()
val formatted = currencyFormatter.format(this)
val cadenceRes = when (cadence) {
PlanCadence.ANNUALLY -> BitwardenString.billing_rate_per_year
PlanCadence.MONTHLY -> BitwardenString.billing_rate_per_month
}
return cadenceRes.asText(formatted)
}
private fun BigDecimal?.toMoneyText(negative: Boolean = false): String =
when {
this == null || this.signum() == 0 -> PLACEHOLDER_TEXT
negative -> "-${currencyFormatter.format(this)}"
else -> currencyFormatter.format(this)
}
private fun SubscriptionInfo.toDescriptionText(
formattedTotal: String,
nextChargeDate: String?,
canceledDate: String?,
suspensionDate: String?,
): Text =
when (status) {
PremiumSubscriptionStatus.ACTIVE ->
BitwardenString.premium_next_charge_summary.asText(
formattedTotal,
nextChargeDate ?: PLACEHOLDER_TEXT,
)
PremiumSubscriptionStatus.CANCELED ->
BitwardenString.subscription_canceled_description.asText(
canceledDate ?: PLACEHOLDER_TEXT,
)
PremiumSubscriptionStatus.OVERDUE_PAYMENT ->
BitwardenString.subscription_overdue_description.asText(
suspensionDate ?: PLACEHOLDER_TEXT,
)
PremiumSubscriptionStatus.PAST_DUE ->
BitwardenString.subscription_past_due_description.asText(
gracePeriodDays ?: 0,
suspensionDate ?: PLACEHOLDER_TEXT,
)
PremiumSubscriptionStatus.PAUSED ->
BitwardenString.subscription_paused_description.asText()
}
private fun Instant.toLocalizedDate(): String =
toFormattedDateStyle(
dateStyle = FormatStyle.LONG,
locale = Locale.US,
clock = clock,
)
// endregion Shared handlers
}
/**
@@ -465,7 +686,7 @@ data class PlanState(
val title: Int
get() = when (viewState) {
is ViewState.Free -> BitwardenString.upgrade_to_premium
ViewState.Premium -> BitwardenString.plan
is ViewState.Premium -> BitwardenString.plan
}
/**
@@ -484,12 +705,24 @@ data class PlanState(
) : ViewState()
/**
* Premium user view. Empty placeholder until PM-35455 wires
* subscription management (status, billing amount, next charge,
* manage plan / cancel actions).
* Premium user view — shows subscription details and management options.
*
* Line-item text fields are always populated: they default to the
* `"--"` placeholder during the initial load and for any value that
* resolves to null or `0.00` (e.g. no additional storage, no discount,
* no tax).
*/
@Parcelize
data object Premium : ViewState()
data class Premium(
val status: PremiumSubscriptionStatus? = null,
val descriptionText: Text? = null,
val billingAmountText: Text = PLACEHOLDER_TEXT.asText(),
val storageCostText: String = PLACEHOLDER_TEXT,
val discountAmountText: String = PLACEHOLDER_TEXT,
val estimatedTaxText: String = PLACEHOLDER_TEXT,
val nextChargeDateText: String? = null,
val showCancelButton: Boolean = false,
) : ViewState()
}
/**
@@ -521,18 +754,47 @@ data class PlanState(
) : DialogState()
/**
* Waiting dialog shown when the user returns from checkout
* without completing payment.
* Waiting dialog shown when the user returns from checkout without
* completing payment.
*/
@Parcelize
data object WaitingForPayment : DialogState()
/**
* Dialog shown after a successful checkout when premium
* status has not yet been provisioned by the server.
* Dialog shown after a successful checkout when premium status has not
* yet been provisioned by the server.
*/
@Parcelize
data object PendingUpgrade : DialogState()
/**
* Confirmation dialog shown before cancelling premium.
*/
@Parcelize
data class CancelConfirmation(
val nextRenewalDate: String,
) : DialogState()
/**
* Loading overlay while fetching the portal URL.
*/
@Parcelize
data object LoadingPortal : DialogState()
/**
* Error dialog shown when the portal URL could not be loaded.
*/
@Parcelize
data object PortalError : DialogState()
/**
* Error dialog shown when subscription details cannot be loaded.
*/
@Parcelize
data class SubscriptionError(
val title: Text,
val message: Text,
) : DialogState()
}
}
@@ -542,14 +804,20 @@ data class PlanState(
sealed class PlanEvent {
/**
* Launch the user's browser with the given checkout [url]
* via AuthTab.
* Launch the user's browser with the given checkout [url] via AuthTab.
*/
data class LaunchBrowser(
val url: String,
val authTabData: AuthTabData,
) : PlanEvent()
/**
* Launch the user's browser with the given portal [url].
*/
data class LaunchPortal(
val url: String,
) : PlanEvent()
/**
* Navigate back to the previous screen.
*/
@@ -573,6 +841,8 @@ sealed class PlanAction {
*/
data object BackClick : PlanAction()
// region Free user actions
/**
* The user clicked the upgrade now button.
*/
@@ -618,14 +888,49 @@ sealed class PlanAction {
*/
data object ContinueClick : PlanAction()
// endregion Free user actions
// region Premium user actions
/**
* The user clicked manage plan.
*/
data object ManagePlanClick : PlanAction()
/**
* The user clicked cancel premium.
*/
data object CancelPremiumClick : PlanAction()
/**
* The user confirmed the cancel premium action.
*/
data object ConfirmCancelClick : PlanAction()
/**
* The user dismissed the cancel confirmation dialog.
*/
data object DismissCancelConfirmation : PlanAction()
/**
* The user dismissed the portal error dialog.
*/
data object DismissPortalError : PlanAction()
/**
* The user clicked retry on the subscription error dialog.
*/
data object RetrySubscriptionClick : PlanAction()
// endregion Premium user actions
/**
* Models actions the view model sends itself.
*/
sealed class Internal : PlanAction() {
/**
* A checkout URL result has been received from the
* repository.
* A checkout URL result has been received from the repository.
*/
data class CheckoutUrlReceive(
val result: CheckoutSessionResult,
@@ -658,5 +963,19 @@ sealed class PlanAction {
data class PricingResultReceive(
val result: PremiumPlanPricingResult,
) : Internal()
/**
* A portal URL result has been received from the repository.
*/
data class PortalUrlReceive(
val result: CustomerPortalResult,
) : Internal()
/**
* A subscription result has been received from the repository.
*/
data class SubscriptionResultReceive(
val result: SubscriptionResult,
) : Internal()
}
}

View File

@@ -7,6 +7,7 @@ import com.x8bit.bitwarden.ui.platform.feature.premium.plan.PlanViewModel
* A collection of handler functions for managing actions within the context of
* the plan screen.
*/
@Suppress("LongParameterList")
data class PlanHandlers(
val onBackClick: () -> Unit,
val onUpgradeNowClick: () -> Unit,
@@ -18,6 +19,12 @@ data class PlanHandlers(
val onGoBackClick: () -> Unit,
val onSyncClick: () -> Unit,
val onContinueClick: () -> Unit,
val onManagePlanClick: () -> Unit,
val onCancelPremiumClick: () -> Unit,
val onConfirmCancelClick: () -> Unit,
val onDismissCancelConfirmation: () -> Unit,
val onDismissPortalError: () -> Unit,
val onRetrySubscriptionClick: () -> Unit,
) {
@Suppress("UndocumentedPublicClass")
companion object {
@@ -38,6 +45,22 @@ data class PlanHandlers(
onGoBackClick = { viewModel.trySendAction(PlanAction.GoBackClick) },
onSyncClick = { viewModel.trySendAction(PlanAction.SyncClick) },
onContinueClick = { viewModel.trySendAction(PlanAction.ContinueClick) },
onManagePlanClick = { viewModel.trySendAction(PlanAction.ManagePlanClick) },
onCancelPremiumClick = {
viewModel.trySendAction(PlanAction.CancelPremiumClick)
},
onConfirmCancelClick = {
viewModel.trySendAction(PlanAction.ConfirmCancelClick)
},
onDismissCancelConfirmation = {
viewModel.trySendAction(PlanAction.DismissCancelConfirmation)
},
onDismissPortalError = {
viewModel.trySendAction(PlanAction.DismissPortalError)
},
onRetrySubscriptionClick = {
viewModel.trySendAction(PlanAction.RetrySubscriptionClick)
},
)
}
}

View File

@@ -0,0 +1,40 @@
package com.x8bit.bitwarden.ui.platform.feature.premium.plan.util
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.platform.theme.color.BitwardenColorScheme
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
/**
* Returns the localized label string resource for a [PremiumSubscriptionStatus].
*/
@StringRes
fun PremiumSubscriptionStatus.labelRes(): Int = when (this) {
PremiumSubscriptionStatus.ACTIVE -> BitwardenString.subscription_status_active
PremiumSubscriptionStatus.CANCELED -> BitwardenString.subscription_status_canceled
PremiumSubscriptionStatus.OVERDUE_PAYMENT -> {
BitwardenString.subscription_status_overdue_payment
}
PremiumSubscriptionStatus.PAST_DUE -> BitwardenString.subscription_status_past_due
PremiumSubscriptionStatus.PAUSED -> BitwardenString.subscription_status_paused
}
/**
* Returns the [BitwardenColorScheme.StatusBadgeVariantColors] used to render the badge for a
* [PremiumSubscriptionStatus].
*/
@Composable
fun PremiumSubscriptionStatus.badgeColors(): BitwardenColorScheme.StatusBadgeVariantColors =
when (this) {
PremiumSubscriptionStatus.ACTIVE -> BitwardenTheme.colorScheme.statusBadge.success
PremiumSubscriptionStatus.CANCELED -> BitwardenTheme.colorScheme.statusBadge.error
PremiumSubscriptionStatus.OVERDUE_PAYMENT,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
-> {
BitwardenTheme.colorScheme.statusBadge.warning
}
}

View File

@@ -2,9 +2,8 @@ package com.x8bit.bitwarden.ui.platform.feature.premium.plan
import android.content.Intent
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
@@ -21,6 +20,7 @@ import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
import io.mockk.every
@@ -34,6 +34,7 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@Suppress("LargeClass")
class PlanScreenTest : BitwardenComposeTest() {
private var onNavigateBackCalled = false
@@ -181,27 +182,6 @@ class PlanScreenTest : BitwardenComposeTest() {
.assertExists()
}
@Test
fun `upgrade now button should be enabled when dialogState is null`() {
composeTestRule
.onNodeWithTag("UpgradeNowButton")
.assertIsEnabled()
}
@Test
fun `upgrade now button should be disabled when dialogState is Loading`() {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.opening_checkout.asText(),
),
)
}
composeTestRule
.onNodeWithTag("UpgradeNowButton")
.assertIsNotEnabled()
}
@Test
fun `loading dialog should render when dialogState is Loading`() {
composeTestRule
@@ -473,6 +453,461 @@ class PlanScreenTest : BitwardenComposeTest() {
}
// endregion GetPricingError dialog tests
// region Premium content rendering
@Test
fun `premium content should render subscription card when viewState is Premium`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
composeTestRule
.onNodeWithTag("BillingAmountRow")
.assertExists()
}
@Test
fun `premium plan name should render`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
composeTestRule
.onNodeWithText("Premium")
.assertIsDisplayed()
}
@Test
fun `status badge should render with Active label for ACTIVE status`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.ACTIVE,
),
)
}
composeTestRule
.onNodeWithText("Active")
.assertIsDisplayed()
}
@Test
fun `status badge should render with Canceled label for CANCELED status`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.CANCELED,
),
)
}
composeTestRule
.onNodeWithText("Canceled")
.assertIsDisplayed()
}
@Test
fun `status badge should render with Overdue payment label for OVERDUE_PAYMENT status`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.OVERDUE_PAYMENT,
),
)
}
composeTestRule
.onNodeWithText("Overdue payment")
.assertIsDisplayed()
}
@Test
fun `status badge should render with Past due label for PAST_DUE status`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.PAST_DUE,
),
)
}
composeTestRule
.onNodeWithText("Past due")
.assertIsDisplayed()
}
@Test
fun `status badge should render with Paused label for PAUSED status`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.PAUSED,
),
)
}
composeTestRule
.onNodeWithText("Paused")
.assertIsDisplayed()
}
@Test
fun `status badge should not render when status is null`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(status = null),
)
}
composeTestRule.onNodeWithText("Active").assertDoesNotExist()
composeTestRule.onNodeWithText("Canceled").assertDoesNotExist()
composeTestRule.onNodeWithText("Overdue payment").assertDoesNotExist()
composeTestRule.onNodeWithText("Past due").assertDoesNotExist()
composeTestRule.onNodeWithText("Paused").assertDoesNotExist()
}
@Test
fun `description text should render when descriptionText is present`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
composeTestRule
.onNodeWithText("Your next charge is for $45.55 USD due on April 2, 2026.")
.assertIsDisplayed()
}
@Test
fun `description text should not render when descriptionText is null`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(descriptionText = null),
)
}
composeTestRule
.onNodeWithText("Your next charge is for $45.55 USD due on April 2, 2026.")
.assertDoesNotExist()
}
// endregion Premium content rendering
// region Line items
@Test
fun `billing amount row should display billingAmountText value`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
composeTestRule
.onNodeWithTag("BillingAmountRow")
.assertExists()
composeTestRule
.onNodeWithText("$19.80 / year")
.assertIsDisplayed()
}
@Test
fun `storage cost row should display storageCostText value`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
composeTestRule
.onNodeWithTag("StorageCostRow")
.assertExists()
composeTestRule
.onNodeWithText("$24.00")
.assertIsDisplayed()
}
@Test
fun `discount row should display discountAmountText value`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
composeTestRule
.onNodeWithTag("DiscountRow")
.assertExists()
composeTestRule
.onNodeWithText("-$2.10")
.assertIsDisplayed()
}
@Test
fun `estimated tax row should display estimatedTaxText value`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
composeTestRule
.onNodeWithTag("EstimatedTaxRow")
.assertExists()
composeTestRule
.onNodeWithText("$3.85")
.assertIsDisplayed()
}
@Test
fun `line items should display -- placeholder when values are defaults`() {
mutableStateFlow.update {
it.copy(viewState = PlanState.ViewState.Premium())
}
// Four rows, each displaying the default placeholder value "--".
composeTestRule
.onAllNodesWithText("--")
.assertCountEquals(4)
}
// endregion Line items
// region Action buttons
@Test
fun `manage plan button click should send ManagePlanClick action`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
composeTestRule
.onNodeWithTag("ManagePlanButton")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(PlanAction.ManagePlanClick) }
}
@Test
fun `cancel premium button should render when showCancelButton is true`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(showCancelButton = true),
)
}
composeTestRule
.onNodeWithTag("CancelPremiumButton")
.assertExists()
}
@Test
fun `cancel premium button should not render when showCancelButton is false`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(showCancelButton = false),
)
}
composeTestRule
.onNodeWithTag("CancelPremiumButton")
.assertDoesNotExist()
}
@Test
fun `cancel premium button click should send CancelPremiumClick action`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(showCancelButton = true),
)
}
composeTestRule
.onNodeWithTag("CancelPremiumButton")
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(PlanAction.CancelPremiumClick) }
}
// endregion Action buttons
// region Premium-flow dialogs
@Test
fun `subscription error dialog should render when dialogState is SubscriptionError`() {
val title = "An error has occurred".asText()
val message = "Unable to load subscription.".asText()
composeTestRule
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Premium(),
dialogState = PlanState.DialogState.SubscriptionError(
title = title,
message = message,
),
)
}
composeTestRule
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
composeTestRule
.onAllNodesWithText("Unable to load subscription.")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
composeTestRule
.onAllNodesWithText("Try again")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
composeTestRule
.onAllNodesWithText("Close")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `subscription error dialog try again click should send RetrySubscriptionClick action`() {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Premium(),
dialogState = PlanState.DialogState.SubscriptionError(
title = "An error has occurred".asText(),
message = "Unable to load subscription.".asText(),
),
)
}
composeTestRule
.onAllNodesWithText("Try again")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(PlanAction.RetrySubscriptionClick) }
}
@Test
fun `subscription error dialog close click should send BackClick action`() {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Premium(),
dialogState = PlanState.DialogState.SubscriptionError(
title = "An error has occurred".asText(),
message = "Unable to load subscription.".asText(),
),
)
}
composeTestRule
.onAllNodesWithText("Close")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(PlanAction.BackClick) }
}
@Test
fun `loading portal dialog should render when dialogState is LoadingPortal`() {
composeTestRule
.onAllNodesWithText("Loading portal…")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE,
dialogState = PlanState.DialogState.LoadingPortal,
)
}
composeTestRule
.onAllNodesWithText("Loading portal…")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `portal error dialog should render when dialogState is PortalError`() {
composeTestRule
.onAllNodesWithText("Something went wrong")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE,
dialogState = PlanState.DialogState.PortalError,
)
}
composeTestRule
.onAllNodesWithText("Something went wrong")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
composeTestRule
.onAllNodesWithText(
"We had trouble loading the management portal, so try again.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
composeTestRule
.onAllNodesWithText("Try again")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
composeTestRule
.onAllNodesWithText("Close")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `portal error dialog dismiss click should send DismissPortalError action`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE,
dialogState = PlanState.DialogState.PortalError,
)
}
composeTestRule
.onAllNodesWithText("Close")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(PlanAction.DismissPortalError) }
}
@Test
fun `cancel confirmation dialog should render when dialogState is CancelConfirmation`() {
composeTestRule
.onAllNodesWithText("Cancel Premium")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE,
dialogState = PlanState.DialogState.CancelConfirmation(
nextRenewalDate = "April 2, 2026",
),
)
}
composeTestRule
.onAllNodesWithText("Cancel Premium")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
composeTestRule
.onAllNodesWithText(
"Youll continue to have Premium access until April 2, 2026.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `cancel confirmation dialog confirm click should send ConfirmCancelClick action`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE,
dialogState = PlanState.DialogState.CancelConfirmation(
nextRenewalDate = "April 2, 2026",
),
)
}
composeTestRule
.onAllNodesWithText("Cancel now")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(PlanAction.ConfirmCancelClick) }
}
@Test
fun `cancel confirmation dialog dismiss click should send DismissCancelConfirmation action`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE,
dialogState = PlanState.DialogState.CancelConfirmation(
nextRenewalDate = "April 2, 2026",
),
)
}
composeTestRule
.onAllNodesWithText("Close")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(PlanAction.DismissCancelConfirmation) }
}
// endregion Premium-flow dialogs
// region LaunchPortal event
@Test
fun `LaunchPortal event should call intentManager launchUri`() {
val url = "https://portal"
mutableEventFlow.tryEmit(PlanEvent.LaunchPortal(url = url))
verify { intentManager.launchUri(url.toUri()) }
}
// endregion LaunchPortal event
}
private val DEFAULT_FREE_STATE = PlanState(
@@ -484,3 +919,17 @@ private val DEFAULT_FREE_STATE = PlanState(
),
dialogState = null,
)
private val DEFAULT_PREMIUM_VIEW_STATE = PlanState.ViewState.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
descriptionText = BitwardenString.premium_next_charge_summary.asText(
"$45.55",
"April 2, 2026",
),
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = "$24.00",
discountAmountText = "-$2.10",
estimatedTaxText = "$3.85",
nextChargeDateText = "April 2, 2026",
showCancelButton = true,
)

View File

@@ -11,7 +11,12 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@@ -31,6 +36,10 @@ import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
@Suppress("LargeClass")
class PlanViewModelTest : BaseViewModelTest() {
@@ -775,38 +784,524 @@ class PlanViewModelTest : BaseViewModelTest() {
// region Premium user path
@Test
fun `initial state should be Premium ViewState for premium user`() =
fun `initial state should be Premium ViewState with loading dialog for premium user`() =
runTest {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isPremium = true)),
)
markUserPremium()
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_PREMIUM_LOADING_STATE, awaitItem())
}
}
@Test
fun `init should fetch subscription for Premium viewstate`() = runTest {
markUserPremium()
createViewModel(subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE)
coVerify(exactly = 1) {
mockBillingRepository.getSubscription()
}
}
@Test
fun `init should not fetch subscription for Free viewstate`() = runTest {
createViewModel()
coVerify(exactly = 0) {
mockBillingRepository.getSubscription()
}
}
@Test
fun `SubscriptionResultReceive Success should populate Premium state from SubscriptionInfo`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE,
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem())
}
}
@Test
fun `SubscriptionResultReceive Success with Canceled status should hide cancel button`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
status = PremiumSubscriptionStatus.CANCELED,
canceledDate = Instant.parse("2026-04-21T00:00:00Z"),
),
),
)
viewModel.stateFlow.test {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Premium,
dialogState = null,
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.CANCELED,
descriptionText = BitwardenString
.subscription_canceled_description
.asText("April 21, 2026"),
showCancelButton = false,
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Success with OverduePayment status should describe overdue`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
status = PremiumSubscriptionStatus.OVERDUE_PAYMENT,
suspensionDate = Instant.parse("2026-04-21T00:00:00Z"),
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.OVERDUE_PAYMENT,
descriptionText = BitwardenString
.subscription_overdue_description
.asText("April 21, 2026"),
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Success with PastDue status should describe grace period`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
status = PremiumSubscriptionStatus.PAST_DUE,
suspensionDate = Instant.parse("2026-04-21T00:00:00Z"),
gracePeriodDays = 7,
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.PAST_DUE,
descriptionText = BitwardenString
.subscription_past_due_description
.asText(7, "April 21, 2026"),
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Success with PastDue and null gracePeriodDays uses fallback`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
status = PremiumSubscriptionStatus.PAST_DUE,
suspensionDate = Instant.parse("2026-04-21T00:00:00Z"),
gracePeriodDays = null,
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.PAST_DUE,
descriptionText = BitwardenString
.subscription_past_due_description
.asText(0, "April 21, 2026"),
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Success with Paused status should describe paused`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
status = PremiumSubscriptionStatus.PAUSED,
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.PAUSED,
descriptionText = BitwardenString
.subscription_paused_description
.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Success with Monthly cadence formats per-month rate`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
cadence = PlanCadence.MONTHLY,
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
billingAmountText = BitwardenString
.billing_rate_per_month
.asText("$19.80"),
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Success with zero seatsCost shows placeholder rate`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
seatsCost = BigDecimal.ZERO,
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
billingAmountText = PLACEHOLDER.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Success with null line items shows placeholder text`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
storageCost = null,
discountAmount = null,
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
storageCostText = PLACEHOLDER,
discountAmountText = PLACEHOLDER,
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Success with zero line items shows placeholder text`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
storageCost = BigDecimal.ZERO,
discountAmount = BigDecimal.ZERO,
estimatedTax = BigDecimal.ZERO,
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
storageCostText = PLACEHOLDER,
discountAmountText = PLACEHOLDER,
estimatedTaxText = PLACEHOLDER,
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Success with null nextCharge shows placeholder date`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
nextCharge = null,
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
descriptionText = BitwardenString
.premium_next_charge_summary
.asText("$45.55", PLACEHOLDER),
nextChargeDateText = null,
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Error should show SubscriptionError dialog`() = runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Error(error = RuntimeException("boom")),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADING_STATE.copy(
dialogState = PlanState.DialogState.SubscriptionError(
title = BitwardenString.subscription_error.asText(),
message = BitwardenString
.trouble_loading_subscription
.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `RetrySubscriptionClick should transition to Loading then refetch subscription`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE,
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem())
viewModel.trySendAction(PlanAction.RetrySubscriptionClick)
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
),
awaitItem(),
)
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem())
}
coVerify(exactly = 2) { mockBillingRepository.getSubscription() }
}
@Test
fun `ManagePlanClick should show LoadingPortal then emit LaunchPortal on success`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE,
portalResult = CustomerPortalResult.Success(url = "https://portal"),
)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem())
viewModel.trySendAction(PlanAction.ManagePlanClick)
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
dialogState = PlanState.DialogState.LoadingPortal,
),
stateFlow.awaitItem(),
)
assertEquals(
PlanEvent.LaunchPortal(url = "https://portal"),
eventFlow.awaitItem(),
)
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem())
}
}
@Test
fun `ManagePlanClick should show PortalError on failure`() = runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE,
portalResult = CustomerPortalResult.Error(error = RuntimeException("boom")),
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem())
viewModel.trySendAction(PlanAction.ManagePlanClick)
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
dialogState = PlanState.DialogState.LoadingPortal,
),
awaitItem(),
)
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
dialogState = PlanState.DialogState.PortalError,
),
awaitItem(),
)
}
}
@Test
fun `CancelPremiumClick should show CancelConfirmation with next renewal date`() = runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE,
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem())
viewModel.trySendAction(PlanAction.CancelPremiumClick)
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
dialogState = PlanState.DialogState.CancelConfirmation(
nextRenewalDate = "April 2, 2026",
),
),
awaitItem(),
)
}
}
@Test
fun `DismissCancelConfirmation should clear dialog`() = runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE,
)
viewModel.stateFlow.test {
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem())
viewModel.trySendAction(PlanAction.CancelPremiumClick)
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
dialogState = PlanState.DialogState.CancelConfirmation(
nextRenewalDate = "April 2, 2026",
),
),
awaitItem(),
)
viewModel.trySendAction(PlanAction.DismissCancelConfirmation)
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem())
}
}
private fun markUserPremium() {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(DEFAULT_ACCOUNT.copy(isPremium = true)),
)
}
// endregion Premium user path
@Suppress("LongParameterList")
private fun createViewModel(
initialState: PlanState? = null,
planMode: PlanMode = PlanMode.Modal,
pricingResult: PremiumPlanPricingResult? = DEFAULT_PRICING_SUCCESS,
subscriptionResult: SubscriptionResult? = null,
portalResult: CustomerPortalResult? = null,
clock: Clock = FIXED_CLOCK,
): PlanViewModel {
coEvery {
mockBillingRepository.getPremiumPlanPricing()
} coAnswers {
pricingResult ?: awaitCancellation()
}
coEvery {
mockBillingRepository.getSubscription()
} coAnswers {
subscriptionResult ?: awaitCancellation()
}
coEvery {
mockBillingRepository.getPortalUrl()
} coAnswers {
portalResult ?: awaitCancellation()
}
val savedStateHandle = SavedStateHandle().apply {
set("state", initialState)
every { toPlanArgs() } returns PlanArgs(planMode = planMode)
@@ -817,6 +1312,7 @@ class PlanViewModelTest : BaseViewModelTest() {
billingRepository = mockBillingRepository,
specialCircumstanceManager = mockSpecialCircumstanceManager,
vaultRepository = mockVaultRepository,
clock = clock,
)
}
}
@@ -860,6 +1356,59 @@ private val DEFAULT_FREE_STATE = PlanState(
)
private const val ANNUAL_PRICE = 19.99
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2026-04-21T00:00:00Z"),
ZoneOffset.UTC,
)
private val SUBSCRIPTION_INFO_ACTIVE = SubscriptionInfo(
status = PremiumSubscriptionStatus.ACTIVE,
cadence = PlanCadence.ANNUALLY,
seatsCost = BigDecimal("19.80"),
storageCost = BigDecimal("24.00"),
discountAmount = BigDecimal("2.10"),
estimatedTax = BigDecimal("3.85"),
nextChargeTotal = BigDecimal("45.55"),
nextCharge = Instant.parse("2026-04-02T00:00:00Z"),
canceledDate = null,
suspensionDate = null,
gracePeriodDays = null,
)
private val SUBSCRIPTION_SUCCESS_ACTIVE =
SubscriptionResult.Success(subscription = SUBSCRIPTION_INFO_ACTIVE)
private val DEFAULT_PRICING_SUCCESS = PremiumPlanPricingResult.Success(
annualPrice = ANNUAL_PRICE,
)
private const val PLACEHOLDER = "--"
private val DEFAULT_PREMIUM_ACTIVE_VIEW_STATE = PlanState.ViewState.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
descriptionText = BitwardenString.premium_next_charge_summary.asText(
"$45.55",
"April 2, 2026",
),
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = "$24.00",
discountAmountText = "-$2.10",
estimatedTaxText = "$3.85",
nextChargeDateText = "April 2, 2026",
showCancelButton = true,
)
private val DEFAULT_PREMIUM_LOADED_STATE = PlanState(
planMode = PlanMode.Modal,
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE,
dialogState = null,
)
private val DEFAULT_PREMIUM_LOADING_STATE = PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Premium(),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)