Benyam
Posted on August 31, 2021
Hello, today we'll see how we can detect card scheme from card number and format the card number dynamically. For example: if the card number is American Express, we'll format the input like this xxxx-xxxxxx-xxxxx and if it was Visa Card number, we'll format it like this xxxx-xxxx-xxxx-xxxx. Finally, we'll validate the if the card number is valid or not using Luhn's algorithm.
First, let's create our composable
@Composable
fun CardNumberTextField() {
var cardNumber by remember {mutableStateOf("")}
OutlinedTextField(
value = cardNumber,
onValueChange = {it -> cardNumber = it},
keyboardOptions = KeyboardOptions(keyboardType =
KeyboardType.Number),
)
}
Now we have created basic OutlinedTextField composable that has it's own state and keyboard type of Number because that it card numbers are always numbers.
The next step is to create a function that identifies card scheme. So, let do that now. Let us define enum of card schemes first.
enum class CardScheme {
JCB, AMEX, DINERS_CLUB, VISA, MASTERCARD, DISCOVER, MAESTRO, UNKNOWN
}
As we can see we've defined 7 card schemes and last one is when card number is unknown.
fun identifyCardScheme(cardNumber: String): CardScheme {
val jcbRegex = Regex("^(?:2131|1800|35)[0-9]{0,}$")
val ameRegex = Regex("^3[47][0-9]{0,}\$")
val dinersRegex = Regex("^3(?:0[0-59]{1}|[689])[0-9]{0,}\$")
val visaRegex = Regex("^4[0-9]{0,}\$")
val masterCardRegex = Regex("^(5[1-5]|222[1-9]|22[3-9]|2[3-6]|27[01]|2720)[0-9]{0,}\$")
val maestroRegex = Regex("^(5[06789]|6)[0-9]{0,}\$")
val discoverRegex =
Regex("^(6011|65|64[4-9]|62212[6-9]|6221[3-9]|622[2-8]|6229[01]|62292[0-5])[0-9]{0,}\$")
val trimmedCardNumber = cardNumber.replace(" ", "")
return when {
trimmedCardNumber.matches(jcbRegex) -> JCB
trimmedCardNumber.matches(ameRegex) -> AMEX
trimmedCardNumber.matches(dinersRegex) -> DINERS_CLUB
trimmedCardNumber.matches(visaRegex) -> VISA
trimmedCardNumber.matches(masterCardRegex) -> MASTERCARD
trimmedCardNumber.matches(discoverRegex) -> DISCOVER
trimmedCardNumber.matches(maestroRegex) -> if (cardNumber[0] == '5') MASTERCARD else MAESTRO
else -> UNKNOWN
}
}
The above function uses RegEx to identify scheme of a given card number and returns enum of type CardScheme we defined earlier.
Now we're able to identify the CardScheme, the next step is to create a formatter that returns a TransformedText. We're going to leverage the visualTransformation capability of our OutlinedTextField.
So, based on my research I have found that American Express and Diner Club only has different formatting and card length while others(listed on enum class) has same length and formatting.
American Express is formatted this way - xxxx-xxxxxx-xxxxx which is 15 in length.
Dinner club is formatted this way - xxxx-xxxxxx-xxxx which is 14 in length.
While, other card schemes(listed on enum class) follow this type of formatting xxxx-xxxx-xxxx-xxxx and 16 in length.
First, let's see how we can format American Express card number. Then we can refactor this function for do the same of other card schemes. But I will leave that for you :)
fun formatAmex(text: AnnotatedString): TransformedText {
//
val trimmed = if (text.text.length >= 15) text.text.substring(0..14) else text.text
var out = ""
for (i in trimmed.indices) {
out += trimmed[i]
// put - character at 3rd and 9th indicies
if (i ==3 || i == 9 && i != 14) out += "-"
}
// original - 345678901234564
// transformed - 3456-7890123-4564
// xxxx-xxxxxx-xxxxx
/**
* The offset translator should ignore the hyphen characters, so conversion from
* original offset to transformed text works like
* - The 4th char of the original text is 5th char in the transformed text. (i.e original[4th] == transformed[5th]])
* - The 11th char of the original text is 13th char in the transformed text. (i.e original[11th] == transformed[13th])
* Similarly, the reverse conversion works like
* - The 5th char of the transformed text is 4th char in the original text. (i.e transformed[5th] == original[4th] )
* - The 13th char of the transformed text is 11th char in the original text. (i.e transformed[13th] == original[11th])
*/
val creditCardOffsetTranslator = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
if (offset <= 3) return offset
if (offset <= 9) return offset + 1
if(offset <= 15) return offset + 2
return 17
}
override fun transformedToOriginal(offset: Int): Int {
if (offset <= 4) return offset
if (offset <= 11) return offset - 1
if(offset <= 17) return offset - 2
return 15
}
}
return TransformedText(AnnotatedString(out), creditCardOffsetTranslator)
}
Formatting Dinners club
fun formatDinnersClub(text: AnnotatedString): TransformedText{
val trimmed = if (text.text.length >= 14) text.text.substring(0..13) else text.text
var out = ""
for (i in trimmed.indices) {
out += trimmed[i]
if (i ==3 || i == 9 && i != 13) out += "-"
}
// xxxx-xxxxxx-xxxx
val creditCardOffsetTranslator = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
if (offset <= 3) return offset
if (offset <= 9) return offset + 1
if(offset <= 14) return offset + 2
return 16
}
override fun transformedToOriginal(offset: Int): Int {
if (offset <= 4) return offset
if (offset <= 11) return offset - 1
if(offset <= 16) return offset - 2
return 14
}
}
return TransformedText(AnnotatedString(out), creditCardOffsetTranslator)
}
However, you can easily refactor formatAmex function because the only difference here between them is in length.
fun formatOtherCardNumbers(text: AnnotatedString): TransformedText {
val trimmed = if (text.text.length >= 16) text.text.substring(0..15) else text.text
var out = ""
for (i in trimmed.indices) {
out += trimmed[i]
if (i % 4 == 3 && i != 15) out += "-"
}
val creditCardOffsetTranslator = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
if (offset <= 3) return offset
if (offset <= 7) return offset + 1
if (offset <= 11) return offset + 2
if (offset <= 16) return offset + 3
return 19
}
override fun transformedToOriginal(offset: Int): Int {
if (offset <= 4) return offset
if (offset <= 9) return offset - 1
if (offset <= 14) return offset - 2
if (offset <= 19) return offset - 3
return 16
}
}
return TransformedText(AnnotatedString(out), creditCardOffsetTranslator)
}
The above function formats other card number schemes, that includes - Visa, Mastercard, Maestro, Discover, and JCB.
Now, we have all the util functions to do detect and format card scheme. Let's updated our OutlinedTextField to incorporate that.
@Composable
fun CardNumberTextField() {
var cardNumber by remember {mutableStateOf("")}
OutlinedTextField(
value = cardNumber,
onValueChange = {it -> cardNumber = it},
keyboardOptions = KeyboardOptions(keyboardType =
KeyboardType.Number),
visualTransformation = VisualTransformation { number ->
when (identifyCardScheme(cardNumber)) {
CardScheme.AMEX -> formatAmex(number)
CardScheme.DINERS_CLUB -> formatDinnersClub(number)
else -> formatOtherCardNumbers(number)
}
},
)
}
Now, our TextField detects and formats card numbers as user types. How cool is that :) . As a bonus, we can validate if the card number is valid or not using Luhn's algortihm.
fun isValidCardNumber = { value ->
var checksum: Int = 0
for (i in value.length - 1 downTo 0 step 2) {
checksum += value[i] - '0'
}
for (i in value.length - 2 downTo 0 step 2) {
val n: Int = (value[i] - '0') * 2
checksum += if (n > 9) n - 9 else n
}
checksum % 10 == 0
}
Then we can add isError field inside our OutlinedTextField and show the error. We can also check if the OutlinedTextField has been focused first then we show the error. But that's a task left for you.
So yeah, this is it. I hope you learned something. This is my first article so don't be hard on me. I was looking on how I can implement it and I felt like someone might be in need to do the same so I rushed to share. :)
Happy hacking!
Posted on August 31, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.