diff --git a/stripe/src/main/java/com/stripe/android/model/CardBrand.kt b/stripe/src/main/java/com/stripe/android/model/CardBrand.kt index 5f97dc7e312..26a65a4546a 100644 --- a/stripe/src/main/java/com/stripe/android/model/CardBrand.kt +++ b/stripe/src/main/java/com/stripe/android/model/CardBrand.kt @@ -11,6 +11,7 @@ enum class CardBrand( val displayName: String, @DrawableRes val icon: Int, @DrawableRes val cvcIcon: Int = R.drawable.stripe_ic_cvc, + @DrawableRes val errorIcon: Int = R.drawable.stripe_ic_error, /** * Accepted CVC lengths @@ -47,6 +48,7 @@ enum class CardBrand( "American Express", R.drawable.stripe_ic_amex, cvcIcon = R.drawable.stripe_ic_cvc_amex, + errorIcon = R.drawable.stripe_ic_error_amex, cvcLength = setOf(3, 4), defaultMaxLength = 15, prefixes = listOf("34", "37"), diff --git a/stripe/src/main/java/com/stripe/android/view/CardInputWidget.kt b/stripe/src/main/java/com/stripe/android/view/CardInputWidget.kt index 9aa2db28e02..b990b9f4420 100644 --- a/stripe/src/main/java/com/stripe/android/view/CardInputWidget.kt +++ b/stripe/src/main/java/com/stripe/android/view/CardInputWidget.kt @@ -70,6 +70,13 @@ class CardInputWidget @JvmOverloads constructor( cardValidCallback?.onInputChanged(invalidFields.isEmpty(), invalidFields) } } + private val inputChangeTextWatcher = object : StripeTextWatcher() { + override fun afterTextChanged(s: Editable?) { + super.afterTextChanged(s) + shouldShowErrorIcon = false + } + } + private val invalidFields: Set get() { return listOfNotNull( @@ -85,6 +92,17 @@ class CardInputWidget @JvmOverloads constructor( ).toSet() } + @VisibleForTesting + internal var shouldShowErrorIcon = false + private set(value) { + val isValueChange = field != value + field = value + + if (isValueChange) { + updateIcon() + } + } + @JvmSynthetic internal var cardNumberIsViewed = true @@ -119,13 +137,17 @@ class CardInputWidget @JvmOverloads constructor( @VisibleForTesting @JvmSynthetic - internal val standardFields: List + internal val requiredFields: List + private val allFields: List + /** + * The [StripeEditText] fields that are currently enabled and active in the UI. + */ @VisibleForTesting - internal val allFields: List + internal val currentFields: List @JvmSynthetic get() { - return standardFields + return requiredFields .plus(postalCodeEditText.takeIf { postalCodeEnabled }) .filterNotNull() } @@ -193,7 +215,7 @@ class CardInputWidget @JvmOverloads constructor( postalCodeRequired && postalCodeEditText.fieldText.isBlank() // Announce error messages for accessibility - allFields + currentFields .filter { it.shouldShowError } .forEach { editText -> editText.errorMessage?.let { errorMessage -> @@ -215,12 +237,15 @@ class CardInputWidget @JvmOverloads constructor( postalCodeEditText.requestFocus() } else -> { + shouldShowErrorIcon = false return Card.Builder(cardNumber, cardDate.first, cardDate.second, cvcValue) .addressZip(postalCodeValue) .loggingTokens(setOf(LOGGING_TOKEN)) } } + shouldShowErrorIcon = true + return null } @@ -237,7 +262,7 @@ class CardInputWidget @JvmOverloads constructor( */ var postalCodeEnabled: Boolean = CardWidget.DEFAULT_POSTAL_CODE_ENABLED set(value) { - updatePostalCodeEditText(value) + onPostalCodeEnabledChanged(value) field = value } @@ -275,26 +300,28 @@ class CardInputWidget @JvmOverloads constructor( expiryDateEditText = expiryDateTextInputLayout.findViewById(R.id.et_expiry_date) cvcNumberEditText = cvcNumberTextInputLayout.findViewById(R.id.et_cvc) postalCodeEditText = postalCodeTextInputLayout.findViewById(R.id.et_postal_code) + postalCodeEditText.configureForGlobal() frameWidthSupplier = { frameLayout.width } cardIconImageView = findViewById(R.id.iv_card_icon) - standardFields = listOf( + requiredFields = listOf( cardNumberEditText, cvcNumberEditText, expiryDateEditText ) + allFields = requiredFields.plus(postalCodeEditText) initView(attrs) } override fun setCardValidCallback(callback: CardValidCallback?) { this.cardValidCallback = callback - standardFields.forEach { it.removeTextChangedListener(cardValidTextWatcher) } + requiredFields.forEach { it.removeTextChangedListener(cardValidTextWatcher) } // only add the TextWatcher if it will be used if (callback != null) { - standardFields.forEach { it.addTextChangedListener(cardValidTextWatcher) } + requiredFields.forEach { it.addTextChangedListener(cardValidTextWatcher) } } // call immediately after setting @@ -362,11 +389,11 @@ class CardInputWidget @JvmOverloads constructor( * Clear all text fields in the CardInputWidget. */ override fun clear() { - if (allFields.any { it.hasFocus() } || this.hasFocus()) { + if (currentFields.any { it.hasFocus() } || this.hasFocus()) { cardNumberEditText.requestFocus() } - allFields.forEach { it.setText("") } + currentFields.forEach { it.setText("") } } /** @@ -375,7 +402,7 @@ class CardInputWidget @JvmOverloads constructor( * @param isEnabled boolean indicating whether fields should be enabled */ override fun setEnabled(isEnabled: Boolean) { - allFields.forEach { it.isEnabled = isEnabled } + currentFields.forEach { it.isEnabled = isEnabled } } /** @@ -414,7 +441,7 @@ class CardInputWidget @JvmOverloads constructor( * `false` otherwise */ override fun isEnabled(): Boolean { - return standardFields.all { it.isEnabled } + return requiredFields.all { it.isEnabled } } override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { @@ -489,7 +516,7 @@ class CardInputWidget @JvmOverloads constructor( } } - private fun updatePostalCodeEditText(isEnabled: Boolean) { + private fun onPostalCodeEnabledChanged(isEnabled: Boolean) { if (isEnabled) { postalCodeEditText.isEnabled = true postalCodeTextInputLayout.visibility = View.VISIBLE @@ -678,7 +705,7 @@ class CardInputWidget @JvmOverloads constructor( cardNumberEditText.hint = it } - allFields.forEach { it.setErrorColor(errorColorInt) } + currentFields.forEach { it.setErrorColor(errorColorInt) } cardNumberEditText.onFocusChangeListener = OnFocusChangeListener { _, hasFocus -> if (hasFocus) { @@ -738,6 +765,8 @@ class CardInputWidget @JvmOverloads constructor( } } + allFields.forEach { it.addTextChangedListener(inputChangeTextWatcher) } + cardNumberEditText.requestFocus() } @@ -957,9 +986,13 @@ class CardInputWidget @JvmOverloads constructor( } private fun updateIcon() { - cardIconImageView.setImageResource(brand.icon) - if (brand == CardBrand.Unknown) { - applyTint(false) + if (shouldShowErrorIcon) { + cardIconImageView.setImageResource(brand.errorIcon) + } else { + cardIconImageView.setImageResource(brand.icon) + if (brand == CardBrand.Unknown) { + applyTint(false) + } } } @@ -967,10 +1000,16 @@ class CardInputWidget @JvmOverloads constructor( hasFocus: Boolean, cvcText: String? ) { - if (shouldIconShowBrand(brand, hasFocus, cvcText)) { - updateIcon() - } else { - updateIconForCvcEntry() + when { + shouldShowErrorIcon -> { + updateIcon() + } + shouldIconShowBrand(brand, hasFocus, cvcText) -> { + updateIcon() + } + else -> { + updateIconForCvcEntry() + } } } diff --git a/stripe/src/test/java/com/stripe/android/view/CardInputTestActivity.kt b/stripe/src/test/java/com/stripe/android/view/CardInputTestActivity.kt index ebcd050fe94..d418dd1dcbd 100644 --- a/stripe/src/test/java/com/stripe/android/view/CardInputTestActivity.kt +++ b/stripe/src/test/java/com/stripe/android/view/CardInputTestActivity.kt @@ -11,28 +11,34 @@ import com.stripe.android.R */ internal class CardInputTestActivity : AppCompatActivity() { - lateinit var cardInputWidget: CardInputWidget - lateinit var cardMultilineWidget: CardMultilineWidget - lateinit var noZipCardMulitlineWidget: CardMultilineWidget - lateinit var maskedCardView: MaskedCardView - - val cardNumberEditText: CardNumberEditText - get() = cardInputWidget.findViewById(R.id.et_card_number) + val cardInputWidget: CardInputWidget by lazy { + CardInputWidget(this) + } + val cardMultilineWidget: CardMultilineWidget by lazy { + CardMultilineWidget(this, shouldShowPostalCode = true) + } + val noZipCardMulitlineWidget: CardMultilineWidget by lazy { + CardMultilineWidget(this, shouldShowPostalCode = false) + } + val maskedCardView: MaskedCardView by lazy { + MaskedCardView(this) + } + val cardNumberEditText: CardNumberEditText by lazy { + cardInputWidget.findViewById(R.id.et_card_number) + } - val expiryDateEditText: ExpiryDateEditText - get() = cardInputWidget.findViewById(R.id.et_expiry_date) + val expiryDateEditText: ExpiryDateEditText by lazy { + cardInputWidget.findViewById(R.id.et_expiry_date) + } - val cvcEditText: StripeEditText - get() = cardInputWidget.findViewById(R.id.et_cvc) + val cvcEditText: StripeEditText by lazy { + cardInputWidget.findViewById(R.id.et_cvc) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setTheme(R.style.StripeDefaultTheme) - cardInputWidget = CardInputWidget(this) - cardMultilineWidget = CardMultilineWidget(this, shouldShowPostalCode = true) - noZipCardMulitlineWidget = CardMultilineWidget(this, shouldShowPostalCode = false) - maskedCardView = MaskedCardView(this) val linearLayout = LinearLayout(this) linearLayout.addView(cardInputWidget) diff --git a/stripe/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt b/stripe/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt index ca9a07bc562..60915460fdd 100644 --- a/stripe/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt +++ b/stripe/src/test/java/com/stripe/android/view/CardInputWidgetTest.kt @@ -65,6 +65,9 @@ internal class CardInputWidgetTest : BaseViewTest( cardInputWidget.findViewById(R.id.et_postal_code) } private val cardInputListener: CardInputListener = mock() + private val cardIcon: ImageView by lazy { + cardInputWidget.findViewById(R.id.iv_card_icon) + } @BeforeTest fun setup() { @@ -1204,13 +1207,13 @@ internal class CardInputWidgetTest : BaseViewTest( @Test fun allFields_equals_standardFields_withPostalCodeDisabled() { cardInputWidget.postalCodeEnabled = false - assertEquals(cardInputWidget.standardFields, cardInputWidget.allFields) + assertEquals(cardInputWidget.requiredFields, cardInputWidget.currentFields) } @Test fun allFields_notEquals_standardFields_withPostalCodeEnabled() { cardInputWidget.postalCodeEnabled = true - assertNotEquals(cardInputWidget.standardFields, cardInputWidget.allFields) + assertNotEquals(cardInputWidget.requiredFields, cardInputWidget.currentFields) } @Test @@ -1277,6 +1280,27 @@ internal class CardInputWidgetTest : BaseViewTest( ) } + @Test + fun shouldShowErrorIcon_shouldBeUpdatedCorrectly() { + cardInputWidget.setExpiryDate(12, 2030) + cardInputWidget.setCvcCode(CVC_VALUE_COMMON) + + // show error icon when validating fields with invalid card number + cardInputWidget.setCardNumber(VISA_NO_SPACES.take(6)) + assertNull(cardInputWidget.paymentMethodCreateParams) + assertTrue(cardInputWidget.shouldShowErrorIcon) + + // don't show error icon after changing input + cardInputWidget.setCardNumber(VISA_NO_SPACES.take(7)) + assertFalse(cardInputWidget.shouldShowErrorIcon) + + // don't show error icon when validating fields with invalid card number + assertNull(cardInputWidget.paymentMethodCreateParams) + cardInputWidget.setCardNumber(VISA_NO_SPACES) + assertNotNull(cardInputWidget.paymentMethodCreateParams) + assertFalse(cardInputWidget.shouldShowErrorIcon) + } + private companion object { // Every Card made by the CardInputView should have the card widget token. private val ATTRIBUTION = setOf(LOGGING_TOKEN)