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() }
+ }
}
}