[PM-37916] chore: Align Premium subscription card line items with Web (#6961)

This commit is contained in:
Patrick Honkonen
2026-05-22 14:45:57 -04:00
committed by GitHub
parent c6a439a791
commit a5f7288208
7 changed files with 296 additions and 58 deletions

View File

@@ -31,6 +31,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -607,28 +608,26 @@ private fun SubscriptionCard(
modifier = rowModifier,
)
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
viewState.storageCostText?.let { storageCostText ->
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.storage_cost),
value = storageCostText,
testTag = "StorageCostRow",
modifier = rowModifier,
)
}
SubscriptionLineItem(
label = stringResource(id = BitwardenString.storage_cost),
value = viewState.storageCostText,
testTag = "StorageCostRow",
modifier = rowModifier,
)
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.discount),
value = viewState.discountAmountText,
valueColor = if (viewState.discountAmountText == "--") {
BitwardenTheme.colorScheme.text.primary
} else {
BitwardenTheme.colorScheme.statusBadge.success.text
},
testTag = "DiscountRow",
modifier = rowModifier,
)
viewState.discountAmountText?.let { discountAmountText ->
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.discount),
value = discountAmountText,
testTag = "DiscountRow",
modifier = rowModifier,
valueColor = BitwardenTheme.colorScheme.statusBadge.success.text,
)
}
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
@@ -638,6 +637,16 @@ private fun SubscriptionCard(
testTag = "EstimatedTaxRow",
modifier = rowModifier,
)
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.total),
value = viewState.totalText(),
testTag = "TotalRow",
modifier = rowModifier,
labelStyle = BitwardenTheme.typography.bodyLargeEmphasis,
)
}
}
@@ -704,6 +713,10 @@ private fun subscriptionDescriptionText(
color = BitwardenTheme.colorScheme.text.secondary,
textStyle = BitwardenTheme.typography.bodyMedium,
)
val emphasisStyle = spanStyleOf(
color = BitwardenTheme.colorScheme.text.secondary,
textStyle = BitwardenTheme.typography.bodyMediumEmphasis,
)
return when (status) {
PremiumSubscriptionStatus.ACTIVE -> annotatedStringResource(
id = BitwardenString.premium_next_charge_summary,
@@ -712,24 +725,28 @@ private fun subscriptionDescriptionText(
nextChargeDateText ?: PLACEHOLDER_TEXT,
),
style = baseStyle,
emphasisHighlightStyle = emphasisStyle,
)
PremiumSubscriptionStatus.CANCELED -> annotatedStringResource(
id = BitwardenString.subscription_canceled_description,
args = arrayOf(canceledDateText ?: suspensionDateText ?: PLACEHOLDER_TEXT),
style = baseStyle,
emphasisHighlightStyle = emphasisStyle,
)
PremiumSubscriptionStatus.PENDING_CANCELLATION -> annotatedStringResource(
id = BitwardenString.subscription_pending_cancellation_description,
args = arrayOf(cancelAtDateText ?: PLACEHOLDER_TEXT),
style = baseStyle,
emphasisHighlightStyle = emphasisStyle,
)
PremiumSubscriptionStatus.UPDATE_PAYMENT -> annotatedStringResource(
id = BitwardenString.subscription_update_payment_description,
args = arrayOf(suspensionDateText ?: PLACEHOLDER_TEXT),
style = baseStyle,
emphasisHighlightStyle = emphasisStyle,
)
PremiumSubscriptionStatus.PAST_DUE -> {
@@ -740,6 +757,7 @@ private fun subscriptionDescriptionText(
days.toString(),
suspensionDateText ?: PLACEHOLDER_TEXT,
style = baseStyle,
emphasisHighlightStyle = emphasisStyle,
)
}
@@ -757,6 +775,9 @@ private fun SubscriptionLineItem(
value: String,
testTag: String,
modifier: Modifier = Modifier,
labelStyle: TextStyle = BitwardenTheme.typography.bodyLarge,
labelColor: Color = BitwardenTheme.colorScheme.text.secondary,
valueStyle: TextStyle = BitwardenTheme.typography.bodyLarge,
valueColor: Color = BitwardenTheme.colorScheme.text.primary,
) {
Row(
@@ -768,12 +789,12 @@ private fun SubscriptionLineItem(
) {
Text(
text = label,
style = BitwardenTheme.typography.bodyLarge,
color = BitwardenTheme.colorScheme.text.secondary,
style = labelStyle,
color = labelColor,
)
Text(
text = value,
style = BitwardenTheme.typography.bodyLarge,
style = valueStyle,
color = valueColor,
)
}
@@ -839,6 +860,7 @@ private fun PlanScreenPremiumAccount_preview() {
storageCostText = "$24.00",
discountAmountText = "-$2.10",
estimatedTaxText = "$3.85",
totalText = BitwardenString.billing_rate_per_year.asText("$45.55"),
nextChargeTotalText = "$45.55",
nextChargeDateText = "April 2, 2026",
showCancelButton = true,
@@ -866,3 +888,45 @@ private fun PlanScreenPremiumAccount_preview() {
}
}
}
@Preview
@OmitFromCoverage
@Composable
private fun PlanScreenPremiumAccountZeroState_preview() {
BitwardenTheme {
BitwardenScaffold {
PremiumContent(
viewState = PlanState.ViewState.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = null,
discountAmountText = null,
estimatedTaxText = "$0.00",
totalText = BitwardenString.billing_rate_per_year.asText("$19.80"),
nextChargeTotalText = "$19.80",
nextChargeDateText = "April 2, 2026",
showCancelButton = true,
),
handlers = PlanHandlers(
onBackClick = {},
onUpgradeNowClick = {},
onDismissError = {},
onRetryClick = {},
onRetryPricingClick = {},
onClosePricingErrorClick = {},
onCancelWaiting = {},
onGoBackClick = {},
onSyncClick = {},
onContinueClick = {},
onManagePlanClick = {},
onCancelPremiumClick = {},
onConfirmCancelClick = {},
onDismissCancelConfirmation = {},
onDismissPortalError = {},
onRetryPortalClick = {},
onRetrySubscriptionClick = {},
),
)
}
}
}

View File

@@ -676,9 +676,10 @@ class PlanViewModel @Inject constructor(
return PlanState.ViewState.Premium(
status = status,
billingAmountText = seatsCost.toBillingAmountText(cadence),
storageCostText = storageCost.toMoneyText(),
discountAmountText = discountAmount.toMoneyText(negative = true),
estimatedTaxText = estimatedTax.toMoneyText(),
storageCostText = storageCost.toOptionalMoneyText(),
discountAmountText = discountAmount.toOptionalMoneyText(negative = true),
estimatedTaxText = estimatedTax.toRequiredMoneyText(),
totalText = nextChargeTotal.toBillingAmountText(cadence),
nextChargeTotalText = formattedTotal,
nextChargeDateText = formattedDate,
cancelAtDateText = formattedCancelAt,
@@ -690,7 +691,6 @@ class PlanViewModel @Inject constructor(
}
private fun BigDecimal.toBillingAmountText(cadence: PlanCadence): Text {
if (this.signum() == 0) return PLACEHOLDER_TEXT.asText()
val formatted = currencyFormatter.format(this)
val cadenceRes = when (cadence) {
PlanCadence.ANNUALLY -> BitwardenString.billing_rate_per_year
@@ -699,9 +699,23 @@ class PlanViewModel @Inject constructor(
return cadenceRes.asText(formatted)
}
private fun BigDecimal?.toMoneyText(negative: Boolean = false): String =
/**
* Formats this amount for an always-rendered line item. Null is coerced to zero so the row
* still shows the locale-formatted `$0.00`, matching the Web convention of always rendering
* the Estimated Tax and Total rows.
*/
private fun BigDecimal?.toRequiredMoneyText(): String =
currencyFormatter.format(this ?: BigDecimal.ZERO)
/**
* Formats this amount for a hide-when-absent line item. Returns `null` when the amount is
* `null` or non-positive so the caller can omit the row entirely (Discount, Storage).
* When [negative] is true, the formatted value is prefixed with `-` to match the canonical
* Web discount styling.
*/
private fun BigDecimal?.toOptionalMoneyText(negative: Boolean = false): String? =
when {
this == null || this.signum() == 0 -> PLACEHOLDER_TEXT
this == null || this.signum() <= 0 -> null
negative -> "-${currencyFormatter.format(this)}"
else -> currencyFormatter.format(this)
}
@@ -802,18 +816,28 @@ data class PlanState(
/**
* Premium user view — shows subscription details and management options.
*
* Line-item text fields are always populated: they default to the
* `"--"` placeholder during the initial load and for any value that
* resolves to null or `0.00` (e.g. no additional storage, no discount,
* no tax).
* 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 = PLACEHOLDER_TEXT.asText(),
val storageCostText: String = PLACEHOLDER_TEXT,
val discountAmountText: String = PLACEHOLDER_TEXT,
val estimatedTaxText: String = PLACEHOLDER_TEXT,
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,

View File

@@ -5,7 +5,6 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
@@ -778,7 +777,7 @@ class PlanScreenTest : BitwardenComposeTest() {
}
@Test
fun `storage cost row should display storageCostText value`() {
fun `storage cost row should display storageCostText value when populated`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
composeTestRule
.onNodeWithTag("StorageCostRow")
@@ -789,7 +788,17 @@ class PlanScreenTest : BitwardenComposeTest() {
}
@Test
fun `discount row should display discountAmountText value`() {
fun `storage cost row should not render when storageCostText is null`() {
mutableStateFlow.update {
it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(storageCostText = null))
}
composeTestRule
.onNodeWithTag("StorageCostRow")
.assertDoesNotExist()
}
@Test
fun `discount row should display discountAmountText value when populated`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
composeTestRule
.onNodeWithTag("DiscountRow")
@@ -800,7 +809,17 @@ class PlanScreenTest : BitwardenComposeTest() {
}
@Test
fun `estimated tax row should display estimatedTaxText value`() {
fun `discount row should not render when discountAmountText is null`() {
mutableStateFlow.update {
it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(discountAmountText = null))
}
composeTestRule
.onNodeWithTag("DiscountRow")
.assertDoesNotExist()
}
@Test
fun `estimated tax row should always display estimatedTaxText value`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
composeTestRule
.onNodeWithTag("EstimatedTaxRow")
@@ -811,14 +830,66 @@ class PlanScreenTest : BitwardenComposeTest() {
}
@Test
fun `line items should display -- placeholder when values are defaults`() {
fun `estimated tax row should display dollar zero zero when amount is zero`() {
mutableStateFlow.update {
it.copy(viewState = PlanState.ViewState.Premium())
it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(estimatedTaxText = "$0.00"))
}
// Four rows, each displaying the default placeholder value "--".
composeTestRule
.onAllNodesWithText("--")
.assertCountEquals(4)
.onNodeWithTag("EstimatedTaxRow")
.assertExists()
composeTestRule
.onNodeWithText("$0.00")
.assertIsDisplayed()
}
@Test
fun `total row should always display totalText value with cadence suffix`() {
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
composeTestRule
.onNodeWithTag("TotalRow")
.assertExists()
composeTestRule
.onNodeWithText("$45.55 / year")
.assertIsDisplayed()
}
@Test
fun `total row should display monthly cadence suffix when cadence is monthly`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(
totalText = BitwardenString.billing_rate_per_month.asText("$0.00"),
),
)
}
composeTestRule
.onNodeWithTag("TotalRow")
.assertExists()
composeTestRule
.onNodeWithText("$0.00 / month")
.assertIsDisplayed()
}
@Test
fun `discount and storage rows should both hide when both texts are null`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(
storageCostText = null,
discountAmountText = null,
),
)
}
composeTestRule
.onNodeWithTag("StorageCostRow")
.assertDoesNotExist()
composeTestRule
.onNodeWithTag("DiscountRow")
.assertDoesNotExist()
// Billing, Tax, and Total are always rendered.
composeTestRule.onNodeWithTag("BillingAmountRow").assertExists()
composeTestRule.onNodeWithTag("EstimatedTaxRow").assertExists()
composeTestRule.onNodeWithTag("TotalRow").assertExists()
}
// endregion Line items
@@ -1273,6 +1344,7 @@ private val DEFAULT_PREMIUM_VIEW_STATE = PlanState.ViewState.Premium(
storageCostText = "$24.00",
discountAmountText = "-$2.10",
estimatedTaxText = "$3.85",
totalText = BitwardenString.billing_rate_per_year.asText("$45.55"),
nextChargeTotalText = "$45.55",
nextChargeDateText = "April 2, 2026",
showCancelButton = true,

View File

@@ -1172,6 +1172,9 @@ class PlanViewModelTest : BaseViewModelTest() {
billingAmountText = BitwardenString
.billing_rate_per_month
.asText("$19.80"),
totalText = BitwardenString
.billing_rate_per_month
.asText("$45.55"),
),
),
awaitItem(),
@@ -1180,7 +1183,7 @@ class PlanViewModelTest : BaseViewModelTest() {
}
@Test
fun `SubscriptionResultReceive Success with zero seatsCost shows placeholder rate`() =
fun `SubscriptionResultReceive Success with zero seatsCost still renders billing row`() =
runTest {
markUserPremium()
@@ -1196,7 +1199,9 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
billingAmountText = PLACEHOLDER.asText(),
billingAmountText = BitwardenString
.billing_rate_per_year
.asText("$0.00"),
),
),
awaitItem(),
@@ -1205,7 +1210,7 @@ class PlanViewModelTest : BaseViewModelTest() {
}
@Test
fun `SubscriptionResultReceive Success with null line items shows placeholder text`() =
fun `SubscriptionResultReceive Success with null line items hides discount and storage rows`() =
runTest {
markUserPremium()
@@ -1222,8 +1227,8 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
storageCostText = PLACEHOLDER,
discountAmountText = PLACEHOLDER,
storageCostText = null,
discountAmountText = null,
),
),
awaitItem(),
@@ -1232,7 +1237,7 @@ class PlanViewModelTest : BaseViewModelTest() {
}
@Test
fun `SubscriptionResultReceive Success with zero line items shows placeholder text`() =
fun `SubscriptionResultReceive Success with zero line items hides discount and storage rows`() =
runTest {
markUserPremium()
@@ -1250,9 +1255,69 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
storageCostText = PLACEHOLDER,
discountAmountText = PLACEHOLDER,
estimatedTaxText = PLACEHOLDER,
storageCostText = null,
discountAmountText = null,
estimatedTaxText = "$0.00",
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Success with zero nextChargeTotal renders zero total row`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
nextChargeTotal = BigDecimal.ZERO,
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
totalText = BitwardenString
.billing_rate_per_year
.asText("$0.00"),
nextChargeTotalText = "$0.00",
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Success with Monthly cadence renders total with month suffix`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
cadence = PlanCadence.MONTHLY,
nextChargeTotal = BigDecimal.ZERO,
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
billingAmountText = BitwardenString
.billing_rate_per_month
.asText("$19.80"),
totalText = BitwardenString
.billing_rate_per_month
.asText("$0.00"),
nextChargeTotalText = "$0.00",
),
),
awaitItem(),
@@ -1738,14 +1803,13 @@ private val DEFAULT_PRICING_SUCCESS = PremiumPlanPricingResult.Success(
annualPrice = ANNUAL_PRICE,
)
private const val PLACEHOLDER = "--"
private val DEFAULT_PREMIUM_ACTIVE_VIEW_STATE = PlanState.ViewState.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = "$24.00",
discountAmountText = "-$2.10",
estimatedTaxText = "$3.85",
totalText = BitwardenString.billing_rate_per_year.asText("$45.55"),
nextChargeTotalText = "$45.55",
nextChargeDateText = "April 2, 2026",
showCancelButton = true,

View File

@@ -18,6 +18,7 @@ data class BitwardenTypography(
val titleMedium: TextStyle,
val titleSmall: TextStyle,
val bodyLarge: TextStyle,
val bodyLargeEmphasis: TextStyle,
val bodyMedium: TextStyle,
val bodyMediumEmphasis: TextStyle,
val bodySmall: TextStyle,

View File

@@ -145,6 +145,18 @@ val bitwardenTypography: BitwardenTypography = BitwardenTypography(
),
platformStyle = PlatformTextStyle(includeFontPadding = false),
),
bodyLargeEmphasis = TextStyle(
fontSize = 15.sp,
lineHeight = 20.sp,
fontFamily = FontFamily(Font(R.font.dm_sans_regular)),
fontWeight = FontWeight.W700,
letterSpacing = 0.sp,
lineHeightStyle = LineHeightStyle(
alignment = LineHeightStyle.Alignment.Center,
trim = LineHeightStyle.Trim.None,
),
platformStyle = PlatformTextStyle(includeFontPadding = false),
),
bodyMedium = TextStyle(
fontSize = 13.sp,
lineHeight = 18.sp,

View File

@@ -1295,6 +1295,7 @@ Do you want to switch to this account?</string>
<string name="storage_cost">Storage cost</string>
<string name="discount">Discount</string>
<string name="estimated_tax">Estimated tax</string>
<string name="total">Total</string>
<string name="billing_rate_per_year">%1$s / year</string>
<string name="billing_rate_per_month">%1$s / month</string>
<string name="manage_plan">Manage plan</string>