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