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 a1d43323aa3..9055081d237 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 @@ -2,9 +2,11 @@ package com.x8bit.bitwarden.ui.platform.feature.premium.plan +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize @@ -17,6 +19,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState @@ -28,9 +31,12 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle @@ -71,6 +77,7 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenPlurals import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme +import com.bitwarden.ui.util.Text import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers @@ -81,6 +88,8 @@ import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.badgeColors import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.labelRes import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.showsFeatureList import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf private const val PLACEHOLDER_TEXT: String = "--" @@ -293,20 +302,19 @@ private fun FreeCloudContent( .fillMaxSize() .verticalScroll(rememberScrollState()), ) { - Spacer(Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(12.dp)) PremiumDetailsCard( - rate = viewState.rate, - frequency = stringResource(id = BitwardenString.per_month), modifier = Modifier.standardHorizontalMargin(), ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(height = 16.dp)) // Hide the Upgrade Now CTA (and its Stripe footer copy) while a Stripe upgrade is // already in flight for the active user. CTAs reappear once the server flips the // user to Premium. if (!viewState.isPremiumUpgradePending) { UpgradeNowCallToAction( + viewState = viewState, onUpgradeNowClick = handlers.onUpgradeNowClick, ) } @@ -316,10 +324,11 @@ private fun FreeCloudContent( @Composable private fun UpgradeNowCallToAction( + viewState: PlanState.ViewState.Content.Free.Cloud, onUpgradeNowClick: () -> Unit, ) { BitwardenFilledButton( - label = stringResource(id = BitwardenString.upgrade_now), + label = stringResource(id = BitwardenString.upgrade_to_premium), onClick = onUpgradeNowClick, icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link), modifier = Modifier @@ -327,23 +336,54 @@ private fun UpgradeNowCallToAction( .fillMaxWidth() .testTag("UpgradeNowButton"), ) + Spacer(modifier = Modifier.height(height = 12.dp)) - Spacer(modifier = Modifier.height(12.dp)) + PriceRow( + rate = viewState.rate, + modifier = Modifier + .fillMaxWidth() + .standardHorizontalMargin(), + ) + Spacer(modifier = Modifier.height(height = 12.dp)) Text( text = stringResource( - id = BitwardenString.youll_go_to_stripes_secure_checkout_to_complete_your_purchase, + id = BitwardenString.youll_complete_the_purchase_with_stripe_secure_checkout, ), style = BitwardenTheme.typography.bodyMedium, - color = BitwardenTheme.colorScheme.text.secondary, + color = BitwardenTheme.colorScheme.text.primary, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() .standardHorizontalMargin() .testTag("StripeFooterText"), ) + Spacer(modifier = Modifier.height(height = 16.dp)) +} - Spacer(modifier = Modifier.height(16.dp)) +@Composable +private fun PriceRow( + rate: String, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = modifier, + ) { + Text( + text = rate, + style = BitwardenTheme.typography.labelMedium, + color = BitwardenTheme.colorScheme.text.primary, + modifier = Modifier.alignByBaseline(), + ) + Spacer(modifier = Modifier.width(width = 4.dp)) + Text( + text = stringResource(id = BitwardenString.per_month_cancel_anytime), + style = BitwardenTheme.typography.labelSmall, + color = BitwardenTheme.colorScheme.text.primary, + modifier = Modifier.alignByBaseline(), + ) + } } @Suppress("MaxLineLength") @@ -432,81 +472,81 @@ private fun ColumnScope.PremiumFeatureRows() { @Composable private fun PremiumDetailsCard( - rate: String, - frequency: String, modifier: Modifier = Modifier, ) { Column( + horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .fillMaxWidth() - .cardStyle( - cardStyle = CardStyle.Full, - // Override bottom padding to account for custom - // `BitwardenContentBlock` vertical padding, below. - paddingBottom = 0.dp, - ), + .run { + if (BitwardenTheme.colorScheme.isDynamicTheme) { + cardStyle(cardStyle = CardStyle.Full, paddingVertical = 0.dp) + } else { + paint( + painter = painterResource(id = BitwardenDrawable.bg_card_gradient), + contentScale = ContentScale.FillBounds, + ) + } + }, ) { - Column( + Spacer(modifier = Modifier.height(height = 8.dp)) + Image( + painter = painterResource(id = BitwardenDrawable.img_premium), + contentDescription = null, modifier = Modifier - .padding(bottom = 16.dp) + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(height = 8.dp)) + Text( + text = stringResource(id = BitwardenString.unlock_advanced_protection), + style = BitwardenTheme.typography.headlineSmall, + color = BitwardenTheme.colorScheme.text.primary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() .standardHorizontalMargin(), - ) { - PriceRow( - rate = rate, - frequency = frequency, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringResource( - id = BitwardenString.unlock_premium_features, - ), - style = BitwardenTheme.typography.bodyMedium, - color = BitwardenTheme.colorScheme.text.secondary, - ) - } - - 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), - ) - } + Spacer(modifier = Modifier.height(height = 12.dp)) + Benefits(modifier = Modifier.standardHorizontalMargin()) + Spacer(modifier = Modifier.height(height = 4.dp)) } } @Composable -private fun PriceRow( - rate: String, - frequency: String, +private fun Benefits( modifier: Modifier = Modifier, + benefits: ImmutableList = persistentListOf( + BitwardenString.breeze_through_2fa_with_built_in_codes.asText(), + BitwardenString.run_reports_to_find_risky_passwords.asText(), + BitwardenString.keep_documents_safe_and_encrypted.asText(), + BitwardenString.add_a_trusted_emergency_contact.asText(), + BitwardenString.identify_unsecure_websites.asText(), + BitwardenString.flag_accounts_with_inactive_2fa.asText(), + BitwardenString.share_files_securely_with_anyone_using_send.asText(), + BitwardenString.receive_24_7_priority_support.asText(), + ), ) { - Row(modifier = modifier) { - Text( - text = rate, - style = BitwardenTheme.typography.headlineMedium, - color = BitwardenTheme.colorScheme.text.primary, - modifier = Modifier.alignByBaseline(), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = frequency, - style = BitwardenTheme.typography.bodyMedium, - color = BitwardenTheme.colorScheme.text.secondary, - modifier = Modifier.alignByBaseline(), - ) + Column(modifier = Modifier.width(intrinsicSize = IntrinsicSize.Max)) { + benefits.forEach { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Icon( + painter = rememberVectorPainter(id = BitwardenDrawable.ic_checkmark_small), + contentDescription = null, + tint = BitwardenTheme.colorScheme.icon.secondary, + ) + Spacer(modifier = Modifier.width(width = 8.dp)) + Text( + text = it(), + style = BitwardenTheme.typography.bodyMedium, + color = BitwardenTheme.colorScheme.text.primary, + ) + } + Spacer(modifier = Modifier.height(height = 12.dp)) + } } } 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 a43196f7144..7ea10315f13 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 @@ -787,7 +787,7 @@ data class PlanState( */ @get:StringRes val title: Int - get() = if (showsPremiumView) BitwardenString.plan else BitwardenString.upgrade_to_premium + get() = if (showsPremiumView) BitwardenString.plan else BitwardenString.premium /** * Models the content state of the plan screen. 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 d98ea1a4c8e..cb751f7c1a6 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 @@ -8,6 +8,7 @@ import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasScrollAction import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription @@ -151,30 +152,43 @@ class PlanScreenTest : BitwardenComposeTest() { fun `price and subtitle should render from state`() { composeTestRule .onNodeWithText("\$1.65") + .performScrollTo() .assertExists() composeTestRule - .onNodeWithText("/ month") + .onNodeWithText("/ month · Cancel anytime") + .performScrollTo() .assertExists() composeTestRule - .onNodeWithText( - "Unlock more advanced features with a Premium plan.", - ) + .onNodeWithText("You’ll complete the purchase with Stripe secure checkout.") + .performScrollTo() .assertExists() } @Test fun `feature list items should render`() { composeTestRule - .onNodeWithText("Built-in authenticator") + .onNodeWithText("Breeze through 2FA with built-in codes") .assertExists() composeTestRule - .onNodeWithText("Emergency access") + .onNodeWithText("Run reports to find risky passwords") .assertExists() composeTestRule - .onNodeWithText("Secure file storage") + .onNodeWithText("Keep documents safe and encrypted") .assertExists() composeTestRule - .onNodeWithText("Breach monitoring") + .onNodeWithText("Add a trusted emergency contact") + .assertExists() + composeTestRule + .onNodeWithText("Identify unsecure websites") + .assertExists() + composeTestRule + .onNodeWithText("Flag accounts with inactive 2FA") + .assertExists() + composeTestRule + .onNodeWithText("Share files securely with anyone using Send") + .assertExists() + composeTestRule + .onNodeWithText("Receive 24/7 priority support") .assertExists() } @@ -492,7 +506,9 @@ class PlanScreenTest : BitwardenComposeTest() { fun `premium plan name should render`() { mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) } composeTestRule - .onNodeWithText("Premium") + .onAllNodesWithText("Premium") + .filterToOne(hasAnyAncestor(hasScrollAction())) + .performScrollTo() .assertIsDisplayed() } diff --git a/ui/README.md b/ui/README.md index 57c71afa8e9..07f3fa1cc03 100644 --- a/ui/README.md +++ b/ui/README.md @@ -22,10 +22,11 @@ All drawables should be named with the appropriate prefix to identify what they | Prefix | Description | |---------|---------------| +| `bg_` | backgrounds | | `gif_` | gifs | | `ic_` | Icons | -| `img_` | Raster Images | | `ill_` | Illustrations | +| `img_` | Raster Images | | `logo_` | Brand Imagery | #### Multi-tonal Illustrations diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/BitwardenColorScheme.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/BitwardenColorScheme.kt index 28313c1fb4e..a8ff7b4e7bf 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/BitwardenColorScheme.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/BitwardenColorScheme.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.graphics.Color @Immutable data class BitwardenColorScheme( val isDarkTheme: Boolean, + val isDynamicTheme: Boolean, val text: TextColors, val background: BackgroundColors, val stroke: StrokeColors, diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/ColorScheme.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/ColorScheme.kt index 65f8318ca72..c6782939c41 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/ColorScheme.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/ColorScheme.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.Color */ val darkBitwardenColorScheme: BitwardenColorScheme = BitwardenColorScheme( isDarkTheme = true, + isDynamicTheme = false, text = BitwardenColorScheme.TextColors( primary = PrimitiveColors.gray200, secondary = PrimitiveColors.gray500, @@ -108,6 +109,7 @@ val darkBitwardenColorScheme: BitwardenColorScheme = BitwardenColorScheme( */ val lightBitwardenColorScheme: BitwardenColorScheme = BitwardenColorScheme( isDarkTheme = false, + isDynamicTheme = false, text = BitwardenColorScheme.TextColors( primary = PrimitiveColors.gray1300, secondary = PrimitiveColors.gray700, @@ -215,6 +217,7 @@ fun dynamicBitwardenColorScheme( val defaultTheme = if (isDarkTheme) darkBitwardenColorScheme else lightBitwardenColorScheme return BitwardenColorScheme( isDarkTheme = isDarkTheme, + isDynamicTheme = true, text = BitwardenColorScheme.TextColors( primary = materialColorScheme.onBackground, secondary = materialColorScheme.onSurface, diff --git a/ui/src/main/res/drawable-hdpi/img_premium.png b/ui/src/main/res/drawable-hdpi/img_premium.png new file mode 100644 index 00000000000..5c9c40ffe44 Binary files /dev/null and b/ui/src/main/res/drawable-hdpi/img_premium.png differ diff --git a/ui/src/main/res/drawable-mdpi/img_premium.png b/ui/src/main/res/drawable-mdpi/img_premium.png new file mode 100644 index 00000000000..90a6c87a533 Binary files /dev/null and b/ui/src/main/res/drawable-mdpi/img_premium.png differ diff --git a/ui/src/main/res/drawable-night-hdpi/img_premium.png b/ui/src/main/res/drawable-night-hdpi/img_premium.png new file mode 100644 index 00000000000..0a686303796 Binary files /dev/null and b/ui/src/main/res/drawable-night-hdpi/img_premium.png differ diff --git a/ui/src/main/res/drawable-night-mdpi/img_premium.png b/ui/src/main/res/drawable-night-mdpi/img_premium.png new file mode 100644 index 00000000000..749c78ea8ac Binary files /dev/null and b/ui/src/main/res/drawable-night-mdpi/img_premium.png differ diff --git a/ui/src/main/res/drawable-night-xhdpi/img_premium.png b/ui/src/main/res/drawable-night-xhdpi/img_premium.png new file mode 100644 index 00000000000..c5d2fdc0239 Binary files /dev/null and b/ui/src/main/res/drawable-night-xhdpi/img_premium.png differ diff --git a/ui/src/main/res/drawable-night-xxhdpi/img_premium.png b/ui/src/main/res/drawable-night-xxhdpi/img_premium.png new file mode 100644 index 00000000000..c59780e5e7b Binary files /dev/null and b/ui/src/main/res/drawable-night-xxhdpi/img_premium.png differ diff --git a/ui/src/main/res/drawable-night-xxxhdpi/img_premium.png b/ui/src/main/res/drawable-night-xxxhdpi/img_premium.png new file mode 100644 index 00000000000..abbdc7a47f9 Binary files /dev/null and b/ui/src/main/res/drawable-night-xxxhdpi/img_premium.png differ diff --git a/ui/src/main/res/drawable-night/bg_card_gradient.xml b/ui/src/main/res/drawable-night/bg_card_gradient.xml new file mode 100644 index 00000000000..9820e15865a --- /dev/null +++ b/ui/src/main/res/drawable-night/bg_card_gradient.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable-xhdpi/img_premium.png b/ui/src/main/res/drawable-xhdpi/img_premium.png new file mode 100644 index 00000000000..5687f96909e Binary files /dev/null and b/ui/src/main/res/drawable-xhdpi/img_premium.png differ diff --git a/ui/src/main/res/drawable-xxhdpi/img_premium.png b/ui/src/main/res/drawable-xxhdpi/img_premium.png new file mode 100644 index 00000000000..453f34177dc Binary files /dev/null and b/ui/src/main/res/drawable-xxhdpi/img_premium.png differ diff --git a/ui/src/main/res/drawable-xxxhdpi/img_premium.png b/ui/src/main/res/drawable-xxxhdpi/img_premium.png new file mode 100644 index 00000000000..59eeb8b5ee2 Binary files /dev/null and b/ui/src/main/res/drawable-xxxhdpi/img_premium.png differ diff --git a/ui/src/main/res/drawable/bg_card_gradient.xml b/ui/src/main/res/drawable/bg_card_gradient.xml new file mode 100644 index 00000000000..320365a6f21 --- /dev/null +++ b/ui/src/main/res/drawable/bg_card_gradient.xml @@ -0,0 +1,218 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/main/res/drawable/ic_checkmark_small.xml b/ui/src/main/res/drawable/ic_checkmark_small.xml new file mode 100644 index 00000000000..c5ca1ff779c --- /dev/null +++ b/ui/src/main/res/drawable/ic_checkmark_small.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 6d175b2dad5..a2daf0aa3c5 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -1276,13 +1276,12 @@ Do you want to switch to this account? Upgrade pending Your upgrade is being processed. You’ll remain on the free plan until it’s complete. Sync now to check, or continue and it will update automatically. Unlock more advanced features with a Premium plan. - / month + / month · Cancel anytime Built-in authenticator Emergency access Secure file storage Breach monitoring - Upgrade now - You’ll go to Stripe’s secure checkout to complete your purchase. + You’ll complete the purchase with Stripe secure checkout. Opening checkout… Secure checkout didn’t load We had trouble opening the payment page, so try again. @@ -1403,4 +1402,13 @@ Do you want to switch to this account? Your request was interrupted because the app needs local network access. You can enable this in your device settings. Continue to Stripe? You’ll be taken to Stripe to manage your subscription cancellation. + Unlock advanced protection + Breeze through 2FA with built-in codes + Run reports to find risky passwords + Add a trusted emergency contact + Identify unsecure websites + Flag accounts with inactive 2FA + Keep documents safe and encrypted + Share files securely with anyone using Send + Receive 24/7 priority support