From 32b704cfde1605d9836322675f2af4e6b5dbedca Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:04:31 -0400 Subject: [PATCH] [PM-35455] feat: Wire premium subscription data into Plan screen (#6819) --- .../feature/premium/plan/PlanScreen.kt | 315 +++++++++- .../feature/premium/plan/PlanViewModel.kt | 431 +++++++++++-- .../premium/plan/handlers/PlanHandlers.kt | 23 + .../PremiumSubscriptionStatusExtensions.kt | 40 ++ .../feature/premium/plan/PlanScreenTest.kt | 495 ++++++++++++++- .../feature/premium/plan/PlanViewModelTest.kt | 565 +++++++++++++++++- ui/src/main/res/values/strings.xml | 28 + 7 files changed, 1790 insertions(+), 107 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/PremiumSubscriptionStatusExtensions.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt index 1bd2185c73..8ba69aa8f4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt @@ -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 = {}, ), ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt index 0e0b9c9916..3357b5b19e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt @@ -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( 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() } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt index 513f8979ee..efd64d7dac 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt @@ -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) + }, ) } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/PremiumSubscriptionStatusExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/PremiumSubscriptionStatusExtensions.kt new file mode 100644 index 0000000000..2531e4a0b3 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/PremiumSubscriptionStatusExtensions.kt @@ -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 + } + } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt index 6f6c2a1971..8d5d9e68ec 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt @@ -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( + "You’ll 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, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt index 73626c43b6..1f33575bab 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt @@ -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(), + ), +) diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 8e3bb0b379..df16800469 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1267,4 +1267,32 @@ Do you want to switch to this account? Archive item Once archived, this item will be excluded from search results and autofill suggestions. Pricing unavailable + Current plan + Premium + Billing amount + Storage cost + Discount + Estimated tax + %1$s / year + %1$s / month + Manage plan + Cancel Premium + Cancel now + You’ll continue to have Premium access until %1$s. + Active + Canceled + Overdue payment + Past due + Paused + Your next charge is for %1$s USD due on %2$s. + Your subscription was canceled on %1$s. Resubscribe to continue using premium features. + We couldn’t process your payment. Update your payment before your subscription ends on %1$s. + You have a grace period of %1$d days from your subscription expiration date. Please resolve the past due amount by %2$s. + Your subscription is paused. Resume to continue using premium features. + Loading subscription… + Loading portal… + Something went wrong + We had trouble loading the management portal, so try again. + Subscription error + We couldn’t load your subscription details. Please try again.