Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ 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.card.BitwardenInfoCalloutCard
import com.bitwarden.ui.platform.components.content.BitwardenContentBlock
import com.bitwarden.ui.platform.components.content.model.ContentBlockData
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.divider.BitwardenHorizontalDivider
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
Expand Down Expand Up @@ -125,13 +127,17 @@ fun PlanScreen(
},
) {
when (val viewState = state.viewState) {
is PlanState.ViewState.Free -> {
FreeContent(
is PlanState.ViewState.Free.Cloud -> {
FreeCloudContent(
viewState = viewState,
handlers = handlers,
)
}

is PlanState.ViewState.Free.SelfHosted -> {
FreeSelfHostedContent()
}

is PlanState.ViewState.Premium -> {
PremiumContent(
viewState = viewState,
Expand Down Expand Up @@ -253,8 +259,8 @@ private fun PlanDialogs(
}

@Composable
private fun FreeContent(
viewState: PlanState.ViewState.Free,
private fun FreeCloudContent(
viewState: PlanState.ViewState.Free.Cloud,
handlers: PlanHandlers,
modifier: Modifier = Modifier,
) {
Expand Down Expand Up @@ -300,6 +306,83 @@ private fun FreeContent(
}
}

@Suppress("MaxLineLength")
@Composable
private fun FreeSelfHostedContent(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(12.dp))
BitwardenInfoCalloutCard(
text = stringResource(
id = BitwardenString
.to_manage_your_premium_subscription_youll_need_to_login_to_your_web_vault_on_a_computer,
),
startIcon = IconData.Local(iconRes = BitwardenDrawable.ic_info_circle),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.testTag("SelfHostedManageOnWebVaultCallout"),
)
Spacer(modifier = Modifier.height(16.dp))
PremiumFeaturesCard(
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}

@Composable
private fun PremiumFeaturesCard(
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.cardStyle(
cardStyle = CardStyle.Full,
// Override bottom padding to account for custom
// `BitwardenContentBlock` vertical padding, below.
paddingBottom = 0.dp,
),
) {
Text(
text = stringResource(id = BitwardenString.unlock_premium_features),
style = BitwardenTheme.typography.labelLarge,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier
.padding(bottom = 16.dp)
.standardHorizontalMargin(),
)

BitwardenHorizontalDivider()

val features = listOf(
BitwardenString.built_in_authenticator,
BitwardenString.emergency_access,
BitwardenString.secure_file_storage,
BitwardenString.breach_monitoring,
)
features.forEachIndexed { index, featureStringRes ->
BitwardenContentBlock(
data = ContentBlockData(
headerText = stringResource(id = featureStringRes),
iconVectorResource = BitwardenDrawable.ic_check_mark,
),
headerTextStyle = BitwardenTheme.typography.titleMedium,
showDivider = index != features.lastIndex,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
}

@Composable
private fun PremiumDetailsCard(
rate: String,
Expand Down Expand Up @@ -631,11 +714,11 @@ private fun SubscriptionLineItem(
@Preview
@OmitFromCoverage
@Composable
private fun PlanScreenFreeAccount_preview() {
private fun PlanScreenFreeCloudAccount_preview() {
BitwardenTheme {
BitwardenScaffold {
FreeContent(
viewState = PlanState.ViewState.Free(
FreeCloudContent(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
Expand Down Expand Up @@ -663,6 +746,17 @@ private fun PlanScreenFreeAccount_preview() {
}
}

@Preview
@OmitFromCoverage
@Composable
private fun PlanScreenFreeSelfHostedFreeAccount_preview() {
BitwardenTheme {
BitwardenScaffold {
FreeSelfHostedContent()
}
}
}

@Preview
@OmitFromCoverage
@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.annotation.StringRes
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.util.toFormattedDateStyle
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
import com.bitwarden.ui.platform.resource.BitwardenDrawable
Expand All @@ -27,6 +28,7 @@ import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.hilt.android.lifecycle.HiltViewModel
Expand Down Expand Up @@ -65,6 +67,7 @@ class PlanViewModel @Inject constructor(
private val billingRepository: BillingRepository,
private val authRepository: AuthRepository,
private val premiumStateManager: PremiumStateManager,
private val environmentRepository: EnvironmentRepository,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val vaultRepository: VaultRepository,
private val clock: Clock,
Expand All @@ -78,12 +81,13 @@ class PlanViewModel @Inject constructor(
?.isPremium == true
val showsPremiumView = isPremium ||
premiumStateManager.subscriptionStatusStateFlow.value.isPremiumViewEligible()
val isSelfHosted = environmentRepository.environment is Environment.SelfHosted
PlanState(
planMode = planMode,
viewState = if (showsPremiumView) {
PlanState.ViewState.Premium()
} else {
PlanState.ViewState.Free(
viewState = when {
showsPremiumView -> PlanState.ViewState.Premium()
isSelfHosted -> PlanState.ViewState.Free.SelfHosted
else -> PlanState.ViewState.Free.Cloud(
rate = PLACEHOLDER_TEXT,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
Expand Down Expand Up @@ -120,7 +124,7 @@ class PlanViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)

onFreeContent {
onFreeCloudContent {
viewModelScope.launch {
sendAction(
PlanAction.Internal.PricingResultReceive(
Expand Down Expand Up @@ -242,7 +246,7 @@ class PlanViewModel @Inject constructor(
}

private fun handleGoBackClick() {
onFreeContent { freeState ->
onFreeCloudContent { freeState ->
freeState.checkoutUrl?.let { url ->
sendEvent(
PlanEvent.LaunchBrowser(
Expand All @@ -269,7 +273,7 @@ class PlanViewModel @Inject constructor(
),
),
)
onFreeContent { freeState ->
onFreeCloudContent { freeState ->
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
Expand Down Expand Up @@ -386,7 +390,7 @@ class PlanViewModel @Inject constructor(
SubscriptionResult.NotFound -> {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Free(
viewState = PlanState.ViewState.Free.Cloud(
rate = PLACEHOLDER_TEXT,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
Expand Down Expand Up @@ -426,8 +430,8 @@ class PlanViewModel @Inject constructor(
val status = (action.state as? SubscriptionStatusState.Available)?.status
?: return
if (!status.isPremiumViewEligible()) return
onFreeContent { freeState ->
if (freeState.isAwaitingPremiumStatus) return@onFreeContent
onFreeCloudContent { freeState ->
if (freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Premium(),
Expand All @@ -453,8 +457,8 @@ class PlanViewModel @Inject constructor(
private fun handleUserStateUpdateReceive(
action: PlanAction.Internal.UserStateUpdateReceive,
) {
onFreeContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeContent
onFreeCloudContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent

val isPremium = action.userState?.activeAccount?.isPremium == true
if (isPremium) {
Expand All @@ -471,7 +475,7 @@ class PlanViewModel @Inject constructor(
specialCircumstanceManager.specialCircumstance = null

if (checkoutResult.callbackResult is PremiumCheckoutCallbackResult.Canceled) {
onFreeContent { freeState ->
onFreeCloudContent { freeState ->
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
Expand All @@ -492,7 +496,7 @@ class PlanViewModel @Inject constructor(
if (isPremium) {
onPremiumUpgradeSuccess()
} else {
onFreeContent { freeState ->
onFreeCloudContent { freeState ->
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
Expand All @@ -516,8 +520,8 @@ class PlanViewModel @Inject constructor(
}

private fun handleSyncCompleteReceive() {
onFreeContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeContent
onFreeCloudContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent

val isPremium = authRepository
.userStateFlow
Expand All @@ -537,7 +541,7 @@ class PlanViewModel @Inject constructor(
}

private fun onPremiumUpgradeSuccess() {
onFreeContent {
onFreeCloudContent {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Premium(),
Expand All @@ -556,7 +560,7 @@ class PlanViewModel @Inject constructor(
}
// The Upgraded to Premium route uses `launchSingleTop = true` so a duplicate event is a
// no-op for the user. The event itself is harmless to re-emit; the state mutation above
// is what's guarded by `onFreeContent`.
// is what's guarded by `onFreeCloudContent`.
sendEvent(PlanEvent.NavigateToUpgradedToPremium)
}

Expand All @@ -569,8 +573,10 @@ class PlanViewModel @Inject constructor(
.format(result.annualPrice / MONTHS_PER_YEAR)
mutableStateFlow.update { currentState ->
val updatedViewState = when (val vs = currentState.viewState) {
is PlanState.ViewState.Free -> vs.copy(rate = formattedRate)
is PlanState.ViewState.Premium -> vs
is PlanState.ViewState.Free.Cloud -> vs.copy(rate = formattedRate)
is PlanState.ViewState.Free.SelfHosted,
is PlanState.ViewState.Premium,
-> vs
}
currentState.copy(
viewState = updatedViewState,
Expand Down Expand Up @@ -610,10 +616,10 @@ class PlanViewModel @Inject constructor(
}
}

private inline fun onFreeContent(
block: (PlanState.ViewState.Free) -> Unit,
private inline fun onFreeCloudContent(
block: (PlanState.ViewState.Free.Cloud) -> Unit,
) {
(state.viewState as? PlanState.ViewState.Free)?.let(block)
(state.viewState as? PlanState.ViewState.Free.Cloud)?.let(block)
}

private inline fun onPremiumContent(
Expand Down Expand Up @@ -728,14 +734,30 @@ data class PlanState(
sealed class ViewState : Parcelable {

/**
* Free user view β€” shows upgrade pricing and feature list.
* Free user view β€” shows the upgrade flow for cloud accounts or a
* "manage on web vault" info card for self-hosted accounts.
*/
@Parcelize
data class Free(
val rate: String,
val checkoutUrl: String?,
val isAwaitingPremiumStatus: Boolean,
) : ViewState()
sealed class Free : ViewState() {

/**
* 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,
) : 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.
Expand Down
Loading
Loading