diff --git a/CHANGELOG.md b/CHANGELOG.md index cfac37b717..7baf399f7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ All notable changes to this project will be documented in this file. ### Removed ### Fixed +## [0.75.0] +### Changed +* ui: update fiserv 3D's flow. The user now has to enter additional information to add a credit card. + * instead of using the CreditCardInput it is now required to use the FiservInput instead. ## [0.74.0] ### Added * core: add new and update existing user agent headers diff --git a/kotlin-sample/src/main/AndroidManifest.xml b/kotlin-sample/src/main/AndroidManifest.xml index 17fdabf6c9..820680dbd2 100644 --- a/kotlin-sample/src/main/AndroidManifest.xml +++ b/kotlin-sample/src/main/AndroidManifest.xml @@ -34,7 +34,7 @@ + android:windowSoftInputMode="adjustResize" /> - navController.navigate(R.id.navigation_credit_card_input, args) + navController.navigate(R.id.navigation_fiserv_input, args) } SnabbleUI.setUiAction( activity, diff --git a/kotlin-sample/src/main/res/navigation/mobile_navigation.xml b/kotlin-sample/src/main/res/navigation/mobile_navigation.xml index f566bd61d2..dc745cb487 100644 --- a/kotlin-sample/src/main/res/navigation/mobile_navigation.xml +++ b/kotlin-sample/src/main/res/navigation/mobile_navigation.xml @@ -165,9 +165,9 @@ android:label="Payone" /> + android:id="@+id/navigation_fiserv_input" + android:name="io.snabble.sdk.ui.payment.fiserv.FiservInputFragment" + android:label="" /> - startActivity(context, CreditCardInputActivity::class.java, args, canGoBack = false) + SHOW_FISERV_INPUT -> + startActivity(context, FiservInputActivity::class.java, args, canGoBack = false) SHOW_PAYONE_INPUT -> startActivity(context, PayoneInputActivity::class.java, args, canGoBack = false) diff --git a/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/utils/TextFieldManager.kt b/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/utils/TextFieldManager.kt index f5cca1b0ed..32d402a833 100644 --- a/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/utils/TextFieldManager.kt +++ b/ui/src/main/java/io/snabble/sdk/ui/cart/shoppingcart/utils/TextFieldManager.kt @@ -1,6 +1,7 @@ package io.snabble.sdk.ui.cart.shoppingcart.utils import androidx.compose.runtime.Composable +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -15,6 +16,10 @@ internal class TextFieldManager( focusManager.clearFocus() keyboardController?.hide() } + + fun moveFocusToNext() { + focusManager.moveFocus(FocusDirection.Next) + } } @Composable diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/CreditCardInputActivity.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/CreditCardInputActivity.kt deleted file mode 100644 index 267578ef5c..0000000000 --- a/ui/src/main/java/io/snabble/sdk/ui/payment/CreditCardInputActivity.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.snabble.sdk.ui.payment - -import androidx.fragment.app.Fragment -import io.snabble.sdk.ui.BaseFragmentActivity - -class CreditCardInputActivity : BaseFragmentActivity() { - companion object { - const val ARG_PROJECT_ID = CreditCardInputView.ARG_PROJECT_ID - const val ARG_PAYMENT_TYPE = CreditCardInputView.ARG_PAYMENT_TYPE - } - - override fun onCreateFragment(): Fragment { - val fragment = CreditCardInputFragment() - fragment.arguments = intent.extras - return fragment - } -} \ No newline at end of file diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/CreditCardInputFragment.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/CreditCardInputFragment.kt deleted file mode 100644 index 66b76dbfc7..0000000000 --- a/ui/src/main/java/io/snabble/sdk/ui/payment/CreditCardInputFragment.kt +++ /dev/null @@ -1,33 +0,0 @@ -package io.snabble.sdk.ui.payment - -import android.os.Bundle -import android.view.View -import io.snabble.sdk.PaymentMethod -import io.snabble.sdk.ui.BaseFragment -import io.snabble.sdk.ui.R -import io.snabble.sdk.ui.utils.serializableExtra - -open class CreditCardInputFragment : BaseFragment( - layoutResId = R.layout.snabble_fragment_cardinput_creditcard, - waitForProject = false -) { - companion object { - const val ARG_PROJECT_ID = CreditCardInputView.ARG_PROJECT_ID - const val ARG_PAYMENT_TYPE = CreditCardInputView.ARG_PAYMENT_TYPE - } - - var projectId: String? = null - var paymentMethod: PaymentMethod? = null - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - projectId = arguments?.getString(ARG_PROJECT_ID, null) - paymentMethod = arguments?.serializableExtra(ARG_PAYMENT_TYPE) - } - - override fun onActualViewCreated(view: View, savedInstanceState: Bundle?) { - val v = view as CreditCardInputView - v.load(projectId, paymentMethod) - } -} diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/PaymentInputViewHelper.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/PaymentInputViewHelper.kt index 9358320daf..60d42a5ff7 100644 --- a/ui/src/main/java/io/snabble/sdk/ui/payment/PaymentInputViewHelper.kt +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/PaymentInputViewHelper.kt @@ -11,6 +11,7 @@ import io.snabble.sdk.payment.PaymentCredentials import io.snabble.sdk.ui.R import io.snabble.sdk.ui.SnabbleUI import io.snabble.sdk.ui.payment.externalbilling.ExternalBillingFragment.Companion.ARG_PROJECT_ID +import io.snabble.sdk.ui.payment.fiserv.FiservInputView import io.snabble.sdk.ui.utils.KeyguardUtils import io.snabble.sdk.ui.utils.UIUtils import io.snabble.sdk.utils.Logger @@ -20,61 +21,42 @@ object PaymentInputViewHelper { @JvmStatic fun openPaymentInputView(context: Context, paymentMethod: PaymentMethod?, projectId: String) { if (KeyguardUtils.isDeviceSecure()) { - val project = Snabble.getProjectById(projectId) - val acceptedOriginTypes = project?.paymentMethodDescriptors - ?.firstOrNull { it.paymentMethod == paymentMethod }?.acceptedOriginTypes.orEmpty() + val project = Snabble.getProjectById(projectId) ?: return + if (paymentMethod == null) { + Logger.e("Payment method requires no credentials or is unsupported") + return + } + val acceptedOriginTypes = project.paymentMethodDescriptors + .firstOrNull { it.paymentMethod == paymentMethod }?.acceptedOriginTypes.orEmpty() val useDatatrans = acceptedOriginTypes.any { it == "datatransAlias" || it == "datatransCreditCardAlias" } val usePayone = acceptedOriginTypes.any { it == "payonePseudoCardPAN" } + val useFiserv = acceptedOriginTypes.any { it == "ipgHostedDataID" } val activity = UIUtils.getHostFragmentActivity(context) val args = Bundle() - if (project != null) { - if (useDatatrans && paymentMethod != null) { - Datatrans.registerCard(activity, project, paymentMethod) - } else if (usePayone && paymentMethod != null) { - Payone.registerCard(activity, project, paymentMethod, Snabble.formPrefillData) - } else { - when (paymentMethod) { - PaymentMethod.VISA -> { - args.putString(CreditCardInputView.ARG_PROJECT_ID, projectId) - args.putSerializable(CreditCardInputView.ARG_PAYMENT_TYPE, PaymentMethod.VISA) - SnabbleUI.executeAction(context, SnabbleUI.Event.SHOW_CREDIT_CARD_INPUT, args) - } - - PaymentMethod.AMEX -> { - args.putString(CreditCardInputView.ARG_PROJECT_ID, projectId) - args.putSerializable(CreditCardInputView.ARG_PAYMENT_TYPE, PaymentMethod.AMEX) - SnabbleUI.executeAction(context, SnabbleUI.Event.SHOW_CREDIT_CARD_INPUT, args) - } - - PaymentMethod.MASTERCARD -> { - args.putString(CreditCardInputView.ARG_PROJECT_ID, projectId) - args.putSerializable(CreditCardInputView.ARG_PAYMENT_TYPE, PaymentMethod.MASTERCARD) - SnabbleUI.executeAction(context, SnabbleUI.Event.SHOW_CREDIT_CARD_INPUT, args) - } - - PaymentMethod.GIROPAY -> { - SnabbleUI.executeAction(context, SnabbleUI.Event.SHOW_GIROPAY_INPUT) - } - - PaymentMethod.DE_DIRECT_DEBIT -> { - SnabbleUI.executeAction(context, SnabbleUI.Event.SHOW_SEPA_CARD_INPUT) - } - - PaymentMethod.PAYONE_SEPA -> { - SnabbleUI.executeAction(context, SnabbleUI.Event.SHOW_PAYONE_SEPA) - } + when { + useDatatrans -> Datatrans.registerCard(activity, project, paymentMethod) + usePayone -> Payone.registerCard(activity, project, paymentMethod, Snabble.formPrefillData) + useFiserv -> { + args.putString(FiservInputView.ARG_PROJECT_ID, projectId) + args.putSerializable(FiservInputView.ARG_PAYMENT_TYPE, paymentMethod) + SnabbleUI.executeAction(context, SnabbleUI.Event.SHOW_FISERV_INPUT, args) + } + paymentMethod == PaymentMethod.EXTERNAL_BILLING -> { + args.putString(ARG_PROJECT_ID, projectId) + SnabbleUI.executeAction(context, SnabbleUI.Event.SHOW_EXTERNAL_BILLING, args) + } - PaymentMethod.EXTERNAL_BILLING -> { - args.putString(ARG_PROJECT_ID, projectId) - SnabbleUI.executeAction(context, SnabbleUI.Event.SHOW_EXTERNAL_BILLING, args) - } + else -> { + val event = when (paymentMethod) { + PaymentMethod.GIROPAY -> SnabbleUI.Event.SHOW_GIROPAY_INPUT + PaymentMethod.DE_DIRECT_DEBIT -> SnabbleUI.Event.SHOW_SEPA_CARD_INPUT + PaymentMethod.PAYONE_SEPA -> SnabbleUI.Event.SHOW_PAYONE_SEPA + else -> null + } ?: return - else -> { - Logger.e("Payment method requires no credentials or is unsupported") - } - } + SnabbleUI.executeAction(context, event, args) } } } else { diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/creditcard/data/CreditCardUrlBuilder.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/creditcard/data/CreditCardUrlBuilder.kt index 3a29e87694..2033b74c20 100644 --- a/ui/src/main/java/io/snabble/sdk/ui/payment/creditcard/data/CreditCardUrlBuilder.kt +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/creditcard/data/CreditCardUrlBuilder.kt @@ -1,30 +1,27 @@ +@file:JvmName("SnabbleCreditCardUrlCreator") package io.snabble.sdk.ui.payment.creditcard.data import android.net.Uri import io.snabble.sdk.PaymentMethod import io.snabble.sdk.Snabble -class CreditCardUrlBuilder { - - fun createUrlFor(projectId: String, paymentType: PaymentMethod): String { - val builder = Uri.Builder() - val paymentMethod = when (paymentType) { - PaymentMethod.MASTERCARD -> "mastercard" - PaymentMethod.AMEX -> "amex" - PaymentMethod.VISA -> "visa" - else -> "visa" - } - val appUserId = Snabble.userPreferences.appUser?.id - val authority = Snabble.endpointBaseUrl.substringAfter("https://") - - return builder.scheme("https") - .authority(authority) - .appendPath(projectId) - .appendPath("telecash") - .appendPath("form") - .appendQueryParameter("platform", "android") - .appendQueryParameter("appUserID", appUserId) - .appendQueryParameter("paymentMethod", paymentMethod) - .build().toString() +fun createCreditCardUrlFor(paymentType: PaymentMethod, url: String): String { + val paymentMethod = when (paymentType) { + PaymentMethod.MASTERCARD -> "mastercard" + PaymentMethod.AMEX -> "amex" + PaymentMethod.VISA -> "visa" + else -> "visa" } + val appUserId = Snabble.userPreferences.appUser?.id + return Uri.parse(url) + .buildUpon() + .appendQueryParameter(PARAM_KEY_PLATFORM, "android") + .appendQueryParameter(PARAM_KEY_ADD_USER_ID, appUserId) + .appendQueryParameter(PARAM_KEY_PAYMENT_METHOD, paymentMethod) + .build() + .toString() } + +private const val PARAM_KEY_PLATFORM = "platform" +private const val PARAM_KEY_ADD_USER_ID = "appUserID" +private const val PARAM_KEY_PAYMENT_METHOD = "paymentMethod" diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/CustomerInfoInputScreen.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/CustomerInfoInputScreen.kt new file mode 100644 index 0000000000..6757a2147e --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/CustomerInfoInputScreen.kt @@ -0,0 +1,200 @@ +package io.snabble.sdk.ui.payment.fiserv + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +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.unit.dp +import io.snabble.sdk.ui.R +import io.snabble.sdk.ui.cart.shoppingcart.utils.rememberTextFieldManager +import io.snabble.sdk.ui.payment.fiserv.domain.Address +import io.snabble.sdk.ui.payment.fiserv.domain.CustomerInfo +import io.snabble.sdk.ui.payment.fiserv.domain.model.country.CountryItem +import io.snabble.sdk.ui.payment.fiserv.widget.CountrySelectionMenu +import io.snabble.sdk.ui.payment.fiserv.widget.TextInput + +@Composable +internal fun CustomerInfoInputScreen( + onSendAction: (CustomerInfo) -> Unit, + onErrorProcessed: () -> Unit, + showError: Boolean, + isLoading: Boolean, + countryItems: List?, + onBackNavigationClick: () -> Unit, +) { + var name by remember { mutableStateOf("") } + var phoneNumber by remember { mutableStateOf("") } + var email by remember { mutableStateOf("") } + var street by remember { mutableStateOf("") } + var zip by remember { mutableStateOf("") } + var city by remember { mutableStateOf("") } + var state by remember { mutableStateOf("") } + var country by remember { mutableStateOf("") } + + val textFieldManager = rememberTextFieldManager() + + val isRequiredStateSet = + if (!countryItems?.firstOrNull { it.code == country }?.stateItems.isNullOrEmpty()) state.isNotEmpty() else true + val areRequiredFieldsSet = + listOf(name, phoneNumber, email, street, zip, city, country).all { it.isNotEmpty() } && isRequiredStateSet + + val createCustomerInfo: () -> CustomerInfo = { + CustomerInfo( + name = name, + phoneNumber = phoneNumber, + email = email, + address = Address( + street = street, zip = zip, city = city, state = state, country = country + ), + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + TextInput( + modifier = Modifier.fillMaxWidth(), + value = name, + onValueChanged = { + name = it + if (showError) onErrorProcessed() + }, + label = stringResource(R.string.Snabble_Payment_CustomerInfo_fullName), + keyboardActions = KeyboardActions( + onNext = { textFieldManager.moveFocusToNext() } + ) + ) + TextInput( + modifier = Modifier.fillMaxWidth(), + value = phoneNumber, + onValueChanged = { + phoneNumber = it + if (showError) onErrorProcessed() + }, + label = stringResource(R.string.Snabble_Payment_CustomerInfo_phoneNumber), + keyboardActions = KeyboardActions( + onNext = { textFieldManager.moveFocusToNext() } + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Phone, + imeAction = ImeAction.Next + ) + ) + TextInput( + modifier = Modifier.fillMaxWidth(), + value = email, + onValueChanged = { + email = it + if (showError) onErrorProcessed() + }, + label = stringResource(R.string.Snabble_Payment_CustomerInfo_email), + keyboardActions = KeyboardActions( + onNext = { textFieldManager.moveFocusToNext() } + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + TextInput( + modifier = Modifier.fillMaxWidth(), + value = street, + onValueChanged = { + street = it + if (showError) onErrorProcessed() + }, + label = stringResource(R.string.Snabble_Payment_CustomerInfo_street), + keyboardActions = KeyboardActions( + onNext = { textFieldManager.moveFocusToNext() } + ), + ) + TextInput( + modifier = Modifier.fillMaxWidth(), + value = zip, + onValueChanged = { + zip = it + if (showError) onErrorProcessed() + }, + label = stringResource(R.string.Snabble_Payment_CustomerInfo_zip), + keyboardActions = KeyboardActions( + onNext = { textFieldManager.moveFocusToNext() } + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next) + ) + TextInput( + modifier = Modifier.fillMaxWidth(), + value = city, + onValueChanged = { + city = it + if (showError) onErrorProcessed() + }, + label = stringResource(R.string.Snabble_Payment_CustomerInfo_city), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + capitalization = KeyboardCapitalization.Words + ), + keyboardActions = KeyboardActions( + onDone = { textFieldManager.clearFocusAndHideKeyboard() } + ) + ) + CountrySelectionMenu( + countryItems = countryItems, + selectedCountryCode = country, + selectedStateCode = null, + onCountrySelected = { (_, countryCode), stateItem -> + country = countryCode + state = stateItem?.code.orEmpty() + if (showError) onErrorProcessed() + } + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { onSendAction(createCustomerInfo()) }, + enabled = !isLoading && areRequiredFieldsSet + ) { + Text(stringResource(R.string.Snabble_Payment_CustomerInfo_next)) + } + AnimatedVisibility(visible = showError) { + Text( + stringResource(R.string.Snabble_Payment_CustomerInfo_error), + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium + ) + } + } + TextButton( + modifier = Modifier.fillMaxWidth(), + onClick = onBackNavigationClick + ) { + Text(text = stringResource(R.string.Snabble_cancel)) + } + } +} diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservInputActivity.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservInputActivity.kt new file mode 100644 index 0000000000..c9f8a177e5 --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservInputActivity.kt @@ -0,0 +1,16 @@ +package io.snabble.sdk.ui.payment.fiserv + +import androidx.fragment.app.Fragment +import io.snabble.sdk.ui.BaseFragmentActivity + +class FiservInputActivity : BaseFragmentActivity() { + + override fun onCreateFragment(): Fragment = FiservInputFragment() + .apply { arguments = intent.extras } + + companion object { + + const val ARG_PROJECT_ID = FiservInputView.ARG_PROJECT_ID + const val ARG_PAYMENT_TYPE = FiservInputView.ARG_PAYMENT_TYPE + } +} diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservInputFragment.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservInputFragment.kt new file mode 100644 index 0000000000..62f44d05b7 --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservInputFragment.kt @@ -0,0 +1,69 @@ +package io.snabble.sdk.ui.payment.fiserv + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.viewinterop.AndroidView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.snabble.sdk.PaymentMethod +import io.snabble.sdk.Snabble +import io.snabble.sdk.ui.payment.PaymentMethodMetaDataHelper +import io.snabble.sdk.ui.utils.ThemeWrapper +import io.snabble.sdk.ui.utils.serializableExtra + +class FiservInputFragment : Fragment() { + + private val viewModel: FiservViewModel by viewModels { FiservViewModelFactory(requireContext()) } + + private lateinit var paymentMethod: PaymentMethod + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + paymentMethod = arguments?.serializableExtra(FiservInputView.ARG_PAYMENT_TYPE) + ?: kotlin.run { activity?.onBackPressed(); return } + + (requireActivity() as? AppCompatActivity)?.supportActionBar?.title = + PaymentMethodMetaDataHelper(requireContext()).labelFor(paymentMethod) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = + ComposeView(inflater.context).apply { + setContent { + val uiState: UiState = viewModel.uiState.collectAsStateWithLifecycle().value + + when { + uiState.formUrl == null -> + ThemeWrapper { + CustomerInfoInputScreen( + onErrorProcessed = { viewModel.errorHandled() }, + isLoading = uiState.isLoading, + onSendAction = { viewModel.sendUserData(it) }, + showError = uiState.showError, + countryItems = uiState.countryItems, + onBackNavigationClick = { activity?.onBackPressed() } + ) + } + + else -> AndroidView( + factory = { context -> + FiservInputView(context) + .apply { + load( + Snabble.checkedInProject.value?.id, + paymentMethod, + uiState.formUrl, + uiState.deletePreAuthUrl + ) + } + } + ) + } + } + } +} diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/CreditCardInputView.java b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservInputView.java similarity index 90% rename from ui/src/main/java/io/snabble/sdk/ui/payment/CreditCardInputView.java rename to ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservInputView.java index 7f7f664217..87629bab09 100644 --- a/ui/src/main/java/io/snabble/sdk/ui/payment/CreditCardInputView.java +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservInputView.java @@ -1,4 +1,4 @@ -package io.snabble.sdk.ui.payment; +package io.snabble.sdk.ui.payment.fiserv; import android.annotation.SuppressLint; import android.app.Activity; @@ -24,6 +24,7 @@ import org.jetbrains.annotations.Nullable; +import java.io.IOException; import java.math.BigDecimal; import java.text.NumberFormat; import java.util.Currency; @@ -36,14 +37,15 @@ import io.snabble.sdk.ui.R; import io.snabble.sdk.ui.SnabbleUI; import io.snabble.sdk.ui.payment.creditcard.data.CreditCardInfo; -import io.snabble.sdk.ui.payment.creditcard.data.CreditCardUrlBuilder; +import io.snabble.sdk.ui.payment.creditcard.data.SnabbleCreditCardUrlCreator; import io.snabble.sdk.ui.telemetry.Telemetry; import io.snabble.sdk.ui.utils.UIUtils; import io.snabble.sdk.utils.Dispatch; import io.snabble.sdk.utils.Logger; import io.snabble.sdk.utils.SimpleActivityLifecycleCallbacks; +import okhttp3.Request; -public class CreditCardInputView extends RelativeLayout { +public class FiservInputView extends RelativeLayout { public static final String ARG_PROJECT_ID = "projectId"; public static final String ARG_PAYMENT_TYPE = "paymentType"; @@ -56,18 +58,20 @@ public class CreditCardInputView extends RelativeLayout { private PaymentMethod paymentType; private String projectId; + private String formUrl; + private String deleteUrl; private TextView threeDHint; private boolean isLoaded; - public CreditCardInputView(Context context) { + public FiservInputView(Context context) { super(context); } - public CreditCardInputView(Context context, AttributeSet attrs) { + public FiservInputView(Context context, AttributeSet attrs) { super(context, attrs); } - public CreditCardInputView(Context context, AttributeSet attrs, int defStyleAttr) { + public FiservInputView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @@ -162,10 +166,11 @@ private void setProject() { } } - public void load(String projectId, PaymentMethod paymentType) { - this.projectId = projectId; + public void load(String projectId, PaymentMethod paymentType, String formUrl, String deletePreAuthUrl) { this.paymentType = paymentType; - + this.projectId = projectId; + this.formUrl = Snabble.getInstance().absoluteUrl(formUrl); + this.deleteUrl = Snabble.getInstance().absoluteUrl(deletePreAuthUrl); inflateView(); } @@ -190,9 +195,8 @@ private Project getProject() { } private void loadUrl() { - CreditCardUrlBuilder builder = new CreditCardUrlBuilder(); - String url = builder.createUrlFor(projectId, paymentType); - webView.loadUrl(url); + final String formUrl = SnabbleCreditCardUrlCreator.createCreditCardUrlFor(paymentType, this.formUrl); + webView.loadUrl(formUrl); } private void authenticateAndSave(final CreditCardInfo creditCardInfo) { @@ -257,9 +261,23 @@ private void save(CreditCardInfo info) { } private void finish() { + deletePreAuth(); SnabbleUI.executeAction(getContext(), SnabbleUI.Event.GO_BACK); } + private void deletePreAuth() { + Dispatch.io(() -> { + final Request request = new Request.Builder().url(deleteUrl).delete().build(); + try { + final Project project = getProject(); + if (project != null) { + project.getOkHttpClient().newCall(request).execute(); + } + } catch (final IOException ignored) { + } + }); + } + private void finishWithError(String failReason) { String errorMessage = getContext().getString(R.string.Snabble_Payment_CreditCard_error); if (failReason != null) { @@ -363,7 +381,7 @@ public void fail(String failReason) { @JavascriptInterface public void abort() { - Dispatch.mainThread(CreditCardInputView.this::finish); + Dispatch.mainThread(FiservInputView.this::finish); } @JavascriptInterface diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservViewModel.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservViewModel.kt new file mode 100644 index 0000000000..39ff761eeb --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservViewModel.kt @@ -0,0 +1,61 @@ +package io.snabble.sdk.ui.payment.fiserv + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.snabble.sdk.PaymentMethod +import io.snabble.sdk.ui.payment.fiserv.domain.CountryItemsRepository +import io.snabble.sdk.ui.payment.fiserv.domain.CustomerInfo +import io.snabble.sdk.ui.payment.fiserv.domain.FiservRepository +import io.snabble.sdk.ui.payment.fiserv.domain.model.country.CountryItem +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal class FiservViewModel( + private val fiservRepo: FiservRepository, + countryItemsRepo: CountryItemsRepository, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val _uiState = MutableStateFlow(UiState(countryItems = countryItemsRepo.loadCountryItems())) + val uiState: StateFlow = _uiState.asStateFlow() + + private val paymentMethod = savedStateHandle.get(FiservInputView.ARG_PAYMENT_TYPE) + + fun sendUserData(customerInfo: CustomerInfo) { + viewModelScope.launch { + paymentMethod ?: return@launch + + _uiState.update { it.copy(isLoading = true, showError = false) } + + fiservRepo.sendUserData(customerInfo, paymentMethod) + .onSuccess { info -> + _uiState.update { + it.copy( + isLoading = false, + formUrl = info.formUrl, + deletePreAuthUrl = info.preAuthDeleteUrl + ) + } + } + .onFailure { + _uiState.update { it.copy(isLoading = false, showError = true) } + } + } + } + + fun errorHandled() { + _uiState.update { it.copy(showError = false) } + } +} + +internal data class UiState( + val isLoading: Boolean = false, + val formUrl: String? = null, + val countryItems: List, + val deletePreAuthUrl: String? = null, + val showError: Boolean = false +) diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservViewModelFactory.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservViewModelFactory.kt new file mode 100644 index 0000000000..d43bc4e93c --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/FiservViewModelFactory.kt @@ -0,0 +1,33 @@ +package io.snabble.sdk.ui.payment.fiserv + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras +import io.snabble.sdk.ui.payment.fiserv.data.CountryItemsRepositoryImpl +import io.snabble.sdk.ui.payment.fiserv.data.FiservRepositoryImpl +import io.snabble.sdk.ui.payment.fiserv.data.country.LocalCountryItemsDataSourceImpl +import io.snabble.sdk.utils.GsonHolder + +class FiservViewModelFactory(private val context: Context) : ViewModelProvider.Factory { + + override fun create(modelClass: Class, extras: CreationExtras): T { + if (!modelClass.isAssignableFrom(FiservViewModel::class.java)) { + throw IllegalArgumentException("Unable to construct viewmodel") + } + + val savedStateHandle = extras.createSavedStateHandle() + @Suppress("UNCHECKED_CAST") + return FiservViewModel( + fiservRepo = FiservRepositoryImpl(), + countryItemsRepo = CountryItemsRepositoryImpl( + localCountryItemsDataSource = LocalCountryItemsDataSourceImpl( + assetManager = context.assets, + gson = GsonHolder.get() + ) + ), + savedStateHandle = savedStateHandle + ) as T + } +} diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/CountryItemsRepositoryImpl.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/CountryItemsRepositoryImpl.kt new file mode 100644 index 0000000000..d0c153bb93 --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/CountryItemsRepositoryImpl.kt @@ -0,0 +1,12 @@ +package io.snabble.sdk.ui.payment.fiserv.data + +import io.snabble.sdk.ui.payment.fiserv.data.country.LocalCountryItemsDataSource +import io.snabble.sdk.ui.payment.fiserv.domain.CountryItemsRepository +import io.snabble.sdk.ui.payment.fiserv.domain.model.country.CountryItem + +internal class CountryItemsRepositoryImpl( + private val localCountryItemsDataSource: LocalCountryItemsDataSource, +) : CountryItemsRepository { + + override fun loadCountryItems(): List = localCountryItemsDataSource.loadCountries() +} diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/CustomerInfoDto.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/CustomerInfoDto.kt new file mode 100644 index 0000000000..312cd1c2cf --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/CustomerInfoDto.kt @@ -0,0 +1,21 @@ +package io.snabble.sdk.ui.payment.fiserv.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class CustomerInfoDto( + @SerialName("name") val name: String, + @SerialName("phoneNumber") val phoneNumber: String, + @SerialName("email") val email: String, + @SerialName("address") val address: AddressDto, +) + +@Serializable +internal data class AddressDto( + @SerialName("street") val street: String, + @SerialName("zip") val zip: String, + @SerialName("city") val city: String, + @SerialName("state") val state: String?, + @SerialName("country") val country: String +) diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/FiservRemoteDataSource.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/FiservRemoteDataSource.kt new file mode 100644 index 0000000000..f449ba715c --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/FiservRemoteDataSource.kt @@ -0,0 +1,99 @@ +package io.snabble.sdk.ui.payment.fiserv.data + +import android.util.Log +import com.google.gson.Gson +import com.google.gson.JsonSyntaxException +import com.google.gson.annotations.SerializedName +import io.snabble.sdk.PaymentMethod +import io.snabble.sdk.Snabble +import io.snabble.sdk.utils.GsonHolder +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal interface FiservRemoteDataSource { + + suspend fun sendUserData(customerInfo: CustomerInfoDto, paymentMethod: PaymentMethod): Result +} + +internal class FiservRemoteDataSourceImpl( + private val snabble: Snabble = Snabble, + private val gson: Gson = GsonHolder.get(), +) : FiservRemoteDataSource { + + override suspend fun sendUserData( + customerInfo: CustomerInfoDto, + paymentMethod: PaymentMethod + ): Result { + val project = snabble.checkedInProject.value ?: return Result.failure(Exception("Missing projectId")) + + val customerInfoPostUrl = project.paymentMethodDescriptors + .firstOrNull { it.paymentMethod == paymentMethod } + ?.links + ?.get("tokenization") + ?.href + ?.let(snabble::absoluteUrl) + ?: return Result.failure(Exception("Missing link to send customer info to")) + + val requestBody: RequestBody = gson.toJson(customerInfo).toRequestBody("application/json".toMediaType()) + val request: Request = Request.Builder() + .url(customerInfoPostUrl) + .post(requestBody) + .build() + + return project.okHttpClient.post(request) + } + + private suspend fun OkHttpClient.post(request: Request) = suspendCoroutine> { + newCall(request).enqueue(object : Callback { + + override fun onResponse(call: Call, response: Response) { + when { + response.isSuccessful -> { + val body = response.body?.string() + val creditCardAuthData: CreditCardAuthData? = try { + gson.fromJson(body, CreditCardAuthData::class.java) + } catch (e: JsonSyntaxException) { + Log.e("Fiserv", "Error parsing pre-registration response", e) + null + } + + val result = if (creditCardAuthData == null) { + Result.failure(Exception("Missing content")) + } else { + Result.success(creditCardAuthData) + } + it.resume(result) + } + + else -> it.resume(Result.failure(Exception(response.message))) + } + } + + override fun onFailure(call: Call, e: IOException) { + it.resume(Result.failure(e)) + } + }) + } +} + +internal data class CreditCardAuthData( + @SerializedName("links") val links: Links +) + +internal data class Links( + @SerializedName("self") val deleteUrl: Link, + @SerializedName("tokenizationForm") val formUrl: Link +) + +internal data class Link( + @SerializedName("href") val href: String +) diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/FiservRepositoryImpl.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/FiservRepositoryImpl.kt new file mode 100644 index 0000000000..ea5339e0fc --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/FiservRepositoryImpl.kt @@ -0,0 +1,36 @@ +package io.snabble.sdk.ui.payment.fiserv.data + +import io.snabble.sdk.PaymentMethod +import io.snabble.sdk.ui.payment.fiserv.domain.CustomerInfo +import io.snabble.sdk.ui.payment.fiserv.domain.FiservRepository + +internal class FiservRepositoryImpl( + private val remoteDataSource: FiservRemoteDataSource = FiservRemoteDataSourceImpl() +) : FiservRepository { + + override suspend fun sendUserData( + customerInfo: CustomerInfo, + paymentMethod: PaymentMethod + ): Result = + remoteDataSource + .sendUserData(customerInfo.toDto(), paymentMethod) + .map { FiservCardRegisterUrls(it.links.formUrl.href, it.links.deleteUrl.href) } +} + +private fun CustomerInfo.toDto() = CustomerInfoDto( + name = name, + phoneNumber = phoneNumber, + email = email, + address = AddressDto( + street = address.street, + zip = address.zip, + city = address.city, + state = address.state.ifEmpty { null }, + country = address.country + ), +) + +data class FiservCardRegisterUrls( + val formUrl: String, + val preAuthDeleteUrl: String +) diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/country/LocalCountryItemsDataSource.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/country/LocalCountryItemsDataSource.kt new file mode 100644 index 0000000000..5f4b7d5eeb --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/country/LocalCountryItemsDataSource.kt @@ -0,0 +1,8 @@ +package io.snabble.sdk.ui.payment.fiserv.data.country + +import io.snabble.sdk.ui.payment.fiserv.domain.model.country.CountryItem + +internal interface LocalCountryItemsDataSource { + + fun loadCountries(): List +} diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/country/LocalCountryItemsDataSourceImpl.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/country/LocalCountryItemsDataSourceImpl.kt new file mode 100644 index 0000000000..53c32201a4 --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/country/LocalCountryItemsDataSourceImpl.kt @@ -0,0 +1,39 @@ +package io.snabble.sdk.ui.payment.fiserv.data.country + +import android.content.res.AssetManager +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import io.snabble.sdk.ui.payment.fiserv.domain.model.country.StateItem +import io.snabble.sdk.ui.payment.fiserv.data.dto.country.CountryDto +import io.snabble.sdk.ui.payment.fiserv.domain.model.country.CountryItem +import java.util.Locale + +internal class LocalCountryItemsDataSourceImpl( + private val assetManager: AssetManager, + private val gson: Gson, +) : LocalCountryItemsDataSource { + + override fun loadCountries(): List { + val typeToken = object : TypeToken>() {}.type + return gson.fromJson>( + assetManager.open(COUNTRIES_AND_STATES_FILE).reader(), + typeToken + ) + .map { (countryCode, states) -> + CountryItem( + displayName = countryCode.displayName, + code = countryCode, + stateItems = states?.map { StateItem.from(it) } + ) + } + .sortedBy { it.displayName } + } + + companion object { + + private const val COUNTRIES_AND_STATES_FILE = "countriesAndStates.json" + } +} + +val String.displayName: String + get() = Locale("", this).displayName diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/dto/country/CountryDto.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/dto/country/CountryDto.kt new file mode 100644 index 0000000000..7cb9c5b703 --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/dto/country/CountryDto.kt @@ -0,0 +1,8 @@ +package io.snabble.sdk.ui.payment.fiserv.data.dto.country + +import com.google.gson.annotations.SerializedName + +internal data class CountryDto( + @SerializedName("code") val countryCode: String, + @SerializedName("states") val states: List? = null, +) diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/dto/country/StateDto.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/dto/country/StateDto.kt new file mode 100644 index 0000000000..333651b935 --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/data/dto/country/StateDto.kt @@ -0,0 +1,5 @@ +package io.snabble.sdk.ui.payment.fiserv.data.dto.country + +import com.google.gson.annotations.SerializedName + +internal class StateDto(@SerializedName("name") val displayName: String, @SerializedName("code") val code: String) diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/CountryItemsRepository.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/CountryItemsRepository.kt new file mode 100644 index 0000000000..bb81032f3d --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/CountryItemsRepository.kt @@ -0,0 +1,8 @@ +package io.snabble.sdk.ui.payment.fiserv.domain + +import io.snabble.sdk.ui.payment.fiserv.domain.model.country.CountryItem + +internal interface CountryItemsRepository { + + fun loadCountryItems(): List +} diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/CustomerInfo.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/CustomerInfo.kt new file mode 100644 index 0000000000..8afc10340e --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/CustomerInfo.kt @@ -0,0 +1,16 @@ +package io.snabble.sdk.ui.payment.fiserv.domain + +data class CustomerInfo( + val name: String, + val phoneNumber: String, + val email: String, + val address: Address, +) + +data class Address( + val street: String, + val zip: String, + val city: String, + val state: String, + val country: String +) diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/FiservRepository.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/FiservRepository.kt new file mode 100644 index 0000000000..7bd9a5129e --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/FiservRepository.kt @@ -0,0 +1,9 @@ +package io.snabble.sdk.ui.payment.fiserv.domain + +import io.snabble.sdk.PaymentMethod +import io.snabble.sdk.ui.payment.fiserv.data.FiservCardRegisterUrls + +internal interface FiservRepository { + + suspend fun sendUserData(customerInfo: CustomerInfo, paymentMethod: PaymentMethod): Result +} diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/model/country/CountryItem.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/model/country/CountryItem.kt new file mode 100644 index 0000000000..c29020cb6c --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/model/country/CountryItem.kt @@ -0,0 +1,3 @@ +package io.snabble.sdk.ui.payment.fiserv.domain.model.country + +internal data class CountryItem(val displayName: String, val code: String, val stateItems: List?) diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/model/country/StateItem.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/model/country/StateItem.kt new file mode 100644 index 0000000000..ea2fe103dd --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/domain/model/country/StateItem.kt @@ -0,0 +1,13 @@ +package io.snabble.sdk.ui.payment.fiserv.domain.model.country + +import io.snabble.sdk.ui.payment.fiserv.data.dto.country.StateDto + +internal data class StateItem(val displayName: String, val code: String) { + companion object { + + fun from(stateDto: StateDto) = StateItem( + displayName = stateDto.displayName, + code = stateDto.code + ) + } +} diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/widget/CountrySelectionMenu.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/widget/CountrySelectionMenu.kt new file mode 100644 index 0000000000..73146a94c8 --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/widget/CountrySelectionMenu.kt @@ -0,0 +1,123 @@ +package io.snabble.sdk.ui.payment.fiserv.widget + +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.sp +import io.snabble.sdk.ui.payment.fiserv.domain.model.country.StateItem +import io.snabble.sdk.ui.R +import io.snabble.sdk.ui.payment.fiserv.data.country.displayName +import io.snabble.sdk.ui.payment.fiserv.domain.model.country.CountryItem +import java.util.Locale + +@Composable +internal fun CountrySelectionMenu( + modifier: Modifier = Modifier, + countryItems: List?, + selectedCountryCode: String? = null, + selectedStateCode: String? = null, + onCountrySelected: (CountryItem, StateItem?) -> Unit, +) { + var showCountryList by remember { mutableStateOf(false) } + var dismissCountryList by remember { mutableStateOf(true) } + + var showStateList by remember { mutableStateOf(false) } + var dismissStateList by remember { mutableStateOf(true) } + + var currentCountryItem by remember { + mutableStateOf( + selectedCountryCode?.let { countryCode -> countryItems?.firstOrNull { it.code == countryCode } } + ?: countryItems.loadDefaultCountry() + .also { country -> onCountrySelected(country, null) } + ) + } + + var currentStateItem by remember { + mutableStateOf( + selectedStateCode?.let { stateCode -> + currentCountryItem.stateItems?.firstOrNull { it.code == stateCode } + } + ) + } + + fun validateStateItem() { + if (currentCountryItem.stateItems?.contains(currentStateItem) != true) { + currentStateItem = null + } + } + + DropDownMenu( + modifier = modifier, + isExpanded = showCountryList && !dismissCountryList, + onExpand = { + showCountryList = !showCountryList + dismissCountryList = false + }, + onDismiss = { dismissCountryList = true }, + label = stringResource(id = R.string.Snabble_Payment_CustomerInfo_country), + value = currentCountryItem.displayName, + menuItems = countryItems + ) { country -> + DropdownMenuItem( + text = { + Text( + text = country.displayName, + style = MaterialTheme.typography.bodyLarge, + fontSize = 17.sp + ) + }, + onClick = { + currentCountryItem = country + validateStateItem() + onCountrySelected(country, currentStateItem) + showCountryList = false + } + ) + } + if (currentCountryItem.stateItems != null) { + DropDownMenu( + modifier = modifier, + isExpanded = showStateList && !dismissStateList, + onExpand = { + showStateList = !showStateList + dismissStateList = false + }, + onDismiss = { dismissStateList = true }, + label = stringResource(id = R.string.Snabble_Payment_CustomerInfo_state), + value = currentStateItem?.displayName + ?: stringResource(id = R.string.Snabble_Payment_CustomerInfo_stateSelect), + menuItems = currentCountryItem.stateItems + ) { state -> + DropdownMenuItem( + text = { + Text( + text = state.displayName, + style = MaterialTheme.typography.bodyLarge, + fontSize = 17.sp + ) + }, + onClick = { + currentStateItem = state + onCountrySelected(currentCountryItem, state) + showStateList = false + } + ) + } + } +} + +private fun List?.loadDefaultCountry(): CountryItem = + this?.firstOrNull { it.displayName == Locale.getDefault().country.displayName } + ?: this?.firstOrNull { it.code == Locale.GERMANY.displayCountry } + ?: CountryItem( + displayName = Locale.GERMANY.country.displayName, + code = Locale.GERMANY.country, + stateItems = null + ) diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/widget/DropDownMenu.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/widget/DropDownMenu.kt new file mode 100644 index 0000000000..25a2c425cc --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/widget/DropDownMenu.kt @@ -0,0 +1,111 @@ +package io.snabble.sdk.ui.payment.fiserv.widget + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.PopupProperties + +@Composable +internal fun DropDownMenu( + modifier: Modifier = Modifier, + isExpanded: Boolean, + onExpand: () -> Unit, + onDismiss: () -> Unit, + label: String, + value: String, + menuItems: List?, + content: @Composable (T) -> Unit, +) { + var maxWidth by remember { mutableStateOf(IntSize.Zero) } + val density = LocalDensity.current + Column( + modifier = Modifier + .fillMaxWidth() + .then(modifier), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .clickable { onExpand() } + .onGloballyPositioned { + maxWidth = it.size + }, + value = value, + onValueChange = {}, + textStyle = MaterialTheme.typography.bodyLarge, + label = { + Text( + text = label, + fontSize = 17.sp + ) + }, + colors = TextFieldDefaults.colors( + disabledContainerColor = MaterialTheme.colorScheme.background, + disabledTextColor = MaterialTheme.colorScheme.onSurface, + disabledLabelColor = MaterialTheme.colorScheme.onSurface, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurface + ), + trailingIcon = { + Icon( + if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = "" + ) + }, + readOnly = true, + enabled = false + ) + } + DropdownMenu( + modifier = Modifier + .background(MaterialTheme.colorScheme.surface) + .height(300.dp) + .widthIn(min = (maxWidth.width / density.density).dp), + expanded = isExpanded, + onDismissRequest = onDismiss, + properties = PopupProperties( + clippingEnabled = false, + dismissOnClickOutside = true + ) + ) { + menuItems?.forEach { selectedItem -> + content(selectedItem) + HorizontalDivider( + thickness = Dp.Hairline, + color = Color.LightGray + ) + } + } + } +} diff --git a/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/widget/TextInput.kt b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/widget/TextInput.kt new file mode 100644 index 0000000000..5f5a40ccbc --- /dev/null +++ b/ui/src/main/java/io/snabble/sdk/ui/payment/fiserv/widget/TextInput.kt @@ -0,0 +1,78 @@ +package io.snabble.sdk.ui.payment.fiserv.widget + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +internal fun TextInput( + modifier: Modifier = Modifier, + value: String, + label: String, + isError: Boolean = false, + onValueChanged: (String) -> Unit, + keyboardOptions: KeyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + capitalization = KeyboardCapitalization.Words + ), + keyboardActions: KeyboardActions, +) { + var textFieldValue by remember { + mutableStateOf(TextFieldValue(text = value, selection = TextRange(value.length, value.length))) + } + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { + OutlinedTextField( + modifier = modifier, + value = textFieldValue, + onValueChange = { + onValueChanged(it.text) + textFieldValue = it + }, + textStyle = MaterialTheme.typography.bodyLarge, + label = { + Text( + text = label, + fontSize = 17.sp + ) + }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + isError = isError, + maxLines = 1, + singleLine = true, + colors = TextFieldDefaults.defaultColors(), + ) + } +} + +@Composable +private fun TextFieldDefaults.defaultColors() = colors( + focusedContainerColor = MaterialTheme.colorScheme.background, + unfocusedContainerColor = MaterialTheme.colorScheme.background, + focusedIndicatorColor = MaterialTheme.colorScheme.primary, + unfocusedIndicatorColor = MaterialTheme.colorScheme.onSurface, + focusedLabelColor = MaterialTheme.colorScheme.primary, + errorContainerColor = MaterialTheme.colorScheme.background, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurface, + errorCursorColor = Color.Red, + errorIndicatorColor = Color.Red, + errorLabelColor = Color.Red +) diff --git a/ui/src/main/res/layout/snabble_fragment_cardinput_creditcard.xml b/ui/src/main/res/layout/snabble_fragment_cardinput_creditcard.xml deleted file mode 100644 index f1feb61099..0000000000 --- a/ui/src/main/res/layout/snabble_fragment_cardinput_creditcard.xml +++ /dev/null @@ -1,6 +0,0 @@ - - \ No newline at end of file diff --git a/ui/src/main/res/layout/snabble_fragment_cardinput_fiserv.xml b/ui/src/main/res/layout/snabble_fragment_cardinput_fiserv.xml new file mode 100644 index 0000000000..e28d2e0d23 --- /dev/null +++ b/ui/src/main/res/layout/snabble_fragment_cardinput_fiserv.xml @@ -0,0 +1,5 @@ + + diff --git a/ui/src/main/res/values-de/strings.xml b/ui/src/main/res/values-de/strings.xml index 67b1a806d0..63495323eb 100644 --- a/ui/src/main/res/values-de/strings.xml +++ b/ui/src/main/res/values-de/strings.xml @@ -101,6 +101,17 @@ Abbrechen nicht möglich Bei der Verarbeitung deiner Kreditkarte ist ein Fehler aufgetreten Läuft ab: %s + Stadt + Land + E-Mail-Adresse + Bitte überprüfe deine Eingaben und versuche es erneut + Vor- und Nachname + Weiter + Telefonnummer + Staat + Bitte wählen + Straße inkl. Hausnummer + Postleitzahl Möchtest Du diese Zahlungsmethode wirklich entfernen? Zahlungsmethode hinzufügen Noch keine Zahlungsmethode hinterlegt. @@ -219,7 +230,7 @@ Ablaufjahr (JJJJ) Bitte fülle das Formular vollständig aus. Nachname - Bundesland + Staat Bitte Staat auswählen Straße inkl. Hausnummer Postleitzahl diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index a391f53dc7..e085da7df9 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -101,6 +101,17 @@ Error cancelling payment There was an error processing your credit card Expires: %s + City + Country + Email + Please check your entries and try again + Full name + Continue + Phone number + State + Pick one + Street + ZIP code Are you sure you want to remove this payment method? Add payment method You don\'t have any payment methods added yet.