Skip to content

Commit

Permalink
Merge pull request #89 from Trendyol/Trendyol/feature/cart_input_view…
Browse files Browse the repository at this point in the history
…/american_express_support

Provide American Express support to cartInputView
  • Loading branch information
turkergoksu authored Mar 3, 2022
2 parents 6edf9bd + 05a0106 commit f6ef150
Show file tree
Hide file tree
Showing 13 changed files with 166 additions and 42 deletions.
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/ComponentVersions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ object ComponentVersions {
const val imageSliderVersion = "1.0.8"
const val phoneNumberVersion = "1.0.2"
const val dialogsVersion = "1.3.1"
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"
Expand Down
2 changes: 2 additions & 0 deletions libraries/card-input-view/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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) }
}
}

Expand All @@ -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()
Expand All @@ -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)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreditCardType> = listOf(
CreditCardType.VISA,
CreditCardType.MASTER_CARD
),
) {

var cardNumber: String = cardInformation.cardNumber
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.trendyol.cardinputview

enum class CreditCardType(
val prefixLength: Int,
val prefixes: List<Int>,
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<CreditCardType>, 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'
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<InputFilter>()
replace(0, length, currentText, 0, currentText.length)
filters = currentFilters
}
Original file line number Diff line number Diff line change
@@ -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 <a href="https://gist.github.com/hleinone/5b445e5475ca9f8a3bdc6a44998f4edd">hleinone gist</a>
*/
internal class CardNumberFormatterTextWatcher : TextWatcher {
internal class CardNumberFormatterTextWatcher(
private val supportedCardTypes: List<CreditCardType>,
) : 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) {
}
Expand All @@ -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<InputFilter>(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)
}
}
Original file line number Diff line number Diff line change
@@ -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<CreditCardType>): 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()
}
}
Original file line number Diff line number Diff line change
@@ -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<CreditCardType>): 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<CreditCardType>): 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"
}
}
Original file line number Diff line number Diff line change
@@ -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<CreditCardType>): 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
Expand All @@ -26,8 +31,4 @@ internal class LuhnValidator {
}

private fun String.digits() = this.map(Character::getNumericValue)

companion object {
private const val LENGTH_CARD_NUMBER = 16
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion libraries/card-input-view/src/main/res/values/dimens.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<dimen name="civ_big">16dp</dimen>

<dimen name="civ_height_input_field">40dp</dimen>
<dimen name="civ_width_expiry_cvv_field">70dp</dimen>
<dimen name="civ_width_expiry_cvv_field">80dp</dimen>
<dimen name="civ_size_cvv_info">30dp</dimen>
<dimen name="civ_stroke_width">0.5dp</dimen>
<dimen name="civ_divider_width">0.5dp</dimen>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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))
Expand All @@ -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()
}
Expand Down

0 comments on commit f6ef150

Please sign in to comment.