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 aac80fe
Show file tree
Hide file tree
Showing 12 changed files with 184 additions and 51 deletions.
Original file line number Diff line number Diff line change
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()
)
}
}
66 changes: 35 additions & 31 deletions payments-model/src/main/java/com/stripe/android/model/CardBrand.kt
Original file line number Diff line number Diff line change
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 @@ -214,18 +214,22 @@ enum class CardBrand(

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
fun getCardBrands(cardNumber: String?): List<CardBrand> {
if (cardNumber.isNullOrBlank()) {
return listOf(Unknown)
}

return getMatchingCards(cardNumber).takeIf {
it.isNotEmpty()
} ?: listOf(Unknown)
}

private fun getMatchingCards(cardNumber: String) = values().filter { cardBrand ->
cardBrand.getPatternForLength(cardNumber)?.matcher(cardNumber)
?.matches() == true
private fun getMatchingCards(cardNumber: String?): List<CardBrand> {
return if (cardNumber.isNullOrBlank()) {
values().toList().filter {
it != Unknown
}
} else {
values().filter { cardBrand ->
cardBrand.getPatternForLength(cardNumber)?.matcher(cardNumber)
?.matches() == true
}
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions payments-ui-core/api/payments-ui-core.api
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,25 @@ internal class CardNumberController constructor(
?: CardBrand.Unknown
}

override val trailingIcon: Flow<TextFieldIcon?> = _fieldValue.map {
override val trailingIcon: Flow<TextFieldIcon> = _fieldValue.map {
val cardBrands = CardBrand.getCardBrands(it)
if (accountRangeService.accountRange != null) {
TextFieldIcon(accountRangeService.accountRange!!.brand.icon, isIcon = false)
TextFieldIcon.Trailing(accountRangeService.accountRange!!.brand.icon, isIcon = false)
} else if (cardBrands.size == 1) {
TextFieldIcon(cardBrands.first().icon, isIcon = false)
TextFieldIcon.Trailing(cardBrands.first().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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class EmailConfig : TextFieldConfig {
override val label = R.string.email
override val keyboard = KeyboardType.Email
override val visualTransformation: VisualTransformation? = null
override val trailingIcon: MutableStateFlow<TextFieldIcon?> = MutableStateFlow(null)
override val trailingIcon: StateFlow<TextFieldIcon?> = MutableStateFlow(null)
override val loading: StateFlow<Boolean> = MutableStateFlow(false)

/**
Expand Down
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class NameConfig : TextFieldConfig {
override val debugLabel = "name"
override val keyboard = KeyboardType.Text
override val visualTransformation: VisualTransformation? = null
override val trailingIcon: MutableStateFlow<TextFieldIcon?> = MutableStateFlow(null)
override val trailingIcon: StateFlow<TextFieldIcon?> = MutableStateFlow(null)
override val loading: StateFlow<Boolean> = MutableStateFlow(false)

override fun determineState(input: String): TextFieldState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class SimpleTextFieldConfig(
Expand All @@ -15,7 +16,7 @@ class SimpleTextFieldConfig(
) : TextFieldConfig {
override val debugLabel: String = "generic_text"
override val visualTransformation: VisualTransformation? = null
override val trailingIcon: MutableStateFlow<TextFieldIcon?> = MutableStateFlow(null)
override val trailingIcon: StateFlow<TextFieldIcon?> = MutableStateFlow(null)
override val loading: MutableStateFlow<Boolean> = MutableStateFlow(false)

override fun determineState(input: String): TextFieldState = object : TextFieldState {
Expand Down
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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 aac80fe

Please sign in to comment.