From e0ed1115cc4138372f6b16e6889d63ffbf1fb36d Mon Sep 17 00:00:00 2001 From: toluo-stripe Date: Tue, 19 Nov 2024 01:19:11 -0500 Subject: [PATCH] Link Payment Details UI --- link/res/drawable/stripe_link_add_green.xml | 16 ++ link/res/drawable/stripe_link_bank.xml | 9 + link/res/drawable/stripe_link_chevron.xml | 10 + link/res/values/strings.xml | 2 + .../link/model/ConsumerPaymentDetailsKtx.kt | 19 ++ .../com/stripe/android/link/theme/Color.kt | 10 + .../com/stripe/android/link/theme/Theme.kt | 1 + .../android/link/ui/wallet/PaymentDetails.kt | 246 ++++++++++++++++++ .../PaymentDetailsListItemScreenShotTest.kt | 176 +++++++++++++ 9 files changed, 489 insertions(+) create mode 100644 link/res/drawable/stripe_link_add_green.xml create mode 100644 link/res/drawable/stripe_link_bank.xml create mode 100644 link/res/drawable/stripe_link_chevron.xml create mode 100644 link/src/main/java/com/stripe/android/link/model/ConsumerPaymentDetailsKtx.kt create mode 100644 link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt create mode 100644 link/src/test/java/com/stripe/android/link/ui/wallet/PaymentDetailsListItemScreenShotTest.kt diff --git a/link/res/drawable/stripe_link_add_green.xml b/link/res/drawable/stripe_link_add_green.xml new file mode 100644 index 00000000000..743e3727e4c --- /dev/null +++ b/link/res/drawable/stripe_link_add_green.xml @@ -0,0 +1,16 @@ + + + + diff --git a/link/res/drawable/stripe_link_bank.xml b/link/res/drawable/stripe_link_bank.xml new file mode 100644 index 00000000000..bf2d04afc6b --- /dev/null +++ b/link/res/drawable/stripe_link_bank.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/link/res/drawable/stripe_link_chevron.xml b/link/res/drawable/stripe_link_chevron.xml new file mode 100644 index 00000000000..4a65ce35e5a --- /dev/null +++ b/link/res/drawable/stripe_link_chevron.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/link/res/values/strings.xml b/link/res/values/strings.xml index 014dd1275a0..c1de04fe999 100644 --- a/link/res/values/strings.xml +++ b/link/res/values/strings.xml @@ -68,4 +68,6 @@ Update card This card has expired. Update your card info or choose a different payment method. + + Remove card diff --git a/link/src/main/java/com/stripe/android/link/model/ConsumerPaymentDetailsKtx.kt b/link/src/main/java/com/stripe/android/link/model/ConsumerPaymentDetailsKtx.kt new file mode 100644 index 00000000000..c7316e3f3f6 --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/model/ConsumerPaymentDetailsKtx.kt @@ -0,0 +1,19 @@ +package com.stripe.android.link.model + +import com.stripe.android.link.R +import com.stripe.android.model.ConsumerPaymentDetails + +internal val ConsumerPaymentDetails.BankAccount.icon + get() = R.drawable.stripe_link_bank + +internal val ConsumerPaymentDetails.PaymentDetails.removeLabel + get() = when (this) { + is ConsumerPaymentDetails.Passthrough, is ConsumerPaymentDetails.Card -> R.string.stripe_wallet_remove_card + is ConsumerPaymentDetails.BankAccount -> R.string.stripe_wallet_remove_linked_account + } + +internal val ConsumerPaymentDetails.PaymentDetails.removeConfirmation + get() = when (this) { + is ConsumerPaymentDetails.Passthrough, is ConsumerPaymentDetails.Card -> R.string.stripe_wallet_remove_card_confirmation + is ConsumerPaymentDetails.BankAccount -> R.string.stripe_wallet_remove_account_confirmation + } diff --git a/link/src/main/java/com/stripe/android/link/theme/Color.kt b/link/src/main/java/com/stripe/android/link/theme/Color.kt index 7638131f9ca..ec6598e43cc 100644 --- a/link/src/main/java/com/stripe/android/link/theme/Color.kt +++ b/link/src/main/java/com/stripe/android/link/theme/Color.kt @@ -17,7 +17,9 @@ private val ButtonLabel = Color(0xFF011E0F) private val ErrorText = Color(0xFFFF2F4C) private val ErrorBackground = Color(0x2EFE87A1) +private val LightComponentBackground = Color.White private val LightComponentBorder = Color(0xFFE0E6EB) +private val LightComponentDivider = Color(0xFFEFF2F4) private val LightTextPrimary = Color(0xFF30313D) private val LightTextSecondary = Color(0xFF6A7383) private val LightTextDisabled = Color(0xFFA3ACBA) @@ -30,7 +32,9 @@ private val LightCloseButton = Color(0xFF30313D) private val LightLinkLogo = Color(0xFF1D3944) private val LightOtpPlaceholder = Color(0xFFEBEEF1) +private val DarkComponentBackground = Color(0x2E747480) private val DarkComponentBorder = Color(0x5C787880) +private val DarkComponentDivider = Color(0x33787880) private val DarkTextPrimary = Color.White private val DarkTextSecondary = Color(0x99EBEBF5) private val DarkTextDisabled = Color(0x61FFFFFF) @@ -42,7 +46,9 @@ private val DarkProgressIndicator = LinkTeal private val DarkOtpPlaceholder = Color(0x61FFFFFF) internal data class LinkColors( + val componentBackground: Color, val componentBorder: Color, + val componentDivider: Color, val actionLabel: Color, val buttonLabel: Color, val actionLabelLight: Color, @@ -64,7 +70,9 @@ internal object LinkThemeConfig { } private val colorsLight = LinkColors( + componentBackground = LightComponentBackground, componentBorder = LightComponentBorder, + componentDivider = LightComponentDivider, buttonLabel = ButtonLabel, actionLabelLight = ActionLightGreen, errorText = ErrorText, @@ -91,7 +99,9 @@ internal object LinkThemeConfig { ) private val colorsDark = colorsLight.copy( + componentBackground = DarkComponentBackground, componentBorder = DarkComponentBorder, + componentDivider = DarkComponentDivider, progressIndicator = DarkProgressIndicator, linkLogo = DarkLinkLogo, closeButton = DarkCloseButton, diff --git a/link/src/main/java/com/stripe/android/link/theme/Theme.kt b/link/src/main/java/com/stripe/android/link/theme/Theme.kt index 7f0ae205935..fe87730ac09 100644 --- a/link/src/main/java/com/stripe/android/link/theme/Theme.kt +++ b/link/src/main/java/com/stripe/android/link/theme/Theme.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.unit.dp private val LocalColors = staticCompositionLocalOf { LinkThemeConfig.colors(false) } +internal val MinimumTouchTargetSize = 48.dp internal val PrimaryButtonHeight = 56.dp internal val AppBarHeight = 56.dp diff --git a/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt b/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt new file mode 100644 index 00000000000..417b510d64a --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt @@ -0,0 +1,246 @@ +package com.stripe.android.link.ui.wallet + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.RadioButton +import androidx.compose.material.RadioButtonDefaults +import androidx.compose.material.TabRowDefaults +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.stripe.android.link.R +import com.stripe.android.link.model.icon +import com.stripe.android.link.theme.MinimumTouchTargetSize +import com.stripe.android.link.theme.linkColors +import com.stripe.android.link.theme.linkShapes +import com.stripe.android.model.ConsumerPaymentDetails +import com.stripe.android.model.ConsumerPaymentDetails.Card +import com.stripe.android.R as StripeR + +@Composable +internal fun PaymentDetailsListItem( + paymentDetails: ConsumerPaymentDetails.PaymentDetails, + enabled: Boolean, + isSelected: Boolean, + isUpdating: Boolean, + onClick: () -> Unit, + onMenuButtonClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 56.dp) + .clickable(enabled = enabled, onClick = onClick), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = null, + modifier = Modifier.padding(start = 20.dp, end = 6.dp), + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.linkColors.actionLabelLight, + unselectedColor = MaterialTheme.linkColors.disabledText + ) + ) + Column( + modifier = Modifier + .padding(vertical = 8.dp) + .weight(1f) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + PaymentDetails(paymentDetails = paymentDetails) + + if (paymentDetails.isDefault) { + Box( + modifier = Modifier + .background( + color = MaterialTheme.colors.secondary, + shape = MaterialTheme.linkShapes.extraSmall + ), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(id = R.string.stripe_wallet_default), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + color = MaterialTheme.linkColors.disabledText, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + } + + val showWarning = (paymentDetails as? Card)?.isExpired ?: false + if (showWarning && !isSelected) { + Icon( + painter = painterResource(R.drawable.stripe_link_error), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.linkColors.errorText + ) + } + } + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(MinimumTouchTargetSize) + .padding(end = 12.dp) + ) { + if (isUpdating) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } else { + IconButton( + onClick = onMenuButtonClick, + enabled = enabled + ) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(StripeR.string.stripe_edit), + tint = MaterialTheme.linkColors.actionLabelLight, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + TabRowDefaults.Divider( + modifier = Modifier.padding(horizontal = 20.dp), + color = MaterialTheme.linkColors.componentDivider, + thickness = 1.dp + ) +} + +@Composable +private fun RowScope.PaymentDetails( + paymentDetails: ConsumerPaymentDetails.PaymentDetails, +) { + when (paymentDetails) { + is Card -> { + CardInfo( + last4 = paymentDetails.last4, + icon = paymentDetails.brand.icon, + contentDescription = paymentDetails.brand.displayName + ) + } + is ConsumerPaymentDetails.BankAccount -> { + BankAccountInfo(bankAccount = paymentDetails) + } + is ConsumerPaymentDetails.Passthrough -> { + CardInfo( + last4 = paymentDetails.last4, + icon = R.drawable.stripe_link_bank, + contentDescription = "Passthrough" + ) + } + } +} + +@Composable +private fun RowScope.CardInfo( + last4: String, + icon: Int, + contentDescription: String? = null +) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = icon), + contentDescription = contentDescription, + modifier = Modifier + .width(38.dp) + .padding(horizontal = 6.dp), + alignment = Alignment.Center, + alpha = LocalContentAlpha.current + ) + Text( + text = "•••• ", + color = MaterialTheme.colors.onPrimary + .copy(alpha = LocalContentAlpha.current) + ) + Text( + text = last4, + color = MaterialTheme.colors.onPrimary + .copy(alpha = LocalContentAlpha.current), + style = MaterialTheme.typography.h6 + ) + } +} + +@Composable +private fun RowScope.BankAccountInfo( + bankAccount: ConsumerPaymentDetails.BankAccount, +) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(bankAccount.icon), + contentDescription = null, + modifier = Modifier + .width(38.dp) + .padding(horizontal = 6.dp), + alignment = Alignment.Center, + alpha = LocalContentAlpha.current, + colorFilter = ColorFilter.tint(MaterialTheme.linkColors.actionLabelLight) + ) + Column(horizontalAlignment = Alignment.Start) { + Text( + text = bankAccount.bankName ?: "Bank", + color = MaterialTheme.colors.onPrimary + .copy(alpha = LocalContentAlpha.current), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.h6 + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "•••• ", + color = MaterialTheme.colors.onSecondary + .copy(alpha = LocalContentAlpha.current), + style = MaterialTheme.typography.body2 + ) + Text( + text = bankAccount.last4, + color = MaterialTheme.colors.onSecondary + .copy(alpha = LocalContentAlpha.current), + style = MaterialTheme.typography.body2 + ) + } + } + } +} diff --git a/link/src/test/java/com/stripe/android/link/ui/wallet/PaymentDetailsListItemScreenShotTest.kt b/link/src/test/java/com/stripe/android/link/ui/wallet/PaymentDetailsListItemScreenShotTest.kt new file mode 100644 index 00000000000..a760dfbb45e --- /dev/null +++ b/link/src/test/java/com/stripe/android/link/ui/wallet/PaymentDetailsListItemScreenShotTest.kt @@ -0,0 +1,176 @@ +package com.stripe.android.link.ui.wallet + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.stripe.android.core.model.CountryCode +import com.stripe.android.model.CardBrand +import com.stripe.android.model.ConsumerPaymentDetails +import com.stripe.android.model.CvcCheck +import com.stripe.android.screenshottesting.FontSize +import com.stripe.android.screenshottesting.PaparazziRule +import com.stripe.android.screenshottesting.SystemAppearance +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +internal class PaymentDetailsListItemScreenShotTest( + private val testCase: TestCase +) { + @get:Rule + val paparazziRule = PaparazziRule( + SystemAppearance.entries, + FontSize.entries, + boxModifier = Modifier + .padding(0.dp) + .fillMaxWidth(), + ) + + @Test + fun test() { + paparazziRule.snapshot { + PaymentDetailsListItem( + paymentDetails = testCase.state.details, + enabled = testCase.state.enabled, + isSelected = testCase.state.isSelected, + isUpdating = testCase.state.isUpdating, + onClick = {}, + onMenuButtonClick = {} + ) + } + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun data(): List { + return listOf( + TestCase( + name = "CardEnabled", + state = State( + details = ConsumerPaymentDetails.Card( + id = "QAAAKJ6", + expiryYear = 2023, + expiryMonth = 12, + isDefault = true, + brand = CardBrand.MasterCard, + last4 = "4444", + cvcCheck = CvcCheck.Pass, + billingAddress = ConsumerPaymentDetails.BillingAddress( + countryCode = CountryCode.US, + postalCode = "12312" + ) + ), + enabled = true, + isSelected = false, + isUpdating = false + ) + ), + TestCase( + name = "CardEnabledSelected", + state = State( + details = ConsumerPaymentDetails.Card( + id = "QAAAKJ6", + expiryYear = 2023, + expiryMonth = 12, + isDefault = true, + brand = CardBrand.MasterCard, + last4 = "4444", + cvcCheck = CvcCheck.Pass, + billingAddress = ConsumerPaymentDetails.BillingAddress( + countryCode = CountryCode.US, + postalCode = "12312" + ) + ), + enabled = true, + isSelected = true, + isUpdating = false + ) + ), + TestCase( + name = "CardEnabledUpdating", + state = State( + details = ConsumerPaymentDetails.Card( + id = "QAAAKJ6", + expiryYear = 2023, + expiryMonth = 12, + isDefault = true, + brand = CardBrand.MasterCard, + last4 = "4444", + cvcCheck = CvcCheck.Pass, + billingAddress = ConsumerPaymentDetails.BillingAddress( + countryCode = CountryCode.US, + postalCode = "12312" + ) + ), + enabled = true, + isSelected = true, + isUpdating = false + ) + ), + TestCase( + name = "CardDisabledSelected", + state = State( + details = ConsumerPaymentDetails.Card( + id = "QAAAKJ6", + expiryYear = 2023, + expiryMonth = 12, + isDefault = true, + brand = CardBrand.MasterCard, + last4 = "4444", + cvcCheck = CvcCheck.Pass, + billingAddress = ConsumerPaymentDetails.BillingAddress( + countryCode = CountryCode.US, + postalCode = "12312" + ) + ), + enabled = false, + isSelected = true, + isUpdating = false + ) + ), + TestCase( + name = "BankAccountEnabled", + state = State( + details = ConsumerPaymentDetails.BankAccount( + id = "wAAACGA", + last4 = "6789", + bankName = "STRIPE TEST BANK", + bankIconCode = null, + isDefault = false, + ), + enabled = true, + isSelected = false, + isUpdating = false + ) + ), + TestCase( + name = "PassTThroughEnabled", + state = State( + details = ConsumerPaymentDetails.Passthrough( + id = "wAAACGA", + last4 = "6789", + ), + enabled = true, + isSelected = false, + isUpdating = false + ) + ) + ) + } + } + + internal data class TestCase(val name: String, val state: State) { + override fun toString(): String = name + } + + internal data class State( + val details: ConsumerPaymentDetails.PaymentDetails, + val enabled: Boolean, + val isSelected: Boolean, + val isUpdating: Boolean, + ) +} \ No newline at end of file