diff --git a/build.gradle b/build.gradle index d6610c5f..c75d0539 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { - androidGradlePluginVersion = '8.2.0' + androidGradlePluginVersion = '8.2.1' kotlinVersion = '1.9.20' kspVersion = '1.9.20-1.0.14' dokkaVersion = '1.9.10' diff --git a/sdk/src/main/res/values-ar/strings.xml b/sdk/src/main/res/values-ar/strings.xml index a0c46d75..1d92e7a3 100644 --- a/sdk/src/main/res/values-ar/strings.xml +++ b/sdk/src/main/res/values-ar/strings.xml @@ -11,4 +11,23 @@ رقم كود التحقق CVV غير صالح حدث خطأ ما، يرجى المحاولة مرة أخرى + + إضافة بطاقة جديدة + إرسال + إلغاء + النظام المفضل للبطاقة + اسم حامل البطاقة + كود التحقق CVV + شهر / سنة + 4242 4242 4242 4242 + عنوان إرسال الفواتير + سطر العنوان %d + إنَّ معلومات البطاقة غير صالحة + إنّ تاريخ صلاحية البطاقة غير صالح + إنّ رقم البطاقة غير صالح + اسم حامل البطاقة غير صالح + رقم كود التحقق CVV غير صالح + حدث خطأ ما، يرجى المحاولة مرة أخرى + تاريخ انتهاء صلاحية البطاقة و/أو كود التحقق CVV غير صالح + diff --git a/sdk/src/main/res/values-fr/strings.xml b/sdk/src/main/res/values-fr/strings.xml index 9577bb4c..acc82290 100644 --- a/sdk/src/main/res/values-fr/strings.xml +++ b/sdk/src/main/res/values-fr/strings.xml @@ -11,4 +11,23 @@ Le code CVV de votre carte est incorrect. Une erreur s’est produite, veuillez réessayer. + + Ajouter une nouvelle carte + Envoyer + Annuler + Réseau de Carte Préféré + Nom du porteur de carte + CVV + MM / AA + 4242 4242 4242 4242 + Adresse de Facturation + Ligne d’adresse %d + Les informations de votre carte sont incorrectes. + La date d’expiration de votre carte est incorrecte. + Votre numéro de carte est incorrect. + Le nom du porteur de carte est incorrect. + Le code CVV de votre carte est incorrect. + Une erreur s’est produite, veuillez réessayer. + La date d’expiration de votre carte ou son code CVV sont incorrects. + diff --git a/sdk/src/main/res/values-pt/strings.xml b/sdk/src/main/res/values-pt/strings.xml index eafff29a..48d2a70a 100644 --- a/sdk/src/main/res/values-pt/strings.xml +++ b/sdk/src/main/res/values-pt/strings.xml @@ -29,4 +29,23 @@ O código de segurança do seu cartão é inválido. Ocorreu um erro ao processar o seu cartão, por favor tente novamente. + + Adicionar novo cartão + Enviar + Cancelar + Rede de pagamento preferencial + Nome no cartão + CVC + MM / AA + 4242 4242 4242 4242 + Morada de faturação + Endereço %d + Os dados do seu cartão são inválidos. + A data de validade do seu cartão é inválida. + O número do seu cartão é inválido. + O nome no cartão é inválido. + O código de segurança do seu cartão é inválido. + Ocorreu um erro ao processar o seu cartão, por favor tente novamente. + A data de validade e/ou o código de segurança do seu cartão são inválidos. + diff --git a/sdk/src/main/res/values/strings.xml b/sdk/src/main/res/values/strings.xml index 0b9fba72..02b0846d 100644 --- a/sdk/src/main/res/values/strings.xml +++ b/sdk/src/main/res/values/strings.xml @@ -29,4 +29,23 @@ Your card CVC is invalid. Something went wrong, please try again. + + Add New Card + Submit + Cancel + Preferred Scheme + Cardholder Name + CVC + MM / YY + 4242 4242 4242 4242 + Billing Address + Address line %d + Your card information is invalid. + Your card expiration date is invalid. + Your card number is invalid. + The cardholder name is invalid. + Your card CVC is invalid. + Something went wrong, please try again. + Your card expiration date and/or CVC is invalid. + diff --git a/ui-core/build.gradle b/ui-core/build.gradle index 4b462c43..aa951550 100644 --- a/ui-core/build.gradle +++ b/ui-core/build.gradle @@ -52,6 +52,12 @@ android { kotlinOptions { jvmTarget = '17' freeCompilerArgs += '-opt-in=com.processout.sdk.ui.core.annotation.ProcessOutInternalApi' + if (project.findProperty("composeCompilerReports") == "true") { + freeCompilerArgs += [ + "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + + project.buildDir.absolutePath + "/compose" + ] + } } buildFeatures { diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POActionsContainer.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POActionsContainer.kt index da145d57..fc722320 100644 --- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POActionsContainer.kt +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POActionsContainer.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi import com.processout.sdk.ui.core.state.POActionStateExtended -import com.processout.sdk.ui.core.state.POImmutableCollection +import com.processout.sdk.ui.core.state.POImmutableList import com.processout.sdk.ui.core.style.POActionsContainerStyle import com.processout.sdk.ui.core.style.POAxis import com.processout.sdk.ui.core.theme.ProcessOutTheme @@ -21,7 +21,7 @@ import com.processout.sdk.ui.core.theme.ProcessOutTheme @ProcessOutInternalApi @Composable fun POActionsContainer( - actions: POImmutableCollection, + actions: POImmutableList, style: POActionsContainer.Style = POActionsContainer.default ) { Column { @@ -62,7 +62,7 @@ fun POActionsContainer( @Composable private fun Actions( - actions: POImmutableCollection, + actions: POImmutableList, modifier: Modifier = Modifier, primaryStyle: POButton.Style = POButton.primary, secondaryStyle: POButton.Style = POButton.secondary diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/filter/POInputFilter.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/filter/POInputFilter.kt new file mode 100644 index 00000000..13522f1f --- /dev/null +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/filter/POInputFilter.kt @@ -0,0 +1,13 @@ +package com.processout.sdk.ui.core.filter + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.input.TextFieldValue +import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi + +/** @suppress */ +@ProcessOutInternalApi +@Immutable +interface POInputFilter { + + fun filter(value: TextFieldValue): TextFieldValue +} diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/formatter/POFormatter.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/formatter/POFormatter.kt deleted file mode 100644 index 60807bcc..00000000 --- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/formatter/POFormatter.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.processout.sdk.ui.core.formatter - -import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi - -/** @suppress */ -@ProcessOutInternalApi -interface POFormatter { - - fun format(string: String): String -} diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POActionState.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POActionState.kt index 39fa934e..6661f20b 100644 --- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POActionState.kt +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POActionState.kt @@ -1,9 +1,11 @@ package com.processout.sdk.ui.core.state +import androidx.compose.runtime.Immutable import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi /** @suppress */ @ProcessOutInternalApi +@Immutable data class POActionState( val text: String, val primary: Boolean, @@ -13,7 +15,19 @@ data class POActionState( /** @suppress */ @ProcessOutInternalApi +@Immutable data class POActionStateExtended( val state: POActionState, val onClick: () -> Unit -) +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as POActionStateExtended + return state == other.state + } + + override fun hashCode(): Int { + return state.hashCode() + } +} diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POFieldState.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POFieldState.kt index 61c7a5fa..b39d51a5 100644 --- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POFieldState.kt +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POFieldState.kt @@ -2,12 +2,15 @@ package com.processout.sdk.ui.core.state import androidx.annotation.DrawableRes import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Immutable import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi -import com.processout.sdk.ui.core.formatter.POFormatter +import com.processout.sdk.ui.core.filter.POInputFilter /** @suppress */ @ProcessOutInternalApi +@Immutable data class POFieldState( val key: String, val value: TextFieldValue = TextFieldValue(), @@ -16,7 +19,8 @@ data class POFieldState( val placeholder: String? = null, @DrawableRes val iconResId: Int? = null, - val formatter: POFormatter? = null, + val inputFilter: POInputFilter? = null, + val visualTransformation: VisualTransformation = VisualTransformation.None, val keyboardOptions: KeyboardOptions = KeyboardOptions.Default, val enabled: Boolean = true, val isError: Boolean = false, diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POImmutableCollection.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POImmutableList.kt similarity index 74% rename from ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POImmutableCollection.kt rename to ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POImmutableList.kt index 185dd208..185e53d3 100644 --- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POImmutableCollection.kt +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POImmutableList.kt @@ -6,6 +6,6 @@ import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi /** @suppress */ @ProcessOutInternalApi @Immutable -data class POImmutableCollection( - val elements: Collection +data class POImmutableList( + val elements: List ) diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POMutableFieldState.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POMutableFieldState.kt new file mode 100644 index 00000000..2f731744 --- /dev/null +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POMutableFieldState.kt @@ -0,0 +1,30 @@ +package com.processout.sdk.ui.core.state + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.input.VisualTransformation +import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi +import com.processout.sdk.ui.core.filter.POInputFilter + +/** @suppress */ +@ProcessOutInternalApi +@Stable +data class POMutableFieldState( + val key: String, + val value: MutableState = mutableStateOf(TextFieldValue()), + val title: String? = null, + val description: String? = null, + val placeholder: String? = null, + @DrawableRes + val iconResId: MutableState = mutableStateOf(null), + val inputFilter: POInputFilter? = null, + val visualTransformation: VisualTransformation = VisualTransformation.None, + val keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + val enabled: Boolean = true, + val isError: Boolean = false, + val forceTextDirectionLtr: Boolean = false +) diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POStableList.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POStableList.kt new file mode 100644 index 00000000..fa6259ac --- /dev/null +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POStableList.kt @@ -0,0 +1,11 @@ +package com.processout.sdk.ui.core.state + +import androidx.compose.runtime.Stable +import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi + +/** @suppress */ +@ProcessOutInternalApi +@Stable +data class POStableList( + val elements: List +) diff --git a/ui/build.gradle b/ui/build.gradle index 26469292..4b2eb8e4 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -54,6 +54,12 @@ android { jvmTarget = '17' freeCompilerArgs += '-opt-in=com.processout.sdk.core.annotation.ProcessOutInternalApi' freeCompilerArgs += '-opt-in=com.processout.sdk.ui.core.annotation.ProcessOutInternalApi' + if (project.findProperty("composeCompilerReports") == "true") { + freeCompilerArgs += [ + "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + + project.buildDir.absolutePath + "/compose" + ] + } } buildFeatures { diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationBottomSheet.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationBottomSheet.kt index b0d5a107..236087bf 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationBottomSheet.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationBottomSheet.kt @@ -67,6 +67,7 @@ internal class CardTokenizationBottomSheet : BaseBottomSheetDialogFragment, onEvent: (CardTokenizationEvent) -> Unit, style: CardTokenizationScreen.Style = CardTokenizationScreen.style() ) { @@ -65,18 +66,91 @@ internal fun CardTokenizationScreen( horizontal = ProcessOutTheme.spacing.extraLarge, vertical = ProcessOutTheme.spacing.large ), - verticalArrangement = Arrangement.spacedBy(ProcessOutTheme.spacing.large) + verticalArrangement = Arrangement.spacedBy(ProcessOutTheme.spacing.small) ) { - // TODO - POTextField( - value = TextFieldValue(), - onValueChange = {}, - modifier = Modifier.fillMaxWidth() - ) + sections.elements.forEach { section -> + section.items.elements.forEach { item -> + Item( + item = item, + onEvent = onEvent, + modifier = Modifier.fillMaxWidth(), + style = style.field + ) + } + } } } } +@Composable +private fun Item( + item: Item, + onEvent: (CardTokenizationEvent) -> Unit, + modifier: Modifier = Modifier, + style: POField.Style = POField.default +) { + when (item) { + is Item.TextField -> TextField( + state = item.state, + onEvent = onEvent, + modifier = modifier, + style = style + ) + is Item.Group -> Row( + horizontalArrangement = Arrangement.spacedBy(ProcessOutTheme.spacing.small) + ) { + item.items.elements.forEach { groupItem -> + Item( + item = groupItem, + onEvent = onEvent, + modifier = Modifier.weight(1f), + style = style + ) + } + } + } +} + +@Composable +private fun TextField( + state: POMutableFieldState, + onEvent: (CardTokenizationEvent) -> Unit, + modifier: Modifier = Modifier, + style: POField.Style = POField.default +) { + POTextField( + value = state.value.value, + onValueChange = { + onEvent( + FieldValueChanged( + key = state.key, + value = state.inputFilter?.filter(it) ?: it + ) + ) + }, + modifier = modifier, + style = style, + enabled = state.enabled, + isError = state.isError, + forceTextDirectionLtr = state.forceTextDirectionLtr, + placeholderText = state.placeholder, + trailingIcon = { state.iconResId.value?.let { AnimatedIcon(id = it) } }, + keyboardOptions = state.keyboardOptions, + visualTransformation = state.visualTransformation + ) +} + +@Composable +private fun AnimatedIcon(@DrawableRes id: Int) { + AnimatedImage( + id = id, + modifier = Modifier + .height(ProcessOutTheme.dimensions.formComponentHeight) + .padding(POField.contentPadding), + contentScale = ContentScale.FillHeight + ) +} + @Composable private fun Actions( primary: POActionState, @@ -97,7 +171,7 @@ private fun Actions( )) } POActionsContainer( - actions = POImmutableCollection( + actions = POImmutableList( if (style.axis == POAxis.Horizontal) actions.reversed() else actions ), style = style diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationState.kt index e42c7719..68c1de60 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationState.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationState.kt @@ -1,9 +1,14 @@ package com.processout.sdk.ui.card.tokenization +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import androidx.compose.ui.text.input.TextFieldValue import com.processout.sdk.core.ProcessOutResult import com.processout.sdk.ui.core.state.POActionState +import com.processout.sdk.ui.core.state.POMutableFieldState +import com.processout.sdk.ui.core.state.POStableList +@Immutable internal data class CardTokenizationState( val title: String, val primaryAction: POActionState, @@ -11,6 +16,17 @@ internal data class CardTokenizationState( val draggable: Boolean ) +@Stable +internal data class CardTokenizationSection( + val items: POStableList +) { + @Stable + sealed interface Item { + data class TextField(val state: POMutableFieldState) : Item + data class Group(val items: POStableList) : Item + } +} + internal sealed interface CardTokenizationEvent { data class FieldValueChanged(val key: String, val value: TextFieldValue) : CardTokenizationEvent data object Submit : CardTokenizationEvent diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationViewModel.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationViewModel.kt index 3462de44..962a4a34 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationViewModel.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationViewModel.kt @@ -1,9 +1,16 @@ package com.processout.sdk.ui.card.tokenization import android.app.Application +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.processout.sdk.R import com.processout.sdk.api.ProcessOut import com.processout.sdk.api.repository.POCardsRepository import com.processout.sdk.core.POFailure @@ -12,7 +19,17 @@ import com.processout.sdk.core.logger.POLogger import com.processout.sdk.ui.card.tokenization.CardTokenizationCompletion.Awaiting import com.processout.sdk.ui.card.tokenization.CardTokenizationCompletion.Failure import com.processout.sdk.ui.card.tokenization.CardTokenizationEvent.* +import com.processout.sdk.ui.card.tokenization.CardTokenizationSection.Item import com.processout.sdk.ui.core.state.POActionState +import com.processout.sdk.ui.core.state.POMutableFieldState +import com.processout.sdk.ui.core.state.POStableList +import com.processout.sdk.ui.shared.filter.CardExpirationInputFilter +import com.processout.sdk.ui.shared.filter.CardNumberInputFilter +import com.processout.sdk.ui.shared.filter.CardSecurityCodeInputFilter +import com.processout.sdk.ui.shared.provider.CardSchemeProvider +import com.processout.sdk.ui.shared.provider.cardSchemeDrawableResId +import com.processout.sdk.ui.shared.transformation.CardExpirationVisualTransformation +import com.processout.sdk.ui.shared.transformation.CardNumberVisualTransformation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -20,7 +37,8 @@ import kotlinx.coroutines.flow.update internal class CardTokenizationViewModel( private val app: Application, private val configuration: POCardTokenizationConfiguration, - private val cardsRepository: POCardsRepository + private val cardsRepository: POCardsRepository, + private val cardSchemeProvider: CardSchemeProvider ) : ViewModel() { class Factory( @@ -32,31 +50,118 @@ internal class CardTokenizationViewModel( CardTokenizationViewModel( app = app, configuration = configuration, - cardsRepository = ProcessOut.instance.cards + cardsRepository = ProcessOut.instance.cards, + cardSchemeProvider = CardSchemeProvider() ) as T } + private enum class CardField(val key: String) { + Number("card-number"), + Expiration("card-expiration"), + CVC("card-cvc"), + Cardholder("cardholder-name") + } + private val _completion = MutableStateFlow(Awaiting) val completion = _completion.asStateFlow() private val _state = MutableStateFlow(initState()) val state = _state.asStateFlow() + private val _sections = mutableStateListOf(cardInformationSection()) + val sections = POStableList(_sections) + private fun initState() = with(configuration) { CardTokenizationState( - title = title ?: "Add New Card", + title = title ?: app.getString(R.string.po_card_tokenization_title), primaryAction = POActionState( - text = primaryActionText ?: "Submit", + text = primaryActionText ?: app.getString(R.string.po_card_tokenization_button_submit), primary = true ), secondaryAction = if (cancellation.secondaryAction) POActionState( - text = secondaryActionText ?: "Cancel", + text = secondaryActionText ?: app.getString(R.string.po_card_tokenization_button_cancel), primary = false ) else null, draggable = cancellation.dragDown ) } + private fun cardInformationSection(): CardTokenizationSection { + val items = mutableListOf( + cardNumberField(), + Item.Group( + items = POStableList( + listOf( + cardExpirationField(), + cvcField() + ) + ) + ) + ) + if (configuration.isCardholderNameFieldVisible) { + items.add(cardholderField()) + } + return CardTokenizationSection(POStableList(items)) + } + + private fun cardNumberField() = Item.TextField( + POMutableFieldState( + key = CardField.Number.key, + placeholder = app.getString(R.string.po_card_tokenization_card_details_number_placeholder), + inputFilter = CardNumberInputFilter(), + visualTransformation = CardNumberVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Next + ), + forceTextDirectionLtr = true + ) + ) + + private fun cardExpirationField() = Item.TextField( + POMutableFieldState( + key = CardField.Expiration.key, + placeholder = app.getString(R.string.po_card_tokenization_card_details_expiration_placeholder), + inputFilter = CardExpirationInputFilter(), + visualTransformation = CardExpirationVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = ImeAction.Next + ), + forceTextDirectionLtr = true + ) + ) + + private fun cvcField() = Item.TextField( + POMutableFieldState( + key = CardField.CVC.key, + placeholder = app.getString(R.string.po_card_tokenization_card_details_cvc_placeholder), + iconResId = mutableStateOf(com.processout.sdk.ui.R.drawable.po_card_back), + inputFilter = CardSecurityCodeInputFilter(scheme = null), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + // TODO: Check for generic approach to determine ImeAction.Done for last item. + imeAction = if (configuration.isCardholderNameFieldVisible) + ImeAction.Next else ImeAction.Done + ), + forceTextDirectionLtr = true + ) + ) + + private fun cardholderField() = Item.TextField( + POMutableFieldState( + key = CardField.Cardholder.key, + placeholder = app.getString(R.string.po_card_tokenization_card_details_cardholder_placeholder), + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + autoCorrect = false, + keyboardType = KeyboardType.Text, + // TODO: Check for generic approach to determine ImeAction.Done for last item. + imeAction = ImeAction.Done + ) + ) + ) + fun onEvent(event: CardTokenizationEvent) = when (event) { is FieldValueChanged -> updateFieldValue(event.key, event.value) Submit -> submit() @@ -65,7 +170,42 @@ internal class CardTokenizationViewModel( } private fun updateFieldValue(key: String, value: TextFieldValue) { - // TODO + field(key)?.let { field -> + field.value.value = value + + // TODO: Resolve and update issuer information by iin locally then call backend when there is at least 6 digits. + // TODO: Update iconResId for card field by resolved scheme. + // TODO: Filter and update CVC field by resolved scheme. + if (key == CardField.Number.key) { + cardSchemeProvider.scheme(cardNumber = value.text).let { scheme -> + field.iconResId.value = scheme?.let { cardSchemeDrawableResId(scheme) } + } + } + } + } + + private fun field(key: String): POMutableFieldState? { + _sections.forEach { section -> + section.items.elements.forEach { item -> + field(key, item) + ?.let { return it } + } + } + return null + } + + private fun field(key: String, item: Item): POMutableFieldState? { + when (item) { + is Item.TextField -> + if (item.state.key == key) { + return item.state + } + is Item.Group -> item.items.elements.forEach { groupItem -> + field(key, groupItem) + ?.let { return it } + } + } + return null } private fun submit() { diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/POCardTokenizationConfiguration.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/POCardTokenizationConfiguration.kt index 0be12857..7a3672ad 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/POCardTokenizationConfiguration.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/POCardTokenizationConfiguration.kt @@ -16,6 +16,7 @@ data class POCardTokenizationConfiguration( val title: String? = null, val primaryActionText: String? = null, val secondaryActionText: String? = null, + val isCardholderNameFieldVisible: Boolean = true, val cancellation: POCancellationConfiguration = POCancellationConfiguration(), val style: Style? = null ) : Parcelable { diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/update/CardUpdateScreen.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/update/CardUpdateScreen.kt index 87e362d3..10f6757a 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/update/CardUpdateScreen.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/update/CardUpdateScreen.kt @@ -1,12 +1,6 @@ package com.processout.sdk.ui.card.update import androidx.annotation.DrawableRes -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions @@ -24,7 +18,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.painterResource import androidx.lifecycle.Lifecycle import com.processout.sdk.ui.card.update.CardUpdateEvent.* import com.processout.sdk.ui.core.component.POActionsContainer @@ -35,9 +28,10 @@ import com.processout.sdk.ui.core.component.field.POTextField import com.processout.sdk.ui.core.state.POActionState import com.processout.sdk.ui.core.state.POActionStateExtended import com.processout.sdk.ui.core.state.POFieldState -import com.processout.sdk.ui.core.state.POImmutableCollection +import com.processout.sdk.ui.core.state.POImmutableList import com.processout.sdk.ui.core.style.POAxis import com.processout.sdk.ui.core.theme.ProcessOutTheme +import com.processout.sdk.ui.shared.composable.AnimatedImage import com.processout.sdk.ui.shared.composable.RequestFocus import com.processout.sdk.ui.shared.composable.rememberLifecycleEvent @@ -102,7 +96,7 @@ internal fun CardUpdateScreen( @Composable private fun Fields( - fields: POImmutableCollection, + fields: POImmutableList, onEvent: (CardUpdateEvent) -> Unit, style: POField.Style = POField.default ) { @@ -116,11 +110,10 @@ private fun Fields( POTextField( value = state.value, onValueChange = { - val formatted = state.formatter?.format(it.text) ?: it.text onEvent( FieldValueChanged( key = state.key, - value = it.copy(text = formatted) + value = state.inputFilter?.filter(it) ?: it ) ) }, @@ -134,7 +127,7 @@ private fun Fields( placeholderText = state.placeholder, trailingIcon = { state.iconResId?.let { AnimatedIcon(id = it) } }, keyboardOptions = state.keyboardOptions, - keyboardActions = if (index == fields.elements.size - 1) + keyboardActions = if (index == fields.elements.lastIndex) KeyboardActions(onDone = { onEvent(Submit) }) else KeyboardActions.Default ) @@ -142,27 +135,14 @@ private fun Fields( } @Composable -private fun AnimatedIcon( - @DrawableRes id: Int, - visibleState: MutableTransitionState = remember { - MutableTransitionState(initialState = false) - .apply { targetState = true } - } -) { - AnimatedVisibility( - visibleState = visibleState, - enter = fadeIn(animationSpec = tween()), - exit = fadeOut(animationSpec = tween()) - ) { - Image( - painter = painterResource(id = id), - contentDescription = null, - modifier = Modifier - .height(ProcessOutTheme.dimensions.formComponentHeight) - .padding(POField.contentPadding), - contentScale = ContentScale.FillHeight - ) - } +private fun AnimatedIcon(@DrawableRes id: Int) { + AnimatedImage( + id = id, + modifier = Modifier + .height(ProcessOutTheme.dimensions.formComponentHeight) + .padding(POField.contentPadding), + contentScale = ContentScale.FillHeight + ) } @Composable @@ -185,7 +165,7 @@ private fun Actions( )) } POActionsContainer( - actions = POImmutableCollection( + actions = POImmutableList( if (style.axis == POAxis.Horizontal) actions.reversed() else actions ), style = style diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/update/CardUpdateState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/update/CardUpdateState.kt index baccdf51..567331a3 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/update/CardUpdateState.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/update/CardUpdateState.kt @@ -1,15 +1,17 @@ package com.processout.sdk.ui.card.update +import androidx.compose.runtime.Immutable import androidx.compose.ui.text.input.TextFieldValue import com.processout.sdk.api.model.response.POCard import com.processout.sdk.core.ProcessOutResult import com.processout.sdk.ui.core.state.POActionState import com.processout.sdk.ui.core.state.POFieldState -import com.processout.sdk.ui.core.state.POImmutableCollection +import com.processout.sdk.ui.core.state.POImmutableList +@Immutable internal data class CardUpdateState( val title: String, - val fields: POImmutableCollection, + val fields: POImmutableList, val primaryAction: POActionState, val secondaryAction: POActionState?, val errorMessage: String? = null, diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/update/CardUpdateViewModel.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/update/CardUpdateViewModel.kt index 564622e3..68158f42 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/card/update/CardUpdateViewModel.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/update/CardUpdateViewModel.kt @@ -26,10 +26,10 @@ import com.processout.sdk.ui.card.update.CardUpdateCompletion.* import com.processout.sdk.ui.card.update.CardUpdateEvent.* import com.processout.sdk.ui.core.state.POActionState import com.processout.sdk.ui.core.state.POFieldState -import com.processout.sdk.ui.core.state.POImmutableCollection +import com.processout.sdk.ui.core.state.POImmutableList import com.processout.sdk.ui.shared.extension.orElse -import com.processout.sdk.ui.shared.formatter.CardSecurityCodeFormatter -import com.processout.sdk.ui.shared.mapper.cardSchemeDrawableResId +import com.processout.sdk.ui.shared.filter.CardSecurityCodeInputFilter +import com.processout.sdk.ui.shared.provider.cardSchemeDrawableResId import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -102,11 +102,11 @@ internal class CardUpdateViewModel( ) } - private fun initFields(): POImmutableCollection { + private fun initFields(): POImmutableList { val fields = mutableListOf() initCardNumberField()?.let { fields.add(it) } fields.add(initCvcField()) - return POImmutableCollection(fields) + return POImmutableList(fields) } private fun initCardNumberField(): POFieldState? = @@ -129,7 +129,7 @@ internal class CardUpdateViewModel( key = Field.CVC.key, placeholder = app.getString(R.string.po_card_update_cvc), iconResId = com.processout.sdk.ui.R.drawable.po_card_back, - formatter = CardSecurityCodeFormatter(scheme = options.cardInformation?.scheme), + inputFilter = CardSecurityCodeInputFilter(scheme = options.cardInformation?.scheme), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Done @@ -173,19 +173,17 @@ internal class CardUpdateViewModel( private fun updateScheme(scheme: String) { _state.update { state -> state.copy( - fields = POImmutableCollection( + fields = POImmutableList( state.fields.elements.map { when (it.key) { Field.Number.key -> it.copy( iconResId = cardSchemeDrawableResId(scheme) ) Field.CVC.key -> { - val formatter = CardSecurityCodeFormatter(scheme = scheme) + val inputFilter = CardSecurityCodeInputFilter(scheme = scheme) it.copy( - value = it.value.copy( - text = formatter.format(it.value.text) - ), - formatter = formatter + value = inputFilter.filter(it.value), + inputFilter = inputFilter ) } else -> it.copy() @@ -213,7 +211,7 @@ internal class CardUpdateViewModel( private fun updateFieldValue(key: String, value: TextFieldValue) { _state.update { state -> state.copy( - fields = POImmutableCollection( + fields = POImmutableList( state.fields.elements.map { when (it.key) { key -> it.copy( @@ -252,7 +250,7 @@ internal class CardUpdateViewModel( submitting: Boolean, errorMessage: String? = null ) = state.copy( - fields = POImmutableCollection( + fields = POImmutableList( state.fields.elements.map { when (it.key) { Field.CVC.key -> it.copy(isError = errorMessage != null) diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/composable/AnimatedImage.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/composable/AnimatedImage.kt new file mode 100644 index 00000000..5204469b --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/composable/AnimatedImage.kt @@ -0,0 +1,41 @@ +package com.processout.sdk.ui.shared.composable + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource + +@Composable +internal fun AnimatedImage( + @DrawableRes id: Int, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + visibleState: MutableTransitionState = remember { + MutableTransitionState(initialState = false) + .apply { targetState = true } + } +) { + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn(animationSpec = tween()), + exit = fadeOut(animationSpec = tween()) + ) { + Image( + painter = painterResource(id = id), + contentDescription = null, + modifier = modifier, + alignment = alignment, + contentScale = contentScale + ) + } +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/composable/Ime.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/composable/Ime.kt index dfa8c8d4..e41f82fb 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/shared/composable/Ime.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/composable/Ime.kt @@ -8,7 +8,12 @@ import androidx.core.view.WindowInsetsCompat @Composable internal fun isImeVisibleAsState(): State { - val isImeVisible = remember { mutableStateOf(false) } + val isImeVisible = remember { + mutableStateOf( + value = false, + policy = neverEqualPolicy() + ) + } val view = LocalView.current.rootView val viewTreeObserver = view.viewTreeObserver DisposableEffect(viewTreeObserver) { diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/filter/CardExpirationInputFilter.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/filter/CardExpirationInputFilter.kt new file mode 100644 index 00000000..f632c3bc --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/filter/CardExpirationInputFilter.kt @@ -0,0 +1,69 @@ +@file:Suppress("RegExpSimplifiable") + +package com.processout.sdk.ui.shared.filter + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.processout.sdk.ui.core.filter.POInputFilter +import kotlin.math.max + +internal class CardExpirationInputFilter : POInputFilter { + + private companion object { + val patternRegex = "^(0+$|0+[1-9]|1[0-2]{0,1}|[2-9])([0-9]{0,2})".toRegex() + const val MONTH_LENGTH = 2 + } + + private data class Expiration( + val month: String, + val year: String + ) + + override fun filter(value: TextFieldValue): TextFieldValue { + val expiration = expiration(value.text) + if (expiration.month.isEmpty()) { + return TextFieldValue() + } + val formatted = formatted(month = expiration.month, year = expiration.year) + val selection = if (value.selection.length == 0 && value.selection.end == value.text.length) + TextRange(index = formatted.length) else value.selection + return value.copy(text = formatted, selection = selection) + } + + private fun expiration(text: String): Expiration { + val normalized = text.filter { it.isDigit() } + if (normalized.isEmpty()) { + return Expiration(month = String(), year = String()) + } + patternRegex.find(normalized)?.let { match -> + val month = match.groupValues[1] + val year = match.groupValues[2] + return Expiration(month = month, year = year) + } + return Expiration(month = String(), year = String()) + } + + private fun formatted(month: String, year: String) = buildString { + append(formatted(month = month, forcePadding = year.isNotEmpty())) + append(year) + } + + private fun formatted(month: String, forcePadding: Boolean): String { + month.toIntOrNull()?.let { monthValue -> + if (monthValue == 0) { + return "0" + } + val monthValueString = monthValue.toString() + val isPadded = month.first() == '0' + if (!(forcePadding || isPadded || (2..9).contains(monthValue))) { + return monthValueString + } + val paddingLength = max(MONTH_LENGTH - monthValueString.length, 0) + return buildString { + append("0".repeat(paddingLength)) + append(monthValueString) + } + } + return month + } +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/filter/CardNumberInputFilter.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/filter/CardNumberInputFilter.kt new file mode 100644 index 00000000..07a78db6 --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/filter/CardNumberInputFilter.kt @@ -0,0 +1,15 @@ +package com.processout.sdk.ui.shared.filter + +import androidx.compose.ui.text.input.TextFieldValue +import com.processout.sdk.ui.core.filter.POInputFilter + +internal class CardNumberInputFilter : POInputFilter { + + private companion object { + const val MAX_LENGTH = 19 // Maximum PAN length based on ISO/IEC 7812 + } + + override fun filter(value: TextFieldValue) = value.copy( + text = value.text.filter { it.isDigit() }.take(MAX_LENGTH) + ) +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/filter/CardSecurityCodeInputFilter.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/filter/CardSecurityCodeInputFilter.kt new file mode 100644 index 00000000..ec64e492 --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/filter/CardSecurityCodeInputFilter.kt @@ -0,0 +1,19 @@ +package com.processout.sdk.ui.shared.filter + +import androidx.compose.ui.text.input.TextFieldValue +import com.processout.sdk.ui.core.filter.POInputFilter + +internal class CardSecurityCodeInputFilter( + private val scheme: String? +) : POInputFilter { + + override fun filter(value: TextFieldValue): TextFieldValue { + var length = 4 + scheme?.let { + if (it != "american express") { + length = 3 + } + } + return value.copy(text = value.text.filter { it.isDigit() }.take(length)) + } +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/formatter/CardSecurityCodeFormatter.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/formatter/CardSecurityCodeFormatter.kt deleted file mode 100644 index a5556d2d..00000000 --- a/ui/src/main/kotlin/com/processout/sdk/ui/shared/formatter/CardSecurityCodeFormatter.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.processout.sdk.ui.shared.formatter - -import com.processout.sdk.ui.core.formatter.POFormatter - -internal class CardSecurityCodeFormatter( - private val scheme: String? -) : POFormatter { - - override fun format(string: String): String { - var length = 4 - scheme?.let { - if (it != "american express") { - length = 3 - } - } - return string.filter { it.isDigit() }.take(length) - } -} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/mapper/CardSchemeDrawableMapper.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/provider/CardSchemeDrawableResProvider.kt similarity index 96% rename from ui/src/main/kotlin/com/processout/sdk/ui/shared/mapper/CardSchemeDrawableMapper.kt rename to ui/src/main/kotlin/com/processout/sdk/ui/shared/provider/CardSchemeDrawableResProvider.kt index 9db846d6..8b818658 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/shared/mapper/CardSchemeDrawableMapper.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/provider/CardSchemeDrawableResProvider.kt @@ -1,4 +1,4 @@ -package com.processout.sdk.ui.shared.mapper +package com.processout.sdk.ui.shared.provider import androidx.annotation.DrawableRes import com.processout.sdk.ui.R diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/provider/CardSchemeProvider.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/provider/CardSchemeProvider.kt new file mode 100644 index 00000000..8d6d7cc7 --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/provider/CardSchemeProvider.kt @@ -0,0 +1,131 @@ +package com.processout.sdk.ui.shared.provider + +import com.processout.sdk.ui.shared.provider.CardSchemeProvider.IssuerNumbers.* +import com.processout.sdk.ui.shared.provider.CardSchemeProvider.IssuerNumbers.Set +import kotlin.math.pow + +internal class CardSchemeProvider { + + private companion object { + const val MAX_IIN_LENGTH = 6 + } + + private data class Issuer( + val scheme: String, + val numbers: IssuerNumbers, + val length: Int + ) + + private sealed interface IssuerNumbers { + data class Set(val set: kotlin.collections.Set) : IssuerNumbers + data class Range(val range: IntRange) : IssuerNumbers + data class Exact(val value: Int) : IssuerNumbers + } + + // https://www.bincodes.com/bin-list + // Sorted by length descending to handle overlapping numbers (like 622126 and 62). + private val issuers: List = listOf( + Issuer(scheme = "discover", numbers = Range(622126..622925), length = 6), + Issuer( + scheme = "elo", + numbers = Set( + setOf( + 401178, 401179, 431274, 438935, 451416, 457393, 457631, 457632, 504175, 506699, 506770, 506771, + 506772, 506773, 506774, 506775, 506776, 506777, 506778, 627780, 636297, 636368, 650031, 650032, + 650033, 650035, 650036, 650037, 650038, 650039, 650050, 650051, 650405, 650406, 650407, 650408, + 650409, 650485, 650486, 650487, 650488, 650489, 650530, 650531, 650532, 650533, 650534, 650535, + 650536, 650537, 650538, 650541, 650542, 650543, 650544, 650545, 650546, 650547, 650548, 650549, + 650590, 650591, 650592, 650593, 650594, 650595, 650596, 650597, 650598, 650710, 650711, 650712, + 650713, 650714, 650715, 650716, 650717, 650718, 650720, 650721, 650722, 650723, 650724, 650725, + 650726, 650727, 650901, 650902, 650903, 650904, 650905, 650906, 650907, 650908, 650909, 650970, + 650971, 650972, 650973, 650974, 650975, 650976, 650977, 650978, 651652, 651653, 651654, 651655, + 651656, 651657, 651658, 651659, 655021, 655022, 655023, 655024, 655025, 655026, 655027, 655028, + 655029, 655050, 655051, 655052, 655053, 655054, 655055, 655056, 655057, 655058 + ) + ), + length = 6 + ), + Issuer( + scheme = "elo", + numbers = Set( + setOf( + 50670, 50671, 50672, 50673, 50674, 50675, 50676, 65004, 65041, 65042, 65043, 65049, 65050, 65051, + 65052, 65055, 65056, 65057, 65058, 65070, 65091, 65092, 65093, 65094, 65095, 65096, 65166, 65167, + 65500, 65501, 65503, 65504 + ) + ), + length = 5 + ), + Issuer( + scheme = "carte bancaire", + numbers = Set( + setOf( + 40100, 40209, 40220, 40221, 40223, 40355, 40593, 40594, 40657, 41503, 41505, 41506, 41507, 41509, + 41628, 41717, 41993, 42011, 42012, 42346, 43782, 43783, 43787, 43950, 43951, 43953, 44244, 44245, + 44841, 44853, 44855, 44995, 44996, 44997, 45051, 45054, 45056, 45330, 45331, 45332, 45333, 45334, + 45335, 45337, 45339, 45560, 45566, 45567, 45568, 45610, 45611, 45612, 45613, 45614, 45615, 45616, + 45617, 45618, 45619, 45620, 45621, 45622, 45623, 45624, 45625, 45626, 45627, 45628, 45629, 45720, + 45721, 45745, 45771, 45929, 45930, 45932, 45933, 45937, 45939, 45940, 45950, 45954, 45958, 46321, + 46361, 46547, 46609, 46625, 46657, 46703, 46896, 46969, 46978, 46980, 46982, 46983, 46986, 47260, + 47268, 47427, 47480, 47717, 47718, 47722, 47726, 47729, 47802, 47809, 47961, 48160, 48362, 48369, + 48373, 48651, 49702, 49703, 49704, 49706, 49707, 49708, 49709, 49710, 49711, 49712, 49713, 49714, + 49715, 49716, 49717, 49718, 49719, 49720, 49721, 49722, 49723, 49724, 49725, 49726, 49727, 49728, + 49729, 49730, 49731, 49732, 49733, 49734, 49735, 49736, 49737, 49738, 49739, 49740, 49741, 49742, + 49743, 49744, 49745, 49746, 49747, 49748, 49749, 49750, 49751, 49752, 49753, 49754, 49755, 49756, + 49757, 49758, 49759, 49760, 49761, 49762, 49763, 49764, 49765, 49766, 49767, 49768, 49769, 49770, + 49771, 49772, 49773, 49774, 49775, 49776, 49777, 49778, 49779, 49780, 49781, 49782, 49783, 49784, + 49785, 49786, 49787, 49788, 49789, 49790, 49791, 49792, 49793, 49794, 49795, 49796, 49797, 49798, + 49799, 49835, 49836, 49837, 49838, 49839, 49900, 49901, 49902, 49903, 49904, 49905, 49906, 49909, + 51300, 51301, 51302, 51303, 51310, 51311, 51312, 51313, 51314, 51315, 51316, 51317, 51318, 51319, + 51320, 51321, 51322, 51323, 51324, 51325, 51326, 51327, 51328, 51329, 51330, 51331, 51332, 51335, + 51336, 51337, 51338, 51341, 51345, 51348, 51350, 51351, 51352, 51353, 51354, 51355, 51356, 51357, + 51360, 51361, 51362, 51363, 51364, 51365, 51366, 51367, 51369, 51370, 51371, 51372, 51373, 51374, + 51375, 51376, 51377, 51378, 51379, 51385, 51386, 51390, 51521, 51620, 51623, 51736, 51750, 51810, + 51992, 52166, 52371, 52886, 52920, 52922, 52931, 52933, 52941, 52942, 52943, 52944, 52945, 52946, + 52947, 52948, 52949, 52954, 53102, 53103, 53119, 53234, 53250, 53253, 53255, 53410, 53411, 53502, + 53506, 53532, 53610, 53611, 53710, 53711, 53801, 54284, 54515, 54953, 54985, 55391, 55397, 55399, + 55420, 55426, 55496, 55700, 55886, 55888, 55892, 55961, 55980, 55981, 56124, 56125, 58170, 58171, + 58172, 58173, 58174, 58175, 58176, 58177, 58178, 58179, 67117, 67759 + ) + ), + length = 5 + ), + Issuer(scheme = "discover", numbers = Exact(6011), length = 4), + Issuer(scheme = "jcb", numbers = Range(3528..3589), length = 4), + Issuer(scheme = "elo", numbers = Exact(509), length = 3), + Issuer(scheme = "discover", numbers = Range(644..649), length = 3), + Issuer(scheme = "diners club carte blanche", numbers = Range(300..305), length = 3), + Issuer(scheme = "diners club international", numbers = Exact(309), length = 3), + Issuer(scheme = "mastercard", numbers = Range(51..55), length = 2), + Issuer(scheme = "discover", numbers = Exact(65), length = 2), + Issuer(scheme = "china union pay", numbers = Exact(62), length = 2), + Issuer(scheme = "american express", numbers = Set(setOf(34, 37)), length = 2), + Issuer(scheme = "maestro", numbers = Set(setOf(50, 56, 57, 58, 59)), length = 2), + Issuer(scheme = "diners club international", numbers = Set(setOf(36, 38, 39)), length = 2), + Issuer(scheme = "diners club united states & canada", numbers = Range(54..55), length = 2), + Issuer(scheme = "visa", numbers = Exact(4), length = 1), + Issuer(scheme = "maestro", numbers = Exact(6), length = 1) + ) + + fun scheme(cardNumber: String): String? { + val normalized = cardNumber.filter { it.isDigit() }.take(MAX_IIN_LENGTH) + if (normalized.startsWith("0")) { + return null + } + normalized.toIntOrNull()?.let { normalizedInt -> + val issuer = issuers.find { issuer -> + val lengthDiff = normalized.length - issuer.length + if (lengthDiff < 0) return@find false + // Equalize lookup value with issuer.length + val value = (normalizedInt / 10f.pow(lengthDiff)).toInt() + when (val numbers = issuer.numbers) { + is Set -> numbers.set.contains(value) + is Range -> numbers.range.contains(value) + is Exact -> numbers.value == value + } + } + return issuer?.scheme + } + return null + } +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/transformation/BaseVisualTransformation.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/transformation/BaseVisualTransformation.kt new file mode 100644 index 00000000..24f27ec7 --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/transformation/BaseVisualTransformation.kt @@ -0,0 +1,41 @@ +package com.processout.sdk.ui.shared.transformation + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +internal abstract class BaseVisualTransformation : VisualTransformation { + + abstract fun transform(text: String): String + + abstract fun isSeparator(char: Char): Boolean + + override fun filter(text: AnnotatedString): TransformedText { + val transformed = transform(text.text) + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = + transformed.mapIndexedNotNull { index, char -> + index.takeIf { !isSeparator(char) }?.plus(1) + }.let { + // Prepend 0 offset so cursor can be placed at the start of the text. + // Replace the last offset with the length of transformed text + // in case it ends with multiple separator chars in a row. + // This allows to place cursor at the end of the text. + listOf(0) + it.dropLast(1) + transformed.length + }.let { it[offset] } + + override fun transformedToOriginal(offset: Int): Int = + transformed.mapIndexedNotNull { index, char -> + index.takeIf { isSeparator(char) } + }.count { separatorIndex -> + // Count how many separators precedes the transformed offset. + separatorIndex < offset + }.let { separatorCount -> + // Calculate the original offset by subtracting the count of separators. + offset - separatorCount + } + } + return TransformedText(AnnotatedString(transformed), offsetMapping) + } +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/transformation/CardExpirationVisualTransformation.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/transformation/CardExpirationVisualTransformation.kt new file mode 100644 index 00000000..03458fc9 --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/transformation/CardExpirationVisualTransformation.kt @@ -0,0 +1,24 @@ +package com.processout.sdk.ui.shared.transformation + +internal class CardExpirationVisualTransformation : BaseVisualTransformation() { + + private companion object { + const val SEPARATOR = " / " + const val DATE_PART_LENGTH = 2 + } + + override fun transform(text: String) = buildString { + val dateParts = text.chunked(DATE_PART_LENGTH) + dateParts.getOrNull(0)?.let { month -> + append(month) + if (month.length == DATE_PART_LENGTH) { + append(SEPARATOR) + } + } + dateParts.getOrNull(1)?.let { year -> + append(year) + } + } + + override fun isSeparator(char: Char) = SEPARATOR.contains(char) +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/formatter/CardNumberFormatter.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/transformation/CardNumberVisualTransformation.kt similarity index 76% rename from ui/src/main/kotlin/com/processout/sdk/ui/shared/formatter/CardNumberFormatter.kt rename to ui/src/main/kotlin/com/processout/sdk/ui/shared/transformation/CardNumberVisualTransformation.kt index d6bccbd4..c0f4f9c6 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/shared/formatter/CardNumberFormatter.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/transformation/CardNumberVisualTransformation.kt @@ -1,11 +1,8 @@ -package com.processout.sdk.ui.shared.formatter +package com.processout.sdk.ui.shared.transformation -import com.processout.sdk.ui.core.formatter.POFormatter - -internal class CardNumberFormatter : POFormatter { +internal class CardNumberVisualTransformation : BaseVisualTransformation() { private companion object { - const val MAX_LENGTH = 19 // Maximum PAN length based on ISO/IEC 7812 const val PLACEHOLDER_CHAR = '#' const val DEFAULT_PATTERN = "#### #### #### #### ###" } @@ -39,23 +36,22 @@ internal class CardNumberFormatter : POFormatter { ) ) - override fun format(string: String): String { - val cardNumber = string.filter { it.isDigit() }.take(MAX_LENGTH) + override fun transform(text: String): String { formats.forEach { format -> - format(cardNumber = cardNumber, format = format) + transform(cardNumber = text, format = format) ?.let { return it } } - return format(cardNumber = cardNumber, pattern = DEFAULT_PATTERN) ?: string + return transform(cardNumber = text, pattern = DEFAULT_PATTERN) ?: text } - private fun format(cardNumber: String, format: CardNumberFormat): String? { + private fun transform(cardNumber: String, format: CardNumberFormat): String? { format.leading.forEach { leading -> // First and last values of the range are expected to be of the same length. val leadingLength = leading.first.toString().length cardNumber.take(leadingLength).let { cardNumberLeading -> if (cardNumberLeading.isNotEmpty() && leading.contains(cardNumberLeading.toInt())) { format.patterns.forEach { pattern -> - format(cardNumber = cardNumber, pattern = pattern) + transform(cardNumber = cardNumber, pattern = pattern) ?.let { return it } } } @@ -64,7 +60,7 @@ internal class CardNumberFormatter : POFormatter { return null } - private fun format(cardNumber: String, pattern: String): String? = + private fun transform(cardNumber: String, pattern: String): String? = buildString { var cardNumberIndex = 0 for (index in pattern.indices) { @@ -80,4 +76,6 @@ internal class CardNumberFormatter : POFormatter { if (cardNumberIndex != cardNumber.length) return null } + + override fun isSeparator(char: Char) = !char.isDigit() }