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 616c80f9d89..f712d13a20f 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
@@ -50,8 +50,11 @@ 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.button.model.BitwardenButtonData
import com.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
import com.bitwarden.ui.platform.components.content.BitwardenContentBlock
+import com.bitwarden.ui.platform.components.content.BitwardenErrorContent
+import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent
import com.bitwarden.ui.platform.components.content.model.ContentBlockData
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
@@ -70,6 +73,8 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme
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.PlanState.ViewState.Error.Type.PRICING_UNAVAILABLE
+import com.x8bit.bitwarden.ui.platform.feature.premium.plan.PlanState.ViewState.Error.Type.SUBSCRIPTION
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
@@ -144,23 +149,47 @@ fun PlanScreen(
},
) {
when (val viewState = state.viewState) {
- is PlanState.ViewState.Free.Cloud -> {
+ is PlanState.ViewState.Content.Free.Cloud -> {
FreeCloudContent(
viewState = viewState,
handlers = handlers,
)
}
- is PlanState.ViewState.Free.SelfHosted -> {
+ is PlanState.ViewState.Content.Free.SelfHosted -> {
FreeSelfHostedContent()
}
- is PlanState.ViewState.Premium -> {
+ is PlanState.ViewState.Content.Premium -> {
PremiumContent(
viewState = viewState,
handlers = handlers,
)
}
+
+ is PlanState.ViewState.Error -> {
+ BitwardenErrorContent(
+ illustrationData = IconData.Local(iconRes = BitwardenDrawable.ill_file_error),
+ message = viewState.message(),
+ buttonData = BitwardenButtonData(
+ label = BitwardenString.try_again.asText(),
+ onClick = {
+ when (viewState.type) {
+ PRICING_UNAVAILABLE -> handlers.onRetryPricingClick()
+ SUBSCRIPTION -> handlers.onRetrySubscriptionClick()
+ }
+ },
+ ),
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ is PlanState.ViewState.Loading -> {
+ BitwardenLoadingContent(
+ text = viewState.message(),
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
}
}
}
@@ -184,18 +213,6 @@ private fun PlanDialogs(
)
}
- is PlanState.DialogState.GetPricingError -> {
- BitwardenTwoButtonDialog(
- title = dialogState.title(),
- message = dialogState.message(),
- confirmButtonText = stringResource(BitwardenString.try_again),
- dismissButtonText = stringResource(BitwardenString.close),
- onConfirmClick = handlers.onRetryPricingClick,
- onDismissClick = handlers.onClosePricingErrorClick,
- onDismissRequest = handlers.onClosePricingErrorClick,
- )
- }
-
is PlanState.DialogState.WaitingForPayment -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.payment_not_received_yet),
@@ -250,18 +267,6 @@ private fun PlanDialogs(
)
}
- 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),
@@ -278,7 +283,7 @@ private fun PlanDialogs(
@Composable
private fun FreeCloudContent(
- viewState: PlanState.ViewState.Free.Cloud,
+ viewState: PlanState.ViewState.Content.Free.Cloud,
handlers: PlanHandlers,
modifier: Modifier = Modifier,
) {
@@ -504,7 +509,7 @@ private fun PriceRow(
@Composable
private fun PremiumContent(
- viewState: PlanState.ViewState.Premium,
+ viewState: PlanState.ViewState.Content.Premium,
handlers: PlanHandlers,
modifier: Modifier = Modifier,
) {
@@ -571,7 +576,7 @@ private fun PremiumContent(
@Composable
private fun SubscriptionCard(
- viewState: PlanState.ViewState.Premium,
+ viewState: PlanState.ViewState.Content.Premium,
modifier: Modifier = Modifier,
) {
Column(
@@ -607,7 +612,7 @@ private fun SubscriptionCard(
@Composable
private fun SubscriptionLineItems(
- viewState: PlanState.ViewState.Premium,
+ viewState: PlanState.ViewState.Content.Premium,
) {
val rowModifier = Modifier
.fillMaxWidth()
@@ -827,7 +832,7 @@ private fun PlanScreenFreeCloudAccount_preview() {
BitwardenTheme {
BitwardenScaffold {
FreeCloudContent(
- viewState = PlanState.ViewState.Free.Cloud(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -875,7 +880,7 @@ private fun PlanScreenPremiumAccount_preview() {
BitwardenTheme {
BitwardenScaffold {
PremiumContent(
- viewState = PlanState.ViewState.Premium(
+ viewState = PlanState.ViewState.Content.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = "$24.00",
@@ -917,7 +922,7 @@ private fun PlanScreenPremiumAccountZeroState_preview() {
BitwardenTheme {
BitwardenScaffold {
PremiumContent(
- viewState = PlanState.ViewState.Premium(
+ viewState = PlanState.ViewState.Content.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = null,
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 33588aaeef5..12490196fe2 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
@@ -36,8 +36,10 @@ import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toDiscountMoney
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toPresentMoneyText
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toRequiredMoneyText
import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -86,80 +88,85 @@ class PlanViewModel @Inject constructor(
premiumStateManager.subscriptionStatusStateFlow.value.isPremiumViewEligible()
val isSelfHosted = premiumStateManager.isSelfHosted
PlanState(
+ isSelfHosted = isSelfHosted,
+ showsPremiumView = showsPremiumView,
planMode = planMode,
viewState = when {
- showsPremiumView -> PlanState.ViewState.Premium()
- isSelfHosted -> PlanState.ViewState.Free.SelfHosted
- else -> PlanState.ViewState.Free.Cloud(
- rate = PLACEHOLDER_TEXT,
- checkoutUrl = null,
- isAwaitingPremiumStatus = false,
- isPremiumUpgradePending = premiumStateManager
- .upgradeLifecycleStateFlow
- .value is UpgradeLifecycleState.UpgradePending,
- )
+ showsPremiumView -> {
+ // We are loading the premium data.
+ PlanState.ViewState.Loading(
+ message = BitwardenString.loading_subscription.asText(),
+ )
+ }
+
+ isSelfHosted -> {
+ // Nothing to load, we are good to go.
+ PlanState.ViewState.Content.Free.SelfHosted
+ }
+
+ else -> {
+ // We are loading the plan details.
+ PlanState.ViewState.Loading(
+ message = BitwardenString.loading.asText(),
+ )
+ }
},
dialogState = null,
)
},
) {
- private val currencyFormatter: NumberFormat =
- NumberFormat.getCurrencyInstance(Locale.US)
+ private val currencyFormatter: NumberFormat = NumberFormat.getCurrencyInstance(Locale.US)
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
- authRepository
- .userStateFlow
- .map { PlanAction.Internal.UserStateUpdateReceive(it) }
- .onEach(::sendAction)
- .launchIn(viewModelScope)
-
- specialCircumstanceManager
- .specialCircumstanceStateFlow
- .map { PlanAction.Internal.SpecialCircumstanceReceive(it) }
- .onEach(::sendAction)
- .launchIn(viewModelScope)
-
- premiumStateManager
- .subscriptionStatusStateFlow
- .map { PlanAction.Internal.SubscriptionStatusUpdateReceive(it) }
- .onEach(::sendAction)
- .launchIn(viewModelScope)
-
- premiumStateManager
- .upgradeLifecycleStateFlow
- .map { PlanAction.Internal.UpgradeLifecycleStateReceive(it) }
+ merge(
+ authRepository.userStateFlow.map { PlanAction.Internal.UserStateUpdateReceive(it) },
+ specialCircumstanceManager
+ .specialCircumstanceStateFlow
+ .map { PlanAction.Internal.SpecialCircumstanceReceive(it) },
+ premiumStateManager
+ .subscriptionStatusStateFlow
+ .map { PlanAction.Internal.SubscriptionStatusUpdateReceive(it) },
+ premiumStateManager
+ .upgradeLifecycleStateFlow
+ .map { PlanAction.Internal.UpgradeLifecycleStateReceive(it) },
+ )
+ .onEach {
+ // Wait until we are in the Content state so we can update everything appropriately
+ mutableStateFlow.first { it.viewState is PlanState.ViewState.Content }
+ }
.onEach(::sendAction)
.launchIn(viewModelScope)
- onFreeCloudContent {
- viewModelScope.launch {
- sendAction(
- PlanAction.Internal.PricingResultReceive(
- result = billingRepository.getPremiumPlanPricing(),
- ),
- )
+ when {
+ state.showsPremiumView -> {
+ // We are loading the premium data.
+ viewModelScope.launch {
+ sendAction(
+ PlanAction.Internal.SubscriptionResultReceive(
+ result = billingRepository.getSubscription(),
+ ),
+ )
+ }
}
- }
- onPremiumContent {
- mutableStateFlow.update {
- it.copy(
- dialogState = PlanState.DialogState.Loading(
- message = BitwardenString.loading_subscription.asText(),
- ),
- )
+ state.isSelfHosted -> {
+ // Nothing to load, we are good to go.
}
- viewModelScope.launch {
- sendAction(
- PlanAction.Internal.SubscriptionResultReceive(
- result = billingRepository.getSubscription(),
- ),
- )
+
+ else -> {
+ // We are loading the plan details.
+ viewModelScope.launch {
+ sendAction(
+ PlanAction.Internal.PricingResultReceive(
+ result = billingRepository.getPremiumPlanPricing(),
+ ),
+ )
+ }
}
}
}
@@ -384,7 +391,7 @@ class PlanViewModel @Inject constructor(
private fun handleRetrySubscriptionClick() {
mutableStateFlow.update {
it.copy(
- dialogState = PlanState.DialogState.Loading(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
@@ -415,15 +422,7 @@ class PlanViewModel @Inject constructor(
SubscriptionResult.NotFound -> {
mutableStateFlow.update {
it.copy(
- viewState = PlanState.ViewState.Free.Cloud(
- rate = PLACEHOLDER_TEXT,
- checkoutUrl = null,
- isAwaitingPremiumStatus = false,
- isPremiumUpgradePending = premiumStateManager
- .upgradeLifecycleStateFlow
- .value is UpgradeLifecycleState.UpgradePending,
- ),
- dialogState = PlanState.DialogState.Loading(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading.asText(),
),
)
@@ -440,11 +439,9 @@ class PlanViewModel @Inject constructor(
is SubscriptionResult.Error -> {
mutableStateFlow.update {
it.copy(
- dialogState = PlanState.DialogState.SubscriptionError(
- title = BitwardenString.subscription_error.asText(),
- message = BitwardenString
- .trouble_loading_subscription
- .asText(),
+ viewState = PlanState.ViewState.Error(
+ message = BitwardenString.trouble_loading_subscription.asText(),
+ type = PlanState.ViewState.Error.Type.SUBSCRIPTION,
),
)
}
@@ -471,15 +468,13 @@ class PlanViewModel @Inject constructor(
private fun handleSubscriptionStatusUpdateReceive(
action: PlanAction.Internal.SubscriptionStatusUpdateReceive,
) {
- val status = (action.state as? SubscriptionStatusState.Available)?.status
- ?: return
+ val status = (action.state as? SubscriptionStatusState.Available)?.status ?: return
if (!status.isPremiumViewEligible()) return
onFreeCloudContent { freeState ->
if (freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
mutableStateFlow.update {
it.copy(
- viewState = PlanState.ViewState.Premium(),
- dialogState = PlanState.DialogState.Loading(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
@@ -501,10 +496,15 @@ class PlanViewModel @Inject constructor(
private fun handleUserStateUpdateReceive(
action: PlanAction.Internal.UserStateUpdateReceive,
) {
+ val isPremium = action.userState?.activeAccount?.isPremium == true
+ mutableStateFlow.update {
+ it.copy(
+ showsPremiumView = isPremium ||
+ premiumStateManager.subscriptionStatusStateFlow.value.isPremiumViewEligible(),
+ )
+ }
onFreeCloudContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
-
- val isPremium = action.userState?.activeAccount?.isPremium == true
if (isPremium) {
onPremiumUpgradeSuccess()
}
@@ -528,7 +528,7 @@ class PlanViewModel @Inject constructor(
specialCircumstanceManager.specialCircumstance = null
mutableStateFlow.update {
it.copy(
- dialogState = PlanState.DialogState.Loading(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
@@ -622,8 +622,7 @@ class PlanViewModel @Inject constructor(
onFreeCloudContent {
mutableStateFlow.update {
it.copy(
- viewState = PlanState.ViewState.Premium(),
- dialogState = PlanState.DialogState.Loading(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
@@ -647,17 +646,16 @@ class PlanViewModel @Inject constructor(
) {
when (val result = action.result) {
is PremiumPlanPricingResult.Success -> {
- val formattedRate = currencyFormatter
- .format(result.annualPrice / MONTHS_PER_YEAR)
- mutableStateFlow.update { currentState ->
- val updatedViewState = when (val vs = currentState.viewState) {
- is PlanState.ViewState.Free.Cloud -> vs.copy(rate = formattedRate)
- is PlanState.ViewState.Free.SelfHosted,
- is PlanState.ViewState.Premium,
- -> vs
- }
- currentState.copy(
- viewState = updatedViewState,
+ mutableStateFlow.update {
+ it.copy(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
+ rate = currencyFormatter.format(result.annualPrice / MONTHS_PER_YEAR),
+ checkoutUrl = null,
+ isAwaitingPremiumStatus = false,
+ isPremiumUpgradePending = premiumStateManager
+ .upgradeLifecycleStateFlow
+ .value is UpgradeLifecycleState.UpgradePending,
+ ),
dialogState = null,
)
}
@@ -666,10 +664,10 @@ class PlanViewModel @Inject constructor(
is PremiumPlanPricingResult.Error -> {
mutableStateFlow.update {
it.copy(
- dialogState = PlanState.DialogState.GetPricingError(
- title = BitwardenString.pricing_unavailable.asText(),
+ viewState = PlanState.ViewState.Error(
message = result.errorMessage?.asText()
- ?: BitwardenString.generic_error_message.asText(),
+ ?: BitwardenString.pricing_unavailable.asText(),
+ type = PlanState.ViewState.Error.Type.PRICING_UNAVAILABLE,
),
)
}
@@ -680,7 +678,7 @@ class PlanViewModel @Inject constructor(
private fun handleRetryPricingClick() {
mutableStateFlow.update {
it.copy(
- dialogState = PlanState.DialogState.Loading(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading.asText(),
),
)
@@ -695,25 +693,25 @@ class PlanViewModel @Inject constructor(
}
private inline fun onFreeCloudContent(
- block: (PlanState.ViewState.Free.Cloud) -> Unit,
+ block: (PlanState.ViewState.Content.Free.Cloud) -> Unit,
) {
- (state.viewState as? PlanState.ViewState.Free.Cloud)?.let(block)
+ (state.viewState as? PlanState.ViewState.Content.Free.Cloud)?.let(block)
}
private inline fun onPremiumContent(
- block: (PlanState.ViewState.Premium) -> Unit,
+ block: (PlanState.ViewState.Content.Premium) -> Unit,
) {
- (state.viewState as? PlanState.ViewState.Premium)?.let(block)
+ (state.viewState as? PlanState.ViewState.Content.Premium)?.let(block)
}
- private fun SubscriptionInfo.toPremiumViewState(): PlanState.ViewState.Premium {
+ private fun SubscriptionInfo.toPremiumViewState(): PlanState.ViewState.Content.Premium {
val formattedTotal = currencyFormatter.format(nextChargeTotal)
val formattedDate = nextCharge?.toLocalizedDate()
val formattedCancelAt = cancelAt?.toLocalizedDate()
val formattedCanceled = canceledDate?.toLocalizedDate()
val formattedSuspension = suspensionDate?.toLocalizedDate()
- return PlanState.ViewState.Premium(
+ return PlanState.ViewState.Content.Premium(
status = status,
billingAmountText = seatsCost.toBillingAmountText(cadence, currencyFormatter),
storageCostText = storageCost.toPresentMoneyText(currencyFormatter),
@@ -760,6 +758,8 @@ data class PlanState(
val planMode: PlanMode,
val viewState: ViewState,
val dialogState: DialogState?,
+ val showsPremiumView: Boolean,
+ val isSelfHosted: Boolean,
) : Parcelable {
/**
@@ -787,10 +787,7 @@ data class PlanState(
*/
@get:StringRes
val title: Int
- get() = when (viewState) {
- is ViewState.Free -> BitwardenString.upgrade_to_premium
- is ViewState.Premium -> BitwardenString.plan
- }
+ get() = if (showsPremiumView) BitwardenString.plan else BitwardenString.upgrade_to_premium
/**
* Models the content state of the plan screen.
@@ -798,65 +795,93 @@ data class PlanState(
sealed class ViewState : Parcelable {
/**
- * Free user view — shows the upgrade flow for cloud accounts or a
- * "manage on web vault" info card for self-hosted accounts.
+ * Displays a loading state.
*/
- sealed class Free : ViewState() {
+ @Parcelize
+ data class Loading(val message: Text) : ViewState()
+ /**
+ * Displays an error state.
+ */
+ @Parcelize
+ data class Error(
+ val message: Text,
+ val type: Type,
+ ) : ViewState() {
/**
- * Free user on a cloud-hosted environment — shows upgrade pricing
- * and feature list.
+ * The specific type of error this represents.
*/
- @Parcelize
- data class Cloud(
- val rate: String,
- val checkoutUrl: String?,
- val isAwaitingPremiumStatus: Boolean,
- val isPremiumUpgradePending: Boolean,
- ) : Free()
+ enum class Type {
+ PRICING_UNAVAILABLE,
+ SUBSCRIPTION,
+ }
+ }
+ /**
+ * Displays a plan content.
+ */
+ sealed class Content : ViewState() {
/**
- * Free user on a self-hosted environment — Stripe checkout is
- * unavailable, so the screen redirects the user to manage their
- * subscription on the web vault.
+ * Free user view — shows the upgrade flow for cloud accounts or a
+ * "manage on web vault" info card for self-hosted accounts.
+ */
+ sealed class Free : Content() {
+
+ /**
+ * Free user on a cloud-hosted environment — shows upgrade pricing
+ * and feature list.
+ */
+ @Parcelize
+ data class Cloud(
+ val rate: String,
+ val checkoutUrl: String?,
+ val isAwaitingPremiumStatus: Boolean,
+ val isPremiumUpgradePending: Boolean,
+ ) : Free()
+
+ /**
+ * Free user on a self-hosted environment — Stripe checkout is
+ * unavailable, so the screen redirects the user to manage their
+ * subscription on the web vault.
+ */
+ @Parcelize
+ data object SelfHosted : Free()
+ }
+
+ /**
+ * Premium user view — shows subscription details and management options.
+ *
+ * Line-item text fields follow two visibility contracts that mirror the
+ * canonical Web subscription card:
+ *
+ * - **Required** ([billingAmountText], [estimatedTaxText], [totalText]):
+ * the row is always rendered. A zero amount is formatted as `$0.00`
+ * rather than hidden. Defaults are sensible empty values used only
+ * during the initial load — the `DialogState.Loading` overlay covers
+ * the screen during the fetch, so these defaults are never surfaced
+ * to the user.
+ * - **Optional** ([storageCostText], [discountAmountText]): a `null`
+ * value signals the screen to omit the row entirely (along with its
+ * leading divider). When non-null, the value is fully formatted by
+ * the view model — the screen renders it verbatim.
*/
@Parcelize
- data object SelfHosted : Free()
+ data class Premium(
+ val status: PremiumSubscriptionStatus? = null,
+ val billingAmountText: Text = "".asText(),
+ val storageCostText: String? = null,
+ val discountAmountText: String? = null,
+ val estimatedTaxText: String = "$0.00",
+ val totalText: Text = "".asText(),
+ val nextChargeTotalText: String? = null,
+ val nextChargeDateText: String? = null,
+ val cancelAtDateText: String? = null,
+ val canceledDateText: String? = null,
+ val suspensionDateText: String? = null,
+ val gracePeriodDays: Int? = null,
+ val showCancelButton: Boolean = false,
+ ) : Content()
}
-
- /**
- * Premium user view — shows subscription details and management options.
- *
- * Line-item text fields follow two visibility contracts that mirror the
- * canonical Web subscription card:
- *
- * - **Required** ([billingAmountText], [estimatedTaxText], [totalText]):
- * the row is always rendered. A zero amount is formatted as `$0.00`
- * rather than hidden. Defaults are sensible empty values used only
- * during the initial load — the `DialogState.Loading` overlay covers
- * the screen during the fetch, so these defaults are never surfaced
- * to the user.
- * - **Optional** ([storageCostText], [discountAmountText]): a `null`
- * value signals the screen to omit the row entirely (along with its
- * leading divider). When non-null, the value is fully formatted by
- * the view model — the screen renders it verbatim.
- */
- @Parcelize
- data class Premium(
- val status: PremiumSubscriptionStatus? = null,
- val billingAmountText: Text = "".asText(),
- val storageCostText: String? = null,
- val discountAmountText: String? = null,
- val estimatedTaxText: String = "$0.00",
- val totalText: Text = "".asText(),
- val nextChargeTotalText: String? = null,
- val nextChargeDateText: String? = null,
- val cancelAtDateText: String? = null,
- val canceledDateText: String? = null,
- val suspensionDateText: String? = null,
- val gracePeriodDays: Int? = null,
- val showCancelButton: Boolean = false,
- ) : ViewState()
}
/**
@@ -878,15 +903,6 @@ data class PlanState(
@Parcelize
data object CheckoutError : DialogState()
- /**
- * Error dialog shown when pricing information cannot be retrieved.
- */
- @Parcelize
- data class GetPricingError(
- val title: Text,
- val message: Text,
- ) : DialogState()
-
/**
* Waiting dialog shown when the user returns from checkout without
* completing payment.
@@ -920,15 +936,6 @@ data class PlanState(
*/
@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()
}
}
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 924a7a4eaa8..d98ea1a4c8e 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
@@ -388,74 +388,95 @@ class PlanScreenTest : BitwardenComposeTest() {
// endregion PendingUpgrade dialog tests
- // region GetPricingError dialog tests
+ // region Loading and Error content
@Test
- fun `get pricing error dialog should render when dialogState is GetPricingError`() {
- val title = "An error has occurred".asText()
- val message = "Unable to retrieve pricing.".asText()
+ fun `loading content should render message when viewState is Loading`() {
+ mutableStateFlow.update {
+ it.copy(
+ viewState = PlanState.ViewState.Loading(
+ message = BitwardenString.loading_subscription.asText(),
+ ),
+ )
+ }
+ composeTestRule
+ .onNodeWithText("Loading subscription…")
+ .assertIsDisplayed()
+ }
+ @Test
+ fun `error content should render message and try again button when viewState is Error`() {
composeTestRule
- .onAllNodesWithText("An error has occurred")
- .filterToOne(hasAnyAncestor(isDialog()))
+ .onNodeWithText("Pricing unavailable")
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
- dialogState = PlanState.DialogState.GetPricingError(
- title = title,
- message = message,
+ viewState = PlanState.ViewState.Error(
+ message = BitwardenString.pricing_unavailable.asText(),
+ type = PlanState.ViewState.Error.Type.PRICING_UNAVAILABLE,
),
)
}
composeTestRule
- .onAllNodesWithText("An error has occurred")
- .filterToOne(hasAnyAncestor(isDialog()))
- .assertExists()
+ .onNodeWithText("Pricing unavailable")
+ .assertIsDisplayed()
composeTestRule
- .onAllNodesWithText("Unable to retrieve pricing.")
- .filterToOne(hasAnyAncestor(isDialog()))
- .assertExists()
+ .onNodeWithText("Try again")
+ .assertIsDisplayed()
}
@Test
- fun `get pricing error dialog try again click should send RetryPricingClick action`() {
+ fun `error content try again click for PRICING_UNAVAILABLE should send RetryPricingClick`() {
mutableStateFlow.update {
it.copy(
- dialogState = PlanState.DialogState.GetPricingError(
- title = "An error has occurred".asText(),
- message = "Unable to retrieve pricing.".asText(),
+ viewState = PlanState.ViewState.Error(
+ message = BitwardenString.pricing_unavailable.asText(),
+ type = PlanState.ViewState.Error.Type.PRICING_UNAVAILABLE,
),
)
}
composeTestRule
- .onAllNodesWithText("Try again")
- .filterToOne(hasAnyAncestor(isDialog()))
+ .onNodeWithText("Try again")
.performClick()
verify { viewModel.trySendAction(PlanAction.RetryPricingClick) }
}
@Test
- fun `get pricing error dialog close click should send ClosePricingErrorClick action`() {
+ fun `error content should render subscription message when type is SUBSCRIPTION`() {
mutableStateFlow.update {
it.copy(
- dialogState = PlanState.DialogState.GetPricingError(
- title = "An error has occurred".asText(),
- message = "Unable to retrieve pricing.".asText(),
+ viewState = PlanState.ViewState.Error(
+ message = BitwardenString.trouble_loading_subscription.asText(),
+ type = PlanState.ViewState.Error.Type.SUBSCRIPTION,
),
)
}
composeTestRule
- .onAllNodesWithText("Close")
- .filterToOne(hasAnyAncestor(isDialog()))
- .performClick()
- verify {
- viewModel.trySendAction(PlanAction.ClosePricingErrorClick)
+ .onNodeWithText(
+ "We couldn’t load your subscription details. Please try again.",
+ )
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun `error content try again click for SUBSCRIPTION should send RetrySubscriptionClick`() {
+ mutableStateFlow.update {
+ it.copy(
+ viewState = PlanState.ViewState.Error(
+ message = BitwardenString.trouble_loading_subscription.asText(),
+ type = PlanState.ViewState.Error.Type.SUBSCRIPTION,
+ ),
+ )
}
+ composeTestRule
+ .onNodeWithText("Try again")
+ .performClick()
+ verify { viewModel.trySendAction(PlanAction.RetrySubscriptionClick) }
}
- // endregion GetPricingError dialog tests
+ // endregion Loading and Error content
// region Premium content rendering
@@ -1067,80 +1088,6 @@ class PlanScreenTest : BitwardenComposeTest() {
// 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
@@ -1294,7 +1241,7 @@ class PlanScreenTest : BitwardenComposeTest() {
@Test
fun `manage subscription info callout should render when self-hosted free`() {
mutableStateFlow.update {
- it.copy(viewState = PlanState.ViewState.Free.SelfHosted)
+ it.copy(viewState = PlanState.ViewState.Content.Free.SelfHosted)
}
composeTestRule
.onNodeWithText(
@@ -1310,7 +1257,7 @@ class PlanScreenTest : BitwardenComposeTest() {
@Test
fun `premium features header should render when self-hosted free`() {
mutableStateFlow.update {
- it.copy(viewState = PlanState.ViewState.Free.SelfHosted)
+ it.copy(viewState = PlanState.ViewState.Content.Free.SelfHosted)
}
composeTestRule
.onNodeWithText("Unlock more advanced features with a Premium plan.")
@@ -1320,7 +1267,7 @@ class PlanScreenTest : BitwardenComposeTest() {
@Test
fun `premium feature list items should render when self-hosted free`() {
mutableStateFlow.update {
- it.copy(viewState = PlanState.ViewState.Free.SelfHosted)
+ it.copy(viewState = PlanState.ViewState.Content.Free.SelfHosted)
}
composeTestRule
.onNodeWithText("Built-in authenticator")
@@ -1371,16 +1318,18 @@ class PlanScreenTest : BitwardenComposeTest() {
private val DEFAULT_FREE_STATE = PlanState(
planMode = PlanMode.Modal,
- viewState = PlanState.ViewState.Free.Cloud(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.65",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
+ showsPremiumView = false,
+ isSelfHosted = false,
)
-private val DEFAULT_PREMIUM_VIEW_STATE = PlanState.ViewState.Premium(
+private val DEFAULT_PREMIUM_VIEW_STATE = PlanState.ViewState.Content.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = "$24.00",
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 779d83685c3..f5152017411 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
@@ -144,7 +144,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_FREE_STATE.copy(
- viewState = PlanState.ViewState.Free.Cloud(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -170,7 +170,9 @@ class PlanViewModelTest : BaseViewModelTest() {
),
)
- val viewModel = createViewModel()
+ // The premium subscription must resolve so the screen reaches a Content state and
+ // the special-circumstance flow is processed.
+ val viewModel = createViewModel(subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE)
viewModel.eventFlow.test {
mutableSpecialCircumstanceStateFlow.value =
@@ -225,7 +227,7 @@ class PlanViewModelTest : BaseViewModelTest() {
)
assertEquals(
DEFAULT_FREE_STATE.copy(
- viewState = PlanState.ViewState.Free.Cloud(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = checkoutUrl,
isAwaitingPremiumStatus = false,
@@ -316,7 +318,7 @@ class PlanViewModelTest : BaseViewModelTest() {
)
assertEquals(
DEFAULT_FREE_STATE.copy(
- viewState = PlanState.ViewState.Free.Cloud(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = checkoutUrl,
isAwaitingPremiumStatus = false,
@@ -389,7 +391,7 @@ class PlanViewModelTest : BaseViewModelTest() {
fun `GoBackClick should emit LaunchBrowser with checkout URL when URL is available`() =
runTest {
val checkoutUrl = "https://checkout.stripe.com/session123"
- val freeState = PlanState.ViewState.Free.Cloud(
+ val freeState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = checkoutUrl,
isAwaitingPremiumStatus = false,
@@ -438,7 +440,7 @@ class PlanViewModelTest : BaseViewModelTest() {
runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
- viewState = PlanState.ViewState.Free.Cloud(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -481,7 +483,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_FREE_STATE.copy(
- viewState = PlanState.ViewState.Free.Cloud(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -499,13 +501,14 @@ class PlanViewModelTest : BaseViewModelTest() {
),
)
- // State transitions to Premium with subscription Loading.
+ // State transitions to a subscription Loading view state.
assertEquals(
DEFAULT_FREE_STATE.copy(
- viewState = PlanState.ViewState.Premium(),
- dialogState = PlanState.DialogState.Loading(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
+ dialogState = PlanState.DialogState.WaitingForPayment,
+ showsPremiumView = true,
),
stateFlow.awaitItem(),
)
@@ -537,7 +540,7 @@ class PlanViewModelTest : BaseViewModelTest() {
// Sync completes without premium — PendingUpgrade shown.
assertEquals(
DEFAULT_FREE_STATE.copy(
- viewState = PlanState.ViewState.Free.Cloud(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -562,7 +565,7 @@ class PlanViewModelTest : BaseViewModelTest() {
fun `ContinueClick dismisses the PendingUpgrade dialog and navigates back`() = runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
- viewState = PlanState.ViewState.Free.Cloud(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -590,7 +593,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_FREE_STATE.copy(
- viewState = PlanState.ViewState.Free.Cloud(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -611,7 +614,7 @@ class PlanViewModelTest : BaseViewModelTest() {
runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
- viewState = PlanState.ViewState.Free.Cloud(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -687,8 +690,10 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
- viewState = PlanState.ViewState.Free.SelfHosted,
+ viewState = PlanState.ViewState.Content.Free.SelfHosted,
dialogState = null,
+ showsPremiumView = false,
+ isSelfHosted = true,
),
awaitItem(),
)
@@ -720,19 +725,7 @@ class PlanViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
viewModel.stateFlow.test {
- assertEquals(
- PlanState(
- planMode = PlanMode.Modal,
- viewState = PlanState.ViewState.Free.Cloud(
- rate = "$1.67",
- checkoutUrl = null,
- isAwaitingPremiumStatus = false,
- isPremiumUpgradePending = false,
- ),
- dialogState = null,
- ),
- awaitItem(),
- )
+ assertEquals(DEFAULT_FREE_STATE, awaitItem())
}
coVerify(exactly = 1) {
mockBillingRepository.getPremiumPlanPricing()
@@ -744,7 +737,7 @@ class PlanViewModelTest : BaseViewModelTest() {
// region Pricing fetch
@Test
- fun `initial state before pricing fetch resolves should show placeholder rate`() =
+ fun `initial state before pricing fetch resolves should show Loading view state`() =
runTest {
val viewModel = createViewModel(pricingResult = null)
@@ -752,13 +745,12 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
- viewState = PlanState.ViewState.Free.Cloud(
- rate = "--",
- checkoutUrl = null,
- isAwaitingPremiumStatus = false,
- isPremiumUpgradePending = false,
+ viewState = PlanState.ViewState.Loading(
+ message = BitwardenString.loading.asText(),
),
dialogState = null,
+ showsPremiumView = false,
+ isSelfHosted = false,
),
awaitItem(),
)
@@ -766,7 +758,7 @@ class PlanViewModelTest : BaseViewModelTest() {
}
@Test
- fun `pricing fetch failure should show GetPricingError dialog`() =
+ fun `pricing fetch failure should show Error view state`() =
runTest {
val viewModel = createViewModel(
pricingResult = PremiumPlanPricingResult.Error(
@@ -778,16 +770,13 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
- viewState = PlanState.ViewState.Free.Cloud(
- rate = "--",
- checkoutUrl = null,
- isAwaitingPremiumStatus = false,
- isPremiumUpgradePending = false,
- ),
- dialogState = PlanState.DialogState.GetPricingError(
- title = BitwardenString.pricing_unavailable.asText(),
- message = BitwardenString.generic_error_message.asText(),
+ viewState = PlanState.ViewState.Error(
+ message = BitwardenString.pricing_unavailable.asText(),
+ type = PlanState.ViewState.Error.Type.PRICING_UNAVAILABLE,
),
+ dialogState = null,
+ showsPremiumView = false,
+ isSelfHosted = false,
),
awaitItem(),
)
@@ -807,16 +796,13 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
- viewState = PlanState.ViewState.Free.Cloud(
- rate = "--",
- checkoutUrl = null,
- isAwaitingPremiumStatus = false,
- isPremiumUpgradePending = false,
- ),
- dialogState = PlanState.DialogState.GetPricingError(
- title = BitwardenString.pricing_unavailable.asText(),
- message = BitwardenString.generic_error_message.asText(),
+ viewState = PlanState.ViewState.Error(
+ message = BitwardenString.pricing_unavailable.asText(),
+ type = PlanState.ViewState.Error.Type.PRICING_UNAVAILABLE,
),
+ dialogState = null,
+ showsPremiumView = false,
+ isSelfHosted = false,
),
awaitItem(),
)
@@ -831,15 +817,12 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
- viewState = PlanState.ViewState.Free.Cloud(
- rate = "--",
- checkoutUrl = null,
- isAwaitingPremiumStatus = false,
- isPremiumUpgradePending = false,
- ),
- dialogState = PlanState.DialogState.Loading(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading.asText(),
),
+ dialogState = null,
+ showsPremiumView = false,
+ isSelfHosted = false,
),
awaitItem(),
)
@@ -851,7 +834,7 @@ class PlanViewModelTest : BaseViewModelTest() {
}
@Test
- fun `ClosePricingErrorClick should clear dialog and emit NavigateBack`() =
+ fun `ClosePricingErrorClick should emit NavigateBack`() =
runTest {
val viewModel = createViewModel(
pricingResult = PremiumPlanPricingResult.Error(
@@ -859,42 +842,12 @@ class PlanViewModelTest : BaseViewModelTest() {
),
)
- viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
- assertEquals(
- PlanState(
- planMode = PlanMode.Modal,
- viewState = PlanState.ViewState.Free.Cloud(
- rate = "--",
- checkoutUrl = null,
- isAwaitingPremiumStatus = false,
- isPremiumUpgradePending = false,
- ),
- dialogState = PlanState.DialogState.GetPricingError(
- title = BitwardenString.pricing_unavailable.asText(),
- message = BitwardenString.generic_error_message.asText(),
- ),
- ),
- stateFlow.awaitItem(),
- )
-
+ viewModel.eventFlow.test {
viewModel.trySendAction(PlanAction.ClosePricingErrorClick)
- assertEquals(
- PlanState(
- planMode = PlanMode.Modal,
- viewState = PlanState.ViewState.Free.Cloud(
- rate = "--",
- checkoutUrl = null,
- isAwaitingPremiumStatus = false,
- isPremiumUpgradePending = false,
- ),
- dialogState = null,
- ),
- stateFlow.awaitItem(),
- )
assertEquals(
PlanEvent.NavigateBack,
- eventFlow.awaitItem(),
+ awaitItem(),
)
}
}
@@ -926,7 +879,7 @@ class PlanViewModelTest : BaseViewModelTest() {
// region Premium user path
@Test
- fun `initial state should be Premium ViewState with loading dialog for premium user`() =
+ fun `initial state should be Loading ViewState for premium user`() =
runTest {
markUserPremium()
@@ -1005,15 +958,12 @@ class PlanViewModelTest : BaseViewModelTest() {
)
val loadingState = awaitItem()
assertEquals(
- PlanState.ViewState.Premium(),
- loadingState.viewState,
- )
- assertEquals(
- PlanState.DialogState.Loading(
+ PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
- loadingState.dialogState,
+ loadingState.viewState,
)
+ assertEquals(null, loadingState.dialogState)
val loadedState = awaitItem()
assertEquals(
DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
@@ -1036,13 +986,15 @@ class PlanViewModelTest : BaseViewModelTest() {
)
viewModel.stateFlow.test {
- assertEquals(DEFAULT_FREE_STATE, awaitItem())
+ // The account is premium, so showsPremiumView stays true even though the
+ // missing subscription drops the screen back to the Free Cloud upgrade view.
+ assertEquals(DEFAULT_FREE_STATE.copy(showsPremiumView = true), awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
- fun `SubscriptionResultReceive NotFound keeps Loading dialog up while pricing fetch is pending`() =
+ fun `SubscriptionResultReceive NotFound keeps Loading view state up while pricing fetch is pending`() =
runTest {
markUserPremium()
@@ -1053,14 +1005,8 @@ class PlanViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test {
assertEquals(
- DEFAULT_FREE_STATE.copy(
- viewState = PlanState.ViewState.Free.Cloud(
- rate = "--",
- checkoutUrl = null,
- isAwaitingPremiumStatus = false,
- isPremiumUpgradePending = false,
- ),
- dialogState = PlanState.DialogState.Loading(
+ DEFAULT_PREMIUM_LOADING_STATE.copy(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading.asText(),
),
),
@@ -1447,7 +1393,7 @@ class PlanViewModelTest : BaseViewModelTest() {
}
@Test
- fun `SubscriptionResultReceive Error should show SubscriptionError dialog`() = runTest {
+ fun `SubscriptionResultReceive Error should show Error view state`() = runTest {
markUserPremium()
val viewModel = createViewModel(
@@ -1457,11 +1403,9 @@ class PlanViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADING_STATE.copy(
- dialogState = PlanState.DialogState.SubscriptionError(
- title = BitwardenString.subscription_error.asText(),
- message = BitwardenString
- .trouble_loading_subscription
- .asText(),
+ viewState = PlanState.ViewState.Error(
+ message = BitwardenString.trouble_loading_subscription.asText(),
+ type = PlanState.ViewState.Error.Type.SUBSCRIPTION,
),
),
awaitItem(),
@@ -1485,7 +1429,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
- dialogState = PlanState.DialogState.Loading(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
),
@@ -1666,7 +1610,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
- dialogState = PlanState.DialogState.Loading(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
),
@@ -1704,7 +1648,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
- dialogState = PlanState.DialogState.Loading(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
),
@@ -1862,13 +1806,15 @@ private val DEFAULT_USER_STATE = UserState(
private val DEFAULT_FREE_STATE = PlanState(
planMode = PlanMode.Modal,
- viewState = PlanState.ViewState.Free.Cloud(
+ viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
+ showsPremiumView = false,
+ isSelfHosted = false,
)
private const val ANNUAL_PRICE = 19.99
@@ -1900,7 +1846,7 @@ private val DEFAULT_PRICING_SUCCESS = PremiumPlanPricingResult.Success(
annualPrice = ANNUAL_PRICE,
)
-private val DEFAULT_PREMIUM_ACTIVE_VIEW_STATE = PlanState.ViewState.Premium(
+private val DEFAULT_PREMIUM_ACTIVE_VIEW_STATE = PlanState.ViewState.Content.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = "$24.00",
@@ -1916,12 +1862,16 @@ private val DEFAULT_PREMIUM_LOADED_STATE = PlanState(
planMode = PlanMode.Modal,
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE,
dialogState = null,
+ showsPremiumView = true,
+ isSelfHosted = false,
)
private val DEFAULT_PREMIUM_LOADING_STATE = PlanState(
planMode = PlanMode.Modal,
- viewState = PlanState.ViewState.Premium(),
- dialogState = PlanState.DialogState.Loading(
+ viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
+ dialogState = null,
+ showsPremiumView = true,
+ isSelfHosted = false,
)
diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml
index 740066c9f66..f620c2f4f0f 100644
--- a/ui/src/main/res/values/strings.xml
+++ b/ui/src/main/res/values/strings.xml
@@ -1324,7 +1324,6 @@ Do you want to switch to this account?
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.
View bank account
View license