diff --git a/build.gradle b/build.gradle index 0addd97e1..e01aaa5d8 100644 --- a/build.gradle +++ b/build.gradle @@ -69,7 +69,7 @@ ext { mockitoInlineVersion = '5.2.0' mockitoKotlinVersion = '5.2.1' robolectricVersion = '4.11.1' - androidxTestCoreVersion = '1.5.0' + androidxTestCoreVersion = '1.6.1' // Legacy volleyVersion = '1.2.1' diff --git a/sdk/src/main/kotlin/com/processout/sdk/api/model/response/InvoiceResponse.kt b/sdk/src/main/kotlin/com/processout/sdk/api/model/response/InvoiceResponse.kt index 553bd82e3..a7c270322 100644 --- a/sdk/src/main/kotlin/com/processout/sdk/api/model/response/InvoiceResponse.kt +++ b/sdk/src/main/kotlin/com/processout/sdk/api/model/response/InvoiceResponse.kt @@ -1,5 +1,6 @@ package com.processout.sdk.api.model.response +import com.processout.sdk.core.annotation.ProcessOutInternalApi import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @@ -25,7 +26,7 @@ data class POInvoice( @Json(name = "return_url") val returnUrl: String?, @Json(name = "payment_methods") - internal val paymentMethods: List? + @ProcessOutInternalApi val paymentMethods: List? ) /** @@ -95,19 +96,7 @@ sealed class PODynamicCheckoutPaymentMethod { val name: String, val logo: POImageResource, @Json(name = "brand_color") - val brandColor: BrandColor - ) - - /** - * Brand color for light/dark themes. - * - * @param[light] Light color HEX. - * @param[dark] Dark color HEX. - */ - @JsonClass(generateAdapter = true) - data class BrandColor( - val light: String, - val dark: String + val brandColor: POColor ) /** @@ -135,7 +124,7 @@ sealed class PODynamicCheckoutPaymentMethod { * * @param[collectionMode] Billing address collection mode. * @param[restrictToCountryCodes] Set of ISO country codes that is supported for the billing address. - * When _null_, all countries are supported. + * When _null_, all countries are provided. */ @JsonClass(generateAdapter = true) data class BillingAddressConfiguration( @@ -170,15 +159,12 @@ sealed class PODynamicCheckoutPaymentMethod { * Alternative payment configuration. * * @param[gatewayConfigurationId] Gateway configuration ID. - * @param[gatewayName] Gateway name. * @param[redirectUrl] Redirect URL. If it's _null_, then payment should go through the native flow. */ @JsonClass(generateAdapter = true) data class AlternativePaymentConfiguration( - @Json(name = "gateway_configuration_uid") + @Json(name = "gateway_configuration_id") val gatewayConfigurationId: String, - @Json(name = "gateway_name") - val gatewayName: String, @Json(name = "redirect_url") val redirectUrl: String? ) diff --git a/sdk/src/main/kotlin/com/processout/sdk/api/model/response/POColor.kt b/sdk/src/main/kotlin/com/processout/sdk/api/model/response/POColor.kt new file mode 100644 index 000000000..6e776ca43 --- /dev/null +++ b/sdk/src/main/kotlin/com/processout/sdk/api/model/response/POColor.kt @@ -0,0 +1,15 @@ +package com.processout.sdk.api.model.response + +import com.squareup.moshi.JsonClass + +/** + * Color for light/dark themes. + * + * @param[light] Light color HEX. + * @param[dark] Dark color HEX. + */ +@JsonClass(generateAdapter = true) +data class POColor( + val light: String, + val dark: String +) diff --git a/sdk/src/main/kotlin/com/processout/sdk/ui/nativeapm/NativeAlternativePaymentMethodViewModel.kt b/sdk/src/main/kotlin/com/processout/sdk/ui/nativeapm/NativeAlternativePaymentMethodViewModel.kt index 8e7afc428..916ef05a0 100644 --- a/sdk/src/main/kotlin/com/processout/sdk/ui/nativeapm/NativeAlternativePaymentMethodViewModel.kt +++ b/sdk/src/main/kotlin/com/processout/sdk/ui/nativeapm/NativeAlternativePaymentMethodViewModel.kt @@ -9,7 +9,6 @@ import android.view.inputmethod.EditorInfo import androidx.annotation.StringRes import androidx.core.os.postDelayed import androidx.core.text.isDigitsOnly -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -51,7 +50,7 @@ import java.text.NumberFormat import java.util.Currency import java.util.concurrent.TimeUnit -internal class NativeAlternativePaymentMethodViewModel( +internal class NativeAlternativePaymentMethodViewModel private constructor( private val app: Application, private val gatewayConfigurationId: String, private val invoiceId: String, @@ -60,7 +59,7 @@ internal class NativeAlternativePaymentMethodViewModel( private val captureRetryStrategy: PORetryStrategy, val options: Options, val logAttributes: Map -) : AndroidViewModel(app) { +) : ViewModel() { class Factory( private val app: Application, @@ -735,7 +734,6 @@ internal class NativeAlternativePaymentMethodViewModel( } override fun onCleared() { - super.onCleared() handler.removeCallbacksAndMessages(null) } } diff --git a/sdk/src/main/kotlin/com/processout/sdk/ui/web/customtab/CustomTabAuthorizationViewModel.kt b/sdk/src/main/kotlin/com/processout/sdk/ui/web/customtab/CustomTabAuthorizationViewModel.kt index 558700888..3706d94cd 100644 --- a/sdk/src/main/kotlin/com/processout/sdk/ui/web/customtab/CustomTabAuthorizationViewModel.kt +++ b/sdk/src/main/kotlin/com/processout/sdk/ui/web/customtab/CustomTabAuthorizationViewModel.kt @@ -14,7 +14,7 @@ import com.processout.sdk.ui.web.customtab.CustomTabAuthorizationActivityContrac import com.processout.sdk.ui.web.customtab.CustomTabAuthorizationUiState.* import java.util.concurrent.TimeUnit -internal class CustomTabAuthorizationViewModel( +internal class CustomTabAuthorizationViewModel private constructor( private val savedState: SavedStateHandle, private val configuration: CustomTabConfiguration ) : ViewModel() { @@ -100,7 +100,6 @@ internal class CustomTabAuthorizationViewModel( } override fun onCleared() { - super.onCleared() timeoutHandler.removeCallbacksAndMessages(null) } } diff --git a/sdk/src/main/res/values-ar/strings.xml b/sdk/src/main/res/values-ar/strings.xml index a1a3858db..7ecc4c835 100644 --- a/sdk/src/main/res/values-ar/strings.xml +++ b/sdk/src/main/res/values-ar/strings.xml @@ -6,9 +6,7 @@ ادفع سيتم إعادة توجيهك لإتمام هذه الدفعة. - طريقة الدفع هذه غير متاحة. لم نتمكن من إتمام دفعتك. يرجى التحقق من التفاصيل أو تجربة طريقة دفع أخرى. - طريقة الدفع المطلوبة غير متاحة، يرجى تجربة طريقة دفع أخرى. الدفع بواسطة %s diff --git a/sdk/src/main/res/values-fr/strings.xml b/sdk/src/main/res/values-fr/strings.xml index 41fcea7d6..c36f1e292 100644 --- a/sdk/src/main/res/values-fr/strings.xml +++ b/sdk/src/main/res/values-fr/strings.xml @@ -3,6 +3,11 @@ fr + + Payer + Vous serez redirigé pour finaliser ce paiement. + Nous n\'avons pas pu finaliser votre paiement. Veuillez vérifier vos informations ou essayer un autre mode de paiement. + Payer avec %s Payer %s @@ -28,7 +33,7 @@ Envoyer Annuler Le code CVV de votre carte est incorrect. - Une erreur s’est produite, veuillez réessayer. + Une erreur s\'est produite, veuillez réessayer. Ajouter une nouvelle carte @@ -40,14 +45,14 @@ MM / AA 4242 4242 4242 4242 Adresse de Facturation - Ligne d’adresse %d + Ligne d\'adresse %d Les informations de votre carte sont incorrectes. - La date d’expiration de votre carte est incorrecte. + La date d\'expiration de votre carte est incorrecte. Votre numéro de carte est incorrect. Le nom du porteur de carte est incorrect. Le code CVV de votre carte est incorrect. - Une erreur s’est produite, veuillez réessayer. - La date d’expiration de votre carte ou son code CVV sont incorrects. + Une erreur s\'est produite, veuillez réessayer. + La date d\'expiration de votre carte ou son code CVV sont incorrects. Zone diff --git a/sdk/src/main/res/values-pl/strings.xml b/sdk/src/main/res/values-pl/strings.xml index 567ec1c76..c69ad69b1 100644 --- a/sdk/src/main/res/values-pl/strings.xml +++ b/sdk/src/main/res/values-pl/strings.xml @@ -6,9 +6,7 @@ Zapłać Zostaniesz przekierowany, aby sfinalizować tę płatność. - Ta metoda płatności nie jest obsługiwana. Nie byliśmy w stanie sfinalizować Twojej płatności. Zweryfikuj swoje dane lub wybierz inną metody płatności. - Wybrana metoda płatności nie jest obsługiwana. Proszę wybierz inną metodę. Zapłać przy pomocy %s diff --git a/sdk/src/main/res/values-pt/strings.xml b/sdk/src/main/res/values-pt/strings.xml index c9966e9b6..664cfbec8 100644 --- a/sdk/src/main/res/values-pt/strings.xml +++ b/sdk/src/main/res/values-pt/strings.xml @@ -6,9 +6,7 @@ Pagar Será redireccionado para finalizar este pagamento. - Este método de pagamento não está disponível. Não foi possível concluir o seu pagamento. Por favor, verifique os detalhes ou tente outro método de pagamento. - O método de pagamento escolhido não está disponível, por favor tente outro método de pagamento. Pagar com %s diff --git a/sdk/src/main/res/values/strings.xml b/sdk/src/main/res/values/strings.xml index a85ff8d91..2eba97a48 100644 --- a/sdk/src/main/res/values/strings.xml +++ b/sdk/src/main/res/values/strings.xml @@ -1,14 +1,12 @@ - + en Pay You will be redirected to finalise this payment. - This payment method is unavailable. We were unable to process your payment. Please check your payment details or try another payment method. - The requested payment method is not available, please try another payment method. Pay with %s 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 26a94d789..f31955cfe 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 @@ -47,6 +47,7 @@ internal class CardTokenizationBottomSheet : BaseBottomSheetDialogFragment finishWithActivityResult( + resultCode = Activity.RESULT_OK, + result = ProcessOutActivityResult.Success(POUnit) + ) + is Failure -> finishWithActivityResult( + resultCode = Activity.RESULT_CANCELED, + result = completion.failure.toActivityResult() + ) + else -> {} + } + + private fun finishWithActivityResult( + resultCode: Int, + result: ProcessOutActivityResult + ) { + setResult(resultCode, Intent().putExtra(EXTRA_RESULT, result)) + finish() + } + override fun finish() { super.finish() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 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 37e775b80..6ab41c923 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 @@ -3,12 +3,23 @@ package com.processout.sdk.ui.checkout import androidx.compose.ui.text.input.TextFieldValue import com.processout.sdk.core.ProcessOutResult +internal sealed interface DynamicCheckoutExtendedEvent : DynamicCheckoutEvent { + data class PaymentMethodSelected(val id: String) : DynamicCheckoutExtendedEvent + data class Dismiss(val failure: ProcessOutResult.Failure) : DynamicCheckoutExtendedEvent +} + internal sealed interface DynamicCheckoutEvent { - data class FieldValueChanged(val id: String, val value: TextFieldValue) : DynamicCheckoutEvent - data class FieldFocusChanged(val id: String, val isFocused: Boolean) : DynamicCheckoutEvent - data class Action(val id: String) : DynamicCheckoutEvent - data class ActionConfirmationRequested(val id: String) : DynamicCheckoutEvent - data class Dismiss(val failure: ProcessOutResult.Failure) : DynamicCheckoutEvent + data class FieldValueChanged( + val paymentMethodId: String, + val fieldId: String, + val value: TextFieldValue + ) : DynamicCheckoutEvent + + data class FieldFocusChanged( + val paymentMethodId: String, + val fieldId: String, + val isFocused: Boolean + ) : DynamicCheckoutEvent } internal sealed interface DynamicCheckoutCompletion { 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 new file mode 100644 index 000000000..59b6d0767 --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractor.kt @@ -0,0 +1,118 @@ +package com.processout.sdk.ui.checkout + +import android.app.Application +import com.processout.sdk.api.model.response.PODynamicCheckoutPaymentMethod +import com.processout.sdk.api.model.response.PODynamicCheckoutPaymentMethod.Flow.express +import com.processout.sdk.api.service.POInvoicesService +import com.processout.sdk.core.POFailure.Code.Generic +import com.processout.sdk.core.ProcessOutResult +import com.processout.sdk.core.logger.POLogger +import com.processout.sdk.core.onFailure +import com.processout.sdk.core.onSuccess +import com.processout.sdk.ui.base.BaseInteractor +import com.processout.sdk.ui.checkout.DynamicCheckoutCompletion.Awaiting +import com.processout.sdk.ui.checkout.DynamicCheckoutCompletion.Failure +import com.processout.sdk.ui.checkout.DynamicCheckoutExtendedEvent.Dismiss +import com.processout.sdk.ui.checkout.DynamicCheckoutExtendedEvent.PaymentMethodSelected +import com.processout.sdk.ui.checkout.DynamicCheckoutInteractorState.PaymentMethod +import com.processout.sdk.ui.checkout.DynamicCheckoutInteractorState.PaymentMethod.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal class DynamicCheckoutInteractor( + private val app: Application, + private val invoiceId: String, + private val invoicesService: POInvoicesService +) : BaseInteractor() { + + private val _completion = MutableStateFlow(Awaiting) + val completion = _completion.asStateFlow() + + private val _state = MutableStateFlow(initState()) + val state = _state.asStateFlow() + + init { + fetchConfiguration() + } + + private fun initState() = DynamicCheckoutInteractorState( + loading = true, + paymentMethods = emptyList(), + selectedPaymentMethodId = null + ) + + private fun fetchConfiguration() { + interactorScope.launch { + invoicesService.invoice(invoiceId) + .onSuccess { invoice -> + val paymentMethods = invoice.paymentMethods + if (paymentMethods.isNullOrEmpty()) { + _completion.update { + Failure( + ProcessOutResult.Failure( + code = Generic(), + message = "Missing remote configuration." + ) + ) + } + return@launch + } + _state.update { + it.copy( + loading = false, + paymentMethods = paymentMethods.map() + ) + } + }.onFailure { failure -> + _completion.update { Failure(failure) } + } + } + } + + private fun List.map(): List = + mapNotNull { + when (it) { + is PODynamicCheckoutPaymentMethod.Card -> Card( + configuration = it.configuration, + display = it.display + ) + is PODynamicCheckoutPaymentMethod.GooglePay -> GooglePay( + configuration = it.configuration + ) + is PODynamicCheckoutPaymentMethod.AlternativePayment -> { + val redirectUrl = it.configuration.redirectUrl + if (redirectUrl != null) { + AlternativePayment( + redirectUrl = redirectUrl, + display = it.display, + isExpress = it.flow == express + ) + } else { + NativeAlternativePayment( + gatewayConfigurationId = it.configuration.gatewayConfigurationId, + display = it.display + ) + } + } + else -> null + } + } + + fun paymentMethod(id: String): PaymentMethod? = + _state.value.paymentMethods.find { it.id == id } + + fun onEvent(event: DynamicCheckoutExtendedEvent) { + when (event) { + is PaymentMethodSelected -> + _state.update { + it.copy(selectedPaymentMethodId = event.id) + } + is Dismiss -> { + POLogger.info("Dismissed: %s", event.failure) + _completion.update { Failure(event.failure) } + } + } + } +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractorState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractorState.kt new file mode 100644 index 000000000..0966e902c --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutInteractorState.kt @@ -0,0 +1,40 @@ +package com.processout.sdk.ui.checkout + +import com.processout.sdk.api.model.response.PODynamicCheckoutPaymentMethod.* +import java.util.UUID + +internal data class DynamicCheckoutInteractorState( + val loading: Boolean, + val paymentMethods: List, + val selectedPaymentMethodId: String? +) { + + sealed interface PaymentMethod { + + val id: String + + data class Card( + override val id: String = UUID.randomUUID().toString(), + val configuration: CardConfiguration, + val display: Display + ) : PaymentMethod + + data class GooglePay( + override val id: String = UUID.randomUUID().toString(), + val configuration: GooglePayConfiguration + ) : PaymentMethod + + data class AlternativePayment( + override val id: String = UUID.randomUUID().toString(), + val redirectUrl: String, + val display: Display, + val isExpress: Boolean + ) : PaymentMethod + + data class NativeAlternativePayment( + override val id: String = UUID.randomUUID().toString(), + val gatewayConfigurationId: String, + val display: Display + ) : PaymentMethod + } +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutScreen.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutScreen.kt index 30917c342..b4257454d 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutScreen.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutScreen.kt @@ -5,15 +5,26 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import com.processout.sdk.ui.checkout.DynamicCheckoutExtendedEvent.PaymentMethodSelected +import com.processout.sdk.ui.checkout.DynamicCheckoutViewModelState.Started +import com.processout.sdk.ui.core.component.field.POField +import com.processout.sdk.ui.core.component.field.radio.PORadioGroup +import com.processout.sdk.ui.core.state.POAvailableValue +import com.processout.sdk.ui.core.state.POImmutableList import com.processout.sdk.ui.core.theme.ProcessOutTheme import com.processout.sdk.ui.shared.component.isImeVisibleAsState @Composable -internal fun DynamicCheckoutScreen() { +internal fun DynamicCheckoutScreen( + state: DynamicCheckoutViewModelState, + onEvent: (DynamicCheckoutEvent) -> Unit, + style: DynamicCheckoutScreen.Style = DynamicCheckoutScreen.style() +) { Column { Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars)) Scaffold( @@ -28,7 +39,13 @@ internal fun DynamicCheckoutScreen() { .padding(scaffoldPadding) .verticalScroll(rememberScrollState()) ) { - Content() + when (state) { + is Started -> Content( + state = state, + onEvent = onEvent + ) + else -> {} + } } } } @@ -46,8 +63,27 @@ private fun Header() { } @Composable -private fun Content() { +private fun Content( + state: Started, + onEvent: (DynamicCheckoutEvent) -> Unit +) { // TODO + val selectedPaymentId = state.regularPayments.elements + .find { it.state.selected }?.id ?: String() + val regularPayments = state.regularPayments.elements + .map { regularPayment -> + POAvailableValue( + value = regularPayment.id, + text = regularPayment.state.name + ) + } + PORadioGroup( + value = selectedPaymentId, + onValueChange = { + onEvent(PaymentMethodSelected(id = it)) + }, + availableValues = POImmutableList(regularPayments) + ) } @Composable @@ -71,3 +107,18 @@ private fun Footer() { ) } } + +internal object DynamicCheckoutScreen { + + @Immutable + data class Style( + val field: POField.Style + ) + + @Composable + fun style(custom: PODynamicCheckoutConfiguration.Style? = null) = Style( + field = custom?.field?.let { + POField.custom(style = it) + } ?: POField.default + ) +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModel.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModel.kt new file mode 100644 index 000000000..7702b53e5 --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModel.kt @@ -0,0 +1,265 @@ +package com.processout.sdk.ui.checkout + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.processout.sdk.R +import com.processout.sdk.api.ProcessOut +import com.processout.sdk.api.model.response.POBillingAddressCollectionMode +import com.processout.sdk.api.model.response.POBillingAddressCollectionMode.* +import com.processout.sdk.api.model.response.PODynamicCheckoutPaymentMethod.CardConfiguration +import com.processout.sdk.api.model.response.PODynamicCheckoutPaymentMethod.Display +import com.processout.sdk.ui.card.tokenization.CardTokenizationEvent +import com.processout.sdk.ui.card.tokenization.CardTokenizationViewModel +import com.processout.sdk.ui.card.tokenization.CardTokenizationViewModelState +import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration +import com.processout.sdk.ui.card.tokenization.POCardTokenizationConfiguration.BillingAddressConfiguration.CollectionMode +import com.processout.sdk.ui.checkout.DynamicCheckoutCompletion.Awaiting +import com.processout.sdk.ui.checkout.DynamicCheckoutEvent.FieldFocusChanged +import com.processout.sdk.ui.checkout.DynamicCheckoutEvent.FieldValueChanged +import com.processout.sdk.ui.checkout.DynamicCheckoutExtendedEvent.PaymentMethodSelected +import com.processout.sdk.ui.checkout.DynamicCheckoutInteractorState.PaymentMethod.* +import com.processout.sdk.ui.checkout.DynamicCheckoutViewModelState.* +import com.processout.sdk.ui.checkout.DynamicCheckoutViewModelState.RegularPayment.Content +import com.processout.sdk.ui.checkout.PODynamicCheckoutConfiguration.Options +import com.processout.sdk.ui.core.state.POActionState +import com.processout.sdk.ui.core.state.POImmutableList +import com.processout.sdk.ui.napm.NativeAlternativePaymentEvent +import com.processout.sdk.ui.napm.NativeAlternativePaymentViewModel +import com.processout.sdk.ui.napm.NativeAlternativePaymentViewModelState +import com.processout.sdk.ui.napm.NativeAlternativePaymentViewModelState.Loading +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn + +internal class DynamicCheckoutViewModel private constructor( + private val app: Application, + private val invoiceId: String, + private val options: Options, + private val interactor: DynamicCheckoutInteractor, + private val cardTokenization: CardTokenizationViewModel, + private val nativeAlternativePayment: NativeAlternativePaymentViewModel +) : ViewModel() { + + class Factory( + private val app: Application, + private val invoiceId: String, + private val options: Options, + private val cardTokenization: CardTokenizationViewModel, + private val nativeAlternativePayment: NativeAlternativePaymentViewModel + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + DynamicCheckoutViewModel( + app = app, + invoiceId = invoiceId, + options = options, + interactor = DynamicCheckoutInteractor( + app = app, + invoiceId = invoiceId, + invoicesService = ProcessOut.instance.invoices + ), + cardTokenization = cardTokenization, + nativeAlternativePayment = nativeAlternativePayment + ) as T + } + + val completion: StateFlow = combine( + interactor.completion, + cardTokenization.completion, + nativeAlternativePayment.completion + ) { interactorCompletion, cardTokenizationCompletion, nativeAlternativePaymentCompletion -> + // TODO: combine completions + interactorCompletion + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = Awaiting + ) + + val state: StateFlow = combine( + interactor.state, + cardTokenization.state, + nativeAlternativePayment.state + ) { interactorState, cardTokenizationState, nativeAlternativePaymentState -> + combine(interactorState, cardTokenizationState, nativeAlternativePaymentState) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = Starting + ) + + init { + addCloseable(interactor.interactorScope) + } + + fun onEvent(event: DynamicCheckoutEvent) { + when (event) { + is PaymentMethodSelected -> onPaymentMethodSelected(event) + is FieldValueChanged -> onFieldValueChanged(event) + is FieldFocusChanged -> onFieldFocusChanged(event) + else -> {} + } + if (event is DynamicCheckoutExtendedEvent) { + interactor.onEvent(event) + } + } + + private fun onPaymentMethodSelected(event: PaymentMethodSelected) { + if (event.id != interactor.state.value.selectedPaymentMethodId) { + interactor.paymentMethod(event.id)?.let { paymentMethod -> + cardTokenization.reset() + nativeAlternativePayment.reset() + when (paymentMethod) { + is Card -> cardTokenization.start( + configuration = cardTokenization.configuration + .apply(paymentMethod.configuration) + ) + is NativeAlternativePayment -> nativeAlternativePayment.start( + invoiceId = invoiceId, + gatewayConfigurationId = paymentMethod.gatewayConfigurationId + ) + else -> {} + } + } + } + } + + private fun POCardTokenizationConfiguration.apply( + configuration: CardConfiguration + ) = copy( + isCardholderNameFieldVisible = configuration.requireCardholderName, + billingAddress = billingAddress.copy( + mode = configuration.billingAddress.collectionMode.map(), + countryCodes = configuration.billingAddress.restrictToCountryCodes + ) + ) + + private fun POBillingAddressCollectionMode.map() = when (this) { + full -> CollectionMode.Full + automatic -> CollectionMode.Automatic + never -> CollectionMode.Never + } + + private fun onFieldValueChanged(event: FieldValueChanged) { + val paymentMethod = interactor.paymentMethod(event.paymentMethodId) + when (paymentMethod) { + is Card -> cardTokenization.onEvent( + CardTokenizationEvent.FieldValueChanged(event.fieldId, event.value) + ) + is NativeAlternativePayment -> nativeAlternativePayment.onEvent( + NativeAlternativePaymentEvent.FieldValueChanged(event.fieldId, event.value) + ) + else -> {} + } + } + + private fun onFieldFocusChanged(event: FieldFocusChanged) { + val paymentMethod = interactor.paymentMethod(event.paymentMethodId) + when (paymentMethod) { + is Card -> cardTokenization.onEvent( + CardTokenizationEvent.FieldFocusChanged(event.fieldId, event.isFocused) + ) + is NativeAlternativePayment -> nativeAlternativePayment.onEvent( + NativeAlternativePaymentEvent.FieldFocusChanged(event.fieldId, event.isFocused) + ) + else -> {} + } + } + + private fun combine( + interactorState: DynamicCheckoutInteractorState, + cardTokenizationState: CardTokenizationViewModelState, + nativeAlternativePaymentState: NativeAlternativePaymentViewModelState + ): DynamicCheckoutViewModelState = + if (interactorState.loading) { + Starting + } else { + Started( + expressPayments = expressPayments(interactorState), + regularPayments = regularPayments(interactorState, cardTokenizationState, nativeAlternativePaymentState) + ) + } + + private fun expressPayments( + interactorState: DynamicCheckoutInteractorState + ): POImmutableList = + interactorState.paymentMethods.mapNotNull { paymentMethod -> + val id = paymentMethod.id + when (paymentMethod) { + is GooglePay -> ExpressPayment.GooglePay(id = id) + is AlternativePayment -> if (paymentMethod.isExpress) { + ExpressPayment.Express( + id = id, + name = paymentMethod.display.name, + logoResource = paymentMethod.display.logo, + brandColor = paymentMethod.display.brandColor + ) + } else null + else -> null + } + }.let { POImmutableList(it) } + + private fun regularPayments( + interactorState: DynamicCheckoutInteractorState, + cardTokenizationState: CardTokenizationViewModelState, + nativeAlternativePaymentState: NativeAlternativePaymentViewModelState + ): POImmutableList = + interactorState.paymentMethods.mapNotNull { paymentMethod -> + val id = paymentMethod.id + val selected = id == interactorState.selectedPaymentMethodId + when (paymentMethod) { + is Card -> RegularPayment( + id = id, + state = regularPaymentState( + display = paymentMethod.display, + selected = selected + ), + content = Content.Card(cardTokenizationState), + action = null + ) + is AlternativePayment -> if (!paymentMethod.isExpress) + RegularPayment( + id = id, + state = regularPaymentState( + display = paymentMethod.display, + description = app.getString(R.string.po_dynamic_checkout_warning_redirect), + selected = selected + ), + content = null, + action = POActionState( + id = id, + text = app.getString(R.string.po_dynamic_checkout_button_pay), + primary = true + ) + ) else null + is NativeAlternativePayment -> RegularPayment( + id = id, + state = regularPaymentState( + display = paymentMethod.display, + loading = nativeAlternativePaymentState is Loading, + selected = selected + ), + content = Content.NativeAlternativePayment(nativeAlternativePaymentState), + action = null + ) + else -> null + } + }.let { POImmutableList(it) } + + private fun regularPaymentState( + display: Display, + description: String? = null, + loading: Boolean = false, + selected: Boolean + ) = RegularPayment.State( + name = display.name, + logoResource = display.logo, + description = description, + loading = loading, + selectable = true, // TODO + selected = selected + ) +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModelState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModelState.kt new file mode 100644 index 000000000..d5c74d465 --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/checkout/DynamicCheckoutViewModelState.kt @@ -0,0 +1,75 @@ +package com.processout.sdk.ui.checkout + +import androidx.compose.runtime.Immutable +import com.processout.sdk.api.model.response.POColor +import com.processout.sdk.api.model.response.POImageResource +import com.processout.sdk.ui.card.tokenization.CardTokenizationViewModelState +import com.processout.sdk.ui.core.state.POActionState +import com.processout.sdk.ui.core.state.POImmutableList +import com.processout.sdk.ui.napm.NativeAlternativePaymentViewModelState + +@Immutable +internal sealed interface DynamicCheckoutViewModelState { + + //region States + + data object Starting : DynamicCheckoutViewModelState + + @Immutable + data class Started( + val expressPayments: POImmutableList, + val regularPayments: POImmutableList + ) : DynamicCheckoutViewModelState + + @Immutable + data class Success( + val message: String + ) : DynamicCheckoutViewModelState + + //endregion + + @Immutable + sealed interface ExpressPayment { + @Immutable + data class GooglePay( + val id: String + ) : ExpressPayment + + @Immutable + data class Express( + val id: String, + val name: String, + val logoResource: POImageResource, + val brandColor: POColor + ) : ExpressPayment + } + + @Immutable + data class RegularPayment( + val id: String, + val state: State, + val content: Content?, + val action: POActionState? + ) { + @Immutable + data class State( + val name: String, + val logoResource: POImageResource, + val description: String?, + val loading: Boolean, + val selectable: Boolean, + val selected: Boolean + ) + + @Immutable + sealed interface Content { + data class Card( + val state: CardTokenizationViewModelState + ) : Content + + data class NativeAlternativePayment( + val state: NativeAlternativePaymentViewModelState + ) : Content + } + } +} 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 32b28ef0c..f945affce 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,9 +9,16 @@ import kotlinx.parcelize.Parcelize @ProcessOutInternalApi @Parcelize data class PODynamicCheckoutConfiguration( - val invoiceId: String + val invoiceId: String, + val options: Options = Options(), + val style: Style? = null ) : Parcelable { + @Parcelize + data class Options( + val title: String? = null + ) : Parcelable + @Parcelize data class Style( val field: POFieldStyle? = null diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentBottomSheet.kt b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentBottomSheet.kt index 762fa3386..95d2ddeb7 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentBottomSheet.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentBottomSheet.kt @@ -62,8 +62,10 @@ internal class NativeAlternativePaymentBottomSheet : BaseBottomSheetDialogFragme message = "Invalid configuration." ) ) + return } } + viewModel.start() } override fun onCreateView( diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentInteractor.kt b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentInteractor.kt index b547cbcd0..3dc3e1f36 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentInteractor.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentInteractor.kt @@ -29,6 +29,7 @@ import com.processout.sdk.core.POFailure.InvalidField import com.processout.sdk.core.POFailure.ValidationCode import com.processout.sdk.core.ProcessOutResult import com.processout.sdk.core.fold +import com.processout.sdk.core.logger.POLogAttribute import com.processout.sdk.core.logger.POLogger import com.processout.sdk.core.onFailure import com.processout.sdk.core.onSuccess @@ -50,17 +51,28 @@ import kotlinx.coroutines.flow.update internal class NativeAlternativePaymentInteractor( private val app: Application, - private val invoiceId: String, - private val gatewayConfigurationId: String, + private var invoiceId: String, + private var gatewayConfigurationId: String, private val options: Options, private val invoicesService: POInvoicesService, private val captureRetryStrategy: PORetryStrategy, private val eventDispatcher: PODefaultNativeAlternativePaymentMethodEventDispatcher, - private val logAttributes: Map + private var logAttributes: Map = logAttributes( + invoiceId = invoiceId, + gatewayConfigurationId = gatewayConfigurationId + ) ) : BaseInteractor() { - companion object { + private companion object { const val SUCCESS_DELAY_MS = 3000L + + fun logAttributes( + invoiceId: String, + gatewayConfigurationId: String + ): Map = mapOf( + POLogAttribute.INVOICE_ID to invoiceId, + POLogAttribute.GATEWAY_CONFIGURATION_ID to gatewayConfigurationId + ) } private val _completion = MutableStateFlow(Awaiting) @@ -76,7 +88,7 @@ internal class NativeAlternativePaymentInteractor( private var captureStartTimestamp = 0L private var capturePassedTimestamp = 0L - init { + fun start() { POLogger.info("Starting native alternative payment.") dispatch(WillStart) dispatchFailure() @@ -84,6 +96,29 @@ internal class NativeAlternativePaymentInteractor( fetchTransactionDetails() } + fun start( + invoiceId: String, + gatewayConfigurationId: String + ) { + this.invoiceId = invoiceId + this.gatewayConfigurationId = gatewayConfigurationId + logAttributes = logAttributes( + invoiceId = invoiceId, + gatewayConfigurationId = gatewayConfigurationId + ) + start() + } + + fun reset() { + onCleared() + interactorScope.coroutineContext.cancelChildren() + latestDefaultValuesRequest = null + captureStartTimestamp = 0L + capturePassedTimestamp = 0L + _completion.update { Awaiting } + _state.update { Loading } + } + private fun fetchTransactionDetails() { interactorScope.launch { invoicesService.fetchNativeAlternativePaymentMethodTransactionDetails( diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentViewModel.kt b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentViewModel.kt index b6fcaa273..03adc7552 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentViewModel.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentViewModel.kt @@ -15,7 +15,6 @@ import com.processout.sdk.api.dispatcher.napm.PODefaultNativeAlternativePaymentM import com.processout.sdk.api.model.response.PONativeAlternativePaymentMethodParameter.ParameterType import com.processout.sdk.api.model.response.PONativeAlternativePaymentMethodParameter.ParameterType.* import com.processout.sdk.api.model.response.PONativeAlternativePaymentMethodTransactionDetails.Invoice -import com.processout.sdk.core.logger.POLogAttribute import com.processout.sdk.core.retry.PORetryStrategy.Exponential import com.processout.sdk.ui.core.state.POActionState import com.processout.sdk.ui.core.state.POActionState.Confirmation @@ -34,7 +33,7 @@ import com.processout.sdk.ui.shared.transformation.PhoneNumberVisualTransformati import java.text.NumberFormat import java.util.Currency -internal class NativeAlternativePaymentViewModel( +internal class NativeAlternativePaymentViewModel private constructor( private val app: Application, private val options: Options, private val interactor: NativeAlternativePaymentInteractor @@ -64,11 +63,7 @@ internal class NativeAlternativePaymentViewModel( maxDelay = 90 * 1000, factor = 1.45 ), - eventDispatcher = PODefaultNativeAlternativePaymentMethodEventDispatcher, - logAttributes = mapOf( - POLogAttribute.INVOICE_ID to invoiceId, - POLogAttribute.GATEWAY_CONFIGURATION_ID to gatewayConfigurationId - ) + eventDispatcher = PODefaultNativeAlternativePaymentMethodEventDispatcher ) ) as T @@ -100,6 +95,18 @@ internal class NativeAlternativePaymentViewModel( addCloseable(interactor.interactorScope) } + fun start() = interactor.start() + + fun start( + invoiceId: String, + gatewayConfigurationId: String + ) = interactor.start( + invoiceId = invoiceId, + gatewayConfigurationId = gatewayConfigurationId + ) + + fun reset() = interactor.reset() + fun onEvent(event: NativeAlternativePaymentEvent) = interactor.onEvent(event) private fun map(