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 @@ + + + +