diff --git a/example/src/main/java/com/stripe/example/activity/CreateCardSourceActivity.kt b/example/src/main/java/com/stripe/example/activity/CreateCardSourceActivity.kt index b17eb78ff6d..a34f635acb9 100644 --- a/example/src/main/java/com/stripe/example/activity/CreateCardSourceActivity.kt +++ b/example/src/main/java/com/stripe/example/activity/CreateCardSourceActivity.kt @@ -2,6 +2,7 @@ package com.stripe.example.activity import android.content.Intent import android.os.Bundle +import android.util.Log import android.view.View import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog @@ -50,11 +51,21 @@ class CreateCardSourceActivity : AppCompatActivity() { setContentView(viewBinding.root) viewBinding.createButton.setOnClickListener { + // If a field is invalid this will shift focus to that field viewBinding.cardWidget.cardParams?.let { createCardSource(it) } ?: showSnackbar("Enter a valid card.") } + viewBinding.cardWidget.setCardValidCallback { isValid, invalidFields -> // We will not call cardParams unless it is valid because + // this will cause the inFocus field to change. + if (isValid) { + Log.e("STRIPE", "Validity: $isValid, ${viewBinding.cardWidget.cardParams}") + } else { + Log.e("STRIPE", "Validity: $isValid, invalidField: $invalidFields") + } + } + viewBinding.recyclerView.setHasFixedSize(true) viewBinding.recyclerView.layoutManager = LinearLayoutManager(this) viewBinding.recyclerView.adapter = sourcesAdapter diff --git a/payments-core/src/main/java/com/stripe/android/view/CardInputWidget.kt b/payments-core/src/main/java/com/stripe/android/view/CardInputWidget.kt index cb68b05837c..617c216dda7 100644 --- a/payments-core/src/main/java/com/stripe/android/view/CardInputWidget.kt +++ b/payments-core/src/main/java/com/stripe/android/view/CardInputWidget.kt @@ -106,6 +106,9 @@ class CardInputWidget @JvmOverloads constructor( }, CardValidCallback.Fields.Cvc.takeIf { this.cvc == null + }, + CardValidCallback.Fields.Postal.takeIf { + isPostalRequired() && postalCodeEditText.postalCode.isNullOrBlank() } ).toSet() } @@ -152,8 +155,8 @@ class CardInputWidget @JvmOverloads constructor( @VisibleForTesting @JvmSynthetic - internal val requiredFields: List - private val allFields: List + internal val requiredFields: MutableSet + private val allFields: Set /** * The [StripeEditText] fields that are currently enabled and active in the UI. @@ -169,7 +172,7 @@ class CardInputWidget @JvmOverloads constructor( /** * A [PaymentMethodCreateParams.Card] representing the card details if all fields are valid; - * otherwise `null` + * otherwise `null`. If a field is invalid focus will shift to the invalid field. */ override val paymentMethodCard: PaymentMethodCreateParams.Card? get() { @@ -197,7 +200,7 @@ class CardInputWidget @JvmOverloads constructor( /** * A [PaymentMethodCreateParams] representing the card details and postal code if all fields - * are valid; otherwise `null` + * are valid; otherwise `null`. If a field is invalid focus will shift to the invalid field */ override val paymentMethodCreateParams: PaymentMethodCreateParams? get() { @@ -208,7 +211,7 @@ class CardInputWidget @JvmOverloads constructor( /** * A [CardParams] representing the card details and postal code if all fields are valid; - * otherwise `null` + * otherwise `null`. If a field is invalid focus will shift to the invalid field. */ override val cardParams: CardParams? get() { @@ -275,7 +278,7 @@ class CardInputWidget @JvmOverloads constructor( /** * The postal code field is enabled by default. Disabling the postal code field may impact * auth success rates, so it is discouraged to disable it unless you are collecting the postal - * code outside of this form. + * code outside of this form. If the postal code is disabled it will not be shown in the view. */ var postalCodeEnabled: Boolean by Delegates.observable( CardWidget.DEFAULT_POSTAL_CODE_ENABLED @@ -291,6 +294,7 @@ class CardInputWidget @JvmOverloads constructor( cvcEditText.imeOptions = EditorInfo.IME_ACTION_DONE } + updatePostalRequired() } /** @@ -302,7 +306,11 @@ class CardInputWidget @JvmOverloads constructor( * Note that some countries do not have postal codes, so requiring postal code will prevent * those users from submitting this form successfully. */ - var postalCodeRequired: Boolean = CardWidget.DEFAULT_POSTAL_CODE_REQUIRED + var postalCodeRequired: Boolean by Delegates.observable( + CardWidget.DEFAULT_POSTAL_CODE_REQUIRED + ) { _, _, _ -> + updatePostalRequired() + } /** * If [postalCodeEnabled] is true and [usZipCodeRequired] is true, then postal code is a @@ -318,8 +326,20 @@ class CardInputWidget @JvmOverloads constructor( } else { postalCodeEditText.config = PostalCodeEditText.Config.Global } + + updatePostalRequired() + } + + private fun updatePostalRequired() { + if (isPostalRequired()) { + requiredFields.add(postalCodeEditText) + } else { + requiredFields.remove(postalCodeEditText) + } } + private fun isPostalRequired() = (postalCodeRequired || usZipCodeRequired) && postalCodeEnabled + private val frameStart: Int get() { val isLtr = context.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_LTR @@ -342,7 +362,7 @@ class CardInputWidget @JvmOverloads constructor( frameWidthSupplier = { containerLayout.width } - requiredFields = listOf( + requiredFields = mutableSetOf( cardNumberEditText, cvcEditText, expiryDateEditText @@ -481,8 +501,8 @@ class CardInputWidget @JvmOverloads constructor( * Override of [View.isEnabled] that returns `true` only * if all three sub-controls are enabled. * - * @return `true` if the card number field, expiry field, and cvc field are enabled, - * `false` otherwise + * @return `true` if the card number field, expiry field, cvc field, and postal (if required) + * are enabled, `false` otherwise */ override fun isEnabled(): Boolean { return requiredFields.all { it.isEnabled } diff --git a/payments-core/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt b/payments-core/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt index f4e1f69b9fa..19ca790991c 100644 --- a/payments-core/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt +++ b/payments-core/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt @@ -1297,18 +1297,18 @@ internal class CardInputWidgetTest { idleLooper() assertThat(cardInputWidget.requiredFields) - .isEqualTo(cardInputWidget.currentFields) + .isEqualTo(cardInputWidget.currentFields.toHashSet()) } @Test fun currentFields_notEquals_requiredFields_withPostalCodeEnabled() { cardInputWidget.postalCodeEnabled = true assertThat(cardInputWidget.requiredFields) - .isNotEqualTo(cardInputWidget.currentFields) + .isNotEqualTo(cardInputWidget.currentFields.toHashSet()) } @Test - fun testCardValidCallback() { + fun testCardValidCallback_withPostalCodeDefaultDisabled() { var currentIsValid = false var currentInvalidFields = emptySet() cardInputWidget.setCardValidCallback { isValid, invalidFields -> @@ -1362,6 +1362,60 @@ internal class CardInputWidgetTest { .containsExactly(CardValidCallback.Fields.Cvc) } + @Test + fun testCardValidCallback_withPostalCodeEnabledNotRequired() { + var currentIsValid = false + var currentInvalidFields = emptySet() + cardInputWidget.postalCodeEnabled = true + cardInputWidget.setCardValidCallback { isValid, invalidFields -> + currentIsValid = isValid + currentInvalidFields = invalidFields + } + assertThat(currentIsValid) + .isFalse() + assertThat(currentInvalidFields) + .containsExactly( + CardValidCallback.Fields.Number, + CardValidCallback.Fields.Expiry, + CardValidCallback.Fields.Cvc + ) + } + + @Test + fun testCardValidCallback_withPostalCodeEnabledAndRequired() { + var currentIsValid = false + var currentInvalidFields = emptySet() + cardInputWidget.postalCodeEnabled = true + cardInputWidget.usZipCodeRequired = true + cardInputWidget.setCardValidCallback { isValid, invalidFields -> + currentIsValid = isValid + currentInvalidFields = invalidFields + } + assertThat(currentIsValid) + .isFalse() + assertThat(currentInvalidFields) + .containsExactly( + CardValidCallback.Fields.Number, + CardValidCallback.Fields.Expiry, + CardValidCallback.Fields.Cvc, + CardValidCallback.Fields.Postal + ) + + cardInputWidget.setCardNumber(VISA_NO_SPACES) + expiryEditText.setText("1250") + cvcEditText.setText("123") + assertThat(currentIsValid).isFalse() + assertThat(currentInvalidFields).containsExactly(CardValidCallback.Fields.Postal) + + postalCodeEditText.setText("12345") + assertThat(currentIsValid).isTrue() + assertThat(currentInvalidFields).isEmpty() + + postalCodeEditText.setText("123") + assertThat(currentIsValid).isFalse() + assertThat(currentInvalidFields).containsExactly(CardValidCallback.Fields.Postal) + } + @Test fun shouldShowErrorIcon_shouldBeUpdatedCorrectly() { cardInputWidget.setExpiryDate(12, 2030) @@ -1480,7 +1534,8 @@ internal class CardInputWidgetTest { @Test fun `Verify on postal code focus change listeners trigger the callback`() { - cardInputWidget.postalCodeEditText.getParentOnFocusChangeListener().onFocusChange(cardInputWidget.postalCodeEditText, true) + cardInputWidget.postalCodeEditText.getParentOnFocusChangeListener() + .onFocusChange(cardInputWidget.postalCodeEditText, true) assertThat(cardInputListener.focusedFields) .contains(CardInputListener.FocusField.PostalCode) @@ -1488,7 +1543,8 @@ internal class CardInputWidgetTest { @Test fun `Verify on cvc focus change listeners trigger the callback`() { - cardInputWidget.cvcEditText.getParentOnFocusChangeListener().onFocusChange(cardInputWidget.cvcEditText, true) + cardInputWidget.cvcEditText.getParentOnFocusChangeListener() + .onFocusChange(cardInputWidget.cvcEditText, true) assertThat(cardInputListener.focusedFields) .contains(CardInputListener.FocusField.Cvc) @@ -1496,7 +1552,8 @@ internal class CardInputWidgetTest { @Test fun `Verify on expiration date focus change listeners trigger the callback`() { - cardInputWidget.expiryDateEditText.getParentOnFocusChangeListener().onFocusChange(cardInputWidget.expiryDateEditText, true) + cardInputWidget.expiryDateEditText.getParentOnFocusChangeListener() + .onFocusChange(cardInputWidget.expiryDateEditText, true) assertThat(cardInputListener.focusedFields) .contains(CardInputListener.FocusField.ExpiryDate) @@ -1504,7 +1561,8 @@ internal class CardInputWidgetTest { @Test fun `Verify on card number focus change listeners trigger the callback`() { - cardInputWidget.cardNumberEditText.getParentOnFocusChangeListener().onFocusChange(cardInputWidget.cardNumberEditText, true) + cardInputWidget.cardNumberEditText.getParentOnFocusChangeListener() + .onFocusChange(cardInputWidget.cardNumberEditText, true) assertThat(cardInputListener.focusedFields) .contains(CardInputListener.FocusField.CardNumber)