From 7d844aae36622128612999eff6d72821938a5bb4 Mon Sep 17 00:00:00 2001 From: jameswoo-stripe <99316447+jameswoo-stripe@users.noreply.github.com> Date: Wed, 25 May 2022 10:04:58 -0700 Subject: [PATCH] Add brand icons to card form --- .../com/stripe/android/model/CardBrandTest.kt | 19 +++++++ .../com/stripe/android/model/CardBrand.kt | 52 ++++++++++--------- payments-ui-core/api/payments-ui-core.api | 1 + .../ui/core/elements/CardNumberController.kt | 17 ++++-- .../android/ui/core/elements/CvcController.kt | 2 +- .../android/ui/core/elements/IbanConfig.kt | 2 +- .../ui/core/elements/TextFieldController.kt | 27 ++++++---- .../android/ui/core/elements/TextFieldUI.kt | 45 +++++++++++++++- .../core/elements/CardNumberControllerTest.kt | 47 +++++++++++++++++ 9 files changed, 170 insertions(+), 42 deletions(-) diff --git a/payments-core/src/test/java/com/stripe/android/model/CardBrandTest.kt b/payments-core/src/test/java/com/stripe/android/model/CardBrandTest.kt index 88768ba29c0..4c4a1184273 100644 --- a/payments-core/src/test/java/com/stripe/android/model/CardBrandTest.kt +++ b/payments-core/src/test/java/com/stripe/android/model/CardBrandTest.kt @@ -3,6 +3,7 @@ package com.stripe.android.model import com.google.common.truth.Truth.assertThat import com.stripe.android.CardNumberFixtures import kotlin.test.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -176,4 +177,22 @@ class CardBrandTest { assertThat(CardBrand.fromCardNumber("561243")) .isEqualTo(CardBrand.MasterCard) } + + @Test + fun cardBrandIsOrdered() { + // Ordered for rendering purposes in the card field + assertContentEquals( + arrayOf( + CardBrand.Visa, + CardBrand.MasterCard, + CardBrand.AmericanExpress, + CardBrand.Discover, + CardBrand.JCB, + CardBrand.DinersClub, + CardBrand.UnionPay, + CardBrand.Unknown + ), + CardBrand.values() + ) + } } diff --git a/payments-model/src/main/java/com/stripe/android/model/CardBrand.kt b/payments-model/src/main/java/com/stripe/android/model/CardBrand.kt index 551b86c1e5a..74910390f1c 100644 --- a/payments-model/src/main/java/com/stripe/android/model/CardBrand.kt +++ b/payments-model/src/main/java/com/stripe/android/model/CardBrand.kt @@ -45,6 +45,30 @@ enum class CardBrand( */ private val variantMaxLength: Map = emptyMap(), ) { + Visa( + "visa", + "Visa", + R.drawable.stripe_ic_visa, + pattern = Pattern.compile("^(4)[0-9]*$"), + partialPatterns = mapOf( + 1 to Pattern.compile("^4$") + ), + ), + + MasterCard( + "mastercard", + "Mastercard", + R.drawable.stripe_ic_mastercard, + pattern = Pattern.compile( + "^(2221|2222|2223|2224|2225|2226|2227|2228|2229|222|223|224|225|226|" + + "227|228|229|23|24|25|26|270|271|2720|50|51|52|53|54|55|56|57|58|59|67)[0-9]*$" + ), + partialPatterns = mapOf( + 1 to Pattern.compile("^2|5|6$"), + 2 to Pattern.compile("^(22|23|24|25|26|27|50|51|52|53|54|55|56|57|58|59|67)$") + ) + ), + AmericanExpress( "amex", "American Express", @@ -105,30 +129,6 @@ enum class CardBrand( ) ), - Visa( - "visa", - "Visa", - R.drawable.stripe_ic_visa, - pattern = Pattern.compile("^(4)[0-9]*$"), - partialPatterns = mapOf( - 1 to Pattern.compile("^4$") - ), - ), - - MasterCard( - "mastercard", - "Mastercard", - R.drawable.stripe_ic_mastercard, - pattern = Pattern.compile( - "^(2221|2222|2223|2224|2225|2226|2227|2228|2229|222|223|224|225|226|" + - "227|228|229|23|24|25|26|270|271|2720|50|51|52|53|54|55|56|57|58|59|67)[0-9]*$" - ), - partialPatterns = mapOf( - 1 to Pattern.compile("^2|5|6$"), - 2 to Pattern.compile("^(22|23|24|25|26|27|50|51|52|53|54|55|56|57|58|59|67)$") - ) - ), - UnionPay( "unionpay", "UnionPay", @@ -215,7 +215,9 @@ enum class CardBrand( @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) fun getCardBrands(cardNumber: String?): List { if (cardNumber.isNullOrBlank()) { - return listOf(Unknown) + return values().toList().filter { + it != Unknown + } } return getMatchingCards(cardNumber).takeIf { diff --git a/payments-ui-core/api/payments-ui-core.api b/payments-ui-core/api/payments-ui-core.api index 16d3e23a58f..ed54582576e 100644 --- a/payments-ui-core/api/payments-ui-core.api +++ b/payments-ui-core/api/payments-ui-core.api @@ -258,6 +258,7 @@ public final class com/stripe/android/ui/core/elements/StaticTextElementUIKt { } public final class com/stripe/android/ui/core/elements/TextFieldUIKt { + public static final fun AnimatedIcons (Ljava/util/List;ZLandroidx/compose/runtime/Composer;I)V public static final fun TextField-PwfN4xk (Lcom/stripe/android/ui/core/elements/TextFieldController;Landroidx/compose/ui/Modifier;IZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V public static final fun TextFieldSection-VyDzSTg (Lcom/stripe/android/ui/core/elements/TextFieldController;Landroidx/compose/ui/Modifier;Ljava/lang/Integer;IZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberController.kt index 5f63df096bc..57b8d8da6af 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberController.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberController.kt @@ -65,11 +65,20 @@ internal class CardNumberController constructor( override val trailingIcon: Flow = _fieldValue.map { val cardBrands = CardBrand.getCardBrands(it) if (accountRangeService.accountRange != null) { - TextFieldIcon(accountRangeService.accountRange!!.brand.icon, isIcon = false) - } else if (cardBrands.size == 1) { - TextFieldIcon(cardBrands.first().icon, isIcon = false) + TextFieldIcon.Trailing(accountRangeService.accountRange!!.brand.icon, isIcon = false) } else { - TextFieldIcon(CardBrand.Unknown.icon, isIcon = false) + val staticIcons = cardBrands.map { cardBrand -> + TextFieldIcon.Trailing(cardBrand.icon, isIcon = false) + }.filterIndexed { index, _ -> index < 3 } + + val animatedIcons = cardBrands.map { cardBrand -> + TextFieldIcon.Trailing(cardBrand.icon, isIcon = false) + }.filterIndexed { index, _ -> index > 2 } + + TextFieldIcon.MultiTrailing( + staticIcons = staticIcons, + animatedIcons = animatedIcons + ) } } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcController.kt index 18ea40b59bb..8a47fdeba0b 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcController.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcController.kt @@ -68,7 +68,7 @@ internal class CvcController constructor( } override val trailingIcon: Flow = cardBrandFlow.map { - TextFieldIcon(it.cvcIcon, isIcon = false) + TextFieldIcon.Trailing(it.cvcIcon, isIcon = false) } override val loading: Flow = MutableStateFlow(false) diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/IbanConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/IbanConfig.kt index b5d132908c5..bc0779272e9 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/IbanConfig.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/IbanConfig.kt @@ -30,7 +30,7 @@ class IbanConfig : TextFieldConfig { override val keyboard = KeyboardType.Ascii override val trailingIcon: MutableStateFlow = MutableStateFlow( - TextFieldIcon( + TextFieldIcon.Trailing( R.drawable.stripe_ic_bank_generic, isIcon = true ) diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldController.kt index 63210b12f01..83ec10a47f4 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldController.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldController.kt @@ -35,15 +35,24 @@ interface TextFieldController : InputController { } @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) -data class TextFieldIcon( - @DrawableRes - val idRes: Int, - @StringRes - val contentDescription: Int? = null, - - /** If it is an icon that should be tinted to match the text the value should be true */ - val isIcon: Boolean -) +sealed class TextFieldIcon { + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + data class Trailing( + @DrawableRes + val idRes: Int, + @StringRes + val contentDescription: Int? = null, + + /** If it is an icon that should be tinted to match the text the value should be true */ + val isIcon: Boolean + ) : TextFieldIcon() + + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) + data class MultiTrailing( + val staticIcons: List, + val animatedIcons: List + ) : TextFieldIcon() +} /** * This class will provide the onValueChanged and onFocusChanged functionality to the field's diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldUI.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldUI.kt index 5c2c3242f61..a418ccc1a36 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldUI.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldUI.kt @@ -3,8 +3,11 @@ package com.stripe.android.ui.core.elements import android.view.KeyEvent import androidx.annotation.RestrictTo import androidx.annotation.StringRes +import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.CircularProgressIndicator @@ -16,6 +19,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -33,8 +37,10 @@ import androidx.compose.ui.semantics.editableText import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp import com.stripe.android.ui.core.R import com.stripe.android.ui.core.paymentsColors +import kotlinx.coroutines.delay /** * This is focused on converting an [TextFieldController] into what is displayed in a section @@ -161,7 +167,21 @@ fun TextField( ) }, trailingIcon = trailingIcon?.let { - { TrailingIcon(it, loading) } + { + when (it) { + is TextFieldIcon.Trailing -> { + TrailingIcon(it, loading) + } + is TextFieldIcon.MultiTrailing -> { + Row(modifier = Modifier.padding(end = 16.dp)) { + it.staticIcons.forEach { + TrailingIcon(it, loading) + } + AnimatedIcons(icons = it.animatedIcons, loading = loading) + } + } + } + } }, isError = shouldShowError, visualTransformation = textFieldController.visualTransformation, @@ -183,6 +203,27 @@ fun TextField( ) } +@Composable +fun AnimatedIcons( + icons: List, + loading: Boolean +) { + if (icons.isEmpty()) return + + val target by produceState(initialValue = icons.first()) { + while (true) { + icons.forEach { + delay(1000) + value = it + } + } + } + + Crossfade(targetState = target) { + TrailingIcon(it, loading) + } +} + @Composable @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) fun TextFieldColors( @@ -205,7 +246,7 @@ fun TextFieldColors( @Composable internal fun TrailingIcon( - trailingIcon: TextFieldIcon, + trailingIcon: TextFieldIcon.Trailing, loading: Boolean ) { if (loading) { diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardNumberControllerTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardNumberControllerTest.kt index 258d4689625..6dff0f57c0a 100644 --- a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardNumberControllerTest.kt +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardNumberControllerTest.kt @@ -6,6 +6,7 @@ import com.stripe.android.cards.CardAccountRangeRepository import com.stripe.android.cards.CardNumber import com.stripe.android.cards.StaticCardAccountRangeSource import com.stripe.android.model.AccountRange +import com.stripe.android.model.CardBrand import com.stripe.android.ui.core.R import com.stripe.android.ui.core.forms.FormFieldEntry import com.stripe.android.utils.TestUtils.idleLooper @@ -130,6 +131,52 @@ internal class CardNumberControllerTest { assertThat(cardNumberController.accountRangeService.accountRange!!.panLength).isEqualTo(19) } + @Test + fun `trailingIcon should have multi trailing icons when field is empty`() { + val trailingIcons = mutableListOf() + cardNumberController.trailingIcon.asLiveData().observeForever { + trailingIcons.add(it) + } + cardNumberController.onValueChange("") + idleLooper() + assertThat(trailingIcons.first() as TextFieldIcon.MultiTrailing) + .isEqualTo( + TextFieldIcon.MultiTrailing( + staticIcons = listOf( + TextFieldIcon.Trailing(CardBrand.Visa.icon, isIcon = false), + TextFieldIcon.Trailing(CardBrand.MasterCard.icon, isIcon = false), + TextFieldIcon.Trailing(CardBrand.AmericanExpress.icon, isIcon = false), + ), + animatedIcons = listOf( + TextFieldIcon.Trailing(CardBrand.Discover.icon, isIcon = false), + TextFieldIcon.Trailing(CardBrand.JCB.icon, isIcon = false), + TextFieldIcon.Trailing(CardBrand.DinersClub.icon, isIcon = false), + TextFieldIcon.Trailing(CardBrand.UnionPay.icon, isIcon = false), + ) + ) + ) + } + + @Test + fun `trailingIcon should have trailing icon when field matches bin`() { + val trailingIcons = mutableListOf() + cardNumberController.trailingIcon.asLiveData().observeForever { + trailingIcons.add(it) + } + cardNumberController.onValueChange("4") + idleLooper() + cardNumberController.onValueChange("") + idleLooper() + assertThat(trailingIcons[0]) + .isInstanceOf(TextFieldIcon.MultiTrailing::class.java) + assertThat(trailingIcons[1] as TextFieldIcon.Trailing) + .isEqualTo( + TextFieldIcon.Trailing(CardBrand.Visa.icon, isIcon = false) + ) + assertThat(trailingIcons[2]) + .isInstanceOf(TextFieldIcon.MultiTrailing::class.java) + } + private class FakeCardAccountRangeRepository : CardAccountRangeRepository { private val staticCardAccountRangeSource = StaticCardAccountRangeSource() override suspend fun getAccountRange(