diff --git a/buildSrc/src/main/kotlin/ComponentVersions.kt b/buildSrc/src/main/kotlin/ComponentVersions.kt index 71927a7c..2c081b73 100644 --- a/buildSrc/src/main/kotlin/ComponentVersions.kt +++ b/buildSrc/src/main/kotlin/ComponentVersions.kt @@ -6,7 +6,7 @@ object ComponentVersions { const val imageSliderVersion = "1.0.8" const val phoneNumberVersion = "1.0.2" const val dialogsVersion = "1.2.8" - const val cardInputViewVersion = "1.1.3" + const val cardInputViewVersion = "1.2.0" const val quantityPickerViewVersion = "1.2.5" const val timelineViewVersion = "1.0.0" const val touchDelegatorVersion = "1.0.0" diff --git a/libraries/card-input-view/README.md b/libraries/card-input-view/README.md index 5e49665e..3e43d1a5 100644 --- a/libraries/card-input-view/README.md +++ b/libraries/card-input-view/README.md @@ -47,6 +47,8 @@ To clear all inputs' errors, call `CardInputView.clearErrors()`. To focus and show soft keyboard on card number field call `CardInputView.focusToCardNumberField()`, to focus CVV field call `CardInputView.focusToCvvField()`. +To set supported credit card types call `CardInputView.setSupportedCardTypes()` by default it supports and formats as `CreditCardType.MASTER_CARD, CreditCardType.VISA`. + For expire month and year selection, you need to open custom dialog or input field, we suggest you to use **Dialogs**, to more information about **Dialogs**, [click here](https://github.com/Trendyol/android-ui-components/tree/master/libraries/dialogs). ## Listeners diff --git a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/CardInputView.kt b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/CardInputView.kt index 6b668638..393b19b7 100644 --- a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/CardInputView.kt +++ b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/CardInputView.kt @@ -18,7 +18,7 @@ class CardInputView : ConstraintLayout { var onCardNumberChanged: ((String) -> Unit)? = null var onCvvChanged: ((String) -> Unit)? = null - var onCvvInfoClicked: (() -> Unit)? = null + var onCvvInfoClicked: ((Int) -> Unit)? = null var onCardNumberComplete: ((Boolean) -> Unit)? = null var onCvvComplete: ((Boolean) -> Unit)? = null var openMonthSelectionListener: (() -> Unit)? = null @@ -27,6 +27,7 @@ class CardInputView : ConstraintLayout { private val binding: ViewCardInputBinding = inflate(R.layout.view_card_input) private val validator by lazy { CreditCardValidator() } + private lateinit var cardNumberFormatterTextWatcher: CardNumberFormatterTextWatcher constructor(context: Context) : super(context) @@ -70,10 +71,14 @@ class CardInputView : ConstraintLayout { fun validate(): Boolean { val cardInformation = binding.viewState?.cardInformation ?: CardInformation() - val isCardNumberValid = validator.isCardNumberValid(cardInformation.cardNumber) + val isCardNumberValid = validator.isCardNumberValid( + cardInformation.cardNumber, binding.viewState?.supportedCardTypes.orEmpty() + ) val isExpiryMonthValid = validator.isExpiryMonthValid(cardInformation.expiryMonth) val isExpiryYearValid = validator.isExpiryYearValid(cardInformation.expiryYear) - val isCvvValid = validator.isCvvValid(cardInformation.cvv) + val isCvvValid = validator.isCvvValid( + cardInformation.cvv, cardInformation.cardNumber, binding.viewState?.supportedCardTypes.orEmpty() + ) binding.viewState = binding.viewState?.copy( cardNumberValid = isCardNumberValid, @@ -181,6 +186,17 @@ class CardInputView : ConstraintLayout { binding.executePendingBindings() } + fun setSupportedCardTypes(vararg cardType: CreditCardType) { + binding.viewState = binding.viewState?.copy( + supportedCardTypes = cardType.asList() + ) + binding.executePendingBindings() + + binding.editTextCardNumber.removeTextChangedListener(cardNumberFormatterTextWatcher) + cardNumberFormatterTextWatcher = CardNumberFormatterTextWatcher(cardType.asList()) + .also { watcher -> binding.editTextCardNumber.addTextChangedListener(watcher) } + } + private fun readAttributes(attrs: AttributeSet?, defStyleAttr: Int) { context.theme.obtainStyledAttributes( attrs, @@ -211,7 +227,7 @@ class CardInputView : ConstraintLayout { it.getDrawable(R.styleable.CardInputView_civ_inputErrorBackground) ?: context.drawable(R.drawable.shape_card_input_field_error_background) - binding.viewState = CardInputViewState( + val viewState = CardInputViewState( cardNumberTitle = cardNumberTitleText, expiryTitle = expiryTitle, expiryMonthTitle = expiryMonthTitle, @@ -225,7 +241,10 @@ class CardInputView : ConstraintLayout { inputBackgroundDrawable = inputBackground?.constantState?.newDrawable(), inputErrorBackgroundDrawable = inputErrorBackground?.constantState?.newDrawable() ) + binding.viewState = viewState binding.executePendingBindings() + cardNumberFormatterTextWatcher = CardNumberFormatterTextWatcher(viewState.supportedCardTypes) + .also { watcher -> binding.editTextCardNumber.addTextChangedListener(watcher) } } } @@ -234,19 +253,23 @@ class CardInputView : ConstraintLayout { with(editTextCardNumber) { setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_NEXT && viewState?.validationEnabled == true) { - val isValid = validator.isCardNumberValid(text?.toString()) + val isValid = + validator.isCardNumberValid(text?.toString(), viewState?.supportedCardTypes.orEmpty()) setCardNumberValidity(isValid) onCardNumberComplete?.invoke(isValid) } actionId == EditorInfo.IME_ACTION_NEXT } addTextChangedListener(CardNumberTextWatcher()) - addTextChangedListener(CardNumberFormatterTextWatcher()) } with(editTextCvv) { setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_DONE && viewState?.validationEnabled == true) { - val isValid = validator.isCvvValid(text?.toString()) + val isValid = validator.isCvvValid( + text?.toString(), + editTextCardNumber.text?.toString(), + viewState?.supportedCardTypes.orEmpty() + ) setCardCvvValidity(isValid) onCvvComplete?.invoke(isValid) if (isValid) editTextCvv.hideKeyboard() @@ -262,7 +285,12 @@ class CardInputView : ConstraintLayout { textViewCardExpiryYear.setOnClickListener { openYearSelectionListener?.invoke() } - textViewCvvInfo.setOnClickListener { onCvvInfoClicked?.invoke() } + textViewCvvInfo.setOnClickListener { + val cvvLength = CreditCardType + .getCreditCardType(viewState?.supportedCardTypes.orEmpty(), editTextCardNumber.text?.toString()) + .cvvLength + onCvvInfoClicked?.invoke(cvvLength) + } } } diff --git a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/CardInputViewState.kt b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/CardInputViewState.kt index fee314da..32a07321 100644 --- a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/CardInputViewState.kt +++ b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/CardInputViewState.kt @@ -25,7 +25,11 @@ data class CardInputViewState( private val expiryYearValid: Boolean = true, private val cvvValid: Boolean = true, private val shouldShowErrors: Boolean = true, - var cardInformation: CardInformation = CardInformation() + var cardInformation: CardInformation = CardInformation(), + val supportedCardTypes: List = listOf( + CreditCardType.VISA, + CreditCardType.MASTER_CARD + ), ) { var cardNumber: String = cardInformation.cardNumber diff --git a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/CreditCardType.kt b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/CreditCardType.kt new file mode 100644 index 00000000..6a2a8b43 --- /dev/null +++ b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/CreditCardType.kt @@ -0,0 +1,33 @@ +package com.trendyol.cardinputview + +enum class CreditCardType( + val prefixLength: Int, + val prefixes: List, + val cvvLength: Int, + val digitPattern: String, +) { + MASTER_CARD(2, (51..55).toList(), 3, "xxxx xxxx xxxx xxxx"), + VISA(1, listOf(4), 3, "xxxx xxxx xxxx xxxx"), + AMERICAN_EXPRESS(2, listOf(34, 37), 4, "xxxx xxxxxx xxxxx"), + DEFAULT(1, (0..9).toList(), 3, "xxxx xxxx xxxx xxxx"); + + companion object { + + fun getCreditCardType(creditCardTypes: List, creditCardNumber: String?): CreditCardType { + if (creditCardNumber.isNullOrBlank() || creditCardTypes.isEmpty()) return DEFAULT + + run loop@{ + creditCardTypes.forEach { type -> + if (creditCardNumber.length < type.prefixLength) return@loop + + val uniquePrefix = creditCardNumber.substring(0 until type.prefixLength).toInt() + val matches = type.prefixes.contains(uniquePrefix) + if (matches) return type + } + } + return DEFAULT + } + + const val DIGIT_PATTERN_MARK = 'x' + } +} diff --git a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/Extensions.kt b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/Extensions.kt index ffbee1e4..9b22177b 100644 --- a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/Extensions.kt +++ b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/Extensions.kt @@ -2,6 +2,8 @@ package com.trendyol.cardinputview import android.content.Context import android.graphics.drawable.Drawable +import android.text.Editable +import android.text.InputFilter import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -43,3 +45,12 @@ internal fun View.hideKeyboard() { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(windowToken, 0) } + +internal fun String.removeNonDigitCharacters() = replace("[^\\d]".toRegex(), "") + +internal fun Editable.updateText(currentText: String) { + val currentFilters = filters + filters = arrayOf() + replace(0, length, currentText, 0, currentText.length) + filters = currentFilters +} diff --git a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/formatter/CardNumberFormatterTextWatcher.kt b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/formatter/CardNumberFormatterTextWatcher.kt index 5c7cab0f..c65f6ce1 100644 --- a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/formatter/CardNumberFormatterTextWatcher.kt +++ b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/formatter/CardNumberFormatterTextWatcher.kt @@ -1,16 +1,17 @@ package com.trendyol.cardinputview.formatter import android.text.Editable -import android.text.InputFilter import android.text.TextWatcher +import com.trendyol.cardinputview.CreditCardType +import com.trendyol.cardinputview.updateText +import java.util.concurrent.atomic.AtomicBoolean -/** - * - * @see hleinone gist - */ -internal class CardNumberFormatterTextWatcher : TextWatcher { +internal class CardNumberFormatterTextWatcher( + private val supportedCardTypes: List, +) : TextWatcher { - private var current = "" + private var editing: AtomicBoolean = AtomicBoolean(false) + private val creditCardMasker = CreditCardMasker() override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { } @@ -19,17 +20,12 @@ internal class CardNumberFormatterTextWatcher : TextWatcher { } override fun afterTextChanged(s: Editable) { - if (s.toString() != current) { - val userInput = s.toString().replace(nonDigits, "") - if (userInput.length <= 16) { - current = userInput.chunked(4).joinToString(" ") - s.filters = arrayOfNulls(0) - } - s.replace(0, s.length, current, 0, current.length) - } - } + if (editing.get()) return + editing.set(true) + + val maskedCardNumber = creditCardMasker.mask(s.toString(), supportedCardTypes) + s.updateText(maskedCardNumber) - companion object { - private val nonDigits = Regex("[^\\d]") + editing.set(false) } } diff --git a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/formatter/CreditCardMasker.kt b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/formatter/CreditCardMasker.kt new file mode 100644 index 00000000..6b5fb689 --- /dev/null +++ b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/formatter/CreditCardMasker.kt @@ -0,0 +1,31 @@ +package com.trendyol.cardinputview.formatter + +import com.trendyol.cardinputview.CreditCardType +import com.trendyol.cardinputview.CreditCardType.Companion.DIGIT_PATTERN_MARK +import com.trendyol.cardinputview.removeNonDigitCharacters + +internal class CreditCardMasker { + + fun mask(creditCardNumber: String, supportedCardTypes: List): String { + val clearedCreditCardNumber = creditCardNumber.removeNonDigitCharacters() + val creditCardType = CreditCardType.getCreditCardType(supportedCardTypes, clearedCreditCardNumber) + return format(clearedCreditCardNumber, creditCardType.digitPattern) + } + + private fun format(cardNumber: String, pattern: String): String { + var formattedCardNumber = "" + var cardNumberIteratorIndex = 0 + run loop@{ + pattern.forEach { character -> + if (character == DIGIT_PATTERN_MARK) { + val digit = cardNumber.getOrNull(cardNumberIteratorIndex) ?: return@loop + formattedCardNumber += digit + cardNumberIteratorIndex++ + } else { + formattedCardNumber += character + } + } + } + return formattedCardNumber.trim() + } +} diff --git a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/validator/CreditCardValidator.kt b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/validator/CreditCardValidator.kt index 0b7deeb4..b7be4c91 100644 --- a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/validator/CreditCardValidator.kt +++ b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/validator/CreditCardValidator.kt @@ -1,14 +1,26 @@ package com.trendyol.cardinputview.validator +import com.trendyol.cardinputview.CreditCardType + internal class CreditCardValidator { - fun isCardNumberValid(cardNumber: String?): Boolean { - return LuhnValidator().isValid(cardNumber) + fun isCardNumberValid(cardNumber: String?, supportedCardTypes: List): Boolean { + return LuhnValidator().isValid(cardNumber, supportedCardTypes) } - fun isCvvValid(cvv: String?): Boolean = cvv?.matches("\\d\\d\\d".toRegex()) == true + fun isCvvValid(cvv: String?, creditCardNumber: String?, supportedCardTypes: List): Boolean { + val cvvLength = CreditCardType.getCreditCardType(supportedCardTypes, creditCardNumber).cvvLength + var cvvRegex = "" + repeat(cvvLength) { cvvRegex += NUMBER_REGEX_PATTERN } + + return cvv?.matches(cvvRegex.toRegex()) == true + } fun isExpiryMonthValid(month: String?) = month?.matches("\\d\\d".toRegex()) == true fun isExpiryYearValid(year: String?) = year?.matches("\\d\\d".toRegex()) == true + + companion object { + private const val NUMBER_REGEX_PATTERN = "\\d" + } } diff --git a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/validator/LuhnValidator.kt b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/validator/LuhnValidator.kt index 9ead89ea..59f04f6a 100644 --- a/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/validator/LuhnValidator.kt +++ b/libraries/card-input-view/src/main/java/com/trendyol/cardinputview/validator/LuhnValidator.kt @@ -1,11 +1,16 @@ package com.trendyol.cardinputview.validator +import com.trendyol.cardinputview.CreditCardType + internal class LuhnValidator { - fun isValid(input: String?): Boolean { - if (input?.replace(" ", "")?.length != LENGTH_CARD_NUMBER) return false + fun isValid(input: String?, supportedCardTypes: List): Boolean { + val creditCardNumber = input ?: return false + val creditCardType = CreditCardType.getCreditCardType(supportedCardTypes, creditCardNumber) + val sanitizedInput = creditCardNumber.replace(" ", "") + val sanitizedCardTypePattern = creditCardType.digitPattern.replace(" ", "") - val sanitizedInput = input.replace(" ", "") + if (sanitizedInput.length < sanitizedCardTypePattern.length) return false return when { valid(sanitizedInput) -> checksum(sanitizedInput) % 10 == 0 @@ -26,8 +31,4 @@ internal class LuhnValidator { } private fun String.digits() = this.map(Character::getNumericValue) - - companion object { - private const val LENGTH_CARD_NUMBER = 16 - } -} \ No newline at end of file +} diff --git a/libraries/card-input-view/src/main/res/layout/view_card_input.xml b/libraries/card-input-view/src/main/res/layout/view_card_input.xml index 0a88ca97..3632d9f2 100644 --- a/libraries/card-input-view/src/main/res/layout/view_card_input.xml +++ b/libraries/card-input-view/src/main/res/layout/view_card_input.xml @@ -182,7 +182,7 @@ android:gravity="center_vertical" android:imeOptions="actionDone" android:inputType="phone" - android:maxLength="3" + android:maxLength="4" android:paddingStart="@dimen/civ_big" android:paddingEnd="@dimen/civ_big" android:saveEnabled="false" diff --git a/libraries/card-input-view/src/main/res/values/dimens.xml b/libraries/card-input-view/src/main/res/values/dimens.xml index 4c538f51..8c8d99c0 100644 --- a/libraries/card-input-view/src/main/res/values/dimens.xml +++ b/libraries/card-input-view/src/main/res/values/dimens.xml @@ -6,7 +6,7 @@ 16dp 40dp - 70dp + 80dp 30dp 0.5dp 0.5dp diff --git a/sample/src/main/java/com/trendyol/uicomponents/CardInputViewActivity.kt b/sample/src/main/java/com/trendyol/uicomponents/CardInputViewActivity.kt index 922f63b2..f8747137 100644 --- a/sample/src/main/java/com/trendyol/uicomponents/CardInputViewActivity.kt +++ b/sample/src/main/java/com/trendyol/uicomponents/CardInputViewActivity.kt @@ -11,6 +11,7 @@ import androidx.core.content.ContextCompat import com.trendyol.cardinputview.CardInformation import com.trendyol.cardinputview.CardInputView import com.trendyol.cardinputview.CardInputViewState +import com.trendyol.cardinputview.CreditCardType import com.trendyol.uicomponents.dialogs.infoDialog import com.trendyol.uicomponents.dialogs.selectionDialog @@ -43,6 +44,11 @@ class CardInputViewActivity : AppCompatActivity() { ) with(cardInputView) { + setSupportedCardTypes( + CreditCardType.MASTER_CARD, + CreditCardType.VISA, + CreditCardType.AMERICAN_EXPRESS + ) onCardNumberChanged = { cardNumber -> if (cardNumber.length <= 1) { cardInputView.setCardTypeLogoDrawable(getCardTypeLogoUrl(cardNumber)) @@ -55,7 +61,7 @@ class CardInputViewActivity : AppCompatActivity() { onCvvInfoClicked = { Toast.makeText( this@CardInputViewActivity, - "CVV number is on the back of your card.", + "Enter $it digit cvv number", Toast.LENGTH_LONG ).show() }