mirror of
https://github.com/bitwarden/android.git
synced 2026-06-20 12:38:11 -05:00
Compare commits
1 Commits
main
...
PM-38363-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eac183ab7 |
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
onFreeCloudContent {
|
||||
viewModelScope.launch {
|
||||
sendAction(
|
||||
PlanAction.Internal.PricingResultReceive(
|
||||
result = billingRepository.getPremiumPlanPricing(),
|
||||
),
|
||||
)
|
||||
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)
|
||||
|
||||
onPremiumContent {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialogState = PlanState.DialogState.Loading(
|
||||
message = BitwardenString.loading_subscription.asText(),
|
||||
),
|
||||
)
|
||||
when {
|
||||
state.showsPremiumView -> {
|
||||
// We are loading the premium data.
|
||||
viewModelScope.launch {
|
||||
sendAction(
|
||||
PlanAction.Internal.SubscriptionResultReceive(
|
||||
result = billingRepository.getSubscription(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
sendAction(
|
||||
PlanAction.Internal.SubscriptionResultReceive(
|
||||
result = billingRepository.getSubscription(),
|
||||
),
|
||||
)
|
||||
|
||||
state.isSelfHosted -> {
|
||||
// Nothing to load, we are good to go.
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
/**
|
||||
* 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()
|
||||
enum class Type {
|
||||
PRICING_UNAVAILABLE,
|
||||
SUBSCRIPTION,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Displays a plan content.
|
||||
*/
|
||||
@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()
|
||||
sealed class Content : ViewState() {
|
||||
/**
|
||||
* 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 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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
.onAllNodesWithText("An error has occurred")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.onNodeWithText("Loading subscription…")
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `error content should render message and try again button when viewState is Error`() {
|
||||
composeTestRule
|
||||
.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()
|
||||
}
|
||||
|
||||
// endregion GetPricingError dialog tests
|
||||
@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 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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -1324,7 +1324,6 @@ Do you want to switch to this account?</string>
|
||||
<string name="loading_portal">Loading portal…</string>
|
||||
<string name="portal_error">Something went wrong</string>
|
||||
<string name="trouble_loading_portal">We had trouble loading the management portal, so try again.</string>
|
||||
<string name="subscription_error">Subscription error</string>
|
||||
<string name="trouble_loading_subscription">We couldn’t load your subscription details. Please try again.</string>
|
||||
<string name="view_bank_account">View bank account</string>
|
||||
<string name="view_license">View license</string>
|
||||
|
||||
Reference in New Issue
Block a user