mirror of
https://github.com/bitwarden/android.git
synced 2026-05-10 15:16:52 -05:00
[PM-35455] feat: Wire premium subscription data into Plan screen (#6819)
This commit is contained in:
@@ -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 = {},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user