diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index c22b6fa9e..131e44d79 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index 116f37a84..1466901d6 100644
--- a/build.gradle
+++ b/build.gradle
@@ -2,11 +2,11 @@
buildscript {
ext {
- androidGradlePluginVersion = '8.9.0'
- kotlinVersion = '2.1.10'
- kspVersion = '2.1.10-1.0.29'
+ androidGradlePluginVersion = '8.9.1'
+ kotlinVersion = '2.1.20'
+ kspVersion = '2.1.20-1.0.32'
dokkaVersion = '1.9.20'
- androidxNavigationVersion = '2.8.6'
+ androidxNavigationVersion = '2.8.9'
nexusPublishPluginVersion = '2.0.0'
}
dependencies {
@@ -38,14 +38,14 @@ ext {
androidxCoreVersion = '1.15.0'
androidxAppCompatVersion = '1.7.0'
- androidxConstraintLayoutVersion = '2.2.0'
- androidxActivityVersion = '1.10.0'
- androidxFragmentVersion = '1.8.5'
+ androidxConstraintLayoutVersion = '2.2.1'
+ androidxActivityVersion = '1.10.1'
+ androidxFragmentVersion = '1.8.6'
androidxLifecycleVersion = '2.8.7'
androidxRecyclerViewVersion = '1.4.0'
androidxSwipeRefreshLayoutVersion = '1.1.0'
androidxBrowserVersion = '1.8.0'
- androidxCameraVersion = '1.4.1'
+ androidxCameraVersion = '1.4.2'
androidxComposeBOMVersion = '2025.03.00'
composeGooglePayButtonVersion = '1.0.0'
diff --git a/example/src/main/AndroidManifest.xml b/example/src/main/AndroidManifest.xml
index 46d6667e5..aa8485c75 100644
--- a/example/src/main/AndroidManifest.xml
+++ b/example/src/main/AndroidManifest.xml
@@ -26,6 +26,10 @@
+
+
diff --git a/example/src/main/kotlin/com/processout/example/ui/screen/card/payment/CardPaymentFragment.kt b/example/src/main/kotlin/com/processout/example/ui/screen/card/payment/CardPaymentFragment.kt
index efad028d3..79a8bb146 100644
--- a/example/src/main/kotlin/com/processout/example/ui/screen/card/payment/CardPaymentFragment.kt
+++ b/example/src/main/kotlin/com/processout/example/ui/screen/card/payment/CardPaymentFragment.kt
@@ -27,7 +27,7 @@ import com.processout.sdk.core.ProcessOutActivityResult
import com.processout.sdk.core.onFailure
import com.processout.sdk.core.onSuccess
import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration
-import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration.Button
+import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration.CardScannerConfiguration
import com.processout.sdk.ui.card.tokenization.POCardTokenizationLauncher
import com.processout.sdk.ui.shared.view.dialog.POAlertDialog
import com.processout.sdk.ui.threeds.PO3DSRedirectCustomTabLauncher
@@ -99,8 +99,8 @@ class CardPaymentFragment : BaseFragment(
viewModel.onTokenizing()
launcher.launch(
POCardTokenizationConfiguration(
- savingAllowed = true,
- submitButton = Button()
+ cardScanner = CardScannerConfiguration(),
+ savingAllowed = true
)
)
}
diff --git a/sdk/src/main/res/values-ar/strings.xml b/sdk/src/main/res/values-ar/strings.xml
index cf66dbbe6..e75130d11 100644
--- a/sdk/src/main/res/values-ar/strings.xml
+++ b/sdk/src/main/res/values-ar/strings.xml
@@ -62,6 +62,7 @@
إضافة بطاقة جديدة
إرسال
إلغاء
+ امسح البطاقة
النظام المفضل للبطاقة
اسم حامل البطاقة
كود التحقق CVV
diff --git a/sdk/src/main/res/values-fr/strings.xml b/sdk/src/main/res/values-fr/strings.xml
index d4aafb842..31d5bb9c1 100644
--- a/sdk/src/main/res/values-fr/strings.xml
+++ b/sdk/src/main/res/values-fr/strings.xml
@@ -60,6 +60,7 @@
Ajouter une nouvelle carte
Envoyer
Annuler
+ Scanner la carte
Réseau de Carte Préféré
Nom du porteur de carte
CVV
diff --git a/sdk/src/main/res/values-pl/strings.xml b/sdk/src/main/res/values-pl/strings.xml
index 4989872df..5134c292e 100644
--- a/sdk/src/main/res/values-pl/strings.xml
+++ b/sdk/src/main/res/values-pl/strings.xml
@@ -61,6 +61,7 @@
Dodaj nową kartę
Zatwierdź
Anuluj
+ Skanowanie karty
Preferowany Schemat
Imię i nazwisko na karcie
CVC
diff --git a/sdk/src/main/res/values-pt/strings.xml b/sdk/src/main/res/values-pt/strings.xml
index e50fe615b..1acf9e383 100644
--- a/sdk/src/main/res/values-pt/strings.xml
+++ b/sdk/src/main/res/values-pt/strings.xml
@@ -60,6 +60,7 @@
Adicionar novo cartão
Enviar
Cancelar
+ Digitalizar cartão
Rede de pagamento preferencial
Nome no cartão
CVC
diff --git a/sdk/src/main/res/values/strings.xml b/sdk/src/main/res/values/strings.xml
index 3b5b540d8..e75db6707 100644
--- a/sdk/src/main/res/values/strings.xml
+++ b/sdk/src/main/res/values/strings.xml
@@ -59,6 +59,7 @@
Add New Card
Submit
Cancel
+ Scan card
Preferred Scheme
Cardholder Name
CVC
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
index 67a7df3bd..02b2dc611 100644
--- 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
@@ -11,6 +11,7 @@ import com.processout.sdk.ui.card.scanner.CardScannerSideEffect.CameraPermission
import com.processout.sdk.ui.card.scanner.recognition.CardRecognitionSession
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
@@ -21,6 +22,10 @@ internal class CardScannerInteractor(
private val cardRecognitionSession: CardRecognitionSession
) : BaseInteractor() {
+ private companion object {
+ const val INIT_DELAY_MS = 500L
+ }
+
private val _completion = MutableStateFlow(Awaiting)
val completion = _completion.asStateFlow()
@@ -31,30 +36,29 @@ internal class CardScannerInteractor(
val sideEffects = _sideEffects.receiveAsFlow()
init {
+ POLogger.info("Starting card scanner.")
collectRecognizedCards()
interactorScope.launch {
+ delay(INIT_DELAY_MS)
_sideEffects.send(CameraPermissionRequest)
}
}
private fun initState() = CardScannerInteractorState(
- currentCard = null,
- isTorchEnabled = false
+ isCameraPermissionGranted = false,
+ isTorchEnabled = false,
+ currentCard = null
)
fun onEvent(event: CardScannerEvent) {
when (event) {
+ is CameraPermissionResult -> handle(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 TorchToggle -> {
+ POLogger.debug("Torch toggle: ${event.isEnabled}.")
+ _state.update { it.copy(isTorchEnabled = event.isEnabled) }
}
is Cancel -> cancel(
ProcessOutResult.Failure(
@@ -66,6 +70,20 @@ internal class CardScannerInteractor(
}
}
+ private fun handle(event: CameraPermissionResult) {
+ _state.update { it.copy(isCameraPermissionGranted = event.isGranted) }
+ if (event.isGranted) {
+ POLogger.info("Started: camera permission is granted.")
+ } else {
+ cancel(
+ ProcessOutResult.Failure(
+ code = Generic(),
+ message = "Camera permission is not granted."
+ )
+ )
+ }
+ }
+
private fun collectRecognizedCards() {
interactorScope.launch(Dispatchers.Main.immediate) {
cardRecognitionSession.currentCard.collect { card ->
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
index daa858faf..ced759b0d 100644
--- 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
@@ -3,6 +3,7 @@ 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
+ val isCameraPermissionGranted: Boolean,
+ val isTorchEnabled: Boolean,
+ val currentCard: POScannedCard?
)
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
index 74df6a4d6..d9e41e5c3 100644
--- 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
@@ -17,8 +17,11 @@ 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.AnimatedVisibility
import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
@@ -133,10 +136,9 @@ internal fun CardScannerScreen(
style = style.description.textStyle
)
CameraPreview(
- isTorchEnabled = state.torchAction.checked,
- currentCard = state.currentCard,
+ state = state,
onEvent = onEvent,
- cameraPreviewStyle = style.cameraPreview,
+ style = style.cameraPreview,
cardStyle = style.card,
modifier = Modifier
.fillMaxWidth()
@@ -161,30 +163,21 @@ internal fun CardScannerScreen(
@Composable
private fun CameraPreview(
- isTorchEnabled: Boolean,
- currentCard: POScannedCard?,
+ state: CardScannerViewModelState,
onEvent: (CardScannerEvent) -> Unit,
- cameraPreviewStyle: CameraPreviewStyle,
+ style: 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
+ width = style.border.width,
+ color = style.border.color,
+ shape = style.shape
)
- .clip(cameraPreviewStyle.shape)
+ .clip(style.shape)
.drawWithContent {
val offsetSizePx = offsetSize.toPx()
val cardSize = androidx.compose.ui.geometry.Size(
@@ -196,7 +189,7 @@ private fun CameraPreview(
val cornerRadius = CornerRadius(cornerRadiusSizePx, cornerRadiusSizePx)
drawContent()
drawWithLayer {
- drawRect(color = cameraPreviewStyle.overlayColor)
+ drawRect(color = style.overlayColor)
drawRoundRect(
size = cardSize,
topLeft = topLeftOffset,
@@ -214,20 +207,32 @@ private fun CameraPreview(
)
}
) {
- AndroidView(
- modifier = modifier.clip(cameraPreviewStyle.shape),
- factory = {
- PreviewView(it).apply {
- controller = cameraController
- clipToOutline = true
- scaleType = PreviewView.ScaleType.FILL_START
- implementationMode = PreviewView.ImplementationMode.COMPATIBLE
+ if (state.isCameraPermissionGranted) {
+ val cameraController = rememberLifecycleCameraController(
+ onAnalyze = { imageProxy ->
+ onEvent(ImageAnalysis(imageProxy))
}
- },
- onRelease = { cameraController.unbind() }
- )
+ )
+ val isTorchEnabled = state.torchAction.checked
+ LaunchedEffect(isTorchEnabled) {
+ cameraController.enableTorch(isTorchEnabled)
+ }
+ AndroidView(
+ modifier = modifier,
+ factory = {
+ PreviewView(it).apply {
+ controller = cameraController
+ scaleType = PreviewView.ScaleType.FILL_START
+ implementationMode = PreviewView.ImplementationMode.COMPATIBLE
+ }
+ },
+ onRelease = { cameraController.unbind() }
+ )
+ } else {
+ Box(modifier.background(Color.Black))
+ }
ScannedCard(
- card = currentCard,
+ card = state.currentCard,
style = cardStyle
)
}
@@ -253,23 +258,12 @@ private fun ScannedCard(
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
- )
- }
+ POTextAutoSize(
+ text = card?.number ?: String(),
+ modifier = Modifier.fillMaxWidth(),
+ color = style.number.color,
+ style = style.number.textStyle
+ )
Row {
POText(
text = card?.cardholderName ?: String(),
@@ -437,5 +431,5 @@ internal object CardScannerScreen {
/** Height to width ratio of a card by ISO/IEC 7810 standard. */
val CardHeightToWidthRatio = 0.63f
- val AnimationDurationMillis = 250
+ val AnimationDurationMillis = 300
}
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
index 575f8019e..d39d077d1 100644
--- 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
@@ -61,7 +61,8 @@ internal class CardScannerViewModel(
description = description ?: app.getString(R.string.po_card_scanner_description),
currentCard = state.currentCard,
torchAction = torchAction(state.isTorchEnabled),
- cancelAction = cancelButton?.toAction()
+ cancelAction = cancelButton?.toAction(),
+ isCameraPermissionGranted = state.isCameraPermissionGranted
)
}
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
index 120d8b9ac..6b91300bc 100644
--- 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
@@ -8,5 +8,6 @@ internal data class CardScannerViewModelState(
val description: String,
val currentCard: POScannedCard?,
val torchAction: POActionState,
- val cancelAction: POActionState?
+ val cancelAction: POActionState?,
+ val isCameraPermissionGranted: Boolean
)
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 70314755e..5b13ed741 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
@@ -15,19 +15,24 @@ import com.processout.sdk.api.dispatcher.PODefaultEventDispatchers
import com.processout.sdk.api.model.response.POCard
import com.processout.sdk.core.ProcessOutActivityResult
import com.processout.sdk.core.ProcessOutResult
+import com.processout.sdk.core.getOrNull
import com.processout.sdk.core.toActivityResult
import com.processout.sdk.ui.base.BaseBottomSheetDialogFragment
+import com.processout.sdk.ui.card.scanner.POCardScannerLauncher
import com.processout.sdk.ui.card.tokenization.CardTokenizationActivityContract.Companion.EXTRA_CONFIGURATION
import com.processout.sdk.ui.card.tokenization.CardTokenizationActivityContract.Companion.EXTRA_RESULT
import com.processout.sdk.ui.card.tokenization.CardTokenizationCompletion.Failure
import com.processout.sdk.ui.card.tokenization.CardTokenizationCompletion.Success
+import com.processout.sdk.ui.card.tokenization.CardTokenizationEvent.CardScannerResult
import com.processout.sdk.ui.card.tokenization.CardTokenizationEvent.Dismiss
+import com.processout.sdk.ui.card.tokenization.CardTokenizationSideEffect.CardScanner
import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration.Button
import com.processout.sdk.ui.core.theme.ProcessOutTheme
import com.processout.sdk.ui.shared.component.displayCutoutHeight
import com.processout.sdk.ui.shared.component.screenModeAsState
import com.processout.sdk.ui.shared.configuration.POBottomSheetConfiguration.Height.Fixed
import com.processout.sdk.ui.shared.configuration.POBottomSheetConfiguration.Height.WrapContent
+import com.processout.sdk.ui.shared.extension.collectImmediately
import kotlin.math.roundToInt
internal class CardTokenizationBottomSheet : BaseBottomSheetDialogFragment() {
@@ -39,21 +44,30 @@ internal class CardTokenizationBottomSheet : BaseBottomSheetDialogFragment
+ viewModel.onEvent(CardScannerResult(result.getOrNull()))
+ }
+ )
viewModel.start()
}
@@ -68,6 +82,8 @@ internal class CardTokenizationBottomSheet : BaseBottomSheetDialogFragment contentHeight
}
},
- style = CardTokenizationScreen.style(custom = configuration?.style)
+ style = CardTokenizationScreen.style(custom = configuration.style)
)
}
}
@@ -90,9 +106,15 @@ internal class CardTokenizationBottomSheet : BaseBottomSheetDialogFragment configuration.cardScanner?.configuration?.let {
+ cardScannerLauncher.launch(configuration = it)
+ }
}
}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationEvent.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationEvent.kt
index 46b8c2ff3..b0a09d2a0 100644
--- a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationEvent.kt
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationEvent.kt
@@ -3,14 +3,20 @@ package com.processout.sdk.ui.card.tokenization
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.card.scanner.recognition.POScannedCard
internal sealed interface CardTokenizationEvent {
data class FieldValueChanged(val id: String, val value: TextFieldValue) : CardTokenizationEvent
data class FieldFocusChanged(val id: String, val isFocused: Boolean) : CardTokenizationEvent
data class Action(val id: String) : CardTokenizationEvent
+ data class CardScannerResult(val card: POScannedCard?) : CardTokenizationEvent
data class Dismiss(val failure: ProcessOutResult.Failure) : CardTokenizationEvent
}
+internal sealed interface CardTokenizationSideEffect {
+ data object CardScanner : CardTokenizationSideEffect
+}
+
internal sealed interface CardTokenizationCompletion {
data object Awaiting : CardTokenizationCompletion
data class Success(val card: POCard) : CardTokenizationCompletion
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationInteractor.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationInteractor.kt
index a5b926082..7192a2f65 100644
--- a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationInteractor.kt
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationInteractor.kt
@@ -20,21 +20,28 @@ import com.processout.sdk.core.logger.POLogger
import com.processout.sdk.core.onFailure
import com.processout.sdk.core.onSuccess
import com.processout.sdk.ui.base.BaseInteractor
+import com.processout.sdk.ui.card.scanner.recognition.POScannedCard
import com.processout.sdk.ui.card.tokenization.CardTokenizationCompletion.*
import com.processout.sdk.ui.card.tokenization.CardTokenizationEvent.*
import com.processout.sdk.ui.card.tokenization.CardTokenizationInteractorState.*
+import com.processout.sdk.ui.card.tokenization.CardTokenizationSideEffect.CardScanner
import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration.BillingAddressConfiguration.CollectionMode.*
import com.processout.sdk.ui.core.state.POAvailableValue
import com.processout.sdk.ui.shared.extension.currentAppLocale
import com.processout.sdk.ui.shared.extension.orElse
+import com.processout.sdk.ui.shared.filter.CardExpirationInputFilter
+import com.processout.sdk.ui.shared.filter.CardNumberInputFilter
import com.processout.sdk.ui.shared.provider.CardSchemeProvider
import com.processout.sdk.ui.shared.provider.address.AddressSpecification
import com.processout.sdk.ui.shared.provider.address.AddressSpecification.AddressUnit
import com.processout.sdk.ui.shared.provider.address.AddressSpecificationProvider
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.Locale
@@ -51,6 +58,7 @@ internal class CardTokenizationInteractor(
private companion object {
const val IIN_LENGTH = 6
const val EXPIRATION_DATE_PART_LENGTH = 2
+ const val CARD_SCANNER_DELAY_MS = 350L
}
private data class Expiration(
@@ -64,6 +72,12 @@ internal class CardTokenizationInteractor(
private val _state = MutableStateFlow(initState())
val state = _state.asStateFlow()
+ private val _sideEffects = Channel()
+ val sideEffects = _sideEffects.receiveAsFlow()
+
+ private val cardNumberInputFilter = CardNumberInputFilter()
+ private val cardExpirationInputFilter = CardExpirationInputFilter()
+
private var latestPreferredSchemeRequest: POCardTokenizationPreferredSchemeRequest? = null
private var latestShouldContinueRequest: POCardTokenizationShouldContinueRequest? = null
@@ -116,8 +130,10 @@ internal class CardTokenizationInteractor(
shouldCollect = configuration.savingAllowed
),
focusedFieldId = CardFieldId.NUMBER,
+ pendingFocusedFieldId = null,
primaryActionId = ActionId.SUBMIT,
- secondaryActionId = ActionId.CANCEL
+ secondaryActionId = ActionId.CANCEL,
+ cardScannerActionId = ActionId.CARD_SCANNER
)
private fun cardFields(): List = mutableListOf(
@@ -176,11 +192,26 @@ internal class CardTokenizationInteractor(
is Action -> when (event.id) {
ActionId.SUBMIT -> submit()
ActionId.CANCEL -> cancel()
+ ActionId.CARD_SCANNER -> startCardScanner()
}
+ is CardScannerResult -> handle(event)
is Dismiss -> POLogger.info("Dismissed: %s", event.failure)
}
}
+ private fun startCardScanner() {
+ interactorScope.launch {
+ rememberAndClearFieldFocus()
+ delay(CARD_SCANNER_DELAY_MS)
+ _sideEffects.send(CardScanner)
+ }
+ }
+
+ private fun handle(event: CardScannerResult) {
+ event.card?.let { updateCardFields(it) }
+ restoreFieldFocus()
+ }
+
//endregion
//region Update Field
@@ -234,12 +265,65 @@ internal class CardTokenizationInteractor(
}
} else field
+ private fun updateCardFields(card: POScannedCard) {
+ POLogger.debug("Updating card field values with the scanned card: $card.")
+ updateFieldValue(
+ id = CardFieldId.NUMBER,
+ value = cardNumberInputFilter.filter(
+ TextFieldValue(
+ text = card.number,
+ selection = TextRange(index = card.number.length)
+ )
+ )
+ )
+ card.expiration?.let {
+ updateFieldValue(
+ id = CardFieldId.EXPIRATION,
+ value = cardExpirationInputFilter.filter(
+ TextFieldValue(
+ text = it.formatted,
+ selection = TextRange(index = it.formatted.length)
+ )
+ )
+ )
+ }
+ card.cardholderName?.let {
+ updateFieldValue(
+ id = CardFieldId.CARDHOLDER,
+ value = TextFieldValue(
+ text = it,
+ selection = TextRange(index = it.length)
+ )
+ )
+ }
+ }
+
private fun updateFieldFocus(id: String, isFocused: Boolean) {
if (isFocused) {
_state.update { it.copy(focusedFieldId = id) }
}
}
+ private fun rememberAndClearFieldFocus() {
+ _state.update {
+ val focusedFieldId = it.focusedFieldId
+ it.copy(
+ focusedFieldId = null,
+ pendingFocusedFieldId = focusedFieldId
+ )
+ }
+ }
+
+ private fun restoreFieldFocus() {
+ _state.update {
+ val pendingFocusedFieldId = it.pendingFocusedFieldId
+ it.copy(
+ focusedFieldId = pendingFocusedFieldId,
+ pendingFocusedFieldId = null
+ )
+ }
+ }
+
//endregion
//region Issuer Information & Preferred Scheme
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationInteractorState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationInteractorState.kt
index 80509bc15..c75a8b772 100644
--- a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationInteractorState.kt
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationInteractorState.kt
@@ -13,8 +13,10 @@ internal data class CardTokenizationInteractorState(
val addressSpecification: AddressSpecification? = null,
val saveCardField: Field,
val focusedFieldId: String?,
+ val pendingFocusedFieldId: String?,
val primaryActionId: String,
val secondaryActionId: String,
+ val cardScannerActionId: String,
val submitAllowed: Boolean = true,
val submitting: Boolean = false,
val errorMessage: String? = null,
@@ -54,5 +56,6 @@ internal data class CardTokenizationInteractorState(
object ActionId {
const val SUBMIT = "submit"
const val CANCEL = "cancel"
+ const val CARD_SCANNER = "card-scanner"
}
}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationScreen.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationScreen.kt
index 9c9737fec..326cc78fa 100644
--- a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationScreen.kt
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationScreen.kt
@@ -113,6 +113,18 @@ private fun Sections(
if (state.focusedFieldId == null) {
LocalFocusManager.current.clearFocus(force = true)
}
+ state.cardScannerAction?.let { action ->
+ POButton(
+ state = action,
+ onClick = { onEvent(Action(id = it)) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .requiredHeightIn(min = dimensions.buttonIconSizeSmall)
+ .padding(bottom = spacing.small),
+ style = style.scanButton,
+ iconSize = dimensions.iconSizeSmall
+ )
+ }
val lifecycleEvent = rememberLifecycleEvent()
state.sections.elements.forEachIndexed { index, section ->
val padding = if (section.id == FUTURE_PAYMENTS) {
@@ -359,6 +371,7 @@ internal object CardTokenizationScreen {
val checkbox: POCheckbox.Style,
val dropdownMenu: PODropdownField.MenuStyle,
val errorMessage: POText.Style,
+ val scanButton: POButton.Style,
val actionsContainer: POActionsContainer.Style,
val backgroundColor: Color,
val dividerColor: Color,
@@ -385,6 +398,9 @@ internal object CardTokenizationScreen {
errorMessage = custom?.errorMessage?.let {
POText.custom(style = it)
} ?: POText.errorLabel,
+ scanButton = custom?.scanButton?.let {
+ POButton.custom(style = it)
+ } ?: POButton.secondary,
actionsContainer = custom?.actionsContainer?.let {
POActionsContainer.custom(style = it)
} ?: POActionsContainer.default,
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 b6341ed9d..e73a7976a 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
@@ -15,6 +15,8 @@ import com.processout.sdk.api.ProcessOut
import com.processout.sdk.api.dispatcher.card.tokenization.PODefaultCardTokenizationEventDispatcher
import com.processout.sdk.ui.card.tokenization.CardTokenizationInteractorState.*
import com.processout.sdk.ui.card.tokenization.CardTokenizationViewModelState.*
+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.core.state.POImmutableList
@@ -70,6 +72,8 @@ internal class CardTokenizationViewModel private constructor(
val state = interactor.state.map(viewModelScope, ::map)
+ val sideEffects = interactor.sideEffects
+
init {
addCloseable(interactor.interactorScope)
}
@@ -117,6 +121,17 @@ internal class CardTokenizationViewModel private constructor(
}
)
},
+ cardScannerAction = cardScanner?.scanButton?.let {
+ POActionState(
+ id = state.cardScannerActionId,
+ text = it.text ?: app.getString(R.string.po_card_tokenization_button_scan),
+ primary = false,
+ icon = it.icon ?: PODrawableImage(
+ resId = com.processout.sdk.ui.R.drawable.po_icon_camera,
+ renderingMode = POImageRenderingMode.TEMPLATE
+ )
+ )
+ },
draggable = bottomSheet.cancellation.dragDown || bottomSheet.expandable
)
}
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationViewModelState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationViewModelState.kt
index 7cb8f5f0c..1d3a02b8d 100644
--- a/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationViewModelState.kt
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/card/tokenization/CardTokenizationViewModelState.kt
@@ -12,6 +12,7 @@ internal data class CardTokenizationViewModelState(
val focusedFieldId: String?,
val primaryAction: POActionState,
val secondaryAction: POActionState?,
+ val cardScannerAction: POActionState?,
val draggable: Boolean
) {
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 5209edd9e..2ee665996 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
@@ -3,6 +3,7 @@ package com.processout.sdk.ui.card.tokenization
import android.os.Parcelable
import androidx.annotation.ColorRes
import com.processout.sdk.api.model.request.POContact
+import com.processout.sdk.ui.card.scanner.POCardScannerConfiguration
import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration.BillingAddressConfiguration.CollectionMode
import com.processout.sdk.ui.core.shared.image.PODrawableImage
import com.processout.sdk.ui.core.style.*
@@ -19,6 +20,7 @@ import kotlinx.parcelize.Parcelize
* @param[title] Custom title.
* @param[cvcRequired] Specifies whether the CVC field should be displayed. Default value is _true_.
* @param[cardholderNameRequired] Specifies whether the cardholder name field should be displayed. Default value is _true_.
+ * @param[cardScanner] Card scanner configuration. Use _null_ to hide, this is a default behaviour.
* @param[billingAddress] Allows to customize the collection of billing address.
* @param[savingAllowed] Displays checkbox that allows to save the card details for future payments.
* @param[submitButton] Submit button configuration.
@@ -32,6 +34,7 @@ data class POCardTokenizationConfiguration(
val title: String? = null,
val cvcRequired: Boolean = true,
val cardholderNameRequired: Boolean = true,
+ val cardScanner: CardScannerConfiguration? = null,
val billingAddress: BillingAddressConfiguration = BillingAddressConfiguration(),
val savingAllowed: Boolean = false,
val submitButton: Button = Button(),
@@ -88,6 +91,18 @@ data class POCardTokenizationConfiguration(
style = style
)
+ /**
+ * Defines card scanner configuration.
+ *
+ * @param[scanButton] Scan button configuration.
+ * @param[configuration] Card scanner configuration.
+ */
+ @Parcelize
+ data class CardScannerConfiguration(
+ val scanButton: Button = Button(),
+ val configuration: POCardScannerConfiguration = POCardScannerConfiguration()
+ ) : Parcelable
+
/**
* Defines billing address configuration.
*
@@ -170,6 +185,7 @@ data class POCardTokenizationConfiguration(
val checkbox: POCheckboxStyle? = null,
val dropdownMenu: PODropdownMenuStyle? = null,
val errorMessage: POTextStyle? = null,
+ val scanButton: POButtonStyle? = null,
val actionsContainer: POActionsContainerStyle? = null,
@ColorRes
val backgroundColorResId: Int? = null,
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutActivity.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutActivity.kt
index dc1f20708..df265443d 100644
--- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutActivity.kt
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutActivity.kt
@@ -4,7 +4,6 @@ import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
-import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.SystemBarStyle
@@ -18,10 +17,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
+import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.android.gms.wallet.Wallet.WalletOptions
-import com.google.android.gms.wallet.WalletConstants
import com.processout.sdk.R
import com.processout.sdk.api.ProcessOut
import com.processout.sdk.api.dispatcher.card.tokenization.PODefaultCardTokenizationEventDispatcher
@@ -31,17 +30,16 @@ import com.processout.sdk.api.model.event.POSavedPaymentMethodsEvent.DidDeleteCu
import com.processout.sdk.api.model.request.POInvoiceRequest
import com.processout.sdk.api.model.response.POAlternativePaymentMethodResponse
import com.processout.sdk.api.model.response.POGooglePayCardTokenizationData
+import com.processout.sdk.core.*
import com.processout.sdk.core.POFailure.Code.Cancelled
import com.processout.sdk.core.POFailure.Code.Generic
-import com.processout.sdk.core.POUnit
-import com.processout.sdk.core.ProcessOutActivityResult
-import com.processout.sdk.core.ProcessOutResult
-import com.processout.sdk.core.toActivityResult
import com.processout.sdk.ui.apm.POAlternativePaymentMethodCustomTabLauncher
import com.processout.sdk.ui.base.BaseTransparentPortraitActivity
+import com.processout.sdk.ui.card.scanner.POCardScannerLauncher
import com.processout.sdk.ui.card.tokenization.CardTokenizationViewModel
import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration
import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration.BillingAddressConfiguration
+import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration.CardScannerConfiguration
import com.processout.sdk.ui.checkout.DynamicCheckoutActivityContract.Companion.EXTRA_CONFIGURATION
import com.processout.sdk.ui.checkout.DynamicCheckoutActivityContract.Companion.EXTRA_RESULT
import com.processout.sdk.ui.checkout.DynamicCheckoutCompletion.Failure
@@ -55,10 +53,8 @@ import com.processout.sdk.ui.googlepay.POGooglePayCardTokenizationLauncher
import com.processout.sdk.ui.napm.NativeAlternativePaymentViewModel
import com.processout.sdk.ui.napm.PONativeAlternativePaymentConfiguration
import com.processout.sdk.ui.napm.PONativeAlternativePaymentConfiguration.PaymentConfirmationConfiguration
-import com.processout.sdk.ui.napm.PONativeAlternativePaymentConfiguration.PaymentConfirmationConfiguration.Companion.DEFAULT_TIMEOUT_SECONDS
import com.processout.sdk.ui.savedpaymentmethods.POSavedPaymentMethodsDelegate
import com.processout.sdk.ui.savedpaymentmethods.POSavedPaymentMethodsLauncher
-import com.processout.sdk.ui.shared.configuration.POBarcodeConfiguration
import com.processout.sdk.ui.shared.extension.collectImmediately
import com.processout.sdk.ui.web.customtab.POCustomTabAuthorizationActivity
import com.processout.sdk.ui.web.customtab.POCustomTabAuthorizationActivityContract
@@ -67,7 +63,7 @@ import com.processout.sdk.ui.web.webview.POWebViewAuthorizationActivityContract
internal class DynamicCheckoutActivity : BaseTransparentPortraitActivity() {
- private var configuration: PODynamicCheckoutConfiguration? = null
+ private lateinit var configuration: PODynamicCheckoutConfiguration
private val viewModel: DynamicCheckoutViewModel by viewModels {
val cardTokenizationEventDispatcher = PODefaultCardTokenizationEventDispatcher()
@@ -88,9 +84,7 @@ internal class DynamicCheckoutActivity : BaseTransparentPortraitActivity() {
}
DynamicCheckoutViewModel.Factory(
app = application,
- configuration = configuration ?: PODynamicCheckoutConfiguration(
- invoiceRequest = POInvoiceRequest(invoiceId = String())
- ),
+ configuration = configuration,
cardTokenization = cardTokenization,
cardTokenizationEventDispatcher = cardTokenizationEventDispatcher,
nativeAlternativePayment = nativeAlternativePayment,
@@ -99,55 +93,64 @@ internal class DynamicCheckoutActivity : BaseTransparentPortraitActivity() {
}
private fun cardTokenizationConfiguration(): POCardTokenizationConfiguration {
- val billingAddress = configuration?.card?.billingAddress
+ val billingAddress = configuration.card.billingAddress
return POCardTokenizationConfiguration(
+ cardScanner = configuration.card.cardScanner?.let {
+ CardScannerConfiguration(
+ scanButton = POCardTokenizationConfiguration.Button(
+ text = it.scanButton.text,
+ icon = it.scanButton.icon
+ ),
+ configuration = it.configuration
+ )
+ },
billingAddress = BillingAddressConfiguration(
- defaultAddress = billingAddress?.defaultAddress,
- attachDefaultsToPaymentMethod = billingAddress?.attachDefaultsToPaymentMethod ?: false
+ defaultAddress = billingAddress.defaultAddress,
+ attachDefaultsToPaymentMethod = billingAddress.attachDefaultsToPaymentMethod
),
- submitButton = configuration?.submitButton?.let {
+ submitButton = configuration.submitButton.let {
POCardTokenizationConfiguration.Button(
text = it.text,
icon = it.icon
)
- } ?: POCardTokenizationConfiguration.Button(),
- cancelButton = configuration?.cancelButton?.let {
+ },
+ cancelButton = configuration.cancelButton?.let {
POCardTokenizationConfiguration.CancelButton(
text = it.text,
icon = it.icon,
confirmation = it.confirmation
)
},
- metadata = configuration?.card?.metadata
+ metadata = configuration.card.metadata
)
}
private fun nativeAlternativePaymentConfiguration(): PONativeAlternativePaymentConfiguration {
- val paymentConfirmation = configuration?.alternativePayment?.paymentConfirmation
+ val paymentConfirmation = configuration.alternativePayment.paymentConfirmation
return PONativeAlternativePaymentConfiguration(
- invoiceId = configuration?.invoiceRequest?.invoiceId ?: String(),
+ invoiceId = configuration.invoiceRequest.invoiceId,
gatewayConfigurationId = String(),
- submitButton = configuration?.submitButton?.map() ?: PONativeAlternativePaymentConfiguration.Button(),
- cancelButton = configuration?.cancelButton?.map(),
+ submitButton = configuration.submitButton.map(),
+ cancelButton = configuration.cancelButton?.map(),
paymentConfirmation = PaymentConfirmationConfiguration(
waitsConfirmation = true,
- timeoutSeconds = paymentConfirmation?.timeoutSeconds ?: DEFAULT_TIMEOUT_SECONDS,
- showProgressIndicatorAfterSeconds = paymentConfirmation?.showProgressIndicatorAfterSeconds,
+ timeoutSeconds = paymentConfirmation.timeoutSeconds,
+ showProgressIndicatorAfterSeconds = paymentConfirmation.showProgressIndicatorAfterSeconds,
hideGatewayDetails = true,
- confirmButton = paymentConfirmation?.confirmButton?.map(),
- cancelButton = paymentConfirmation?.cancelButton?.map()
+ confirmButton = paymentConfirmation.confirmButton?.map(),
+ cancelButton = paymentConfirmation.cancelButton?.map()
),
- barcode = configuration?.alternativePayment?.barcode
- ?: POBarcodeConfiguration(saveButton = POBarcodeConfiguration.Button()),
- inlineSingleSelectValuesLimit = configuration?.alternativePayment?.inlineSingleSelectValuesLimit ?: 5,
+ barcode = configuration.alternativePayment.barcode,
+ inlineSingleSelectValuesLimit = configuration.alternativePayment.inlineSingleSelectValuesLimit,
skipSuccessScreen = true
)
}
- private fun Button.map() = PONativeAlternativePaymentConfiguration.Button(
- text = text,
- icon = icon
- )
+ private fun Button.map() =
+ PONativeAlternativePaymentConfiguration.Button(
+ text = text,
+ icon = icon
+ )
private fun CancelButton.map() =
PONativeAlternativePaymentConfiguration.CancelButton(
@@ -180,17 +183,17 @@ internal class DynamicCheckoutActivity : BaseTransparentPortraitActivity() {
)
private var pendingPermissionRequest: PermissionRequest? = null
+ private lateinit var cardScannerLauncher: POCardScannerLauncher
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT))
- if (savedInstanceState == null) {
- initConfiguration()
- }
+ initConfiguration()
dispatchBackPressed()
googlePayLauncher = POGooglePayCardTokenizationLauncher.create(
from = this,
walletOptions = WalletOptions.Builder()
- .setEnvironment(configuration?.googlePay?.environment?.value ?: WalletConstants.ENVIRONMENT_TEST)
+ .setEnvironment(configuration.googlePay.environment.value)
.build(),
callback = ::handleGooglePay
)
@@ -203,6 +206,12 @@ internal class DynamicCheckoutActivity : BaseTransparentPortraitActivity() {
delegate = savedPaymentMethodsDelegate,
callback = { pendingSavedPaymentMethods = false }
)
+ cardScannerLauncher = POCardScannerLauncher.create(
+ from = this,
+ callback = { result ->
+ viewModel.onEvent(CardScannerResult(result.getOrNull()))
+ }
+ )
setContent {
val isLightTheme = !isSystemInDarkTheme()
ProcessOutTheme(isLightTheme = isLightTheme) {
@@ -217,7 +226,7 @@ internal class DynamicCheckoutActivity : BaseTransparentPortraitActivity() {
state = viewModel.state.collectAsStateWithLifecycle().value,
onEvent = remember { viewModel::onEvent },
style = DynamicCheckoutScreen.style(
- custom = configuration?.style,
+ custom = configuration.style,
isLightTheme = isLightTheme
),
isLightTheme = isLightTheme
@@ -229,23 +238,22 @@ internal class DynamicCheckoutActivity : BaseTransparentPortraitActivity() {
private fun initConfiguration() {
@Suppress("DEPRECATION")
configuration = intent.getParcelableExtra(EXTRA_CONFIGURATION)
- configuration?.run {
- if (invoiceRequest.invoiceId.isBlank()) {
- viewModel.onEvent(
- Dismiss(
- ProcessOutResult.Failure(
- code = Generic(),
- message = "Invalid configuration: 'invoiceId' is required."
- )
+ ?: PODynamicCheckoutConfiguration(invoiceRequest = POInvoiceRequest(invoiceId = String()))
+ if (configuration.invoiceRequest.invoiceId.isBlank()) {
+ viewModel.onEvent(
+ Dismiss(
+ ProcessOutResult.Failure(
+ code = Generic(),
+ message = "Invalid configuration: 'invoiceId' is required."
)
)
- }
+ )
}
}
private fun dispatchBackPressed() {
onBackPressedDispatcher.addCallback(this) {
- if (configuration?.cancelOnBackPressed == true) {
+ if (configuration.cancelOnBackPressed) {
viewModel.onEvent(
Dismiss(
ProcessOutResult.Failure(
@@ -267,7 +275,7 @@ internal class DynamicCheckoutActivity : BaseTransparentPortraitActivity() {
is AlternativePayment -> {
pendingAlternativePayment = sideEffect
alternativePaymentLauncher.launch(
- uri = Uri.parse(sideEffect.redirectUrl),
+ uri = sideEffect.redirectUrl.toUri(),
returnUrl = sideEffect.returnUrl
)
}
@@ -276,6 +284,9 @@ internal class DynamicCheckoutActivity : BaseTransparentPortraitActivity() {
savedPaymentMethodsLauncher.launch(sideEffect.configuration)
}
is PermissionRequest -> requestPermission(sideEffect)
+ CardScanner -> configuration.card.cardScanner?.configuration?.let {
+ cardScannerLauncher.launch(configuration = it)
+ }
CancelWebAuthorization -> cancelWebAuthorization()
BeforeSuccess -> if (pendingSavedPaymentMethods) {
savedPaymentMethodsLauncher.finish()
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutEvent.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutEvent.kt
index be56b2f7c..6b34ce12c 100644
--- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutEvent.kt
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutEvent.kt
@@ -4,6 +4,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import com.processout.sdk.api.model.response.POAlternativePaymentMethodResponse
import com.processout.sdk.api.model.response.POGooglePayCardTokenizationData
import com.processout.sdk.core.ProcessOutResult
+import com.processout.sdk.ui.card.scanner.recognition.POScannedCard
import com.processout.sdk.ui.savedpaymentmethods.POSavedPaymentMethodsConfiguration
import org.json.JSONObject
@@ -55,6 +56,10 @@ internal sealed interface DynamicCheckoutEvent {
val isGranted: Boolean
) : DynamicCheckoutEvent
+ data class CardScannerResult(
+ val card: POScannedCard?
+ ) : DynamicCheckoutEvent
+
data class CustomerTokenDeleted(
val tokenId: String
) : DynamicCheckoutEvent
@@ -85,6 +90,8 @@ internal sealed interface DynamicCheckoutSideEffect {
val permission: String
) : DynamicCheckoutSideEffect
+ data object CardScanner : DynamicCheckoutSideEffect
+
data object CancelWebAuthorization : DynamicCheckoutSideEffect
data object BeforeSuccess : DynamicCheckoutSideEffect
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt
index a7f51f7f6..12700670c 100644
--- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt
@@ -39,10 +39,7 @@ import com.processout.sdk.core.logger.POLogger
import com.processout.sdk.core.onFailure
import com.processout.sdk.core.onSuccess
import com.processout.sdk.ui.base.BaseInteractor
-import com.processout.sdk.ui.card.tokenization.CardTokenizationCompletion
-import com.processout.sdk.ui.card.tokenization.CardTokenizationEvent
-import com.processout.sdk.ui.card.tokenization.CardTokenizationViewModel
-import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration
+import com.processout.sdk.ui.card.tokenization.*
import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration.BillingAddressConfiguration.CollectionMode
import com.processout.sdk.ui.checkout.DynamicCheckoutCompletion.*
import com.processout.sdk.ui.checkout.DynamicCheckoutEvent.*
@@ -128,6 +125,7 @@ internal class DynamicCheckoutInteractor(
private suspend fun start() {
handleCompletions()
dispatchEvents()
+ dispatchSideEffects()
collectInvoice()
collectInvoiceAuthorizationRequest()
collectTokenizedCard()
@@ -421,6 +419,7 @@ internal class DynamicCheckoutInteractor(
is GooglePayResult -> handleGooglePay(event.paymentMethodId, event.result)
is AlternativePaymentResult -> handleAlternativePayment(event.paymentMethodId, event.result)
is PermissionRequestResult -> handlePermission(event)
+ is CardScannerResult -> handleCardScanner(event)
is CustomerTokenDeleted -> deleteLocalCustomerToken(event.tokenId)
is Dismiss -> dismiss(event)
}
@@ -1022,7 +1021,19 @@ internal class DynamicCheckoutInteractor(
eventDispatcher.send(request)
}
}
- interactorScope.launch {
+ }
+
+ private fun dispatchSideEffects() {
+ interactorScope.launch(Dispatchers.Main.immediate) {
+ cardTokenization.sideEffects.collect { sideEffect ->
+ when (sideEffect) {
+ CardTokenizationSideEffect.CardScanner -> {
+ _sideEffects.send(DynamicCheckoutSideEffect.CardScanner)
+ }
+ }
+ }
+ }
+ interactorScope.launch(Dispatchers.Main.immediate) {
nativeAlternativePayment.sideEffects.collect { sideEffect ->
when (sideEffect) {
is NativeAlternativePaymentSideEffect.PermissionRequest ->
@@ -1052,6 +1063,13 @@ internal class DynamicCheckoutInteractor(
}
}
+ private fun handleCardScanner(result: CardScannerResult) {
+ result.card?.let { POLogger.debug("Scanned card: $it") }
+ cardTokenization.onEvent(
+ CardTokenizationEvent.CardScannerResult(result.card)
+ )
+ }
+
private fun deleteLocalCustomerToken(tokenId: String) {
_state.update { state ->
state.copy(
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 d6ac5cd1f..388ac2a70 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
@@ -9,6 +9,7 @@ import androidx.annotation.IntRange
import com.google.android.gms.wallet.WalletConstants
import com.processout.sdk.api.model.request.POContact
import com.processout.sdk.api.model.request.POInvoiceRequest
+import com.processout.sdk.ui.card.scanner.POCardScannerConfiguration
import com.processout.sdk.ui.checkout.PODynamicCheckoutConfiguration.GooglePayConfiguration.Environment
import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi
import com.processout.sdk.ui.core.shared.image.PODrawableImage
@@ -66,15 +67,29 @@ data class PODynamicCheckoutConfiguration(
/**
* Specifies card payment configuration.
*
+ * @param[cardScanner] Card scanner configuration. Use _null_ to hide.
* @param[billingAddress] Specifies billing address configuration.
* @param[metadata] Metadata related to the card.
*/
@Parcelize
data class CardConfiguration(
+ val cardScanner: CardScannerConfiguration? = CardScannerConfiguration(),
val billingAddress: BillingAddressConfiguration = BillingAddressConfiguration(),
val metadata: Map? = null
) : Parcelable {
+ /**
+ * Specifies card scanner configuration.
+ *
+ * @param[scanButton] Scan button configuration.
+ * @param[configuration] Card scanner configuration.
+ */
+ @Parcelize
+ data class CardScannerConfiguration(
+ val scanButton: Button = Button(),
+ val configuration: POCardScannerConfiguration = POCardScannerConfiguration()
+ ) : Parcelable
+
/**
* Specifies billing address configuration.
*
@@ -311,6 +326,7 @@ data class PODynamicCheckoutConfiguration(
* @param[errorText] Error text style.
* @param[messageBox] Message box style.
* @param[dialog] Dialog style.
+ * @param[scanCardButton] Scan card button style.
* @param[actionsContainer] Style of action buttons and their container.
* @param[backgroundColorResId] Color resource ID for background.
* @param[progressIndicatorColorResId] Color resource ID for progress indicator.
@@ -333,6 +349,7 @@ data class PODynamicCheckoutConfiguration(
val errorText: POTextStyle? = null,
val messageBox: POMessageBoxStyle? = null,
val dialog: PODialogStyle? = null,
+ val scanCardButton: POButtonStyle? = null,
val actionsContainer: POActionsContainerStyle? = null,
@ColorRes
val backgroundColorResId: Int? = null,
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/screen/CardTokenization.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/screen/CardTokenization.kt
index 4019641a5..e65eb377a 100644
--- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/screen/CardTokenization.kt
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/screen/CardTokenization.kt
@@ -17,12 +17,8 @@ import com.processout.sdk.ui.card.tokenization.CardTokenizationViewModelState
import com.processout.sdk.ui.card.tokenization.CardTokenizationViewModelState.Item
import com.processout.sdk.ui.card.tokenization.CardTokenizationViewModelState.SectionId.FUTURE_PAYMENTS
import com.processout.sdk.ui.checkout.DynamicCheckoutEvent
-import com.processout.sdk.ui.checkout.DynamicCheckoutEvent.FieldFocusChanged
-import com.processout.sdk.ui.checkout.DynamicCheckoutEvent.FieldValueChanged
-import com.processout.sdk.ui.core.component.POAnimatedImage
-import com.processout.sdk.ui.core.component.POExpandableText
-import com.processout.sdk.ui.core.component.PORequestFocus
-import com.processout.sdk.ui.core.component.POText
+import com.processout.sdk.ui.checkout.DynamicCheckoutEvent.*
+import com.processout.sdk.ui.core.component.*
import com.processout.sdk.ui.core.component.field.POField
import com.processout.sdk.ui.core.component.field.checkbox.POCheckbox
import com.processout.sdk.ui.core.component.field.dropdown.PODropdownField
@@ -43,6 +39,25 @@ internal fun CardTokenization(
if (state.focusedFieldId == null) {
LocalFocusManager.current.clearFocus(force = true)
}
+ state.cardScannerAction?.let { action ->
+ POButton(
+ state = action,
+ onClick = {
+ onEvent(
+ Action(
+ actionId = it,
+ paymentMethodId = id
+ )
+ )
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .requiredHeightIn(min = dimensions.buttonIconSizeSmall)
+ .padding(bottom = spacing.small),
+ style = style.scanCardButton,
+ iconSize = dimensions.iconSizeSmall
+ )
+ }
val lifecycleEvent = rememberLifecycleEvent()
state.sections.elements.forEachIndexed { index, section ->
val padding = if (section.id == FUTURE_PAYMENTS) {
@@ -191,7 +206,7 @@ private fun TextField(
enabled = isPrimaryActionEnabled,
onClick = {
onEvent(
- DynamicCheckoutEvent.Action(
+ Action(
actionId = it,
paymentMethodId = id
)
diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/screen/DynamicCheckoutScreen.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/screen/DynamicCheckoutScreen.kt
index a1ecaed9d..5dc582707 100644
--- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/screen/DynamicCheckoutScreen.kt
+++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/screen/DynamicCheckoutScreen.kt
@@ -678,6 +678,7 @@ internal object DynamicCheckoutScreen {
val errorText: POText.Style,
val messageBox: POMessageBox.Style,
val dialog: PODialog.Style,
+ val scanCardButton: POButton.Style,
val actionsContainer: POActionsContainer.Style,
val backgroundColor: Color,
val progressIndicatorColor: Color,
@@ -751,6 +752,9 @@ internal object DynamicCheckoutScreen {
dialog = custom?.dialog?.let {
PODialog.custom(style = it)
} ?: PODialog.default,
+ scanCardButton = custom?.scanCardButton?.let {
+ POButton.custom(style = it)
+ } ?: POButton.secondary,
actionsContainer = custom?.actionsContainer?.let {
POActionsContainer.custom(style = it)
} ?: POActionsContainer.default,
diff --git a/ui/src/main/res/drawable/po_icon_camera.xml b/ui/src/main/res/drawable/po_icon_camera.xml
new file mode 100644
index 000000000..bbd415a37
--- /dev/null
+++ b/ui/src/main/res/drawable/po_icon_camera.xml
@@ -0,0 +1,20 @@
+
+
+
+