diff --git a/build.gradle b/build.gradle index 15e90b9c4..8fd7fd87e 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ buildscript { ext { - androidGradlePluginVersion = '8.4.0' + androidGradlePluginVersion = '8.4.1' kotlinVersion = '1.9.24' kspVersion = '1.9.24-1.0.20' dokkaVersion = '1.9.20' @@ -59,6 +59,7 @@ ext { okioVersion = '3.9.0' coilVersion = '2.6.0' commonMarkVersion = '0.22.0' + libphonenumberVersion = '8.13.37' checkout3dsSdkVersion = '3.2.2' adyen3dsSdkVersion = '2.2.15' diff --git a/sdk/src/main/kotlin/com/processout/sdk/api/model/request/PONativeAlternativePaymentMethodDefaultValuesRequest.kt b/sdk/src/main/kotlin/com/processout/sdk/api/model/request/PONativeAlternativePaymentMethodDefaultValuesRequest.kt index e3d237597..8143aab4d 100644 --- a/sdk/src/main/kotlin/com/processout/sdk/api/model/request/PONativeAlternativePaymentMethodDefaultValuesRequest.kt +++ b/sdk/src/main/kotlin/com/processout/sdk/api/model/request/PONativeAlternativePaymentMethodDefaultValuesRequest.kt @@ -1,6 +1,7 @@ package com.processout.sdk.api.model.request import com.processout.sdk.api.model.response.PONativeAlternativePaymentMethodParameter +import com.processout.sdk.core.annotation.ProcessOutInternalApi import java.util.UUID /** @@ -11,7 +12,7 @@ import java.util.UUID * @param[parameters] Collection of parameters that can be inspected to decide if default values should be provided. * @param[uuid] Unique identifier of request. */ -data class PONativeAlternativePaymentMethodDefaultValuesRequest internal constructor( +data class PONativeAlternativePaymentMethodDefaultValuesRequest @ProcessOutInternalApi constructor( val gatewayConfigurationId: String, val invoiceId: String, val parameters: List, diff --git a/sdk/src/main/kotlin/com/processout/sdk/core/util/MarkdownUtils.kt b/sdk/src/main/kotlin/com/processout/sdk/core/util/MarkdownUtils.kt deleted file mode 100644 index f38d0af69..000000000 --- a/sdk/src/main/kotlin/com/processout/sdk/core/util/MarkdownUtils.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.processout.sdk.core.util - -private const val ESCAPE_CHAR = '\\' -private const val MARKDOWN_SPECIAL_CHARS = "\\`*_{}[]()#+-.!" - -internal fun escapedMarkdown(text: String?): String? = - text?.let { - StringBuilder().let { - text.forEach { char -> - if (MARKDOWN_SPECIAL_CHARS.contains(char)) { - it.append(ESCAPE_CHAR) - } - it.append(char) - } - it.toString() - } - } diff --git a/sdk/src/main/kotlin/com/processout/sdk/core/util/POMarkdownUtils.kt b/sdk/src/main/kotlin/com/processout/sdk/core/util/POMarkdownUtils.kt new file mode 100644 index 000000000..865c8e8b6 --- /dev/null +++ b/sdk/src/main/kotlin/com/processout/sdk/core/util/POMarkdownUtils.kt @@ -0,0 +1,20 @@ +package com.processout.sdk.core.util + +import com.processout.sdk.core.annotation.ProcessOutInternalApi + +/** @suppress */ +@ProcessOutInternalApi +object POMarkdownUtils { + + private const val ESCAPE_CHAR = '\\' + private const val MARKDOWN_SPECIAL_CHARS = "\\`*_{}[]()#+-.!" + + fun escapedMarkdown(text: String) = buildString { + text.forEach { char -> + if (MARKDOWN_SPECIAL_CHARS.contains(char)) { + append(ESCAPE_CHAR) + } + append(char) + } + } +} 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 87abc70e9..898ab8855 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 @@ -35,7 +35,7 @@ import com.processout.sdk.core.ProcessOutResult import com.processout.sdk.core.logger.POLogger import com.processout.sdk.core.retry.PORetryStrategy import com.processout.sdk.core.retry.PORetryStrategy.Exponential -import com.processout.sdk.core.util.escapedMarkdown +import com.processout.sdk.core.util.POMarkdownUtils.escapedMarkdown import com.processout.sdk.ui.nativeapm.NativeAlternativePaymentMethodUiState.* import com.processout.sdk.ui.nativeapm.PONativeAlternativePaymentMethodConfiguration.* import com.processout.sdk.ui.nativeapm.PONativeAlternativePaymentMethodConfiguration.Options.Companion.DEFAULT_PAYMENT_CONFIRMATION_TIMEOUT_SECONDS @@ -603,7 +603,7 @@ internal class NativeAlternativePaymentMethodViewModel( inputParameters = parameters?.toInputParameters() ?: emptyList(), successMessage = options.successMessage ?: app.getString(R.string.po_native_apm_success_message), - customerActionMessageMarkdown = escapedMarkdown(gateway.customerActionMessage), + customerActionMessageMarkdown = gateway.customerActionMessage?.let { escapedMarkdown(it) }, customerActionImageUrl = gateway.customerActionImageUrl, primaryActionText = options.primaryActionText ?: invoice.formatPrimaryActionText(), secondaryAction = options.secondaryAction?.toUiModel(), @@ -658,8 +658,8 @@ internal class NativeAlternativePaymentMethodViewModel( private fun getInputHint(type: ParameterType) = when (type) { - PHONE -> app.getString(R.string.po_native_apm_input_hint_phone) - EMAIL -> app.getString(R.string.po_native_apm_input_hint_email) + EMAIL -> app.getString(R.string.po_native_apm_email_placeholder) + PHONE -> app.getString(R.string.po_native_apm_phone_placeholder) else -> null } diff --git a/sdk/src/main/res/values-pl/strings.xml b/sdk/src/main/res/values-pl/strings.xml index f5ca02705..646d37f21 100644 --- a/sdk/src/main/res/values-pl/strings.xml +++ b/sdk/src/main/res/values-pl/strings.xml @@ -12,8 +12,8 @@ Anuluj płatność Nie teraz Sukces!\nPłatność przyjęta. - Twój numer telefonu - imię@przykład.pl + imię@przykład.pl + Twój numer telefonu Parametr jest wmagany. Niepoprawny numer. Niepoprawna wartość. diff --git a/sdk/src/main/res/values-pt/strings.xml b/sdk/src/main/res/values-pt/strings.xml index dbb523892..43e0fd0f4 100644 --- a/sdk/src/main/res/values-pt/strings.xml +++ b/sdk/src/main/res/values-pt/strings.xml @@ -12,8 +12,8 @@ Cancelar pagamento Agora não Successo!\nPagamento aprovado. - Insira o seu número de telemóvel - nome@exemplo.pt + nome@exemplo.pt + Insira o seu número de telemóvel Campo obrigatório. Número inválido. Texto inválido. diff --git a/sdk/src/main/res/values/strings.xml b/sdk/src/main/res/values/strings.xml index 449df48b1..8f46ba21f 100644 --- a/sdk/src/main/res/values/strings.xml +++ b/sdk/src/main/res/values/strings.xml @@ -12,8 +12,8 @@ Cancel payment Not now Success!\nPayment approved. - Enter phone number - name@example.com + name@example.com + Enter phone number Parameter is required. Number is not valid. Value is not valid. diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POHeader.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POHeader.kt index 2c8666c28..90ea1724f 100644 --- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POHeader.kt +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/POHeader.kt @@ -65,7 +65,7 @@ private fun titlePadding( if (withDragHandle) PaddingValues( start = spacing.extraLarge, end = spacing.extraLarge, - top = spacing.small, + top = spacing.medium, bottom = spacing.large ) else PaddingValues( horizontal = spacing.extraLarge, diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/code/POCodeField.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/code/POCodeField.kt index f51384a6d..e07aedd01 100644 --- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/code/POCodeField.kt +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/code/POCodeField.kt @@ -17,13 +17,11 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.key.* -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalTextToolbar -import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.* import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.LayoutDirection import androidx.lifecycle.Lifecycle import com.processout.sdk.ui.core.annotation.ProcessOutInternalApi import com.processout.sdk.ui.core.component.PORequestFocus @@ -44,130 +42,134 @@ fun POCodeField( style: POField.Style = POCodeField.default, length: Int = POCodeField.LengthMax, horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + enabled: Boolean = true, isError: Boolean = false, isFocused: Boolean = false, lifecycleEvent: Lifecycle.Event? = null, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default ) { - Row( - modifier = Modifier - .fillMaxWidth() - .focusGroup(), - horizontalArrangement = Arrangement.spacedBy( - space = ProcessOutTheme.spacing.small, - alignment = horizontalAlignment - ), - verticalAlignment = Alignment.CenterVertically - ) { - val validLength by remember { mutableIntStateOf(validLength(length)) } - var values by remember { mutableStateOf(values(value.text, validLength)) } - var focusedIndex by remember { mutableIntStateOf(values.focusedIndex()) } - val clipboardManager = LocalClipboardManager.current - CompositionLocalProvider( - LocalTextToolbar provides ProcessOutTextToolbar( - view = LocalView.current, - onPasteRequested = { - if (clipboardManager.hasText()) { - val pastedValues = values( - text = clipboardManager.getText()?.text ?: String(), - length = validLength - ) - if (!pastedValues.all { it.text.isEmpty() }) { - values = pastedValues - focusedIndex = values.focusedIndex() - onValueChange(values.textFieldValue()) - } - } - }, - hideUnspecifiedActions = true - ) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Row( + modifier = Modifier + .fillMaxWidth() + .focusGroup(), + horizontalArrangement = Arrangement.spacedBy( + space = ProcessOutTheme.spacing.small, + alignment = horizontalAlignment + ), + verticalAlignment = Alignment.CenterVertically ) { - val focusManager = LocalFocusManager.current - for (textFieldIndex in values.indices) { - val focusRequester = remember { FocusRequester() } - POTextField( - value = values.getOrNull(textFieldIndex) ?: TextFieldValue(), - onValueChange = { updatedValue -> - if (updatedValue.selection.length == 0) { - val filteredText = updatedValue.text.filter { it.isDigit() } - values = values.mapIndexed { valueIndex, textFieldValue -> - if (valueIndex == textFieldIndex) { - val updatedText = filteredText.firstOrNull()?.toString() ?: String() - val isTextChanged = textFieldValue.text != updatedText - TextFieldValue( - text = updatedText, - selection = if (isTextChanged) { - TextRange(updatedText.length) - } else { - updatedValue.selection - } - ) - } else { - textFieldValue.copy(selection = TextRange.Zero) - } + val validLength by remember { mutableIntStateOf(validLength(length)) } + var values by remember { mutableStateOf(values(value.text, validLength)) } + var focusedIndex by remember { mutableIntStateOf(values.focusedIndex()) } + val clipboardManager = LocalClipboardManager.current + CompositionLocalProvider( + LocalTextToolbar provides ProcessOutTextToolbar( + view = LocalView.current, + onPasteRequested = { + if (clipboardManager.hasText()) { + val pastedValues = values( + text = clipboardManager.getText()?.text ?: String(), + length = validLength + ) + if (!pastedValues.all { it.text.isEmpty() }) { + values = pastedValues + focusedIndex = values.focusedIndex() + onValueChange(values.textFieldValue()) } - if (textFieldIndex != values.lastIndex && - filteredText.length == 2 && - updatedValue.selection.start == 2 - ) { - val nextText = filteredText.last().toString() - values = values.mapIndexed { index, textFieldValue -> - if (index == textFieldIndex + 1) { + } + }, + hideUnspecifiedActions = true + ) + ) { + val focusManager = LocalFocusManager.current + for (textFieldIndex in values.indices) { + val focusRequester = remember { FocusRequester() } + POTextField( + value = values.getOrNull(textFieldIndex) ?: TextFieldValue(), + onValueChange = { updatedValue -> + if (updatedValue.selection.length == 0) { + val filteredText = updatedValue.text.filter { it.isDigit() } + values = values.mapIndexed { valueIndex, textFieldValue -> + if (valueIndex == textFieldIndex) { + val updatedText = filteredText.firstOrNull()?.toString() ?: String() + val isTextChanged = textFieldValue.text != updatedText TextFieldValue( - text = nextText, - selection = TextRange(nextText.length) + text = updatedText, + selection = if (isTextChanged) { + TextRange(updatedText.length) + } else { + updatedValue.selection + } ) } else { textFieldValue.copy(selection = TextRange.Zero) } } + if (textFieldIndex != values.lastIndex && + filteredText.length == 2 && + updatedValue.selection.start == 2 + ) { + val nextText = filteredText.last().toString() + values = values.mapIndexed { index, textFieldValue -> + if (index == textFieldIndex + 1) { + TextFieldValue( + text = nextText, + selection = TextRange(nextText.length) + ) + } else { + textFieldValue.copy(selection = TextRange.Zero) + } + } + } + onValueChange(values.textFieldValue()) } - onValueChange(values.textFieldValue()) - } - }, - modifier = modifier - .requiredWidth(ProcessOutTheme.dimensions.formComponentHeight) - .onPreviewKeyEvent { - when { - it.key == Key.Backspace && it.type == KeyEventType.KeyDown -> { - if (textFieldIndex != 0 && values[textFieldIndex].selection.start == 0) { - values = values.mapIndexed { index, textFieldValue -> - if (index == textFieldIndex - 1) { - TextFieldValue() - } else { - textFieldValue.copy(selection = TextRange.Zero) + }, + modifier = modifier + .requiredWidth(ProcessOutTheme.dimensions.formComponentHeight) + .onPreviewKeyEvent { + when { + it.key == Key.Backspace && it.type == KeyEventType.KeyDown -> { + if (textFieldIndex != 0 && values[textFieldIndex].selection.start == 0) { + values = values.mapIndexed { index, textFieldValue -> + if (index == textFieldIndex - 1) { + TextFieldValue() + } else { + textFieldValue.copy(selection = TextRange.Zero) + } } + focusManager.moveFocus(FocusDirection.Previous) + onValueChange(values.textFieldValue()) } - focusManager.moveFocus(FocusDirection.Previous) - onValueChange(values.textFieldValue()) + false } - false - } - else -> { - if (it.type == KeyEventType.KeyDown && textFieldIndex != values.lastIndex) { - focusManager.moveFocus(FocusDirection.Next) + else -> { + if (it.type == KeyEventType.KeyDown && textFieldIndex != values.lastIndex) { + focusManager.moveFocus(FocusDirection.Next) + } + false } - false } } + .focusRequester(focusRequester) + .onFocusChanged { + if (it.isFocused) { + focusedIndex = textFieldIndex + } + }, + style = style(style), + enabled = enabled, + isError = isError, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions + ) + if (isFocused && textFieldIndex == focusedIndex) { + if (lifecycleEvent == Lifecycle.Event.ON_RESUME) { + PORequestFocus(focusRequester, lifecycleEvent) + } else { + PORequestFocus(focusRequester) } - .focusRequester(focusRequester) - .onFocusChanged { - if (it.isFocused) { - focusedIndex = textFieldIndex - } - }, - style = style(style), - isError = isError, - keyboardOptions = keyboardOptions, - keyboardActions = keyboardActions - ) - if (isFocused && textFieldIndex == focusedIndex) { - if (lifecycleEvent == Lifecycle.Event.ON_RESUME) { - PORequestFocus(focusRequester, lifecycleEvent) - } else { - PORequestFocus(focusRequester) } } } @@ -245,8 +247,8 @@ object POCodeField { ) } - internal val LengthMin = 1 - internal val LengthMax = 6 + val LengthMin = 1 + val LengthMax = 6 internal fun validLength(length: Int): Int { if (length in LengthMin..LengthMax) { diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/code/POLabeledCodeField.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/code/POLabeledCodeField.kt index 973fc01a5..7273070a1 100644 --- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/code/POLabeledCodeField.kt +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/code/POLabeledCodeField.kt @@ -25,6 +25,7 @@ fun POLabeledCodeField( labelsStyle: POFieldLabels.Style = POFieldLabels.default, length: Int = POCodeField.LengthMax, horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + enabled: Boolean = true, isError: Boolean = false, isFocused: Boolean = false, lifecycleEvent: Lifecycle.Event? = null, @@ -44,6 +45,7 @@ fun POLabeledCodeField( style = fieldStyle, length = length, horizontalAlignment = horizontalAlignment, + enabled = enabled, isError = isError, isFocused = isFocused, lifecycleEvent = lifecycleEvent, diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/dropdown/PODropdownField.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/dropdown/PODropdownField.kt index 24d19cfe0..2dbf66d55 100644 --- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/dropdown/PODropdownField.kt +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/dropdown/PODropdownField.kt @@ -44,7 +44,6 @@ fun PODropdownField( modifier: Modifier = Modifier, fieldStyle: POField.Style = POField.default, menuStyle: PODropdownField.MenuStyle = PODropdownField.defaultMenu, - enabled: Boolean = true, isError: Boolean = false, placeholderText: String? = null ) { @@ -63,7 +62,7 @@ fun PODropdownField( onValueChange = {}, modifier = modifier.menuAnchor(), style = fieldStyle, - enabled = enabled, + enabled = true, readOnly = true, isDropdown = true, isError = isError, diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/dropdown/POLabeledDropdownField.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/dropdown/POLabeledDropdownField.kt index 6d053b190..485a60ffc 100644 --- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/dropdown/POLabeledDropdownField.kt +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/component/field/dropdown/POLabeledDropdownField.kt @@ -23,7 +23,6 @@ fun POLabeledDropdownField( fieldStyle: POField.Style = POField.default, menuStyle: PODropdownField.MenuStyle = PODropdownField.defaultMenu, labelsStyle: POFieldLabels.Style = POFieldLabels.default, - enabled: Boolean = true, isError: Boolean = false, placeholderText: String? = null ) { @@ -39,7 +38,6 @@ fun POLabeledDropdownField( modifier = modifier, fieldStyle = fieldStyle, menuStyle = menuStyle, - enabled = enabled, isError = isError, placeholderText = placeholderText ) diff --git a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POFieldState.kt b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POFieldState.kt index bf90038ab..1fe8eac2d 100644 --- a/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POFieldState.kt +++ b/ui-core/src/main/kotlin/com/processout/sdk/ui/core/state/POFieldState.kt @@ -15,6 +15,7 @@ data class POFieldState( val id: String, val value: TextFieldValue = TextFieldValue(), val availableValues: POImmutableList? = null, + val length: Int? = null, val title: String? = null, val description: String? = null, val placeholder: String? = null, diff --git a/ui/build.gradle b/ui/build.gradle index 00d27144c..cb5950ce1 100644 --- a/ui/build.gradle +++ b/ui/build.gradle @@ -94,6 +94,7 @@ dependencies { api "com.google.android.gms:play-services-wallet:$gmsWalletVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$kotlinxCoroutinesPlayServicesVersion" + implementation "com.googlecode.libphonenumber:libphonenumber:$libphonenumberVersion" ksp "com.squareup.moshi:moshi-kotlin-codegen:$moshiVersion" } 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 629bf0ae0..d4d2ac978 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 @@ -249,7 +249,6 @@ private fun DropdownField( }, fieldStyle = fieldStyle, menuStyle = menuStyle, - enabled = state.enabled, isError = state.isError, placeholderText = state.placeholder ) 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 71d9c8cd9..3f2396d30 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 @@ -1,20 +1,41 @@ package com.processout.sdk.ui.napm import android.app.Application +import android.util.Patterns +import androidx.annotation.StringRes +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.text.isDigitsOnly +import com.processout.sdk.R import com.processout.sdk.api.dispatcher.napm.PODefaultNativeAlternativePaymentMethodEventDispatcher +import com.processout.sdk.api.model.event.PONativeAlternativePaymentMethodEvent +import com.processout.sdk.api.model.event.PONativeAlternativePaymentMethodEvent.* +import com.processout.sdk.api.model.request.PONativeAlternativePaymentMethodDefaultValuesRequest +import com.processout.sdk.api.model.request.PONativeAlternativePaymentMethodRequest +import com.processout.sdk.api.model.response.PONativeAlternativePaymentMethodParameter +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.PONativeAlternativePaymentMethodParameterValues +import com.processout.sdk.api.model.response.PONativeAlternativePaymentMethodState +import com.processout.sdk.api.model.response.PONativeAlternativePaymentMethodState.* +import com.processout.sdk.api.model.response.PONativeAlternativePaymentMethodTransactionDetails import com.processout.sdk.api.service.POInvoicesService -import com.processout.sdk.core.POFailure +import com.processout.sdk.core.POFailure.Code.* +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.logger.POLogger +import com.processout.sdk.core.onFailure +import com.processout.sdk.core.onSuccess import com.processout.sdk.core.retry.PORetryStrategy import com.processout.sdk.ui.base.BaseInteractor +import com.processout.sdk.ui.core.state.POAvailableValue import com.processout.sdk.ui.napm.NativeAlternativePaymentCompletion.Awaiting import com.processout.sdk.ui.napm.NativeAlternativePaymentCompletion.Failure import com.processout.sdk.ui.napm.NativeAlternativePaymentEvent.* import com.processout.sdk.ui.napm.NativeAlternativePaymentInteractorState.* import com.processout.sdk.ui.napm.PONativeAlternativePaymentConfiguration.Options -import kotlinx.coroutines.delay +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -42,19 +63,242 @@ internal class NativeAlternativePaymentInteractor( private val _state = MutableStateFlow(Loading) val state = _state.asStateFlow() + private var latestDefaultValuesRequest: PONativeAlternativePaymentMethodDefaultValuesRequest? = null + init { - // TODO + POLogger.info("Starting native alternative payment.") + dispatch(WillStart) + dispatchFailure() + collectDefaultValues() + fetchTransactionDetails() + } + + private fun fetchTransactionDetails() { interactorScope.launch { - delay(2000) - _state.update { - UserInput( - UserInputStateValue( - primaryActionId = ActionId.SUBMIT, - secondaryActionId = ActionId.CANCEL + invoicesService.fetchNativeAlternativePaymentMethodTransactionDetails( + invoiceId = invoiceId, + gatewayConfigurationId = gatewayConfigurationId + ).onSuccess { details -> + with(details) { + handleState( + stateValue = toStateValue(), + paymentState = state, + parameters = parameters, + parameterValues = parameterValues, + coroutineScope = this@launch ) + } + }.onFailure { failure -> + POLogger.info("Failed to fetch transaction details: %s", failure) + _completion.update { Failure(failure) } + } + } + } + + private fun PONativeAlternativePaymentMethodTransactionDetails.toStateValue() = + UserInputStateValue( + invoice = invoice, + gateway = gateway, + fields = emptyList(), + focusedFieldId = null, + primaryActionId = ActionId.SUBMIT, + secondaryActionId = ActionId.CANCEL, + submitAllowed = true, + submitting = false + ) + + private suspend fun handleState( + stateValue: UserInputStateValue, + paymentState: PONativeAlternativePaymentMethodState?, + parameters: List?, + parameterValues: PONativeAlternativePaymentMethodParameterValues?, + coroutineScope: CoroutineScope + ) { + when (paymentState) { + CUSTOMER_INPUT, null -> handleCustomerInput(stateValue, parameters) + PENDING_CAPTURE -> handlePendingCapture(stateValue, parameterValues, coroutineScope) + CAPTURED -> handleCaptured(stateValue) + FAILED -> _completion.update { + Failure( + ProcessOutResult.Failure( + code = Generic(), + message = "Payment has failed." + ).also { POLogger.info("%s", it) } + ) + } + } + } + + private fun handleCustomerInput( + stateValue: UserInputStateValue, + parameters: List? + ) { + if (parameters.isNullOrEmpty()) { + _completion.update { + Failure( + ProcessOutResult.Failure( + code = Internal(), + message = "Input parameters is missing in response." + ).also { POLogger.error("%s", it, attributes = logAttributes) } + ) + } + return + } + if (failWithUnknownInputParameter(parameters)) { + return + } + val fields = parameters.toFields() + val focusedFieldId = fields.getOrNull(0)?.let { field -> + if (field.type != SINGLE_SELECT) field.id else null + } + val updatedStateValue = stateValue.copy( + fields = fields, + focusedFieldId = focusedFieldId + ) + val isLoading = _state.value is Loading + if (eventDispatcher.subscribedForDefaultValuesRequest()) { + _state.update { + if (isLoading) { + Loaded(updatedStateValue) + } else { + Submitted(updatedStateValue) + } + } + requestDefaultValues(parameters) + } else if (isLoading) { + startUserInput(updatedStateValue) + } else { + continueUserInput(updatedStateValue) + } + } + + private fun failWithUnknownInputParameter( + parameters: List + ): Boolean { + parameters.find { it.type() == UNKNOWN }?.let { parameter -> + _completion.update { + Failure( + ProcessOutResult.Failure( + code = Internal(), + message = "Unknown input parameter type: ${parameter.rawType}" + ).also { POLogger.error("%s", it, attributes = logAttributes) } + ) + } + return true + } + return false + } + + private fun List.toFields() = + map { parameter -> + with(parameter) { + val defaultValue = availableValues?.find { it.default == true }?.value ?: String() + Field( + id = key, + value = TextFieldValue( + text = defaultValue, + selection = TextRange(defaultValue.length) + ), + availableValues = availableValues?.map { + POAvailableValue( + value = it.value, + text = it.displayName + ) + }, + type = type(), + length = length, + displayName = displayName, + description = null, + required = required, + isValid = true ) } } + + private fun startUserInput(stateValue: UserInputStateValue) { + _state.update { UserInput(stateValue) } + + // TODO +// uiModel.secondaryAction?.let { +// scheduleSecondaryActionEnabling(it) { enableSecondaryAction() } +// } + + dispatch(DidStart) + POLogger.info("Started. Waiting for payment parameters.") + } + + private fun continueUserInput(stateValue: UserInputStateValue) { + _state.update { + UserInput( + stateValue.copy( + submitAllowed = true, + submitting = false + ) + ) + } + dispatch(DidSubmitParameters(additionalParametersExpected = true)) + POLogger.info("Submitted. Waiting for additional payment parameters.") + } + + private fun requestDefaultValues(parameters: List) { + interactorScope.launch { + val request = PONativeAlternativePaymentMethodDefaultValuesRequest( + gatewayConfigurationId = gatewayConfigurationId, + invoiceId = invoiceId, + parameters = parameters + ) + latestDefaultValuesRequest = request + eventDispatcher.send(request) + POLogger.debug("Requested to provide default values for payment parameters: %s", request) + } + } + + private fun collectDefaultValues() { + interactorScope.launch { + eventDispatcher.defaultValuesResponse.collect { response -> + if (response.uuid == latestDefaultValuesRequest?.uuid) { + latestDefaultValuesRequest = null + POLogger.debug("Collected default values for payment parameters: %s", response) + _state.whenLoaded { stateValue -> + startUserInput(stateValue.updateFieldValues(response.defaultValues)) + } + _state.whenSubmitted { stateValue -> + continueUserInput(stateValue.updateFieldValues(response.defaultValues)) + } + } + } + } + } + + private fun UserInputStateValue.updateFieldValues( + values: Map + ): UserInputStateValue { + val updatedFields = fields.map { field -> + values.entries.find { it.key == field.id }?.let { + val value = field.length?.let { length -> + it.value.take(length) + } ?: it.value + field.copy( + value = TextFieldValue( + text = value, + selection = TextRange(value.length) + ) + ) + } ?: field + } + return copy(fields = updatedFields) + } + + private fun handlePendingCapture( + stateValue: UserInputStateValue, + parameterValues: PONativeAlternativePaymentMethodParameterValues?, + coroutineScope: CoroutineScope + ) { + // TODO + } + + private fun handleCaptured(stateValue: UserInputStateValue) { + // TODO } fun onEvent(event: NativeAlternativePaymentEvent) { @@ -65,46 +309,235 @@ internal class NativeAlternativePaymentInteractor( ActionId.SUBMIT -> submit() ActionId.CANCEL -> cancel() } - is Dismiss -> POLogger.info("Dismissed: %s", event.failure, attributes = logAttributes) + is Dismiss -> POLogger.info("Dismissed: %s", event.failure) } } private fun updateFieldValue(id: String, value: TextFieldValue) { - // TODO + _state.whenUserInput { stateValue -> + val previousValue = stateValue.fields.find { it.id == id }?.value ?: TextFieldValue() + val isTextChanged = value.text != previousValue.text + val updatedStateValue = stateValue.copy( + fields = stateValue.fields.map { field -> + updatedField(id, value, field, isTextChanged) + } + ) + _state.update { UserInput(updatedStateValue) } + if (isTextChanged) { + POLogger.debug("Field is edited by the user: %s", id) + dispatch(ParametersChanged) + if (updatedStateValue.areAllFieldsValid()) { + _state.update { UserInput(updatedStateValue.copy(submitAllowed = true)) } + } + } + } + } + + private fun updatedField( + id: String, + value: TextFieldValue, + field: Field, + isTextChanged: Boolean + ): Field { + if (field.id != id) { + return field + } + return if (isTextChanged) { + field.copy(value = value, description = null, isValid = true) + } else { + field.copy(value = value) + } } private fun updateFieldFocus(id: String, isFocused: Boolean) { - // TODO + if (isFocused) { + _state.whenUserInput { stateValue -> + _state.update { + UserInput(stateValue.copy(focusedFieldId = id)) + } + } + } } private fun submit() { - // TODO - interactorScope.launch { + _state.whenUserInput { stateValue -> + POLogger.info("Will submit payment parameters.") + dispatch(WillSubmitParameters) + val invalidFields = stateValue.fields.mapNotNull { it.validate() } + if (invalidFields.isNotEmpty()) { + val failure = ProcessOutResult.Failure( + code = Validation(ValidationCode.general), + message = "Invalid fields.", + invalidFields = invalidFields + ) + handlePaymentFailure( + failure = failure, + replaceWithLocalMessage = false + ) + return@whenUserInput + } _state.update { - Capturing( - CaptureStateValue( - logoUrl = null, - secondaryActionId = ActionId.CANCEL + UserInput( + stateValue.copy( + submitAllowed = true, + submitting = true ) ) } - delay(2000) - _state.whenCapturing { stateValue -> - _state.update { - Captured(stateValue) + initiatePayment() + } + } + + private fun Field.validate(): InvalidField? { + val value = value.text + if (required && value.isBlank()) { + return invalidField(R.string.po_native_apm_error_required_parameter) + } + length?.let { + if (value.length != it) { + return InvalidField( + name = id, + message = app.resources.getQuantityString( + R.plurals.po_native_apm_error_invalid_length, it, it + ) + ) + } + } + when (type) { + NUMERIC -> if (!value.isDigitsOnly()) + return invalidField(R.string.po_native_apm_error_invalid_number) + EMAIL -> if (!Patterns.EMAIL_ADDRESS.matcher(value).matches()) + return invalidField(R.string.po_native_apm_error_invalid_email) + PHONE -> if (!Patterns.PHONE.matcher(value).matches()) + return invalidField(R.string.po_native_apm_error_invalid_phone) + else -> {} + } + return null + } + + private fun Field.invalidField( + @StringRes messageResId: Int + ) = InvalidField( + name = id, + message = app.getString(messageResId) + ) + + private fun UserInputStateValue.areAllFieldsValid() = fields.all { it.isValid } + + private fun initiatePayment() { + _state.whenUserInput { stateValue -> + interactorScope.launch { + val parameters = mutableMapOf() + stateValue.fields.forEach { + parameters[it.id] = it.value.text } + val request = PONativeAlternativePaymentMethodRequest( + invoiceId = invoiceId, + gatewayConfigurationId = gatewayConfigurationId, + parameters = parameters + ) + invoicesService.initiatePayment(request) + .onSuccess { payment -> + with(payment) { + handleState( + stateValue = stateValue, + paymentState = state, + parameters = parameterDefinitions, + parameterValues = parameterValues, + coroutineScope = this@launch + ) + } + } + .onFailure { failure -> + handlePaymentFailure( + failure = failure, + replaceWithLocalMessage = true + ) + } + } + } + } + + private fun handlePaymentFailure( + failure: ProcessOutResult.Failure, + replaceWithLocalMessage: Boolean // TODO: Delete this when backend localization is ready. + ) { + _state.whenUserInput { stateValue -> + val invalidFields = failure.invalidFields + if (invalidFields.isNullOrEmpty()) { + POLogger.info("Unrecoverable payment failure: %s", failure) + _completion.update { Failure(failure) } + return@whenUserInput + } + val updatedFields = stateValue.fields.map { field -> + invalidFields.find { it.name == field.id }?.let { invalidField -> + field.copy( + description = fieldErrorMessage( + originalMessage = invalidField.message, + replaceWithLocalMessage = replaceWithLocalMessage, + type = field.type + ), + isValid = false + ) + } ?: field + } + val firstInvalidFieldId = updatedFields.find { !it.isValid }?.id + _state.update { + UserInput( + stateValue.copy( + fields = updatedFields, + focusedFieldId = firstInvalidFieldId ?: stateValue.focusedFieldId, + submitAllowed = updatedFields.all { it.isValid }, + submitting = false + ) + ) } + POLogger.info("Recovered after the failure: %s", failure) + dispatch(DidFailToSubmitParameters(failure)) } } + // TODO: Delete this when backend localization is ready. + private fun fieldErrorMessage( + originalMessage: String?, + replaceWithLocalMessage: Boolean, + type: ParameterType + ): String? = + if (replaceWithLocalMessage) + when (type) { + NUMERIC -> app.getString(R.string.po_native_apm_error_invalid_number) + TEXT -> app.getString(R.string.po_native_apm_error_invalid_text) + EMAIL -> app.getString(R.string.po_native_apm_error_invalid_email) + PHONE -> app.getString(R.string.po_native_apm_error_invalid_phone) + else -> null + } + else originalMessage + private fun cancel() { _completion.update { Failure( ProcessOutResult.Failure( - code = POFailure.Code.Cancelled, + code = Cancelled, message = "Cancelled by the user with secondary cancel action." - ).also { POLogger.info("Cancelled: %s", it, attributes = logAttributes) } + ).also { POLogger.info("Cancelled: %s", it) } ) } } + + private fun dispatch(event: PONativeAlternativePaymentMethodEvent) { + interactorScope.launch { + eventDispatcher.send(event) + POLogger.debug("Event has been sent: %s", event) + } + } + + private fun dispatchFailure() { + interactorScope.launch { + _completion.collect { + if (it is Failure) { + dispatch(DidFail(it.failure)) + } + } + } + } } diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentInteractorState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentInteractorState.kt index 8ec3ada46..6274bc0e8 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentInteractorState.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentInteractorState.kt @@ -1,5 +1,10 @@ package com.processout.sdk.ui.napm +import androidx.compose.ui.text.input.TextFieldValue +import com.processout.sdk.api.model.response.PONativeAlternativePaymentMethodParameter.ParameterType +import com.processout.sdk.api.model.response.PONativeAlternativePaymentMethodTransactionDetails.Gateway +import com.processout.sdk.api.model.response.PONativeAlternativePaymentMethodTransactionDetails.Invoice +import com.processout.sdk.ui.core.state.POAvailableValue import com.processout.sdk.ui.napm.NativeAlternativePaymentInteractorState.* import kotlinx.coroutines.flow.MutableStateFlow @@ -32,15 +37,34 @@ internal sealed interface NativeAlternativePaymentInteractorState { //endregion data class UserInputStateValue( + val invoice: Invoice, + val gateway: Gateway, + val fields: List, + val focusedFieldId: String?, val primaryActionId: String, - val secondaryActionId: String + val secondaryActionId: String, + val submitAllowed: Boolean, + val submitting: Boolean ) data class CaptureStateValue( + val paymentProviderName: String?, val logoUrl: String?, val secondaryActionId: String ) + data class Field( + val id: String, + val value: TextFieldValue, + val availableValues: List?, + val type: ParameterType, + val length: Int?, + val displayName: String, + val description: String?, + val required: Boolean, + val isValid: Boolean + ) + object ActionId { const val SUBMIT = "submit" const val CANCEL = "cancel" diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentScreen.kt b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentScreen.kt index 31e54814f..0e782e6e6 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentScreen.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentScreen.kt @@ -8,10 +8,7 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Scaffold @@ -21,28 +18,38 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.rememberNestedScrollInteropConnection import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource +import androidx.lifecycle.Lifecycle import com.processout.sdk.ui.R -import com.processout.sdk.ui.core.component.POActionsContainer -import com.processout.sdk.ui.core.component.POCircularProgressIndicator -import com.processout.sdk.ui.core.component.POHeader -import com.processout.sdk.ui.core.component.POText +import com.processout.sdk.ui.core.component.* import com.processout.sdk.ui.core.component.field.POField +import com.processout.sdk.ui.core.component.field.POFieldLabels import com.processout.sdk.ui.core.component.field.code.POCodeField +import com.processout.sdk.ui.core.component.field.code.POLabeledCodeField import com.processout.sdk.ui.core.component.field.dropdown.PODropdownField +import com.processout.sdk.ui.core.component.field.dropdown.POLabeledDropdownField +import com.processout.sdk.ui.core.component.field.radio.POLabeledRadioField import com.processout.sdk.ui.core.component.field.radio.PORadioGroup +import com.processout.sdk.ui.core.component.field.text.POLabeledTextField import com.processout.sdk.ui.core.state.POActionState +import com.processout.sdk.ui.core.state.POFieldState import com.processout.sdk.ui.core.state.POImmutableList import com.processout.sdk.ui.core.style.POAxis import com.processout.sdk.ui.core.theme.ProcessOutTheme -import com.processout.sdk.ui.napm.NativeAlternativePaymentEvent.Action +import com.processout.sdk.ui.napm.NativeAlternativePaymentEvent.* import com.processout.sdk.ui.napm.NativeAlternativePaymentScreen.AnimationDurationMillis import com.processout.sdk.ui.napm.NativeAlternativePaymentScreen.animatedBackgroundColor +import com.processout.sdk.ui.napm.NativeAlternativePaymentScreen.codeFieldHorizontalAlignment import com.processout.sdk.ui.napm.NativeAlternativePaymentViewModelState.* +import com.processout.sdk.ui.napm.NativeAlternativePaymentViewModelState.Field.* +import com.processout.sdk.ui.shared.composable.rememberLifecycleEvent @Composable internal fun NativeAlternativePaymentScreen( @@ -112,12 +119,228 @@ private fun UserInput( style: NativeAlternativePaymentScreen.Style ) { AnimatedVisibility { - Column { - // TODO + Column( + verticalArrangement = Arrangement.spacedBy(ProcessOutTheme.spacing.small) + ) { + val lifecycleEvent = rememberLifecycleEvent() + val labelsStyle = remember { + POFieldLabels.Style( + title = style.label, + description = style.errorMessage + ) + } + val isPrimaryActionEnabled = with(state.primaryAction) { enabled && !loading } + state.fields.elements.forEach { field -> + when (field) { + is TextField -> TextField( + state = field.state, + onEvent = onEvent, + lifecycleEvent = lifecycleEvent, + focusedFieldId = state.focusedFieldId, + isPrimaryActionEnabled = isPrimaryActionEnabled, + fieldStyle = style.field, + labelsStyle = labelsStyle, + modifier = Modifier.fillMaxWidth() + ) + is CodeField -> CodeField( + state = field.state, + onEvent = onEvent, + lifecycleEvent = lifecycleEvent, + focusedFieldId = state.focusedFieldId, + isPrimaryActionEnabled = isPrimaryActionEnabled, + fieldStyle = style.codeField, + labelsStyle = labelsStyle, + horizontalAlignment = codeFieldHorizontalAlignment(state.fields.elements) + ) + is RadioField -> RadioField( + state = field.state, + onEvent = onEvent, + radioGroupStyle = style.radioGroup, + labelsStyle = labelsStyle + ) + is DropdownField -> DropdownField( + state = field.state, + onEvent = onEvent, + fieldStyle = style.field, + menuStyle = style.dropdownMenu, + modifier = Modifier.fillMaxWidth() + ) + } + } } } } +@Composable +private fun TextField( + state: POFieldState, + onEvent: (NativeAlternativePaymentEvent) -> Unit, + lifecycleEvent: Lifecycle.Event, + focusedFieldId: String?, + isPrimaryActionEnabled: Boolean, + fieldStyle: POField.Style, + labelsStyle: POFieldLabels.Style, + modifier: Modifier = Modifier +) { + val focusRequester = remember { FocusRequester() } + POLabeledTextField( + value = state.value, + onValueChange = { + onEvent( + FieldValueChanged( + id = state.id, + value = state.inputFilter?.filter(it) ?: it + ) + ) + }, + title = state.title ?: String(), + description = state.description, + modifier = modifier + .focusRequester(focusRequester) + .onFocusChanged { + onEvent( + FieldFocusChanged( + id = state.id, + isFocused = it.isFocused + ) + ) + }, + fieldStyle = fieldStyle, + labelsStyle = labelsStyle, + enabled = state.enabled, + isError = state.isError, + forceTextDirectionLtr = state.forceTextDirectionLtr, + placeholderText = state.placeholder, + visualTransformation = state.visualTransformation, + keyboardOptions = state.keyboardOptions, + keyboardActions = POField.keyboardActions( + imeAction = state.keyboardOptions.imeAction, + actionId = state.keyboardActionId, + enabled = isPrimaryActionEnabled, + onClick = { onEvent(Action(id = it)) } + ) + ) + if (state.id == focusedFieldId && lifecycleEvent == Lifecycle.Event.ON_RESUME) { + PORequestFocus(focusRequester, lifecycleEvent) + } +} + +@Composable +private fun CodeField( + state: POFieldState, + onEvent: (NativeAlternativePaymentEvent) -> Unit, + lifecycleEvent: Lifecycle.Event, + focusedFieldId: String?, + isPrimaryActionEnabled: Boolean, + fieldStyle: POField.Style, + labelsStyle: POFieldLabels.Style, + horizontalAlignment: Alignment.Horizontal, + modifier: Modifier = Modifier +) { + POLabeledCodeField( + value = state.value, + onValueChange = { + onEvent( + FieldValueChanged( + id = state.id, + value = it + ) + ) + }, + title = state.title ?: String(), + description = state.description, + modifier = modifier + .onFocusChanged { + onEvent( + FieldFocusChanged( + id = state.id, + isFocused = it.isFocused + ) + ) + }, + fieldStyle = fieldStyle, + labelsStyle = labelsStyle, + length = state.length ?: POCodeField.LengthMax, + horizontalAlignment = horizontalAlignment, + enabled = state.enabled, + isError = state.isError, + isFocused = state.id == focusedFieldId, + lifecycleEvent = lifecycleEvent, + keyboardOptions = state.keyboardOptions, + keyboardActions = POField.keyboardActions( + imeAction = state.keyboardOptions.imeAction, + actionId = state.keyboardActionId, + enabled = isPrimaryActionEnabled, + onClick = { onEvent(Action(id = it)) } + ) + ) +} + +@Composable +private fun RadioField( + state: POFieldState, + onEvent: (NativeAlternativePaymentEvent) -> Unit, + radioGroupStyle: PORadioGroup.Style, + labelsStyle: POFieldLabels.Style, + modifier: Modifier = Modifier +) { + POLabeledRadioField( + value = state.value, + onValueChange = { + onEvent( + FieldValueChanged( + id = state.id, + value = it + ) + ) + }, + availableValues = state.availableValues ?: POImmutableList(emptyList()), + title = state.title ?: String(), + description = state.description, + modifier = modifier, + radioGroupStyle = radioGroupStyle, + labelsStyle = labelsStyle, + isError = state.isError + ) +} + +@Composable +private fun DropdownField( + state: POFieldState, + onEvent: (NativeAlternativePaymentEvent) -> Unit, + fieldStyle: POField.Style, + menuStyle: PODropdownField.MenuStyle, + modifier: Modifier = Modifier +) { + POLabeledDropdownField( + value = state.value, + onValueChange = { + onEvent( + FieldValueChanged( + id = state.id, + value = it + ) + ) + }, + availableValues = state.availableValues ?: POImmutableList(emptyList()), + title = state.title ?: String(), + description = state.description, + modifier = modifier + .onFocusChanged { + onEvent( + FieldFocusChanged( + id = state.id, + isFocused = it.isFocused + ) + ) + }, + fieldStyle = fieldStyle, + menuStyle = menuStyle, + isError = state.isError, + placeholderText = state.placeholder + ) +} + @Composable private fun Capture( state: Capture, @@ -295,4 +518,8 @@ internal object NativeAlternativePaymentScreen { }, animationSpec = tween(durationMillis = AnimationDurationMillis, easing = LinearEasing) ).value + + fun codeFieldHorizontalAlignment(fields: List): Alignment.Horizontal = + if (fields.size == 1 && fields[0] is CodeField) + Alignment.CenterHorizontally else Alignment.Start } 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 a726635d0..702143302 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 @@ -1,20 +1,38 @@ package com.processout.sdk.ui.napm import android.app.Application +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation 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.dispatcher.napm.PODefaultNativeAlternativePaymentMethodEventDispatcher +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.retry.PORetryStrategy.Exponential +import com.processout.sdk.core.util.POMarkdownUtils.escapedMarkdown import com.processout.sdk.ui.core.state.POActionState +import com.processout.sdk.ui.core.state.POFieldState +import com.processout.sdk.ui.core.state.POImmutableList import com.processout.sdk.ui.napm.NativeAlternativePaymentInteractor.Companion.LOG_ATTRIBUTE_GATEWAY_CONFIGURATION_ID import com.processout.sdk.ui.napm.NativeAlternativePaymentInteractor.Companion.LOG_ATTRIBUTE_INVOICE_ID -import com.processout.sdk.ui.napm.NativeAlternativePaymentViewModelState.* +import com.processout.sdk.ui.napm.NativeAlternativePaymentInteractorState.* +import com.processout.sdk.ui.napm.NativeAlternativePaymentViewModelState.Field.* import com.processout.sdk.ui.napm.PONativeAlternativePaymentConfiguration.Options import com.processout.sdk.ui.napm.PONativeAlternativePaymentConfiguration.PaymentConfirmationConfiguration.Companion.DEFAULT_TIMEOUT_SECONDS import com.processout.sdk.ui.napm.PONativeAlternativePaymentConfiguration.PaymentConfirmationConfiguration.Companion.MAX_TIMEOUT_SECONDS +import com.processout.sdk.ui.napm.PONativeAlternativePaymentConfiguration.SecondaryAction import com.processout.sdk.ui.shared.extension.map +import com.processout.sdk.ui.shared.filter.PhoneNumberInputFilter +import com.processout.sdk.ui.shared.transformation.PhoneNumberVisualTransformation +import java.text.NumberFormat +import java.util.Currency internal class NativeAlternativePaymentViewModel( private val app: Application, @@ -64,6 +82,16 @@ internal class NativeAlternativePaymentViewModel( ) } + private companion object { + const val CODE_FIELD_LENGTH_MIN = 1 + const val CODE_FIELD_LENGTH_MAX = 6 + } + + private data class KeyboardAction( + val imeAction: ImeAction, + val actionId: String? + ) + val completion = interactor.completion val state = interactor.state.map(viewModelScope, ::map) @@ -76,40 +104,213 @@ internal class NativeAlternativePaymentViewModel( private fun map( state: NativeAlternativePaymentInteractorState - ): NativeAlternativePaymentViewModelState = with(options) { - when (state) { - NativeAlternativePaymentInteractorState.Loading -> Loading - is NativeAlternativePaymentInteractorState.UserInput -> - with(state.value) { - UserInput( - title = "Title", - primaryAction = POActionState( - id = primaryActionId, - text = "Submit", - primary = true - ), - secondaryAction = POActionState( - id = secondaryActionId, - text = "Cancel", - primary = false - ) - ) + ): NativeAlternativePaymentViewModelState = when (state) { + Loading -> NativeAlternativePaymentViewModelState.Loading + is UserInput -> state.toUserInput() + is Capturing -> state.toCapture() + is Captured -> state.toCapture() + else -> this@NativeAlternativePaymentViewModel.state.value + } + + private fun UserInput.toUserInput() = with(value) { + NativeAlternativePaymentViewModelState.UserInput( + title = options.title ?: app.getString(R.string.po_native_apm_title_format, gateway.displayName), + fields = fields.map(), + focusedFieldId = focusedFieldId, + primaryAction = POActionState( + id = primaryActionId, + text = options.primaryActionText ?: invoice.formatPrimaryActionText(), + primary = true, + enabled = submitAllowed, + loading = submitting + ), + secondaryAction = options.secondaryAction?.toActionState( + id = secondaryActionId, + enabled = !submitting + ), + actionMessageMarkdown = gateway.customerActionMessage?.let { escapedMarkdown(it) }, + actionImageUrl = gateway.customerActionImageUrl, + successMessage = options.successMessage ?: app.getString(R.string.po_native_apm_success_message) + ) + } + + private fun Capturing.toCapture() = with(value) { + NativeAlternativePaymentViewModelState.Capture( + paymentProviderName = paymentProviderName, + logoUrl = logoUrl, + secondaryAction = options.paymentConfirmation.secondaryAction?.toActionState( + id = secondaryActionId, + enabled = true + ), + isCaptured = false + ) + } + + private fun Captured.toCapture() = with(value) { + NativeAlternativePaymentViewModelState.Capture( + paymentProviderName = paymentProviderName, + logoUrl = logoUrl, + secondaryAction = null, + isCaptured = true + ) + } + + private fun List.map(): POImmutableList { + val lastFocusableFieldId = lastFocusableFieldId() + val fields = map { field -> + val keyboardAction = keyboardAction(field.id, lastFocusableFieldId) + when (field.type) { + NUMERIC -> if (field.length in CODE_FIELD_LENGTH_MIN..CODE_FIELD_LENGTH_MAX) { + field.toCodeField(keyboardAction) + } else { + field.toTextField(keyboardAction) } - is NativeAlternativePaymentInteractorState.Capturing -> - Capture( - secondaryAction = POActionState( - id = state.value.secondaryActionId, - text = "Cancel", - primary = false - ), - isCaptured = false - ) - is NativeAlternativePaymentInteractorState.Captured -> - Capture( - secondaryAction = null, - isCaptured = true - ) - else -> this@NativeAlternativePaymentViewModel.state.value + SINGLE_SELECT -> { + val availableValuesCount = field.availableValues?.size ?: 0 + if (availableValuesCount <= options.inlineSingleSelectValuesLimit) { + field.toRadioField() + } else { + field.toDropdownField() + } + } + else -> field.toTextField(keyboardAction) + } + } + return POImmutableList(fields) + } + + private fun Field.toTextField( + keyboardAction: KeyboardAction + ): NativeAlternativePaymentViewModelState.Field = + TextField( + POFieldState( + id = id, + value = value, + title = displayName, + description = description, + placeholder = type.placeholder(), + isError = !isValid, + forceTextDirectionLtr = setOf(NUMERIC, EMAIL, PHONE).contains(type), + inputFilter = if (type == PHONE) PhoneNumberInputFilter() else null, + visualTransformation = if (type == PHONE) PhoneNumberVisualTransformation() else VisualTransformation.None, + keyboardOptions = type.keyboardOptions(keyboardAction.imeAction), + keyboardActionId = keyboardAction.actionId + ) + ) + + private fun Field.toCodeField( + keyboardAction: KeyboardAction + ): NativeAlternativePaymentViewModelState.Field = + CodeField( + POFieldState( + id = id, + value = value, + length = length, + title = displayName, + description = description, + isError = !isValid, + keyboardOptions = type.keyboardOptions(keyboardAction.imeAction), + keyboardActionId = keyboardAction.actionId + ) + ) + + private fun Field.toRadioField(): NativeAlternativePaymentViewModelState.Field = + RadioField( + POFieldState( + id = id, + value = value, + availableValues = availableValues?.let { POImmutableList(it) }, + title = displayName, + description = description, + isError = !isValid + ) + ) + + private fun Field.toDropdownField(): NativeAlternativePaymentViewModelState.Field = + DropdownField( + POFieldState( + id = id, + value = value, + availableValues = availableValues?.let { POImmutableList(it) }, + title = displayName, + description = description, + isError = !isValid + ) + ) + + private fun List.lastFocusableFieldId(): String? { + reversed().forEach { field -> + if (field.type != SINGLE_SELECT) { + return field.id + } + } + return null + } + + private fun keyboardAction(fieldId: String, lastFocusableFieldId: String?) = + if (fieldId == lastFocusableFieldId) { + KeyboardAction( + imeAction = ImeAction.Done, + actionId = ActionId.SUBMIT + ) + } else { + KeyboardAction( + imeAction = ImeAction.Next, + actionId = null + ) + } + + private fun ParameterType.keyboardOptions( + imeAction: ImeAction + ): KeyboardOptions = when (this) { + NUMERIC -> KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = imeAction + ) + TEXT -> KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text, + imeAction = imeAction + ) + EMAIL -> KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = imeAction + ) + PHONE -> KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = imeAction + ) + SINGLE_SELECT -> KeyboardOptions.Default + UNKNOWN -> KeyboardOptions( + imeAction = imeAction + ) + } + + private fun ParameterType.placeholder(): String? = when (this) { + EMAIL -> app.getString(R.string.po_native_apm_email_placeholder) + PHONE -> app.getString(R.string.po_native_apm_phone_placeholder) + else -> null + } + + private fun Invoice.formatPrimaryActionText() = + try { + val price = NumberFormat.getCurrencyInstance().apply { + currency = Currency.getInstance(currencyCode) + }.format(amount.toDouble()) + app.getString(R.string.po_native_apm_submit_button_text_format, price) + } catch (_: Exception) { + app.getString(R.string.po_native_apm_submit_button_text) } + + private fun SecondaryAction.toActionState( + id: String, + enabled: Boolean + ): POActionState = when (this) { + is SecondaryAction.Cancel -> POActionState( + id = id, + text = text ?: app.getString(R.string.po_native_apm_cancel_button_text), + primary = false, + enabled = enabled + ) } } diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentViewModelState.kt b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentViewModelState.kt index b5a365d75..28b0ed87b 100644 --- a/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentViewModelState.kt +++ b/ui/src/main/kotlin/com/processout/sdk/ui/napm/NativeAlternativePaymentViewModelState.kt @@ -2,21 +2,43 @@ package com.processout.sdk.ui.napm import androidx.compose.runtime.Immutable import com.processout.sdk.ui.core.state.POActionState +import com.processout.sdk.ui.core.state.POFieldState +import com.processout.sdk.ui.core.state.POImmutableList @Immutable internal sealed interface NativeAlternativePaymentViewModelState { + + //region States + data object Loading : NativeAlternativePaymentViewModelState @Immutable data class UserInput( val title: String, + val fields: POImmutableList, + val focusedFieldId: String?, val primaryAction: POActionState, - val secondaryAction: POActionState? + val secondaryAction: POActionState?, + val actionMessageMarkdown: String?, + val actionImageUrl: String?, + val successMessage: String ) : NativeAlternativePaymentViewModelState @Immutable data class Capture( + val paymentProviderName: String?, + val logoUrl: String?, val secondaryAction: POActionState?, val isCaptured: Boolean ) : NativeAlternativePaymentViewModelState + + //endregion + + @Immutable + sealed interface Field { + data class TextField(val state: POFieldState) : Field + data class CodeField(val state: POFieldState) : Field + data class RadioField(val state: POFieldState) : Field + data class DropdownField(val state: POFieldState) : Field + } } diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/filter/PhoneNumberInputFilter.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/filter/PhoneNumberInputFilter.kt new file mode 100644 index 000000000..007936e5b --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/filter/PhoneNumberInputFilter.kt @@ -0,0 +1,40 @@ +package com.processout.sdk.ui.shared.filter + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.processout.sdk.ui.core.filter.POInputFilter + +internal class PhoneNumberInputFilter : POInputFilter { + + override fun filter(value: TextFieldValue): TextFieldValue { + var filtered = value.text.filter { it.isDigit() } + filtered = "+$filtered" + val lengthDiff = value.text.length - filtered.length + val selection = with(value) { + if (selection.length == 0) { + if (selection.start == text.length) { + TextRange(filtered.length) + } else if (selection.start == 0) { + TextRange(selection.start + 1) + } else if (lengthDiff > 0) { + TextRange(selection.start - lengthDiff) + } else { + selection + } + } else { + selection + } + } + return value.copy(text = filtered, selection = selection) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + return true + } + + override fun hashCode(): Int { + return javaClass.hashCode() + } +} diff --git a/ui/src/main/kotlin/com/processout/sdk/ui/shared/transformation/PhoneNumberVisualTransformation.kt b/ui/src/main/kotlin/com/processout/sdk/ui/shared/transformation/PhoneNumberVisualTransformation.kt new file mode 100644 index 000000000..b2c7f08a9 --- /dev/null +++ b/ui/src/main/kotlin/com/processout/sdk/ui/shared/transformation/PhoneNumberVisualTransformation.kt @@ -0,0 +1,57 @@ +package com.processout.sdk.ui.shared.transformation + +import android.telephony.PhoneNumberUtils +import android.text.Selection +import com.google.i18n.phonenumbers.PhoneNumberUtil +import java.util.Locale + +internal class PhoneNumberVisualTransformation( + private val countryCode: String = Locale.getDefault().country +) : BaseVisualTransformation() { + + private val formatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(countryCode) + + override fun transform(text: String): String { + formatter.clear() + var formatted: String? = null + val cursorIndex = Selection.getSelectionEnd(text) - 1 + var lastNonSeparator = 0.toChar() + var hasCursor = false + text.forEachIndexed { index, char -> + if (PhoneNumberUtils.isNonSeparator(char)) { + if (lastNonSeparator.code != 0) { + formatted = formatted(lastNonSeparator, hasCursor) + hasCursor = false + } + lastNonSeparator = char + } + if (index == cursorIndex) { + hasCursor = true + } + } + if (lastNonSeparator.code != 0) { + formatted = formatted(lastNonSeparator, hasCursor) + } + return formatted ?: text + } + + private fun formatted(lastNonSeparator: Char, hasCursor: Boolean) = + if (hasCursor) { + formatter.inputDigitAndRememberPosition(lastNonSeparator) + } else { + formatter.inputDigit(lastNonSeparator) + } + + override fun isSeparator(char: Char) = !PhoneNumberUtils.isNonSeparator(char) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as PhoneNumberVisualTransformation + return countryCode == other.countryCode + } + + override fun hashCode(): Int { + return countryCode.hashCode() + } +}