diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 000000000..4a53bee8c
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index f5413ac5a..116f37a84 100644
--- a/build.gradle
+++ b/build.gradle
@@ -2,7 +2,7 @@
buildscript {
ext {
- androidGradlePluginVersion = '8.8.0'
+ androidGradlePluginVersion = '8.9.0'
kotlinVersion = '2.1.10'
kspVersion = '2.1.10-1.0.29'
dokkaVersion = '1.9.20'
@@ -45,13 +45,15 @@ ext {
androidxRecyclerViewVersion = '1.4.0'
androidxSwipeRefreshLayoutVersion = '1.1.0'
androidxBrowserVersion = '1.8.0'
+ androidxCameraVersion = '1.4.1'
- androidxComposeBOMVersion = '2025.01.01'
+ androidxComposeBOMVersion = '2025.03.00'
composeGooglePayButtonVersion = '1.0.0'
materialVersion = '1.12.0'
gmsWalletVersion = '19.4.0'
+ mlkitTextRecognitionVersion = '19.0.1'
kotlinxCoroutinesPlayServicesVersion = '1.9.0'
retrofitVersion = '2.11.0'
diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml
index ce136aa22..46d6667e5 100644
--- a/example/src/main/AndroidManifest.xml
+++ b/example/src/main/AndroidManifest.xml
@@ -4,6 +4,9 @@
+
+
+
(
private val cardsRepository = ProcessOut.instance.cards
private lateinit var cardUpdateLauncher: POCardUpdateLauncher
+ private lateinit var cardScannerLauncher: POCardScannerLauncher
private lateinit var googlePayLauncher: POGooglePayCardTokenizationLauncher
override fun onCreate(savedInstanceState: Bundle?) {
@@ -43,6 +47,10 @@ class FeaturesFragment : BaseFragment(
from = this,
callback = ::handleCardUpdateResult
)
+ cardScannerLauncher = POCardScannerLauncher.create(
+ from = this,
+ callback = ::handleCardScannerResult
+ )
googlePayLauncher = POGooglePayCardTokenizationLauncher.create(
from = this,
walletOptions = WalletOptions.Builder()
@@ -76,6 +84,7 @@ class FeaturesFragment : BaseFragment(
}
}
setupCardUpdate()
+ setupCardScanner()
setupGooglePay()
}
@@ -134,6 +143,28 @@ class FeaturesFragment : BaseFragment(
}
}
+ private fun setupCardScanner() {
+ binding.cardScannerButton.setOnClickListener {
+ cardScannerLauncher.launch(POCardScannerConfiguration())
+ }
+ }
+
+ private fun handleCardScannerResult(result: ProcessOutActivityResult) {
+ result
+ .onSuccess {
+ showAlert(
+ title = getString(R.string.card_scanner),
+ message = it.toString()
+ )
+ }
+ .onFailure {
+ showAlert(
+ title = getString(R.string.card_scanner),
+ message = it.toMessage()
+ )
+ }
+ }
+
private fun setupGooglePay() {
lifecycleScope.launch {
if (!googlePayLauncher.isReadyToPay(GooglePayConfiguration.isReadyToPayRequest())) {
diff --git a/example/src/main/res/layout/fragment_features.xml b/example/src/main/res/layout/fragment_features.xml
index 893993b2e..4e59e172c 100644
--- a/example/src/main/res/layout/fragment_features.xml
+++ b/example/src/main/res/layout/fragment_features.xml
@@ -39,6 +39,14 @@
android:layout_marginTop="@dimen/button_space_vertical"
android:text="@string/card_update" />
+
+
ProcessOut SDK Example
- Dynamic Checkout (WIP)
+ Dynamic Checkout (Beta)
Native Alternative Payment
Launch Native APM
Launch Native APM (Compose)
@@ -13,6 +13,7 @@
Card Payment
Card Update
+ Card Scanner
Card Details
Number
Expiration Month
diff --git a/sdk/build.gradle b/sdk/build.gradle
index 8bf4ee171..fc2ef0382 100644
--- a/sdk/build.gradle
+++ b/sdk/build.gradle
@@ -110,7 +110,7 @@ dependencies {
api "com.google.android.material:material:$materialVersion"
api "com.google.android.gms:play-services-wallet:$gmsWalletVersion"
- implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$kotlinxCoroutinesPlayServicesVersion"
+ api "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$kotlinxCoroutinesPlayServicesVersion"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-moshi:$retrofitVersion"
diff --git a/sdk/src/main/res/values-ar/strings.xml b/sdk/src/main/res/values-ar/strings.xml
index ec251a80e..cf66dbbe6 100644
--- a/sdk/src/main/res/values-ar/strings.xml
+++ b/sdk/src/main/res/values-ar/strings.xml
@@ -45,6 +45,11 @@
- الطول غير صالح، المتوقع %d أحرف
+
+ امسح البطاقة
+ ضع بطاقتك داخل الإطار لمسحها ضوئيًا
+ إضافة يدويًا
+
تفاصيل الدفع
كود التحقق CVV
diff --git a/sdk/src/main/res/values-fr/strings.xml b/sdk/src/main/res/values-fr/strings.xml
index 995ce48d6..d4aafb842 100644
--- a/sdk/src/main/res/values-fr/strings.xml
+++ b/sdk/src/main/res/values-fr/strings.xml
@@ -43,6 +43,11 @@
- Longueur incorrecte, %d caractères sont attendus.
+
+ Scanner la carte
+ Placez votre carte dans le cadre pour la scanner.
+ Ajouter manuellement
+
Informations de Paiement
CVV
diff --git a/sdk/src/main/res/values-pl/strings.xml b/sdk/src/main/res/values-pl/strings.xml
index 140b5956d..4989872df 100644
--- a/sdk/src/main/res/values-pl/strings.xml
+++ b/sdk/src/main/res/values-pl/strings.xml
@@ -44,6 +44,11 @@
- Nieprawidłowa długość. Oczekiwano %d znaków.
+
+ Skanowanie karty
+ Umieść kartę w ramce, aby ją zeskanować.
+ Dodaj ręcznie
+
Szczegóły Płatności
CVC
diff --git a/sdk/src/main/res/values-pt/strings.xml b/sdk/src/main/res/values-pt/strings.xml
index cb44da8b9..e50fe615b 100644
--- a/sdk/src/main/res/values-pt/strings.xml
+++ b/sdk/src/main/res/values-pt/strings.xml
@@ -43,6 +43,11 @@
- Quantidade de caracteres incorreta, espera-se %d caracteres.
+
+ Digitalizar cartão
+ Posicione o cartão na moldura para digitalizá-lo.
+ Adicionar manualmente
+
Detalhes do pagamento
CVC
diff --git a/sdk/src/main/res/values/strings.xml b/sdk/src/main/res/values/strings.xml
index e06f000b3..3b5b540d8 100644
--- a/sdk/src/main/res/values/strings.xml
+++ b/sdk/src/main/res/values/strings.xml
@@ -42,6 +42,11 @@
- Invalid length, expected %d characters.
+
+ Scan card
+ Position your card in the frame to scan it.
+ Add manually
+
Payment Details
CVC
diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POButton.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POButton.kt
index 634f5c462..0a49cb1b9 100644
--- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POButton.kt
+++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POButton.kt
@@ -1,3 +1,5 @@
+@file:OptIn(ExperimentalMaterial3Api::class)
+
package com.processout.sdk.ui.core.component
import androidx.compose.foundation.BorderStroke
@@ -48,22 +50,36 @@ fun POButton(
style: POButton.Style = POButton.primary,
enabled: Boolean = true,
loading: Boolean = false,
+ checked: Boolean = false,
+ onCheckedChange: ((Boolean) -> Unit)? = null,
leadingContent: @Composable RowScope.() -> Unit = {},
icon: PODrawableImage? = null,
iconSize: Dp = dimensions.iconSizeMedium,
progressIndicatorSize: POButton.ProgressIndicatorSize = Medium,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
+ val onClickHandler = if (onCheckedChange != null) {
+ {
+ onClick()
+ onCheckedChange(!checked)
+ }
+ } else {
+ onClick
+ }
val pressed by interactionSource.collectIsPressedAsState()
- val colors = colors(style = style, enabled = enabled, loading = loading, pressed = pressed)
- CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
+ val colors = colors(style = style, enabled = enabled, loading = loading, pressed = pressed, checked = checked)
+ val rippleConfiguration = if (onCheckedChange != null) null else LocalRippleConfiguration.current
+ CompositionLocalProvider(
+ LocalRippleConfiguration provides rippleConfiguration,
+ LocalMinimumInteractiveComponentSize provides Dp.Unspecified
+ ) {
Button(
- onClick = onClick,
+ onClick = onClickHandler,
modifier = modifier,
enabled = enabled && !loading,
colors = colors,
shape = if (enabled) style.normal.shape else style.disabled.shape,
- border = border(style = style, enabled = enabled, pressed = pressed),
+ border = border(style = style, enabled = enabled, pressed = pressed, checked = checked),
elevation = elevation(style = style, enabled = enabled, loading = loading),
contentPadding = contentPadding(style = style, enabled = enabled),
interactionSource = interactionSource
@@ -142,6 +158,7 @@ fun POButton(
style = style,
enabled = enabled,
loading = loading,
+ checked = checked,
leadingContent = leadingContent,
icon = icon,
iconSize = iconSize,
@@ -343,11 +360,12 @@ object POButton {
style: Style,
enabled: Boolean,
loading: Boolean,
- pressed: Boolean
+ pressed: Boolean,
+ checked: Boolean
): ButtonColors {
val normalTextColor: Color
val normalBackgroundColor: Color
- if (pressed) with(style.highlighted) {
+ if (pressed || checked) with(style.highlighted) {
normalTextColor = textColor
normalBackgroundColor = backgroundColor
} else with(style.normal) {
@@ -376,9 +394,10 @@ object POButton {
internal fun border(
style: Style,
enabled: Boolean,
- pressed: Boolean
+ pressed: Boolean,
+ checked: Boolean
): BorderStroke {
- val normalBorderColor = if (pressed) style.highlighted.borderColor else style.normal.border.color
+ val normalBorderColor = if (pressed || checked) style.highlighted.borderColor else style.normal.border.color
return if (enabled) style.normal.border.solid(color = normalBorderColor)
else style.disabled.border.solid()
}
diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POButtonToggle.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POButtonToggle.kt
new file mode 100644
index 000000000..ec0e01ebd
--- /dev/null
+++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POButtonToggle.kt
@@ -0,0 +1,43 @@
+package com.processout.sdk.ui.core.component
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi
+import com.processout.sdk.ui.core.component.POButton.ProgressIndicatorSize.Medium
+import com.processout.sdk.ui.core.shared.image.PODrawableImage
+import com.processout.sdk.ui.core.theme.ProcessOutTheme.dimensions
+
+/** @suppress */
+@ProcessOutInternalApi
+@Composable
+fun POButtonToggle(
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ text: String? = null,
+ style: POButton.Style = POButton.ghostEqualPadding,
+ enabled: Boolean = true,
+ loading: Boolean = false,
+ icon: PODrawableImage? = null,
+ iconSize: Dp = dimensions.iconSizeMedium,
+ progressIndicatorSize: POButton.ProgressIndicatorSize = Medium,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+) {
+ POButton(
+ text = text ?: String(),
+ onClick = {},
+ modifier = modifier,
+ style = style,
+ enabled = enabled,
+ loading = loading,
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ icon = icon,
+ iconSize = iconSize,
+ progressIndicatorSize = progressIndicatorSize,
+ interactionSource = interactionSource
+ )
+}
diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POTextAutoSize.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POTextAutoSize.kt
new file mode 100644
index 000000000..699e11efe
--- /dev/null
+++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POTextAutoSize.kt
@@ -0,0 +1,51 @@
+package com.processout.sdk.ui.core.component
+
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.style.TextAlign
+import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi
+import com.processout.sdk.ui.core.theme.ProcessOutTheme
+
+/** @suppress */
+@ProcessOutInternalApi
+@Composable
+fun POTextAutoSize(
+ text: String,
+ modifier: Modifier = Modifier,
+ color: Color = Color.Unspecified,
+ style: TextStyle = ProcessOutTheme.typography.body1,
+ fontStyle: FontStyle? = null,
+ textAlign: TextAlign? = null,
+ step: Float = 0.01f
+) {
+ var resizedStyle by remember { mutableStateOf(style) }
+ var readyToDraw by remember { mutableStateOf(false) }
+ POText(
+ text = text,
+ modifier = modifier.drawWithContent {
+ if (readyToDraw) {
+ drawContent()
+ }
+ },
+ color = color,
+ style = resizedStyle,
+ fontStyle = fontStyle,
+ textAlign = textAlign,
+ onTextLayout = { result ->
+ if (result.didOverflowWidth) {
+ resizedStyle = resizedStyle.copy(
+ fontSize = resizedStyle.fontSize * (1 - step),
+ lineHeight = resizedStyle.lineHeight * (1 - step)
+ )
+ } else {
+ readyToDraw = true
+ }
+ },
+ softWrap = false,
+ maxLines = 1
+ )
+}
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 d9d10e179..753393038 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
@@ -13,6 +13,7 @@ data class POActionState(
val primary: Boolean,
val enabled: Boolean = true,
val loading: Boolean = false,
+ val checked: Boolean = false,
val icon: PODrawableImage? = null,
val confirmation: Confirmation? = null
) {
diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/theme/Shapes.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/theme/Shapes.kt
index 26f376f28..c725e7306 100644
--- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/theme/Shapes.kt
+++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/theme/Shapes.kt
@@ -12,6 +12,7 @@ import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi
@Immutable
data class POShapes(
val roundedCornersSmall: CornerBasedShape = RoundedCornerShape(4.dp),
+ val roundedCornersMedium: CornerBasedShape = RoundedCornerShape(8.dp),
val roundedCornersLarge: CornerBasedShape = RoundedCornerShape(16.dp),
val topRoundedCornersLarge: CornerBasedShape = RoundedCornerShape(
topStart = 16.dp, topEnd = 16.dp
diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/theme/Typography.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/theme/Typography.kt
index 3544a153e..11fe578aa 100644
--- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/theme/Typography.kt
+++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/theme/Typography.kt
@@ -28,6 +28,12 @@ data class POTypography(
fontSize = 20.sp,
lineHeight = 24.sp
),
+ val largeTitle: TextStyle = TextStyle(
+ fontFamily = WorkSans,
+ fontWeight = FontWeight.Normal,
+ fontSize = 28.sp,
+ lineHeight = 32.sp
+ ),
val subheading: TextStyle = TextStyle(
fontFamily = WorkSans,
fontWeight = FontWeight.Medium,
@@ -46,6 +52,12 @@ data class POTypography(
fontSize = 14.sp,
lineHeight = 18.sp
),
+ val body3: TextStyle = TextStyle(
+ fontFamily = WorkSans,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp
+ ),
val button: TextStyle = TextStyle(
fontFamily = WorkSans,
fontWeight = FontWeight.Medium,
diff --git a/ui/build.gradle b/ui/build.gradle
index ee0f06ab3..2a9dea4fa 100644
--- a/ui/build.gradle
+++ b/ui/build.gradle
@@ -93,10 +93,15 @@ dependencies {
api "androidx.activity:activity-compose:$androidxActivityVersion"
api "androidx.lifecycle:lifecycle-viewmodel-compose:$androidxLifecycleVersion"
- implementation "io.coil-kt:coil-compose:$coilVersion"
+ implementation "androidx.camera:camera-camera2:$androidxCameraVersion"
+ implementation "androidx.camera:camera-lifecycle:$androidxCameraVersion"
+ implementation "androidx.camera:camera-view:$androidxCameraVersion"
+
+ implementation "com.google.android.gms:play-services-mlkit-text-recognition:$mlkitTextRecognitionVersion"
implementation "com.google.pay.button:compose-pay-button:$composeGooglePayButtonVersion"
implementation "com.google.zxing:core:$zxingVersion"
implementation "com.googlecode.libphonenumber:libphonenumber:$libphonenumberVersion"
+ implementation "io.coil-kt:coil-compose:$coilVersion"
ksp "com.squareup.moshi:moshi-kotlin-codegen:$moshiVersion"
}
diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml
index 0fa3dacc4..9cb9a6403 100644
--- a/ui/src/main/AndroidManifest.xml
+++ b/ui/src/main/AndroidManifest.xml
@@ -30,6 +30,12 @@
android:launchMode="singleTop"
android:theme="@style/Theme.ProcessOut.BottomSheet" />
+
+
>() {
+
+ companion object {
+ const val EXTRA_CONFIGURATION = "${BuildConfig.LIBRARY_PACKAGE_NAME}.EXTRA_CONFIGURATION"
+ const val EXTRA_RESULT = "${BuildConfig.LIBRARY_PACKAGE_NAME}.EXTRA_RESULT"
+ }
+
+ override fun createIntent(
+ context: Context,
+ input: POCardScannerConfiguration
+ ) = Intent(context, CardScannerActivity::class.java)
+ .putExtra(EXTRA_CONFIGURATION, input)
+
+ @Suppress("DEPRECATION")
+ override fun parseResult(
+ resultCode: Int,
+ intent: Intent?
+ ): ProcessOutActivityResult {
+ intent?.setExtrasClassLoader(ProcessOutActivityResult::class.java.classLoader)
+ return intent?.getParcelableExtra(EXTRA_RESULT)
+ ?: ProcessOutActivityResult.Failure(
+ code = POFailure.Code.Internal(),
+ message = "Activity result was not provided."
+ ).also { POLogger.error("%s", it) }
+ }
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerBottomSheet.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerBottomSheet.kt
new file mode 100644
index 000000000..27ff6a0db
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerBottomSheet.kt
@@ -0,0 +1,154 @@
+package com.processout.sdk.ui.card.scanner
+
+import android.Manifest
+import android.app.Activity
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.*
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.processout.sdk.core.ProcessOutActivityResult
+import com.processout.sdk.core.ProcessOutResult
+import com.processout.sdk.core.toActivityResult
+import com.processout.sdk.ui.base.BaseBottomSheetDialogFragment
+import com.processout.sdk.ui.card.scanner.CardScannerActivityContract.Companion.EXTRA_CONFIGURATION
+import com.processout.sdk.ui.card.scanner.CardScannerActivityContract.Companion.EXTRA_RESULT
+import com.processout.sdk.ui.card.scanner.CardScannerCompletion.Failure
+import com.processout.sdk.ui.card.scanner.CardScannerCompletion.Success
+import com.processout.sdk.ui.card.scanner.CardScannerEvent.CameraPermissionResult
+import com.processout.sdk.ui.card.scanner.CardScannerEvent.Dismiss
+import com.processout.sdk.ui.card.scanner.CardScannerSideEffect.CameraPermissionRequest
+import com.processout.sdk.ui.card.scanner.recognition.POScannedCard
+import com.processout.sdk.ui.core.theme.ProcessOutTheme
+import com.processout.sdk.ui.shared.component.screenModeAsState
+import com.processout.sdk.ui.shared.extension.collectImmediately
+
+internal class CardScannerBottomSheet : BaseBottomSheetDialogFragment() {
+
+ companion object {
+ val tag: String = CardScannerBottomSheet::class.java.simpleName
+ }
+
+ override var expandable = false
+ override val defaultViewHeight = 0
+
+ private var configuration: POCardScannerConfiguration? = null
+
+ private val viewModel: CardScannerViewModel by viewModels {
+ CardScannerViewModel.Factory(
+ app = requireActivity().application,
+ configuration = configuration ?: POCardScannerConfiguration()
+ )
+ }
+
+ private val cameraPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { isGranted ->
+ viewModel.onEvent(CameraPermissionResult(isGranted = isGranted))
+ }
+
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ @Suppress("DEPRECATION")
+ configuration = arguments?.getParcelable(EXTRA_CONFIGURATION)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View = ComposeView(requireContext()).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setContent {
+ ProcessOutTheme {
+ with(viewModel.completion.collectAsStateWithLifecycle()) {
+ LaunchedEffect(value) { handle(value) }
+ }
+ viewModel.sideEffects.collectImmediately { handle(it) }
+
+ var viewHeight by remember { mutableIntStateOf(defaultViewHeight) }
+ with(screenModeAsState(viewHeight = viewHeight)) {
+ LaunchedEffect(value) { apply(value) }
+ }
+ CardScannerScreen(
+ state = viewModel.state.collectAsStateWithLifecycle().value,
+ onEvent = remember { viewModel::onEvent },
+ onContentHeightChanged = { contentHeight ->
+ viewHeight = contentHeight
+ },
+ style = CardScannerScreen.style(custom = configuration?.style)
+ )
+ }
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ configuration?.let { apply(it.cancellation) }
+ }
+
+ private fun handle(sideEffect: CardScannerSideEffect) {
+ when (sideEffect) {
+ CameraPermissionRequest -> requestCameraPermission()
+ }
+ }
+
+ private fun requestCameraPermission() {
+ when {
+ ContextCompat.checkSelfPermission(
+ requireContext(),
+ Manifest.permission.CAMERA
+ ) == PackageManager.PERMISSION_GRANTED ->
+ viewModel.onEvent(CameraPermissionResult(isGranted = true))
+ ActivityCompat.shouldShowRequestPermissionRationale(
+ requireActivity(),
+ Manifest.permission.CAMERA
+ ) -> viewModel.onEvent(CameraPermissionResult(isGranted = false))
+ else -> cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+
+ private fun handle(completion: CardScannerCompletion) =
+ when (completion) {
+ is Success -> finishWithActivityResult(
+ resultCode = Activity.RESULT_OK,
+ result = ProcessOutActivityResult.Success(completion.card)
+ )
+ is Failure -> finishWithActivityResult(
+ resultCode = Activity.RESULT_CANCELED,
+ result = completion.failure.toActivityResult()
+ )
+ else -> {}
+ }
+
+ override fun onCancellation(failure: ProcessOutResult.Failure) = dismiss(failure)
+
+ fun dismiss(failure: ProcessOutResult.Failure) {
+ viewModel.onEvent(Dismiss(failure))
+ finishWithActivityResult(
+ resultCode = Activity.RESULT_CANCELED,
+ result = failure.toActivityResult()
+ )
+ }
+
+ private fun finishWithActivityResult(
+ resultCode: Int,
+ result: ProcessOutActivityResult
+ ) {
+ setActivityResult(
+ resultCode = resultCode,
+ extraName = EXTRA_RESULT,
+ result = result
+ )
+ finish()
+ }
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerEvent.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerEvent.kt
new file mode 100644
index 000000000..58d764262
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerEvent.kt
@@ -0,0 +1,23 @@
+package com.processout.sdk.ui.card.scanner
+
+import androidx.camera.core.ImageProxy
+import com.processout.sdk.core.ProcessOutResult
+import com.processout.sdk.ui.card.scanner.recognition.POScannedCard
+
+internal sealed interface CardScannerEvent {
+ data class CameraPermissionResult(val isGranted: Boolean) : CardScannerEvent
+ data class ImageAnalysis(val imageProxy: ImageProxy) : CardScannerEvent
+ data class TorchToggle(val isEnabled: Boolean) : CardScannerEvent
+ data class Dismiss(val failure: ProcessOutResult.Failure) : CardScannerEvent
+ data object Cancel : CardScannerEvent
+}
+
+internal sealed interface CardScannerSideEffect {
+ data object CameraPermissionRequest : CardScannerSideEffect
+}
+
+internal sealed interface CardScannerCompletion {
+ data object Awaiting : CardScannerCompletion
+ data class Success(val card: POScannedCard) : CardScannerCompletion
+ data class Failure(val failure: ProcessOutResult.Failure) : CardScannerCompletion
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractor.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractor.kt
new file mode 100644
index 000000000..67a7df3bd
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractor.kt
@@ -0,0 +1,86 @@
+package com.processout.sdk.ui.card.scanner
+
+import com.processout.sdk.core.POFailure.Code.Cancelled
+import com.processout.sdk.core.POFailure.Code.Generic
+import com.processout.sdk.core.ProcessOutResult
+import com.processout.sdk.core.logger.POLogger
+import com.processout.sdk.ui.base.BaseInteractor
+import com.processout.sdk.ui.card.scanner.CardScannerCompletion.*
+import com.processout.sdk.ui.card.scanner.CardScannerEvent.*
+import com.processout.sdk.ui.card.scanner.CardScannerSideEffect.CameraPermissionRequest
+import com.processout.sdk.ui.card.scanner.recognition.CardRecognitionSession
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+internal class CardScannerInteractor(
+ private val cardRecognitionSession: CardRecognitionSession
+) : BaseInteractor() {
+
+ private val _completion = MutableStateFlow(Awaiting)
+ val completion = _completion.asStateFlow()
+
+ private val _state = MutableStateFlow(initState())
+ val state = _state.asStateFlow()
+
+ private val _sideEffects = Channel()
+ val sideEffects = _sideEffects.receiveAsFlow()
+
+ init {
+ collectRecognizedCards()
+ interactorScope.launch {
+ _sideEffects.send(CameraPermissionRequest)
+ }
+ }
+
+ private fun initState() = CardScannerInteractorState(
+ currentCard = null,
+ isTorchEnabled = false
+ )
+
+ fun onEvent(event: CardScannerEvent) {
+ when (event) {
+ is ImageAnalysis -> interactorScope.launch {
+ cardRecognitionSession.recognize(event.imageProxy)
+ }
+ is TorchToggle -> _state.update { it.copy(isTorchEnabled = event.isEnabled) }
+ is CameraPermissionResult -> if (!event.isGranted) {
+ cancel(
+ ProcessOutResult.Failure(
+ code = Generic(),
+ message = "Camera permission is not granted."
+ )
+ )
+ }
+ is Cancel -> cancel(
+ ProcessOutResult.Failure(
+ code = Cancelled,
+ message = "Cancelled by the user with the cancel action."
+ )
+ )
+ is Dismiss -> POLogger.info("Dismissed: %s", event.failure)
+ }
+ }
+
+ private fun collectRecognizedCards() {
+ interactorScope.launch(Dispatchers.Main.immediate) {
+ cardRecognitionSession.currentCard.collect { card ->
+ _state.update { it.copy(currentCard = card) }
+ }
+ }
+ interactorScope.launch(Dispatchers.Main.immediate) {
+ cardRecognitionSession.mostFrequentCard.collect { card ->
+ _completion.update { Success(card) }
+ }
+ }
+ }
+
+ private fun cancel(failure: ProcessOutResult.Failure) {
+ POLogger.info("Cancelled: %s", failure)
+ _completion.update { Failure(failure) }
+ }
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractorState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractorState.kt
new file mode 100644
index 000000000..daa858faf
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerInteractorState.kt
@@ -0,0 +1,8 @@
+package com.processout.sdk.ui.card.scanner
+
+import com.processout.sdk.ui.card.scanner.recognition.POScannedCard
+
+internal data class CardScannerInteractorState(
+ val currentCard: POScannedCard?,
+ val isTorchEnabled: Boolean
+)
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerScreen.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerScreen.kt
new file mode 100644
index 000000000..74df6a4d6
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerScreen.kt
@@ -0,0 +1,441 @@
+@file:Suppress("MayBeConstant")
+
+package com.processout.sdk.ui.card.scanner
+
+import android.content.Context
+import android.util.Size
+import androidx.camera.core.CameraSelector.DEFAULT_BACK_CAMERA
+import androidx.camera.core.ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888
+import androidx.camera.core.ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST
+import androidx.camera.core.ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY
+import androidx.camera.core.ImageProxy
+import androidx.camera.core.resolutionselector.ResolutionSelector
+import androidx.camera.core.resolutionselector.ResolutionSelector.PREFER_CAPTURE_RATE_OVER_HIGHER_RESOLUTION
+import androidx.camera.core.resolutionselector.ResolutionStrategy
+import androidx.camera.core.resolutionselector.ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER
+import androidx.camera.view.CameraController.IMAGE_ANALYSIS
+import androidx.camera.view.CameraController.IMAGE_CAPTURE
+import androidx.camera.view.LifecycleCameraController
+import androidx.camera.view.PreviewView
+import androidx.compose.animation.*
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.processout.sdk.ui.card.scanner.CardScannerEvent.*
+import com.processout.sdk.ui.card.scanner.CardScannerScreen.AnimationDurationMillis
+import com.processout.sdk.ui.card.scanner.CardScannerScreen.CameraPreviewStyle
+import com.processout.sdk.ui.card.scanner.CardScannerScreen.CardHeightToWidthRatio
+import com.processout.sdk.ui.card.scanner.CardScannerScreen.CardStyle
+import com.processout.sdk.ui.card.scanner.recognition.POScannedCard
+import com.processout.sdk.ui.core.component.*
+import com.processout.sdk.ui.core.theme.ProcessOutTheme.colors
+import com.processout.sdk.ui.core.theme.ProcessOutTheme.dimensions
+import com.processout.sdk.ui.core.theme.ProcessOutTheme.shapes
+import com.processout.sdk.ui.core.theme.ProcessOutTheme.spacing
+import com.processout.sdk.ui.core.theme.ProcessOutTheme.typography
+import com.processout.sdk.ui.shared.extension.conditional
+import com.processout.sdk.ui.shared.extension.dpToPx
+import com.processout.sdk.ui.shared.extension.drawWithLayer
+
+@Composable
+internal fun CardScannerScreen(
+ state: CardScannerViewModelState,
+ onEvent: (CardScannerEvent) -> Unit,
+ onContentHeightChanged: (Int) -> Unit,
+ style: CardScannerScreen.Style = CardScannerScreen.style()
+) {
+ Scaffold(
+ modifier = Modifier.clip(shape = shapes.topRoundedCornersLarge),
+ containerColor = style.backgroundColor
+ ) { scaffoldPadding ->
+ val verticalSpacingPx = (spacing.large * 2).dpToPx()
+ Column(
+ modifier = Modifier
+ .verticalScroll(
+ state = rememberScrollState(),
+ enabled = false
+ )
+ .onGloballyPositioned {
+ val contentHeight = it.size.height + verticalSpacingPx
+ onContentHeightChanged(contentHeight)
+ }
+ .padding(scaffoldPadding)
+ ) {
+ POButtonToggle(
+ checked = state.torchAction.checked,
+ onCheckedChange = { onEvent(TorchToggle(isEnabled = it)) },
+ modifier = Modifier
+ .padding(
+ top = spacing.medium,
+ start = spacing.medium
+ )
+ .requiredSizeIn(
+ minWidth = dimensions.buttonIconSizeSmall,
+ minHeight = dimensions.buttonIconSizeSmall
+ ),
+ style = style.torchToggle,
+ icon = state.torchAction.icon,
+ iconSize = dimensions.iconSizeSmall
+ )
+ val density = LocalDensity.current
+ var cameraPreviewHeight by remember { mutableStateOf(0.dp) }
+ val cameraPreviewOffsetCorrelation = spacing.large
+ Column(
+ modifier = Modifier
+ .padding(
+ start = spacing.large,
+ end = spacing.large,
+ bottom = spacing.large
+ )
+ .onGloballyPositioned {
+ with(density) {
+ val height = (it.size.width * CardHeightToWidthRatio).toDp()
+ cameraPreviewHeight = height + cameraPreviewOffsetCorrelation
+ }
+ },
+ verticalArrangement = Arrangement.spacedBy(spacing.large),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ POText(
+ text = state.title,
+ color = style.title.color,
+ style = style.title.textStyle
+ )
+ POText(
+ text = state.description,
+ color = style.description.color,
+ style = style.description.textStyle
+ )
+ CameraPreview(
+ isTorchEnabled = state.torchAction.checked,
+ currentCard = state.currentCard,
+ onEvent = onEvent,
+ cameraPreviewStyle = style.cameraPreview,
+ cardStyle = style.card,
+ modifier = Modifier
+ .fillMaxWidth()
+ .requiredHeight(cameraPreviewHeight)
+ )
+ state.cancelAction?.let {
+ POButton(
+ state = it,
+ onClick = { onEvent(Cancel) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .requiredHeightIn(min = dimensions.buttonIconSizeSmall),
+ style = style.cancelButton,
+ confirmationDialogStyle = style.dialog,
+ iconSize = dimensions.iconSizeSmall
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CameraPreview(
+ isTorchEnabled: Boolean,
+ currentCard: POScannedCard?,
+ onEvent: (CardScannerEvent) -> Unit,
+ cameraPreviewStyle: CameraPreviewStyle,
+ cardStyle: CardStyle,
+ modifier: Modifier = Modifier,
+ offsetSize: Dp = spacing.extraLarge
+) {
+ val cameraController = rememberLifecycleCameraController(
+ onAnalyze = { imageProxy ->
+ onEvent(ImageAnalysis(imageProxy))
+ }
+ )
+ LaunchedEffect(isTorchEnabled) {
+ cameraController.enableTorch(isTorchEnabled)
+ }
+ Box(
+ modifier = modifier
+ .border(
+ width = cameraPreviewStyle.border.width,
+ color = cameraPreviewStyle.border.color,
+ shape = cameraPreviewStyle.shape
+ )
+ .clip(cameraPreviewStyle.shape)
+ .drawWithContent {
+ val offsetSizePx = offsetSize.toPx()
+ val cardSize = androidx.compose.ui.geometry.Size(
+ width = size.width - offsetSizePx * 2,
+ height = size.height - offsetSizePx * 2
+ )
+ val topLeftOffset = Offset(offsetSizePx, offsetSizePx)
+ val cornerRadiusSizePx = cardStyle.borderRadius.toPx()
+ val cornerRadius = CornerRadius(cornerRadiusSizePx, cornerRadiusSizePx)
+ drawContent()
+ drawWithLayer {
+ drawRect(color = cameraPreviewStyle.overlayColor)
+ drawRoundRect(
+ size = cardSize,
+ topLeft = topLeftOffset,
+ cornerRadius = cornerRadius,
+ color = Color.Transparent,
+ blendMode = BlendMode.SrcIn
+ )
+ }
+ drawRoundRect(
+ size = cardSize,
+ topLeft = topLeftOffset,
+ cornerRadius = cornerRadius,
+ style = Stroke(width = cardStyle.border.width.toPx()),
+ color = cardStyle.border.color
+ )
+ }
+ ) {
+ AndroidView(
+ modifier = modifier.clip(cameraPreviewStyle.shape),
+ factory = {
+ PreviewView(it).apply {
+ controller = cameraController
+ clipToOutline = true
+ scaleType = PreviewView.ScaleType.FILL_START
+ implementationMode = PreviewView.ImplementationMode.COMPATIBLE
+ }
+ },
+ onRelease = { cameraController.unbind() }
+ )
+ ScannedCard(
+ card = currentCard,
+ style = cardStyle
+ )
+ }
+}
+
+@Composable
+private fun ScannedCard(
+ card: POScannedCard?,
+ style: CardStyle
+) {
+ AnimatedVisibility(
+ visible = card != null,
+ enter = fadeIn(animationSpec = tween(durationMillis = AnimationDurationMillis)),
+ exit = fadeOut(animationSpec = tween(durationMillis = AnimationDurationMillis))
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 44.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ modifier = Modifier.requiredHeightIn(min = 90.dp),
+ verticalArrangement = Arrangement.spacedBy(space = 10.dp)
+ ) {
+ AnimatedContent(
+ targetState = card?.number,
+ transitionSpec = {
+ fadeIn(
+ animationSpec = tween(durationMillis = AnimationDurationMillis)
+ ) togetherWith fadeOut(
+ animationSpec = tween(durationMillis = AnimationDurationMillis)
+ )
+ }
+ ) { number ->
+ POTextAutoSize(
+ text = number ?: String(),
+ modifier = Modifier.fillMaxWidth(),
+ color = style.number.color,
+ style = style.number.textStyle
+ )
+ }
+ Row {
+ POText(
+ text = card?.cardholderName ?: String(),
+ modifier = Modifier.weight(1f),
+ color = style.cardholderName.color,
+ style = style.cardholderName.textStyle,
+ maxLines = 2
+ )
+ val expiration = card?.expiration?.formatted ?: String()
+ POTextAutoSize(
+ text = expiration,
+ modifier = Modifier.conditional(
+ condition = expiration.isBlank(),
+ whenTrue = { requiredWidthIn(min = 88.dp) },
+ whenFalse = { padding(horizontal = spacing.large) }
+ ),
+ color = style.expiration.color,
+ style = style.expiration.textStyle
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun rememberLifecycleCameraController(
+ onAnalyze: (ImageProxy) -> Unit,
+ context: Context = LocalContext.current,
+ lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
+): LifecycleCameraController = remember {
+ val executor = ContextCompat.getMainExecutor(context)
+ LifecycleCameraController(context).apply {
+ cameraSelector = DEFAULT_BACK_CAMERA
+ initializationFuture.addListener(
+ {
+ setEnabledUseCases(IMAGE_CAPTURE or IMAGE_ANALYSIS)
+ imageCaptureMode = CAPTURE_MODE_MAXIMIZE_QUALITY
+ imageAnalysisOutputImageFormat = OUTPUT_IMAGE_FORMAT_YUV_420_888
+ imageAnalysisBackpressureStrategy = STRATEGY_KEEP_ONLY_LATEST
+ imageAnalysisResolutionSelector = ResolutionSelector.Builder()
+ .setAllowedResolutionMode(PREFER_CAPTURE_RATE_OVER_HIGHER_RESOLUTION)
+ .setResolutionStrategy(
+ ResolutionStrategy(
+ Size(1280, 960),
+ FALLBACK_RULE_CLOSEST_LOWER
+ )
+ ).build()
+ setImageAnalysisAnalyzer(executor) { imageProxy ->
+ onAnalyze(imageProxy)
+ }
+ bindToLifecycle(lifecycleOwner)
+ },
+ executor
+ )
+ }
+}
+
+internal object CardScannerScreen {
+
+ @Immutable
+ data class Style(
+ val title: POText.Style,
+ val description: POText.Style,
+ val cameraPreview: CameraPreviewStyle,
+ val card: CardStyle,
+ val torchToggle: POButton.Style,
+ val cancelButton: POButton.Style,
+ val dialog: PODialog.Style,
+ val backgroundColor: Color
+ )
+
+ @Immutable
+ data class CameraPreviewStyle(
+ val shape: Shape,
+ val border: POBorderStroke,
+ val overlayColor: Color
+ )
+
+ @Immutable
+ data class CardStyle(
+ val number: POText.Style,
+ val expiration: POText.Style,
+ val cardholderName: POText.Style,
+ val border: POBorderStroke,
+ val borderRadius: Dp
+ )
+
+ @Composable
+ fun style(custom: POCardScannerConfiguration.Style? = null) = Style(
+ title = custom?.title?.let {
+ POText.custom(style = it)
+ } ?: POText.body1,
+ description = custom?.description?.let {
+ POText.custom(style = it)
+ } ?: POText.Style(
+ color = colors.text.muted,
+ textStyle = typography.body2
+ ),
+ cameraPreview = custom?.cameraPreview?.custom() ?: defaultCameraPreview,
+ card = custom?.card?.custom() ?: defaultCard,
+ torchToggle = custom?.torchToggle?.let {
+ POButton.custom(style = it)
+ } ?: POButton.ghostEqualPadding,
+ cancelButton = custom?.cancelButton?.let {
+ POButton.custom(style = it)
+ } ?: POButton.secondary,
+ dialog = custom?.dialog?.let {
+ PODialog.custom(style = it)
+ } ?: PODialog.default,
+ backgroundColor = custom?.backgroundColorResId?.let {
+ colorResource(id = it)
+ } ?: colors.surface.default
+ )
+
+ private val defaultCameraPreview: CameraPreviewStyle
+ @Composable get() = CameraPreviewStyle(
+ shape = shapes.roundedCornersMedium,
+ border = POBorderStroke(width = 0.dp, color = Color.Transparent),
+ overlayColor = Color.Black.copy(alpha = 0.4f)
+ )
+
+ @Composable
+ private fun POCardScannerConfiguration.CameraPreviewStyle.custom() =
+ CameraPreviewStyle(
+ shape = RoundedCornerShape(size = border.radiusDp.dp),
+ border = POBorderStroke(
+ width = border.widthDp.dp,
+ color = colorResource(id = border.colorResId)
+ ),
+ overlayColor = colorResource(id = overlayColorResId)
+ )
+
+ private val defaultCard: CardStyle
+ @Composable get() = CardStyle(
+ number = POText.Style(
+ color = Color.White,
+ textStyle = typography.largeTitle.copy(lineHeight = 28.sp)
+ ),
+ expiration = POText.Style(
+ color = Color.White,
+ textStyle = typography.body3.copy(lineHeight = 20.sp)
+ ),
+ cardholderName = POText.Style(
+ color = Color.White,
+ textStyle = typography.body3.copy(lineHeight = 20.sp)
+ ),
+ border = POBorderStroke(width = 1.dp, color = Color.White),
+ borderRadius = 8.dp
+ )
+
+ @Composable
+ private fun POCardScannerConfiguration.CardStyle.custom() =
+ CardStyle(
+ number = POText.custom(style = number),
+ expiration = POText.custom(style = expiration),
+ cardholderName = POText.custom(style = cardholderName),
+ border = POBorderStroke(
+ width = border.widthDp.dp,
+ color = colorResource(id = border.colorResId)
+ ),
+ borderRadius = border.radiusDp.dp
+ )
+
+ /** Height to width ratio of a card by ISO/IEC 7810 standard. */
+ val CardHeightToWidthRatio = 0.63f
+
+ val AnimationDurationMillis = 250
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModel.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModel.kt
new file mode 100644
index 000000000..575f8019e
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModel.kt
@@ -0,0 +1,99 @@
+package com.processout.sdk.ui.card.scanner
+
+import android.app.Application
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.processout.sdk.R
+import com.processout.sdk.ui.card.scanner.POCardScannerConfiguration.CancelButton
+import com.processout.sdk.ui.card.scanner.recognition.CardExpirationDetector
+import com.processout.sdk.ui.card.scanner.recognition.CardNumberDetector
+import com.processout.sdk.ui.card.scanner.recognition.CardRecognitionSession
+import com.processout.sdk.ui.card.scanner.recognition.CardholderNameDetector
+import com.processout.sdk.ui.core.shared.image.PODrawableImage
+import com.processout.sdk.ui.core.shared.image.POImageRenderingMode
+import com.processout.sdk.ui.core.state.POActionState
+import com.processout.sdk.ui.core.state.POActionState.Confirmation
+import com.processout.sdk.ui.shared.extension.map
+
+internal class CardScannerViewModel(
+ private val app: Application,
+ private val configuration: POCardScannerConfiguration,
+ private val interactor: CardScannerInteractor
+) : ViewModel() {
+
+ class Factory(
+ private val app: Application,
+ private val configuration: POCardScannerConfiguration
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T =
+ CardScannerViewModel(
+ app = app,
+ configuration = configuration,
+ interactor = CardScannerInteractor(
+ cardRecognitionSession = CardRecognitionSession(
+ numberDetector = CardNumberDetector(),
+ expirationDetector = CardExpirationDetector(),
+ cardholderNameDetector = CardholderNameDetector(),
+ shouldScanExpiredCard = configuration.shouldScanExpiredCard
+ )
+ )
+ ) as T
+ }
+
+ val completion = interactor.completion
+
+ val state = interactor.state.map(viewModelScope, ::map)
+
+ val sideEffects = interactor.sideEffects
+
+ init {
+ addCloseable(interactor.interactorScope)
+ }
+
+ fun onEvent(event: CardScannerEvent) = interactor.onEvent(event)
+
+ private fun map(state: CardScannerInteractorState) =
+ with(configuration) {
+ CardScannerViewModelState(
+ title = title ?: app.getString(R.string.po_card_scanner_title),
+ description = description ?: app.getString(R.string.po_card_scanner_description),
+ currentCard = state.currentCard,
+ torchAction = torchAction(state.isTorchEnabled),
+ cancelAction = cancelButton?.toAction()
+ )
+ }
+
+ private fun torchAction(isTorchEnabled: Boolean) =
+ POActionState(
+ id = "torch",
+ text = String(),
+ primary = false,
+ checked = isTorchEnabled,
+ icon = PODrawableImage(
+ resId = if (isTorchEnabled)
+ com.processout.sdk.ui.R.drawable.po_icon_lightning_slash else
+ com.processout.sdk.ui.R.drawable.po_icon_lightning,
+ renderingMode = POImageRenderingMode.ORIGINAL
+ )
+ )
+
+ private fun CancelButton.toAction() =
+ POActionState(
+ id = "cancel",
+ text = text ?: app.getString(R.string.po_card_scanner_button_cancel),
+ primary = false,
+ icon = icon,
+ confirmation = confirmation?.run {
+ Confirmation(
+ title = title ?: app.getString(R.string.po_cancel_confirmation_title),
+ message = message,
+ confirmActionText = confirmActionText
+ ?: app.getString(R.string.po_cancel_confirmation_confirm),
+ dismissActionText = dismissActionText
+ ?: app.getString(R.string.po_cancel_confirmation_dismiss)
+ )
+ }
+ )
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModelState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModelState.kt
new file mode 100644
index 000000000..120d8b9ac
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/CardScannerViewModelState.kt
@@ -0,0 +1,12 @@
+package com.processout.sdk.ui.card.scanner
+
+import com.processout.sdk.ui.card.scanner.recognition.POScannedCard
+import com.processout.sdk.ui.core.state.POActionState
+
+internal data class CardScannerViewModelState(
+ val title: String,
+ val description: String,
+ val currentCard: POScannedCard?,
+ val torchAction: POActionState,
+ val cancelAction: POActionState?
+)
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/POCardScannerConfiguration.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/POCardScannerConfiguration.kt
new file mode 100644
index 000000000..00b139083
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/POCardScannerConfiguration.kt
@@ -0,0 +1,103 @@
+package com.processout.sdk.ui.card.scanner
+
+import android.os.Parcelable
+import androidx.annotation.ColorRes
+import com.processout.sdk.ui.core.shared.image.PODrawableImage
+import com.processout.sdk.ui.core.style.POBorderStyle
+import com.processout.sdk.ui.core.style.POButtonStyle
+import com.processout.sdk.ui.core.style.PODialogStyle
+import com.processout.sdk.ui.core.style.POTextStyle
+import com.processout.sdk.ui.shared.configuration.POActionConfirmationConfiguration
+import com.processout.sdk.ui.shared.configuration.POCancellationConfiguration
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Specifies card scanner configuration.
+ *
+ * @param[title] Custom title.
+ * @param[description] Custom description.
+ * @param[cancelButton] Cancel button configuration. Pass _null_ to hide.
+ * @param[cancellation] Specifies cancellation behaviour.
+ * @param[shouldScanExpiredCard] Specifies whether the card scanner allows to scan expired cards.
+ * Default value is _false_.
+ * @param[style] Custom style.
+ */
+@Parcelize
+data class POCardScannerConfiguration(
+ val title: String? = null,
+ val description: String? = null,
+ val cancelButton: CancelButton? = CancelButton(),
+ val cancellation: POCancellationConfiguration = POCancellationConfiguration(),
+ val shouldScanExpiredCard: Boolean = false,
+ val style: Style? = null
+) : Parcelable {
+
+ /**
+ * Cancel button configuration.
+ *
+ * @param[text] Button text. Pass _null_ to use default text.
+ * @param[icon] Button icon drawable resource. Pass _null_ to hide.
+ * @param[confirmation] Specifies action confirmation configuration (e.g. dialog).
+ * Use _null_ to disable, this is a default behaviour.
+ */
+ @Parcelize
+ data class CancelButton(
+ val text: String? = null,
+ val icon: PODrawableImage? = null,
+ val confirmation: POActionConfirmationConfiguration? = null
+ ) : Parcelable
+
+ /**
+ * Specifies card scanner style.
+ *
+ * @param[title] Title style.
+ * @param[description] Description style.
+ * @param[cameraPreview] Camera preview style.
+ * @param[card] Scanned card details style.
+ * @param[torchToggle] Torch toggle button style.
+ * @param[cancelButton] Cancel button style.
+ * @param[dialog] Dialog style.
+ * @param[backgroundColorResId] Color resource ID for background.
+ */
+ @Parcelize
+ data class Style(
+ val title: POTextStyle? = null,
+ val description: POTextStyle? = null,
+ val cameraPreview: CameraPreviewStyle? = null,
+ val card: CardStyle? = null,
+ val torchToggle: POButtonStyle? = null,
+ val cancelButton: POButtonStyle? = null,
+ val dialog: PODialogStyle? = null,
+ @ColorRes
+ val backgroundColorResId: Int? = null
+ ) : Parcelable
+
+ /**
+ * Specifies the style of a camera preview.
+ *
+ * @param[border] Border style.
+ * @param[overlayColorResId] Color resource ID for camera preview overlay.
+ */
+ @Parcelize
+ data class CameraPreviewStyle(
+ val border: POBorderStyle,
+ @ColorRes
+ val overlayColorResId: Int
+ ) : Parcelable
+
+ /**
+ * Specifies the style of a scanned card details view.
+ *
+ * @param[number] Card number style.
+ * @param[expiration] Card expiration style.
+ * @param[cardholderName] Cardholder name style.
+ * @param[border] Border style.
+ */
+ @Parcelize
+ data class CardStyle(
+ val number: POTextStyle,
+ val expiration: POTextStyle,
+ val cardholderName: POTextStyle,
+ val border: POBorderStyle
+ ) : Parcelable
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/POCardScannerLauncher.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/POCardScannerLauncher.kt
new file mode 100644
index 000000000..d846c111c
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/POCardScannerLauncher.kt
@@ -0,0 +1,64 @@
+package com.processout.sdk.ui.card.scanner
+
+import android.content.Context
+import androidx.activity.ComponentActivity
+import androidx.activity.result.ActivityResultLauncher
+import androidx.core.app.ActivityOptionsCompat
+import androidx.fragment.app.Fragment
+import com.processout.sdk.R
+import com.processout.sdk.core.ProcessOutActivityResult
+import com.processout.sdk.ui.card.scanner.recognition.POScannedCard
+
+/**
+ * Launcher that starts [CardScannerActivity] and provides the result.
+ */
+class POCardScannerLauncher private constructor(
+ private val launcher: ActivityResultLauncher,
+ private val activityOptions: ActivityOptionsCompat
+) {
+
+ companion object {
+ /**
+ * Creates the launcher from Fragment.
+ * __Note:__ Required to call in _onCreate()_ to register for activity result.
+ */
+ fun create(
+ from: Fragment,
+ callback: (ProcessOutActivityResult) -> Unit
+ ) = POCardScannerLauncher(
+ launcher = from.registerForActivityResult(
+ CardScannerActivityContract(),
+ callback
+ ),
+ activityOptions = createActivityOptions(from.requireContext())
+ )
+
+ /**
+ * Creates the launcher from Activity.
+ * __Note:__ Required to call in _onCreate()_ to register for activity result.
+ */
+ fun create(
+ from: ComponentActivity,
+ callback: (ProcessOutActivityResult) -> Unit
+ ) = POCardScannerLauncher(
+ launcher = from.registerForActivityResult(
+ CardScannerActivityContract(),
+ from.activityResultRegistry,
+ callback
+ ),
+ activityOptions = createActivityOptions(from)
+ )
+
+ private fun createActivityOptions(context: Context) =
+ ActivityOptionsCompat.makeCustomAnimation(
+ context, R.anim.po_slide_in_vertical, 0
+ )
+ }
+
+ /**
+ * Launches the activity.
+ */
+ fun launch(configuration: POCardScannerConfiguration) {
+ launcher.launch(configuration, activityOptions)
+ }
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardAttributeDetector.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardAttributeDetector.kt
new file mode 100644
index 000000000..79c6fed5d
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardAttributeDetector.kt
@@ -0,0 +1,6 @@
+package com.processout.sdk.ui.card.scanner.recognition
+
+internal interface CardAttributeDetector {
+
+ fun firstMatch(candidates: List): T?
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardExpirationDetector.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardExpirationDetector.kt
new file mode 100644
index 000000000..c9e90f0ba
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardExpirationDetector.kt
@@ -0,0 +1,49 @@
+package com.processout.sdk.ui.card.scanner.recognition
+
+import java.util.Calendar
+
+internal class CardExpirationDetector(
+ private val calendar: Calendar = Calendar.getInstance()
+) : CardAttributeDetector {
+
+ private val expirationRegex = """(?): POScannedCard.Expiration? {
+ val matches = mutableListOf()
+ candidates.reversed().forEach { candidate ->
+ expirationRegex.findAll(candidate).forEach { match ->
+ val month = match.groupValues[1].toInt()
+ val year = normalized(match.groupValues[2].toInt())
+ matches.add(
+ POScannedCard.Expiration(
+ month = month,
+ year = year,
+ isExpired = isExpired(month = month, year = year),
+ formatted = formatted(month = month, year = year)
+ )
+ )
+ }
+ }
+ return matches.maxWithOrNull(compareBy({ it.year }, { it.month }))
+ }
+
+ private fun normalized(year: Int): Int {
+ if (year >= 100) {
+ return year
+ }
+ val currentYear = calendar.get(Calendar.YEAR)
+ return (currentYear / 100) * 100 + year
+ }
+
+ private fun isExpired(month: Int, year: Int): Boolean {
+ val currentMonth = calendar.get(Calendar.MONTH) + 1
+ val currentYear = calendar.get(Calendar.YEAR)
+ return year < currentYear || (year == currentYear && month < currentMonth)
+ }
+
+ private fun formatted(month: Int, year: Int): String {
+ val formattedMonth = month.toString().padStart(length = 2, padChar = '0')
+ val formattedYear = year % 100
+ return "$formattedMonth / $formattedYear"
+ }
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardNumberDetector.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardNumberDetector.kt
new file mode 100644
index 000000000..d50717cc4
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardNumberDetector.kt
@@ -0,0 +1,37 @@
+package com.processout.sdk.ui.card.scanner.recognition
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.input.VisualTransformation
+import com.processout.sdk.ui.shared.transformation.CardNumberVisualTransformation
+
+internal class CardNumberDetector(
+ private val visualTransformation: VisualTransformation = CardNumberVisualTransformation()
+) : CardAttributeDetector {
+
+ private val numberRegex = Regex("(?:\\d\\s*){12,19}")
+
+ override fun firstMatch(candidates: List): String? {
+ candidates.forEach { candidate ->
+ numberRegex.find(candidate)?.let { match ->
+ val number = match.value.filterNot { it.isWhitespace() }
+ if (isLuhnChecksumValid(number)) {
+ return visualTransformation.filter(AnnotatedString(number)).text.text
+ }
+ }
+ }
+ return null
+ }
+
+ private fun isLuhnChecksumValid(number: String): Boolean {
+ val reversedDigits = number.reversed().map { it.digitToInt() }
+ val checksum = reversedDigits.mapIndexed { index, digit ->
+ if (index % 2 == 1) {
+ val doubled = digit * 2
+ if (doubled > 9) doubled - 9 else doubled
+ } else {
+ digit
+ }
+ }.sum()
+ return checksum % 10 == 0
+ }
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardRecognitionSession.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardRecognitionSession.kt
new file mode 100644
index 000000000..b3dd072c8
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardRecognitionSession.kt
@@ -0,0 +1,113 @@
+package com.processout.sdk.ui.card.scanner.recognition
+
+import android.graphics.Bitmap
+import androidx.camera.core.ImageProxy
+import com.google.mlkit.vision.text.Text
+import com.google.mlkit.vision.text.TextRecognition
+import com.google.mlkit.vision.text.latin.TextRecognizerOptions
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.tasks.await
+
+internal class CardRecognitionSession(
+ private val numberDetector: CardAttributeDetector,
+ private val expirationDetector: CardAttributeDetector,
+ private val cardholderNameDetector: CardAttributeDetector,
+ private val shouldScanExpiredCard: Boolean
+) {
+
+ private companion object {
+ const val MIN_CONFIDENCE = 0.8f
+ const val RECOGNITION_DURATION_MS = 3000L
+ }
+
+ private val _currentCard = Channel()
+ val currentCard = _currentCard.receiveAsFlow()
+
+ private val _mostFrequentCard = Channel()
+ val mostFrequentCard = _mostFrequentCard.receiveAsFlow()
+
+ private val textRecognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
+
+ private var startTimestamp = 0L
+ private val recognizedCards = mutableListOf()
+
+ suspend fun recognize(imageProxy: ImageProxy) {
+ val text = textRecognizer.process(
+ imageProxy.croppedBitmap(),
+ imageProxy.imageInfo.rotationDegrees
+ ).await()
+ val candidates = text.candidates(MIN_CONFIDENCE)
+ val number = numberDetector.firstMatch(candidates)
+ if (number != null) {
+ if (startTimestamp == 0L) {
+ startTimestamp = System.currentTimeMillis()
+ }
+ val card = POScannedCard(
+ number = number,
+ expiration = expirationDetector.firstMatch(candidates),
+ cardholderName = cardholderNameDetector.firstMatch(candidates)
+ )
+ recognizedCards.add(card)
+ if (!shouldScanExpiredCard && card.expiration?.isExpired == true) {
+ _currentCard.send(null)
+ } else {
+ _currentCard.send(card)
+ }
+ }
+ if (System.currentTimeMillis() - startTimestamp > RECOGNITION_DURATION_MS) {
+ if (recognizedCards.isNotEmpty()) {
+ sendMostFrequentCard()
+ recognizedCards.clear()
+ }
+ startTimestamp = 0L
+ }
+ imageProxy.close()
+ }
+
+ private fun ImageProxy.croppedBitmap() =
+ Bitmap.createBitmap(
+ toBitmap(), 0, 0,
+ cropRect.width(),
+ cropRect.height()
+ )
+
+ private fun Text.candidates(minConfidence: Float): List {
+ val candidates = mutableListOf()
+ textBlocks.forEach { textBlock ->
+ textBlock.lines.forEach forEachLine@{ line ->
+ if (line.elements.isEmpty()) {
+ return@forEachLine
+ }
+ val isConfident = line.elements.all { it.confidence >= minConfidence }
+ if (isConfident) {
+ candidates.add(line.text)
+ }
+ }
+ }
+ return candidates
+ }
+
+ private suspend fun sendMostFrequentCard() {
+ val reversedCards = recognizedCards.reversed()
+ val number = reversedCards.map { it.number }.mostFrequent()
+ if (number == null) {
+ return
+ }
+ val card = POScannedCard(
+ number = number,
+ expiration = reversedCards.mapNotNull { it.expiration }.mostFrequent(),
+ cardholderName = reversedCards.mapNotNull { it.cardholderName }.mostFrequent()
+ )
+ if (!shouldScanExpiredCard && card.expiration?.isExpired == true) {
+ return
+ }
+ return _mostFrequentCard.send(card)
+ }
+
+ private fun List.mostFrequent(): T? =
+ this.groupingBy { it }
+ .eachCount()
+ .maxByOrNull { it.value }
+ ?.key
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardholderNameDetector.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardholderNameDetector.kt
new file mode 100644
index 000000000..44e00e562
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/CardholderNameDetector.kt
@@ -0,0 +1,143 @@
+package com.processout.sdk.ui.card.scanner.recognition
+
+import java.text.Normalizer
+
+internal class CardholderNameDetector : CardAttributeDetector {
+
+ private val delimiterRegex = Regex("\\W+")
+ private val diacriticsRegex = Regex("\\p{InCombiningDiacriticalMarks}+")
+
+ override fun firstMatch(candidates: List): String? =
+ candidates.reversed().find { candidate ->
+ candidate
+ .stripDiacritics()
+ .uppercase()
+ .split(delimiterRegex)
+ .forEach { word ->
+ if (word.any { it.isDigit() || it == '_' } ||
+ restrictedWords.contains(word)) {
+ return@find false
+ }
+ }
+ true
+ }
+
+ private fun String.stripDiacritics(): String {
+ val normalized = Normalizer.normalize(this, Normalizer.Form.NFD)
+ return diacriticsRegex.replace(normalized, String())
+ }
+
+ private val restrictedWords = setOf(
+ // Card networks
+ "VISA",
+ "MASTERCARD",
+ "AMEX",
+ "AMERICAN",
+ "EXPRESS",
+ "DISCOVER",
+ "DINERS",
+ "CLUB",
+ "UNION",
+ "PAY",
+ "JCB",
+ "NETWORK",
+ "INTERNATIONAL",
+ "CARD",
+ "MEMBER",
+ "SECURE",
+ "CREDIT",
+ "DEBIT",
+ "CHIP",
+ "NFC",
+
+ // Card labels
+ "PLATINUM",
+ "GOLD",
+ "SILVER",
+ "TITANIUM",
+ "BUSINESS",
+ "CORPORATE",
+ "REWARD",
+ "SECURED",
+ "ADVANCE",
+ "WORLD",
+ "ELITE",
+ "PREFERRED",
+ "INFINITE",
+ "SELECT",
+ "PRIVILEGE",
+ "PREMIER",
+ "PLUS",
+ "EDGE",
+ "ULTIMATE",
+ "SIGNATURE",
+
+ // Issuing banks (generic terms and widely used names)
+ "BANK",
+ "CHASE",
+ "CITI",
+ "WELLS",
+ "FARGO",
+ "CAPITAL",
+ "HSBC",
+ "BARCLAYS",
+ "SANTANDER",
+ "BBVA",
+ "NATWEST",
+ "RBC",
+ "TD",
+ "SCOTIABANK",
+ "BMO",
+ "SOCIETE",
+ "GENERALE",
+ "STANDARD",
+ "CHARTERED",
+ "DEUTSCHE",
+
+ // Contact information
+ "ADDRESS",
+ "STREET",
+ "ROAD",
+ "AVENUE",
+ "CITY",
+ "STATE",
+ "ZIP",
+ "COUNTRY",
+ "TELEPHONE",
+ "EMAIL",
+
+ // Security features and miscellaneous text
+ "VALID",
+ "THRU",
+ "EXPIRY",
+ "DATE",
+ "EXPIRES",
+ "FROM",
+ "UNTIL",
+ "AUTHORIZED",
+ "USER",
+ "USE",
+ "ONLY",
+ "AUTHORIZATION",
+ "SIGNATURE",
+ "LINE",
+ "VOID",
+ "MAGNETIC",
+ "STRIPE",
+ "NUMBER",
+ "CODE",
+ "SECURE",
+
+ // Generic terms
+ "CONTACT",
+ "SERVICE",
+ "CUSTOMER",
+ "SUPPORT",
+ "WEBSITE",
+ "HOTLINE",
+ "HELP",
+ "TERMS",
+ "CONDITIONS",
+ "LIMITATIONS"
+ )
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/POScannedCard.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/POScannedCard.kt
new file mode 100644
index 000000000..bdbcd1581
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/scanner/recognition/POScannedCard.kt
@@ -0,0 +1,35 @@
+package com.processout.sdk.ui.card.scanner.recognition
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Scanned card details.
+ *
+ * @param[number] Formatted card number.
+ * @param[expiration] Card expiration details.
+ * @param[cardholderName] Cardholder name.
+ */
+@Parcelize
+data class POScannedCard(
+ val number: String,
+ val expiration: Expiration?,
+ val cardholderName: String?
+) : Parcelable {
+
+ /**
+ * Card expiration details.
+ *
+ * @param[month] Expiration month.
+ * @param[year] Expiration year as a four digits number.
+ * @param[isExpired] Indicates whether the expiration date has past, making the card expired.
+ * @param[formatted] Formatted month and year.
+ */
+ @Parcelize
+ data class Expiration(
+ val month: Int,
+ val year: Int,
+ val isExpired: Boolean,
+ val formatted: String
+ ) : Parcelable
+}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/PODynamicCheckoutConfiguration.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/PODynamicCheckoutConfiguration.kt
index 5fee25344..d6ac5cd1f 100644
--- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/PODynamicCheckoutConfiguration.kt
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/PODynamicCheckoutConfiguration.kt
@@ -18,6 +18,8 @@ import com.processout.sdk.ui.shared.configuration.POBarcodeConfiguration
import kotlinx.parcelize.Parcelize
/**
+ * Specifies dynamic checkout configuration.
+ *
* @param[invoiceRequest] Request to fetch invoice for payment.
* @param[expressCheckout] Express checkout section configuration.
* @param[card] Card payment configuration.
@@ -293,7 +295,7 @@ data class PODynamicCheckoutConfiguration(
) : Parcelable
/**
- * Specifies screen style.
+ * Specifies dynamic checkout style.
*
* @param[sectionHeader] Section header style.
* @param[googlePayButton] Google Pay button style.
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/extension/DrawExtensions.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/extension/DrawExtensions.kt
new file mode 100644
index 000000000..e9ffd6fbb
--- /dev/null
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/extension/DrawExtensions.kt
@@ -0,0 +1,15 @@
+package com.processout.sdk.ui.shared.extension
+
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.nativeCanvas
+
+/**
+ * Adds a layer to apply blend modes.
+ */
+internal fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) {
+ with(drawContext.canvas.nativeCanvas) {
+ val checkPoint = saveLayer(null, null)
+ block()
+ restoreToCount(checkPoint)
+ }
+}
diff --git a/ui/src/main/res/drawable-night/po_icon_close.xml b/ui/src/main/res/drawable-night/po_icon_close.xml
index 55d2292a4..5852e4abc 100644
--- a/ui/src/main/res/drawable-night/po_icon_close.xml
+++ b/ui/src/main/res/drawable-night/po_icon_close.xml
@@ -7,7 +7,7 @@
android:fillColor="#00000000"
android:pathData="M12.5,3.5L3.5,12.5M12.5,12.5L3.5,3.5"
android:strokeWidth="1.25"
- android:strokeColor="#8A8D93"
+ android:strokeColor="#C0C3C8"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
diff --git a/ui/src/main/res/drawable-night/po_icon_lightning.xml b/ui/src/main/res/drawable-night/po_icon_lightning.xml
new file mode 100644
index 000000000..307e6eca9
--- /dev/null
+++ b/ui/src/main/res/drawable-night/po_icon_lightning.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/ui/src/main/res/drawable-night/po_icon_lightning_slash.xml b/ui/src/main/res/drawable-night/po_icon_lightning_slash.xml
new file mode 100644
index 000000000..842124cd5
--- /dev/null
+++ b/ui/src/main/res/drawable-night/po_icon_lightning_slash.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/ui/src/main/res/drawable-night/po_icon_settings.xml b/ui/src/main/res/drawable-night/po_icon_settings.xml
index 5838cf0dd..bd26bdee8 100644
--- a/ui/src/main/res/drawable-night/po_icon_settings.xml
+++ b/ui/src/main/res/drawable-night/po_icon_settings.xml
@@ -7,14 +7,14 @@
android:fillColor="#00000000"
android:pathData="M6.263,12.914L6.653,13.79C6.769,14.051 6.958,14.273 7.197,14.428C7.437,14.584 7.716,14.667 8.001,14.667C8.287,14.667 8.566,14.584 8.805,14.428C9.045,14.273 9.234,14.051 9.349,13.79L9.739,12.914C9.878,12.603 10.111,12.344 10.406,12.173C10.702,12.002 11.045,11.929 11.386,11.965L12.339,12.067C12.623,12.097 12.909,12.044 13.163,11.914C13.418,11.785 13.629,11.584 13.772,11.337C13.914,11.09 13.982,10.807 13.967,10.522C13.952,10.237 13.855,9.963 13.687,9.732L13.123,8.956C12.922,8.678 12.814,8.343 12.816,8C12.816,7.658 12.924,7.324 13.126,7.047L13.69,6.272C13.858,6.041 13.955,5.767 13.97,5.482C13.985,5.197 13.917,4.914 13.775,4.667C13.632,4.419 13.421,4.219 13.166,4.089C12.912,3.96 12.626,3.907 12.342,3.937L11.389,4.038C11.048,4.074 10.705,4.001 10.409,3.83C10.113,3.659 9.88,3.398 9.742,3.086L9.349,2.21C9.234,1.949 9.045,1.727 8.805,1.572C8.566,1.416 8.287,1.333 8.001,1.333C7.716,1.333 7.437,1.416 7.197,1.572C6.958,1.727 6.769,1.949 6.653,2.21L6.263,3.086C6.125,3.398 5.892,3.659 5.597,3.83C5.3,4.001 4.957,4.074 4.617,4.038L3.66,3.937C3.377,3.907 3.09,3.96 2.836,4.089C2.582,4.219 2.37,4.419 2.228,4.667C2.085,4.914 2.017,5.197 2.032,5.482C2.047,5.767 2.144,6.041 2.312,6.272L2.877,7.047C3.078,7.324 3.186,7.658 3.186,8C3.186,8.342 3.078,8.676 2.877,8.953L2.312,9.728C2.144,9.959 2.047,10.233 2.032,10.518C2.017,10.803 2.085,11.086 2.228,11.333C2.371,11.58 2.582,11.781 2.836,11.91C3.09,12.04 3.377,12.093 3.66,12.063L4.614,11.962C4.954,11.926 5.297,11.999 5.594,12.17C5.89,12.341 6.125,12.601 6.263,12.914Z"
android:strokeWidth="1.25"
- android:strokeColor="#8A8D93"
+ android:strokeColor="#C0C3C8"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
diff --git a/ui/src/main/res/drawable/po_icon_lightning.xml b/ui/src/main/res/drawable/po_icon_lightning.xml
new file mode 100644
index 000000000..34925a31c
--- /dev/null
+++ b/ui/src/main/res/drawable/po_icon_lightning.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/ui/src/main/res/drawable/po_icon_lightning_slash.xml b/ui/src/main/res/drawable/po_icon_lightning_slash.xml
new file mode 100644
index 000000000..377daf967
--- /dev/null
+++ b/ui/src/main/res/drawable/po_icon_lightning_slash.xml
@@ -0,0 +1,27 @@
+
+
+
+
+