Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add formatted content description for Expiration Date text field #10185

Merged
merged 21 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions payments-ui-core/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,23 +43,21 @@
<ID>MagicNumber:CardNumberVisualTransformations.kt$CardNumberVisualTransformations.NineteenPanLength.&lt;no name provided>$4</ID>
<ID>MagicNumber:CardNumberVisualTransformations.kt$CardNumberVisualTransformations.NineteenPanLength.&lt;no name provided>$7</ID>
<ID>MagicNumber:CardNumberVisualTransformations.kt$CardNumberVisualTransformations.NineteenPanLength.&lt;no name provided>$9</ID>
<ID>MagicNumber:ExpiryDateContentDescriptionFormatter.kt$12</ID>
<ID>MagicNumber:ExpiryDateContentDescriptionFormatter.kt$2000</ID>
<ID>MagicNumber:ExpiryDateContentDescriptionFormatter.kt$9</ID>
<ID>MagicNumber:IbanConfig.kt$IbanConfig$10</ID>
<ID>MagicNumber:IbanConfig.kt$IbanConfig$4</ID>
<ID>MagicNumber:Menu.kt$0.8f</ID>
<ID>MaxLineLength:AddressElementTest.kt$AddressElementTest$fun</ID>
<ID>MaxLineLength:AndroidMenu.kt$*</ID>
<ID>MaxLineLength:CardDetailsControllerTest.kt$CardDetailsControllerTest$fun</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.AMEX_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.DINERS_CLUB_14_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.DINERS_CLUB_16_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.DISCOVER_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.JCB_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.UNIONPAY_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberConfigTest.kt$CardNumberConfigTest$Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.VISA_NO_SPACES)).text)</ID>
<ID>MaxLineLength:CardNumberControllerTest.kt$CardNumberControllerTest$fun</ID>
<ID>SpreadOperator:BsbElementUI.kt$( it.errorMessage, *args )</ID>
<ID>SpreadOperator:MandateTextElement.kt$MandateTextElement$(stringResId, *args.toTypedArray())</ID>
<ID>SpreadOperator:MandateTextUI.kt$(element.stringResId, *element.args.toTypedArray())</ID>
<ID>SwallowedException:ExpiryDateContentDescriptionFormatter.kt$e: Exception</ID>
<ID>TooGenericExceptionCaught:ExpiryDateContentDescriptionFormatter.kt$e: Exception</ID>
<ID>TooGenericExceptionCaught:PlacesClientProxy.kt$DefaultPlacesClientProxy$e: Exception</ID>
<ID>UtilityClassWithPublicConstructor:FieldValuesToParamsMapConverter.kt$FieldValuesToParamsMapConverter</ID>
</CurrentIssues>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,12 @@ internal class CardDetailsController(
val expirationDateElement = SimpleTextElement(
IdentifierSpec.Generic("date"),
SimpleTextFieldController(
DateConfig(),
textFieldConfig = DateConfig(),
initialValue = initialValues[IdentifierSpec.CardExpMonth] +
initialValues[IdentifierSpec.CardExpYear]?.takeLast(2)
initialValues[IdentifierSpec.CardExpYear]?.takeLast(2),
overrideContentDescriptionProvider = ::formatExpirationDateForAccessibility,
shouldAnnounceFieldValue = false,
shouldAnnounceLabel = false
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import com.stripe.android.cards.CardAccountRangeService
import com.stripe.android.cards.CardNumber
import com.stripe.android.cards.DefaultStaticCardAccountRanges
import com.stripe.android.cards.StaticCardAccountRanges
import com.stripe.android.core.strings.plus
import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.model.AccountRange
import com.stripe.android.model.CardBrand
Expand Down Expand Up @@ -108,7 +108,9 @@ internal class DefaultCardNumberController(
_fieldValue.mapAsStateFlow { cardTextFieldConfig.convertToRaw(it) }

// This makes the screen reader read out numbers digit by digit
override val contentDescription: StateFlow<String> = _fieldValue.mapAsStateFlow { it.asIndividualDigits() }
override val contentDescription: StateFlow<ResolvableString> = _fieldValue.mapAsStateFlow {
it.asIndividualDigits().resolvableString
}

private val isEligibleForCardBrandChoice = cardBrandChoiceConfig is CardBrandChoiceConfig.Eligible
private val brandChoices = MutableStateFlow<List<CardBrand>>(listOf())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.model.CardBrand
import com.stripe.android.ui.core.asIndividualDigits
import com.stripe.android.uicore.elements.FieldError
Expand Down Expand Up @@ -56,7 +58,9 @@ class CvcController constructor(
_fieldValue.mapAsStateFlow { cvcTextFieldConfig.convertToRaw(it) }

// This makes the screen reader read out numbers digit by digit
override val contentDescription: StateFlow<String> = _fieldValue.mapAsStateFlow { it.asIndividualDigits() }
override val contentDescription: StateFlow<ResolvableString> = _fieldValue.mapAsStateFlow {
it.asIndividualDigits().resolvableString
}

private val _fieldState = combineAsStateFlow(cardBrandFlow, _fieldValue) { brand, fieldValue ->
cvcTextFieldConfig.determineState(brand, fieldValue, brand.maxCvcLength)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.stripe.android.ui.core.elements

import androidx.appcompat.app.AppCompatDelegate
import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.uicore.R
import java.text.SimpleDateFormat
import java.util.Locale

internal fun formatExpirationDateForAccessibility(input: String): ResolvableString {
if (input.isEmpty()) {
return resolvableString(R.string.stripe_expiration_date_empty_content_description)
}

val locale = AppCompatDelegate.getApplicationLocales()[0] ?: Locale.getDefault()

val canOnlyBeSingleDigitMonth = input.isNotBlank() && !(input[0] == '0' || input[0] == '1')
val canOnlyBeJanuary = input.length > 1 && input.take(2).toInt() > 12
val isSingleDigitMonth = canOnlyBeSingleDigitMonth || canOnlyBeJanuary

val lastIndexOfMonth = if (isSingleDigitMonth) 0 else 1
val month = input.take(lastIndexOfMonth + 1).toIntOrNull()
val year = input.slice(lastIndexOfMonth + 1..input.lastIndex).toIntOrNull()

try {
if (month != null) {
val monthName = SimpleDateFormat("MM", locale).parse("$month")?.let {
SimpleDateFormat("MMMM", locale).format(it)
}

return when (year) {
null -> return resolvableString(
R.string.stripe_expiration_date_month_complete_content_description,
monthName
)
in 0..9 -> resolvableString(
R.string.stripe_expiration_date_year_incomplete_content_description,
monthName
)
else -> resolvableString(
R.string.stripe_expiration_date_content_description,
monthName,
2000 + year
)
}
}

return input.resolvableString
} catch (e: Exception) {
return input.resolvableString
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which exceptions are we catching here and why is it ok to ignore them?

Can you consider:

  • catching specific exceptions here
  • adding a comment about why it is ok that we are ignoring the exceptions

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to ParseException and left a comment on why it's fine to ignore. ParseException should never be thrown but since the text passed in here is coming directly from the TextField input we don't want to crash the app if somehow a non numeric string is entered

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.stripe.android.utils

import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import com.google.common.truth.Truth.assertThat
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.ui.core.elements.formatExpirationDateForAccessibility
import com.stripe.android.uicore.R
import org.junit.Test

class ExpiryDateContentDescriptionFormatterTest {

@Test
fun `formats correctly for empty input`() {
val result = formatExpirationDateForAccessibility("4")
val expected = resolvableString(
R.string.stripe_expiration_date_month_complete_content_description,
"April"
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats correctly for month only`() {
val result = formatExpirationDateForAccessibility("4")
val expected = resolvableString(
R.string.stripe_expiration_date_month_complete_content_description,
"April"
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats correctly for month and incomplete year`() {
val result = formatExpirationDateForAccessibility("55")
val expected = resolvableString(
R.string.stripe_expiration_date_year_incomplete_content_description,
"May"
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats correctly for month and year`() {
val result = formatExpirationDateForAccessibility("555")
val expected = resolvableString(
R.string.stripe_expiration_date_content_description,
"May",
2055
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats correctly for double digit month`() {
val result = formatExpirationDateForAccessibility("1255")
val expected = resolvableString(
R.string.stripe_expiration_date_content_description,
"December",
2055
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats correctly for single digit month with leading 0`() {
val result = formatExpirationDateForAccessibility("0155")
val expected = resolvableString(
R.string.stripe_expiration_date_content_description,
"January",
2055
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats first two numbers as month if less than 13`() {
val result = formatExpirationDateForAccessibility("126")
val expected = resolvableString(
R.string.stripe_expiration_date_year_incomplete_content_description,
"December"
)

assertThat(result).isEqualTo(expected)
}

@Test
fun `formats month correctly based on locale`() {
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags("FR"))
val result = formatExpirationDateForAccessibility("126")
val expected = resolvableString(
R.string.stripe_expiration_date_year_incomplete_content_description,
"décembre"
)

assertThat(result).isEqualTo(expected)
}
}
2 changes: 1 addition & 1 deletion stripe-ui-core/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<ID>FunctionParameterNaming:IdentifierSpec.kt$IdentifierSpec.Companion$_value: String</ID>
<ID>LongMethod:Html.kt$@Composable @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) fun annotatedStringResource( text: String, imageGetter: Map&lt;String, EmbeddableImage> = emptyMap(), urlSpanStyle: SpanStyle = SpanStyle(textDecoration = TextDecoration.Underline) ): AnnotatedString</ID>
<ID>LongMethod:OTPElementUI.kt$@OptIn(ExperimentalComposeUiApi::class) @Composable @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) fun OTPElementUI( enabled: Boolean, element: OTPElement, modifier: Modifier = Modifier, boxShape: Shape = MaterialTheme.shapes.medium, boxTextStyle: TextStyle = OTPElementUI.defaultTextStyle(), boxSpacing: Dp = 8.dp, middleSpacing: Dp = 20.dp, otpInputPlaceholder: String = "●", colors: OTPElementColors = OTPElementColors( selectedBorder = MaterialTheme.colors.primary, placeholder = MaterialTheme.stripeColors.placeholderText ), focusRequester: FocusRequester = remember { FocusRequester() } )</ID>
<ID>LongMethod:TextFieldUI.kt$@Composable internal fun TextFieldUi( value: TextFieldValue, enabled: Boolean, loading: Boolean, label: String?, placeholder: String?, trailingIcon: TextFieldIcon?, showOptionalLabel: Boolean, shouldShowError: Boolean, modifier: Modifier = Modifier, visualTransformation: VisualTransformation = VisualTransformation.None, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions(), onValueChange: (value: TextFieldValue) -> Unit = {}, onDropdownItemClicked: (item: TextFieldIcon.Dropdown.Item) -> Unit = {} )</ID>
<ID>LongMethod:TextFieldUI.kt$@Composable internal fun TextFieldUi( value: TextFieldValue, enabled: Boolean, loading: Boolean, label: String?, placeholder: String?, trailingIcon: TextFieldIcon?, showOptionalLabel: Boolean, shouldShowError: Boolean, shouldAnnounceLabel: Boolean, modifier: Modifier = Modifier, visualTransformation: VisualTransformation = VisualTransformation.None, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions(), onValueChange: (value: TextFieldValue) -> Unit = {}, onDropdownItemClicked: (item: TextFieldIcon.Dropdown.Item) -> Unit = {} )</ID>
<ID>MagicNumber:AnimationConstants.kt$34</ID>
<ID>MagicNumber:DateConfig.kt$DateConfig$4</ID>
<ID>MagicNumber:DateConfig.kt$DateConfig.Companion$100</ID>
Expand Down
8 changes: 8 additions & 0 deletions stripe-ui-core/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,12 @@
<string name="stripe_invalid_expiry_month">Your card\'s expiration month is invalid.</string>
<!-- String to describe an invalid year in expiry date. -->
<string name="stripe_invalid_expiry_year">Your card\'s expiration year is invalid.</string>
<!-- String to describe an empty expiration date text field. -->
<string name="stripe_expiration_date_empty_content_description">Expiration date. Two digit month and two digit year, empty</string>
<!-- String to describe an expiration date text field with complete month and empty year. -->
<string name="stripe_expiration_date_month_complete_content_description">Expiration date. %s and two digit year, empty</string>
<!-- String to describe an expiration date text field with complete month and incomplete year. -->
<string name="stripe_expiration_date_year_incomplete_content_description">Expiration date. %s and two digit year, incomplete</string>
<!-- String to describe an complete expiration date text field. Formatted as 'Expiration date. [month] [year] -->
<string name="stripe_expiration_date_content_description">Expiration date. %s %s</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.uicore.forms.FormFieldEntry
import com.stripe.android.uicore.utils.combineAsStateFlow
import com.stripe.android.uicore.utils.mapAsStateFlow
Expand Down Expand Up @@ -48,7 +50,7 @@ class AddressTextFieldController(

override val rawFieldValue: StateFlow<String> = _fieldValue.mapAsStateFlow { config.convertToRaw(it) }

override val contentDescription: StateFlow<String> = _fieldValue.asStateFlow()
override val contentDescription: StateFlow<ResolvableString> = _fieldValue.mapAsStateFlow { it.resolvableString }

private val _fieldState = MutableStateFlow<TextFieldState>(TextFieldStateConstants.Error.Blank)
override val fieldState: StateFlow<TextFieldState> = _fieldState.asStateFlow()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import com.stripe.android.core.strings.ResolvableString
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.uicore.elements.TextFieldStateConstants.Error.Blank
import com.stripe.android.uicore.forms.FormFieldEntry
import com.stripe.android.uicore.utils.combineAsStateFlow
Expand Down Expand Up @@ -51,7 +52,7 @@ interface TextFieldController : InputController, SectionFieldComposable {

// This dictates how the accessibility reader reads the text in the field.
// Default this to _fieldValue to read the field normally
val contentDescription: StateFlow<String>
val contentDescription: StateFlow<ResolvableString>

@Composable
override fun ComposeUI(
Expand All @@ -61,7 +62,7 @@ interface TextFieldController : InputController, SectionFieldComposable {
hiddenIdentifiers: Set<IdentifierSpec>,
lastTextFieldIdentifier: IdentifierSpec?,
nextFocusDirection: FocusDirection,
previousFocusDirection: FocusDirection
previousFocusDirection: FocusDirection,
) {
TextField(
textFieldController = this,
Expand Down Expand Up @@ -124,7 +125,10 @@ sealed class TextFieldIcon {
class SimpleTextFieldController(
val textFieldConfig: TextFieldConfig,
override val showOptionalLabel: Boolean = false,
override val initialValue: String? = null
override val initialValue: String? = null,
private val overrideContentDescriptionProvider: ((fieldValue: String) -> ResolvableString)? = null,
private val shouldAnnounceLabel: Boolean = true,
private val shouldAnnounceFieldValue: Boolean = true
) : TextFieldController, SectionFieldErrorController {
override val trailingIcon: StateFlow<TextFieldIcon?> = textFieldConfig.trailingIcon
override val capitalization: KeyboardCapitalization = textFieldConfig.capitalization
Expand Down Expand Up @@ -153,7 +157,9 @@ class SimpleTextFieldController(

override val rawFieldValue: StateFlow<String> = _fieldValue.mapAsStateFlow { textFieldConfig.convertToRaw(it) }

override val contentDescription: StateFlow<String> = _fieldValue.asStateFlow()
override val contentDescription: StateFlow<ResolvableString> = _fieldValue.mapAsStateFlow {
overrideContentDescriptionProvider?.invoke(it) ?: it.resolvableString
}

private val _fieldState = MutableStateFlow<TextFieldState>(Blank)
override val fieldState: StateFlow<TextFieldState> = _fieldState.asStateFlow()
Expand Down Expand Up @@ -214,4 +220,30 @@ class SimpleTextFieldController(
override fun onFocusChange(newHasFocus: Boolean) {
_hasFocus.value = newHasFocus
}

@Composable
override fun ComposeUI(
enabled: Boolean,
field: SectionFieldElement,
modifier: Modifier,
hiddenIdentifiers: Set<IdentifierSpec>,
lastTextFieldIdentifier: IdentifierSpec?,
nextFocusDirection: FocusDirection,
previousFocusDirection: FocusDirection,
) {
TextField(
textFieldController = this,
enabled = enabled,
imeAction = if (lastTextFieldIdentifier == field.identifier) {
ImeAction.Done
} else {
ImeAction.Next
},
modifier = modifier,
nextFocusDirection = nextFocusDirection,
previousFocusDirection = previousFocusDirection,
shouldAnnounceLabel = shouldAnnounceLabel,
shouldAnnounceFieldValue = shouldAnnounceFieldValue
)
}
}
Loading
Loading