Formatting credit card number input in Jetpack compose Android
Tags: android, kotlin, compose, text
Published: 2021-08-31 20:43:01

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!