Skip to content

Commit

Permalink
Add brand icons to card form
Browse files Browse the repository at this point in the history
  • Loading branch information
jameswoo-stripe committed May 25, 2022
1 parent 95c943d commit 7d844aa
Show file tree
Hide file tree
Showing 9 changed files with 170 additions and 42 deletions.
Expand Up @@ -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
Expand Down Expand Up @@ -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()
)
}
}
52 changes: 27 additions & 25 deletions payments-model/src/main/java/com/stripe/android/model/CardBrand.kt
Expand Up @@ -45,6 +45,30 @@ enum class CardBrand(
*/
private val variantMaxLength: Map<Pattern, Int> = 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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -215,7 +215,9 @@ enum class CardBrand(
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun getCardBrands(cardNumber: String?): List<CardBrand> {
if (cardNumber.isNullOrBlank()) {
return listOf(Unknown)
return values().toList().filter {
it != Unknown
}
}

return getMatchingCards(cardNumber).takeIf {
Expand Down
1 change: 1 addition & 0 deletions payments-ui-core/api/payments-ui-core.api
Expand Up @@ -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
}
Expand Down
Expand Up @@ -65,11 +65,20 @@ internal class CardNumberController constructor(
override val trailingIcon: Flow<TextFieldIcon?> = _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
)
}
}

Expand Down
Expand Up @@ -68,7 +68,7 @@ internal class CvcController constructor(
}

override val trailingIcon: Flow<TextFieldIcon?> = cardBrandFlow.map {
TextFieldIcon(it.cvcIcon, isIcon = false)
TextFieldIcon.Trailing(it.cvcIcon, isIcon = false)
}

override val loading: Flow<Boolean> = MutableStateFlow(false)
Expand Down
Expand Up @@ -30,7 +30,7 @@ class IbanConfig : TextFieldConfig {
override val keyboard = KeyboardType.Ascii

override val trailingIcon: MutableStateFlow<TextFieldIcon?> = MutableStateFlow(
TextFieldIcon(
TextFieldIcon.Trailing(
R.drawable.stripe_ic_bank_generic,
isIcon = true
)
Expand Down
Expand Up @@ -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<Trailing>,
val animatedIcons: List<Trailing>
) : TextFieldIcon()
}

/**
* This class will provide the onValueChanged and onFocusChanged functionality to the field's
Expand Down
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -183,6 +203,27 @@ fun TextField(
)
}

@Composable
fun AnimatedIcons(
icons: List<TextFieldIcon.Trailing>,
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(
Expand All @@ -205,7 +246,7 @@ fun TextFieldColors(

@Composable
internal fun TrailingIcon(
trailingIcon: TextFieldIcon,
trailingIcon: TextFieldIcon.Trailing,
loading: Boolean
) {
if (loading) {
Expand Down
Expand Up @@ -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
Expand Down Expand Up @@ -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<TextFieldIcon?>()
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<TextFieldIcon?>()
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(
Expand Down

0 comments on commit 7d844aa

Please sign in to comment.