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.