diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/ExpiryDateVisualTransformationTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/ExpiryDateVisualTransformationTest.kt index bdfac1fda0c..6e029565827 100644 --- a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/ExpiryDateVisualTransformationTest.kt +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/ExpiryDateVisualTransformationTest.kt @@ -1,34 +1,64 @@ package com.stripe.android.ui.core.elements import androidx.compose.ui.text.AnnotatedString -import com.google.common.truth.Truth +import androidx.compose.ui.text.input.TransformedText +import com.google.common.truth.Truth.assertThat import com.stripe.android.uicore.elements.ExpiryDateVisualTransformation import org.junit.Test internal class ExpiryDateVisualTransformationTest { + private val transform = ExpiryDateVisualTransformation() @Test fun `verify 19 get separated between 1 and 9`() { - Truth.assertThat(transform.filter(AnnotatedString("19")).text.text) - .isEqualTo("1 / 9") + val result = transform.filter(AnnotatedString("19")) + assertThat(result.text.text).isEqualTo("1 / 9") + assertCorrectMapping(original = "19", result) } @Test fun `verify 123 get separated between 2 and 3`() { - Truth.assertThat(transform.filter(AnnotatedString("123")).text.text) - .isEqualTo("12 / 3") + val result = transform.filter(AnnotatedString("123")) + assertThat(result.text.text).isEqualTo("12 / 3") + assertCorrectMapping(original = "123", result) + } + + @Test + fun `verify 143 get separated between 1 and 4`() { + val result = transform.filter(AnnotatedString("143")) + assertThat(result.text.text).isEqualTo("1 / 43") + assertCorrectMapping(original = "143", result) } @Test fun `verify 093 get separated between 9 and 3`() { - Truth.assertThat(transform.filter(AnnotatedString("093")).text.text) - .isEqualTo("09 / 3") + val result = transform.filter(AnnotatedString("093")) + assertThat(result.text.text).isEqualTo("09 / 3") + assertCorrectMapping(original = "093", result) } @Test fun `verify 53 get separated between 5 and 3`() { - Truth.assertThat(transform.filter(AnnotatedString("53")).text.text) - .isEqualTo("5 / 3") + val result = transform.filter(AnnotatedString("53")) + assertThat(result.text.text).isEqualTo("5 / 3") + assertCorrectMapping(original = "53", result) + } + + private fun assertCorrectMapping( + original: String, + result: TransformedText, + ) { + val transformed = result.text.text + + for (offset in 0..original.length) { + val transformedOffset = result.offsetMapping.originalToTransformed(offset) + assertThat(transformedOffset).isIn(0..transformed.length) + } + + for (offset in 0..result.text.text.length) { + val originalOffset = result.offsetMapping.transformedToOriginal(offset) + assertThat(originalOffset).isIn(0..original.length) + } } } diff --git a/stripe-ui-core/detekt-baseline.xml b/stripe-ui-core/detekt-baseline.xml index f76ad56dfab..c3b4ea44bd5 100644 --- a/stripe-ui-core/detekt-baseline.xml +++ b/stripe-ui-core/detekt-baseline.xml @@ -23,6 +23,7 @@ MagicNumber:DropdownFieldUI.kt$.8f MagicNumber:DropdownFieldUI.kt$.9f MagicNumber:DropdownFieldUI.kt$8.9f + MagicNumber:ExpiryDateVisualTransformation.kt$ExpiryDateVisualTransformation$12 MagicNumber:Html.kt$0.1f MagicNumber:PostalCodeVisualTransformation.kt$PostalCodeVisualTransformation.<no name provided>$3 MagicNumber:PostalCodeVisualTransformation.kt$PostalCodeVisualTransformation.<no name provided>$5 diff --git a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/ExpiryDateVisualTransformation.kt b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/ExpiryDateVisualTransformation.kt index 9fc9a7eb813..8a809a35c38 100644 --- a/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/ExpiryDateVisualTransformation.kt +++ b/stripe-ui-core/src/main/java/com/stripe/android/uicore/elements/ExpiryDateVisualTransformation.kt @@ -17,39 +17,54 @@ class ExpiryDateVisualTransformation : VisualTransformation { * 2, if the first number is 11 or 12 it will be after the second digit, * if the number is 01 it will be after the second digit. */ - var separatorAfterIndex = 1 - if (text.isNotBlank() && !(text[0] == '0' || text[0] == '1')) { - separatorAfterIndex = 0 - } else if (text.length > 1 && - (text[0] == '1' && requireNotNull(text[1].digitToInt()) > 2) - ) { - separatorAfterIndex = 0 - } + val canOnlyBeSingleDigitMonth = text.isNotBlank() && !(text[0] == '0' || text[0] == '1') + val canOnlyBeJanuary = text.length > 1 && text.text.take(2).toInt() > 12 + val isSingleDigitMonth = canOnlyBeSingleDigitMonth || canOnlyBeJanuary + + val lastIndexOfMonth = if (isSingleDigitMonth) 0 else 1 - var out = "" - for (i in text.indices) { - out += text[i] - if (i == separatorAfterIndex) { - out += separator + val output = buildString { + for ((index, char) in text.withIndex()) { + append(char) + if (index == lastIndexOfMonth) { + append(separator) + } } } + val outputOffsets = calculateOutputOffsets(output) + val separatorIndices = calculateSeparatorOffsets(output) + val offsetTranslator = object : OffsetMapping { - override fun originalToTransformed(offset: Int) = - if (offset <= separatorAfterIndex) { - offset - } else { - offset + separator.length - } - override fun transformedToOriginal(offset: Int) = - if (offset <= separatorAfterIndex + 1) { - offset - } else { - offset - separator.length - } + override fun originalToTransformed(offset: Int): Int { + return outputOffsets[offset] + } + + override fun transformedToOriginal(offset: Int): Int { + val separatorCharactersBeforeOffset = separatorIndices.count { it < offset } + return offset - separatorCharactersBeforeOffset + } + } + + return TransformedText(AnnotatedString(output), offsetTranslator) + } + + private fun calculateOutputOffsets(output: String): List { + val digitOffsets = output.mapIndexedNotNull { index, char -> + // +1 because we're looking for offsets, not indices + index.takeIf { char.isDigit() }?.plus(1) } - return TransformedText(AnnotatedString(out), offsetTranslator) + // We're adding 0 so that the cursor can be placed at the start of the text, + // and replace the last digit offset with the length of the output. The latter + // is so that the offsets are set correctly for text such as "4 / ". + return listOf(0) + digitOffsets.dropLast(1) + output.length + } + + private fun calculateSeparatorOffsets(output: String): List { + return output.mapIndexedNotNull { index, c -> + index.takeUnless { c.isDigit() } + } } }