Skip to content

Commit

Permalink
Add ability to require ZIP code in CardInputWidget and CardMultilineW…
Browse files Browse the repository at this point in the history
…idget (#2278)

Summary
Enable via code:
```
cardMultilineWidget.usZipCodeRequired = true
```

Enable via XML layout:
```
<com.stripe.android.view.CardMultilineWidget
    android:id="@+id/card_multiline_widget"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:shouldRequireUsZipCode="true"/>
```

Motivation
Fixes #2273

Testing
Added tests and manually verified
  • Loading branch information
mshafrir-stripe authored Mar 10, 2020
1 parent ecc5ea5 commit cbfc611
Show file tree
Hide file tree
Showing 8 changed files with 260 additions and 16 deletions.
1 change: 1 addition & 0 deletions stripe/res/values/attrs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
<declare-styleable name="CardElement">
<attr name="shouldShowPostalCode" format="boolean" />
<attr name="shouldRequirePostalCode" format="boolean" />
<attr name="shouldRequireUsZipCode" format="boolean" />
</declare-styleable>
</resources>
32 changes: 29 additions & 3 deletions stripe/src/main/java/com/stripe/android/view/CardInputWidget.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class CardInputWidget @JvmOverloads constructor(
private val cardNumberTextInputLayout = viewBinding.cardNumberTextInputLayout
private val expiryDateTextInputLayout = viewBinding.expiryDateTextInputLayout
private val cvcNumberTextInputLayout = viewBinding.cvcTextInputLayout
private val postalCodeTextInputLayout = viewBinding.postalCodeTextInputLayout
internal val postalCodeTextInputLayout = viewBinding.postalCodeTextInputLayout

@JvmSynthetic
internal val cardNumberEditText = viewBinding.cardNumberEditText
Expand Down Expand Up @@ -130,7 +130,7 @@ class CardInputWidget @JvmOverloads constructor(
private val postalCodeValue: String?
get() {
return if (postalCodeEnabled) {
postalCodeEditText.fieldText
postalCodeEditText.postalCode
} else {
null
}
Expand Down Expand Up @@ -223,7 +223,8 @@ class CardInputWidget @JvmOverloads constructor(
expiryDateEditText.shouldShowError = cardDate == null
cvcNumberEditText.shouldShowError = cvcValue == null
postalCodeEditText.shouldShowError =
postalCodeRequired && postalCodeEditText.fieldText.isBlank()
(postalCodeRequired || usZipCodeRequired) &&
postalCodeEditText.postalCode.isNullOrBlank()

// Announce error messages for accessibility
currentFields
Expand Down Expand Up @@ -298,6 +299,22 @@ class CardInputWidget @JvmOverloads constructor(
*/
var postalCodeRequired: Boolean = CardWidget.DEFAULT_POSTAL_CODE_REQUIRED

/**
* If [postalCodeEnabled] is true and [usZipCodeRequired] is true, then postal code is a
* required field and must be a 5-digit US zip code.
*
* If [postalCodeEnabled] is false, this value is ignored.
*/
var usZipCodeRequired: Boolean by Delegates.observable(
CardWidget.DEFAULT_US_ZIP_CODE_REQUIRED
) { _, _, zipCodeRequired ->
if (zipCodeRequired) {
postalCodeEditText.config = PostalCodeEditText.Config.US
} else {
postalCodeEditText.config = PostalCodeEditText.Config.Global
}
}

init {
// This ensures that onRestoreInstanceState is called
// during rotations.
Expand All @@ -318,6 +335,11 @@ class CardInputWidget @JvmOverloads constructor(
initView(attrs)
}

override fun onFinishInflate() {
super.onFinishInflate()
postalCodeEditText.config = PostalCodeEditText.Config.Global
}

override fun setCardValidCallback(callback: CardValidCallback?) {
this.cardValidCallback = callback
requiredFields.forEach { it.removeTextChangedListener(cardValidTextWatcher) }
Expand Down Expand Up @@ -800,6 +822,10 @@ class CardInputWidget @JvmOverloads constructor(
R.styleable.CardElement_shouldRequirePostalCode,
CardWidget.DEFAULT_POSTAL_CODE_REQUIRED
)
usZipCodeRequired = typedArray.getBoolean(
R.styleable.CardElement_shouldRequireUsZipCode,
CardWidget.DEFAULT_US_ZIP_CODE_REQUIRED
)
} finally {
typedArray.recycle()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import com.stripe.android.view.CardInputListener.FocusField.Companion.FOCUS_EXPI
import com.stripe.android.view.CardInputListener.FocusField.Companion.FOCUS_POSTAL
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.properties.Delegates

/**
* A multiline card input widget using the support design library's [TextInputLayout]
Expand Down Expand Up @@ -101,6 +102,22 @@ class CardMultilineWidget @JvmOverloads constructor(
*/
var postalCodeRequired: Boolean = CardWidget.DEFAULT_POSTAL_CODE_REQUIRED

/**
* If [shouldShowPostalCode] is true and [usZipCodeRequired] is true, then postal code is a
* required field and must be a 5-digit US zip code.
*
* If [shouldShowPostalCode] is false, this value is ignored.
*/
var usZipCodeRequired: Boolean by Delegates.observable(
CardWidget.DEFAULT_US_ZIP_CODE_REQUIRED
) { _, _, zipCodeRequired ->
if (zipCodeRequired) {
postalCodeEditText.config = PostalCodeEditText.Config.US
} else {
postalCodeEditText.config = PostalCodeEditText.Config.Global
}
}

/**
* Gets a [PaymentMethodCreateParams.Card] object from the user input, if all fields are
* valid. If not, returns `null`.
Expand Down Expand Up @@ -149,7 +166,7 @@ class CardMultilineWidget @JvmOverloads constructor(
get() = if (shouldShowPostalCode && validateAllFields()) {
PaymentMethod.BillingDetails.Builder()
.setAddress(Address.Builder()
.setPostalCode(postalCodeEditText.text?.toString())
.setPostalCode(postalCodeEditText.postalCode)
.build()
)
} else {
Expand Down Expand Up @@ -307,7 +324,7 @@ class CardMultilineWidget @JvmOverloads constructor(

override fun onFinishInflate() {
super.onFinishInflate()
postalCodeEditText.configureForUs()
postalCodeEditText.config = PostalCodeEditText.Config.US
}

/**
Expand Down Expand Up @@ -364,7 +381,8 @@ class CardMultilineWidget @JvmOverloads constructor(
expiryDateEditText.shouldShowError = !expiryIsValid
cvcEditText.shouldShowError = !cvcIsValid
postalCodeEditText.shouldShowError =
postalCodeRequired && postalCodeEditText.fieldText.isBlank()
(postalCodeRequired || usZipCodeRequired) &&
postalCodeEditText.postalCode.isNullOrBlank()

allFields.firstOrNull { it.shouldShowError }?.requestFocus()

Expand Down Expand Up @@ -525,6 +543,10 @@ class CardMultilineWidget @JvmOverloads constructor(
R.styleable.CardElement_shouldRequirePostalCode,
CardWidget.DEFAULT_POSTAL_CODE_REQUIRED
)
usZipCodeRequired = a.getBoolean(
R.styleable.CardElement_shouldRequireUsZipCode,
CardWidget.DEFAULT_US_ZIP_CODE_REQUIRED
)
} finally {
a.recycle()
}
Expand Down
1 change: 1 addition & 0 deletions stripe/src/main/java/com/stripe/android/view/CardWidget.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,6 @@ internal interface CardWidget {
companion object {
internal const val DEFAULT_POSTAL_CODE_ENABLED = true
internal const val DEFAULT_POSTAL_CODE_REQUIRED = false
internal const val DEFAULT_US_ZIP_CODE_REQUIRED = false
}
}
43 changes: 37 additions & 6 deletions stripe/src/main/java/com/stripe/android/view/PostalCodeEditText.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,35 @@ import android.view.View
import androidx.annotation.StringRes
import com.google.android.material.textfield.TextInputLayout
import com.stripe.android.R
import java.util.regex.Pattern
import kotlin.properties.Delegates

class PostalCodeEditText @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.appcompat.R.attr.editTextStyle
) : StripeEditText(context, attrs, defStyleAttr) {

internal var config: Config by Delegates.observable(
Config.Global
) { _, oldValue, newValue ->
when (newValue) {
Config.Global -> configureForGlobal()
Config.US -> configureForUs()
}
}

internal val postalCode: String?
get() {
return if (config == Config.US) {
fieldText.takeIf {
ZIP_CODE_PATTERN.matcher(fieldText).matches()
}
} else {
fieldText
}
}

init {
setErrorMessage(resources.getString(R.string.invalid_zip))
maxLines = 1
Expand All @@ -42,8 +64,7 @@ class PostalCodeEditText @JvmOverloads constructor(
/**
* Configure the field for United States users
*/
@JvmSynthetic
internal fun configureForUs() {
private fun configureForUs() {
updateHint(R.string.address_label_zip_code)
filters = arrayOf(InputFilter.LengthFilter(MAX_LENGTH_US))
keyListener = DigitsKeyListener.getInstance(false, true)
Expand All @@ -53,8 +74,7 @@ class PostalCodeEditText @JvmOverloads constructor(
/**
* Configure the field for global users
*/
@JvmSynthetic
internal fun configureForGlobal() {
private fun configureForGlobal() {
updateHint(R.string.address_label_postal_code)
filters = arrayOf(InputFilter.LengthFilter(MAX_LENGTH_GLOBAL))
keyListener = TextKeyListener.getInstance()
Expand All @@ -67,8 +87,12 @@ class PostalCodeEditText @JvmOverloads constructor(
*/
private fun updateHint(@StringRes hintRes: Int) {
getTextInputLayout()?.let {
it.hint = resources.getString(hintRes)
} ?: setHint(hintRes)
if (it.isHintEnabled) {
it.hint = resources.getString(hintRes)
} else {
setHint(hintRes)
}
}
}

/**
Expand All @@ -85,8 +109,15 @@ class PostalCodeEditText @JvmOverloads constructor(
return null
}

internal enum class Config {
Global,
US
}

private companion object {
private const val MAX_LENGTH_US = 5
private const val MAX_LENGTH_GLOBAL = 13

private val ZIP_CODE_PATTERN = Pattern.compile("^[0-9]{5}$")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1307,6 +1307,55 @@ internal class CardInputWidgetTest : BaseViewTest<CardInputTestActivity>(
.isEqualTo("0000 0000 0000 ")
}

@Test
fun usZipCodeRequired_whenFalse_shouldSetPostalCodeHint() {
cardInputWidget.usZipCodeRequired = false
assertThat(cardInputWidget.postalCodeEditText.hint)
.isEqualTo("Postal code")

cardInputWidget.setCardNumber(VISA_WITH_SPACES)
cardInputWidget.expiryDateEditText.append("12")
cardInputWidget.expiryDateEditText.append("50")
cardInputWidget.cvcNumberEditText.append("123")

assertThat(cardInputWidget.card)
.isNotNull()
}

@Test
fun usZipCodeRequired_whenTrue_withInvalidZipCode_shouldReturnNullCard() {
cardInputWidget.usZipCodeRequired = true
assertThat(cardInputWidget.postalCodeEditText.hint)
.isEqualTo("ZIP code")

cardInputWidget.setCardNumber(VISA_WITH_SPACES)
cardInputWidget.expiryDateEditText.append("12")
cardInputWidget.expiryDateEditText.append("50")
cardInputWidget.cvcNumberEditText.append("123")

// invalid zipcode
cardInputWidget.postalCodeEditText.setText("1234")
assertThat(cardInputWidget.card)
.isNull()
}

@Test
fun usZipCodeRequired_whenTrue_withValidZipCode_shouldReturnNotNullCard() {
cardInputWidget.usZipCodeRequired = true
assertThat(cardInputWidget.postalCodeEditText.hint)
.isEqualTo("ZIP code")

cardInputWidget.setCardNumber(VISA_WITH_SPACES)
cardInputWidget.expiryDateEditText.append("12")
cardInputWidget.expiryDateEditText.append("50")
cardInputWidget.cvcNumberEditText.append("123")

// valid zipcode
cardInputWidget.postalCodeEditText.setText("12345")
assertThat(cardInputWidget.card)
.isNotNull()
}

private companion object {
// Every Card made by the CardInputView should have the card widget token.
private val ATTRIBUTION = setOf(LOGGING_TOKEN)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,55 @@ internal class CardMultilineWidgetTest {
.isEqualTo("ZIP code")
}

@Test
fun usZipCodeRequired_whenFalse_shouldSetPostalCodeHint() {
cardMultilineWidget.usZipCodeRequired = false
assertThat(cardMultilineWidget.postalInputLayout.hint)
.isEqualTo("Postal code")

cardMultilineWidget.setCardNumber(VISA_WITH_SPACES)
fullGroup.expiryDateEditText.append("12")
fullGroup.expiryDateEditText.append("50")
fullGroup.cvcEditText.append("123")

assertThat(cardMultilineWidget.card)
.isNotNull()
}

@Test
fun usZipCodeRequired_whenTrue_withInvalidZipCode_shouldReturnNullCard() {
cardMultilineWidget.usZipCodeRequired = true
assertThat(cardMultilineWidget.postalInputLayout.hint)
.isEqualTo("ZIP code")

cardMultilineWidget.setCardNumber(VISA_WITH_SPACES)
fullGroup.expiryDateEditText.append("12")
fullGroup.expiryDateEditText.append("50")
fullGroup.cvcEditText.append("123")

// invalid zipcode
fullGroup.postalCodeEditText.setText("1234")
assertThat(cardMultilineWidget.card)
.isNull()
}

@Test
fun usZipCodeRequired_whenTrue_withValidZipCode_shouldReturnNotNullCard() {
cardMultilineWidget.usZipCodeRequired = true
assertThat(cardMultilineWidget.postalInputLayout.hint)
.isEqualTo("ZIP code")

cardMultilineWidget.setCardNumber(VISA_WITH_SPACES)
fullGroup.expiryDateEditText.append("12")
fullGroup.expiryDateEditText.append("50")
fullGroup.cvcEditText.append("123")

// valid zipcode
fullGroup.postalCodeEditText.setText("12345")
assertThat(cardMultilineWidget.card)
.isNotNull()
}

@Test
fun setEnabled_setsEnabledPropertyOnAllChildWidgets() {
assertTrue(cardMultilineWidget.isEnabled)
Expand Down
Loading

0 comments on commit cbfc611

Please sign in to comment.