diff --git a/LICENSE b/LICENSE index 40ca753..f0c581f 100644 --- a/LICENSE +++ b/LICENSE @@ -162,7 +162,7 @@ other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. - 9. Accepting Warranty or Additional Support. While redistributing + 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this diff --git a/README.md b/README.md index fcbe425..b2903b6 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,11 @@ # Spreedly Checkout — Android Example -> **This project is for demonstration and reference purposes only.** It is not intended for use in production environments. The code, configuration, and architecture shown here illustrate SDK integration patterns — adapt them to your own security requirements, backend infrastructure, and key management practices before shipping to production. - -This sample app demonstrates the [Spreedly Android Checkout SDK](https://github.com/spreedly/checkout-android-sdk) at version **1.0.1** (tag `v1.0.1`). +This sample app demonstrates the [Spreedly Android Checkout SDK](https://github.com/spreedly/checkout-android-sdk) at version **1.1.0** (tag `v1.1.0`). ## Setup 1. Clone this repository -2. Add your GitHub Packages credentials to `~/.gradle/gradle.properties`. Even though [checkout-android-maven](https://github.com/spreedly/checkout-android-maven) is public, GitHub Packages still requires authentication to download Maven artifacts. Create a PAT with `read:packages` scope and add: +2. Add your GitHub Packages credentials to `~/.gradle/gradle.properties`: ```properties gpr.usr=YOUR_GITHUB_USERNAME @@ -22,10 +20,10 @@ gpr.key=YOUR_GITHUB_TOKEN All SDK modules are resolved from GitHub Packages: ```kotlin -implementation("com.spreedly:checkout-paymentsheet:1.0.1") -implementation("com.spreedly:checkout-braintree-apm:1.0.1") -implementation("com.spreedly:checkout-stripe-apm:1.0.1") -implementation("com.spreedly:checkout-threeds:1.0.1") +implementation("com.spreedly:checkout-paymentsheet:1.1.0") +implementation("com.spreedly:checkout-braintree-apm:1.1.0") +implementation("com.spreedly:checkout-stripe-apm:1.1.0") +implementation("com.spreedly:checkout-threeds:1.1.0") ``` ## SDK Documentation @@ -35,11 +33,4 @@ implementation("com.spreedly:checkout-threeds:1.0.1") ## Support -- **Spreedly Documentation**: [docs.spreedly.com](https://docs.spreedly.com/) -- **Support Portal**: [spreedly.com/support](https://spreedly.com/support/) - -## Legal - -- [Terms of Service](https://legal.spreedly.com/#terms) -- [Privacy Policy](https://legal.spreedly.com/#privacy-policy) -- [License](LICENSE) (Apache 2.0) +For questions or issues, please contact the Spreedly team or create an issue in the [SDK repository](https://github.com/spreedly/checkout-android-sdk/issues). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5b4219..886f610 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -127,6 +127,12 @@ android { excludes += "META-INF/NOTICE.md" } } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } kotlin { @@ -137,10 +143,11 @@ kotlin { dependencies { // ✅ Use paymentsheet which includes payments-core and hosted-fields - implementation("com.spreedly:checkout-paymentsheet:1.0.1") - implementation("com.spreedly:checkout-braintree-apm:1.0.1") - implementation("com.spreedly:checkout-stripe-apm:1.0.1") - implementation("com.spreedly:checkout-threeds:1.0.1") + implementation("com.spreedly:checkout-paymentsheet:1.1.0") + implementation("com.spreedly:checkout-braintree-apm:1.1.0") + implementation("com.spreedly:checkout-stripe-apm:1.1.0") + implementation("com.spreedly:checkout-stripe-radar:1.1.0") + implementation("com.spreedly:checkout-threeds:1.1.0") implementation(libs.kotlinx.serialization.json) implementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/androidTest/java/com/spreedly/example/paymenttypes/BasicCheckoutMaskToggleTest.kt b/app/src/androidTest/java/com/spreedly/example/paymenttypes/BasicCheckoutMaskToggleTest.kt new file mode 100644 index 0000000..f1bed41 --- /dev/null +++ b/app/src/androidTest/java/com/spreedly/example/paymenttypes/BasicCheckoutMaskToggleTest.kt @@ -0,0 +1,48 @@ +package com.spreedly.example.paymenttypes + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.spreedly.example.screens.basiccheckout.BasicCheckoutScreen +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BasicCheckoutMaskToggleTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun basicCheckout_hasMerchantMaskToggle_andNoSdkPanEye() { + composeTestRule.setContent { + BasicCheckoutScreen() + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Pretty").assertIsDisplayed() + composeTestRule.onNodeWithText("Plain").assertIsDisplayed() + composeTestRule.onNodeWithText("Masked").assertIsDisplayed() + composeTestRule.onNodeWithText("toggleMask()").assertIsDisplayed() + + composeTestRule.onNode(hasContentDescription("Show card number")).assertDoesNotExist() + composeTestRule.onNode(hasContentDescription("Hide card number")).assertDoesNotExist() + } + + @Test + fun basicCheckout_formatSegmentsAreClickable() { + composeTestRule.setContent { + BasicCheckoutScreen() + } + + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Plain").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Masked").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText("Card Number").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/com/spreedly/example/paymenttypes/BasicCheckoutRotationInstrumentedTest.kt b/app/src/androidTest/java/com/spreedly/example/paymenttypes/BasicCheckoutRotationInstrumentedTest.kt new file mode 100644 index 0000000..1d6edb0 --- /dev/null +++ b/app/src/androidTest/java/com/spreedly/example/paymenttypes/BasicCheckoutRotationInstrumentedTest.kt @@ -0,0 +1,30 @@ +package com.spreedly.example.paymenttypes + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.spreedly.example.TestConfiguration +import com.spreedly.example.screens.basiccheckout.BasicCheckoutScreen +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BasicCheckoutRotationInstrumentedTest { + @get:Rule + val composeTestRule = createAndroidComposeRule() + + @Test + fun basicCheckout_recreate_preserves_screen() { + composeTestRule.setContent { + BasicCheckoutScreen() + } + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(TestConfiguration.UIElements.BASIC_CHECKOUT_TITLE).assertIsDisplayed() + composeTestRule.activityRule.scenario.recreate() + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(TestConfiguration.UIElements.BASIC_CHECKOUT_TITLE).assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/spreedly/example/JavaHostedFieldsActivity.java b/app/src/main/java/com/spreedly/example/JavaHostedFieldsActivity.java index 04edd19..a0a8be8 100644 --- a/app/src/main/java/com/spreedly/example/JavaHostedFieldsActivity.java +++ b/app/src/main/java/com/spreedly/example/JavaHostedFieldsActivity.java @@ -10,7 +10,6 @@ import androidx.compose.ui.platform.ComposeView; import androidx.cardview.widget.CardView; import android.widget.TextView; - import com.spreedly.app.R; import com.spreedly.example.models.SavedPaymentMethod; import com.spreedly.example.repository.PaymentMethodRepository; @@ -26,14 +25,14 @@ public class JavaHostedFieldsActivity extends AppCompatActivity { private static final String TAG = "JavaHostedFieldsActivity"; + private ComposeView merchantMaskToggleComposeView; private ComposeView hostedFieldsComposeView; private ComposeView recacheComposeView; private ComposeView savedCardsComposeView; private CardView tokenCard; private TextView tokenText; private Button retokenizeButton; - - // Configuration change helper + private ConfigurationChangeHelper configHelper; private Spreedly sdk; private PaymentMethodRepository repository; @@ -43,22 +42,19 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_java_hosted_fields); - // Initialize configuration change helper configHelper = new ConfigurationChangeHelper(this); sdk = configHelper.getSdk(); - - // Initialize Payment Method Repository + repository = new PaymentMethodRepository(getApplicationContext()); initializeViews(); setupHostedFieldsCompose(); setupRecacheUI(); loadSavedPaymentMethods(); - observeViewModelState(); - - // Handle configuration change restoration + observePaymentToken(); + configHelper.onCreate(savedInstanceState); - + Log.d(TAG, "Activity created with configuration change support for hosted fields"); } @@ -74,8 +70,8 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) { configHelper.onConfigurationChanged(); } - private void initializeViews() { + merchantMaskToggleComposeView = findViewById(R.id.merchant_mask_toggle_compose_view); hostedFieldsComposeView = findViewById(R.id.hosted_fields_compose_view); recacheComposeView = findViewById(R.id.recache_compose_view); savedCardsComposeView = findViewById(R.id.saved_cards_compose_view); @@ -83,7 +79,6 @@ private void initializeViews() { tokenText = findViewById(R.id.token_text); retokenizeButton = findViewById(R.id.retokenize_button); - // Initially hide token card tokenCard.setVisibility(View.GONE); retokenizeButton.setOnClickListener(v -> { @@ -94,96 +89,80 @@ private void initializeViews() { private void setupHostedFieldsCompose() { try { - // Use the helper to set up the hosted fields form with reactive state observation - JavaHostedFieldsWrapper.setupHostedFieldsCompose(hostedFieldsComposeView, sdk, configHelper.getViewModel()); - Log.d(TAG, "Hosted Fields ComposeView setup completed with reactive state observation"); + JavaHostedFieldsWrapper.setupMerchantMaskToggle( + merchantMaskToggleComposeView, + sdk, + this::refreshHostedFieldsCompose + ); + refreshHostedFieldsCompose(); + Log.d(TAG, "Hosted Fields ComposeView setup completed"); } catch (Exception e) { Log.e(TAG, "Failed to setup hosted fields ComposeView", e); - Toast.makeText(this, "Failed to setup hosted fields: " + e.getMessage(), + Toast.makeText(this, "Failed to setup hosted fields: " + e.getMessage(), Toast.LENGTH_LONG).show(); } } - private void observeViewModelState() { - // Monitor ViewModel state changes instead of using SpreedlyHelper to avoid - // multiple observers on the same SDK paymentResultFlow which can cause race conditions - - // Use a simple background thread to check for token changes - // The ViewModel already handles all payment result events properly - new Thread(() -> { - String lastToken = ""; - while (!isFinishing() && !isDestroyed()) { - try { - Thread.sleep(500); // Check every 500ms - - String currentToken = configHelper.getCurrentToken(); - if (currentToken != null && !currentToken.isEmpty() && !currentToken.equals(lastToken)) { - // New token detected - payment completed successfully - lastToken = currentToken; - runOnUiThread(() -> { - // Show the real token from the payment - tokenText.setText(currentToken); - tokenCard.setVisibility(View.VISIBLE); - - Toast.makeText(JavaHostedFieldsActivity.this, - "Payment method created successfully!", Toast.LENGTH_SHORT).show(); - Log.d(TAG, "Payment method created - form data preserved during orientation changes"); - }); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } catch (Exception e) { - // Continue monitoring - } - } - }).start(); - - Log.d(TAG, "ViewModel state observation setup complete for hosted fields"); + private void refreshHostedFieldsCompose() { + JavaHostedFieldsWrapper.setupHostedFieldsCompose( + hostedFieldsComposeView, + sdk, + configHelper.getViewModel() + ); + } + + private void observePaymentToken() { + JavaHostedFieldsWrapper.observePaymentToken( + this, + configHelper.getViewModel(), + this::showPaymentToken + ); + } + + private void showPaymentToken(String token) { + runOnUiThread(() -> { + tokenText.setText(token); + tokenCard.setVisibility(View.VISIBLE); + Toast.makeText( + JavaHostedFieldsActivity.this, + "Payment method created successfully!", + Toast.LENGTH_SHORT + ).show(); + }); } private void setupRecacheUI() { - // Set up the recache UI composable RecacheJavaHelper.setupRecacheUI(recacheComposeView, sdk); Log.d(TAG, "Recache UI setup completed"); } - + private void loadSavedPaymentMethods() { - // Initialize with mock data if empty repository.initializeMockDataIfEmpty(); - - // Get saved payment methods from repository List savedMethods = repository.getSavedPaymentMethods(); - - // Display saved cards using Compose displaySavedCards(savedMethods); - Log.d(TAG, "Loaded " + savedMethods.size() + " saved payment methods"); } - + private void displaySavedCards(List savedMethods) { if (savedMethods.isEmpty()) { return; } - + SpreedlyRecacheWrapper.setupSavedPaymentMethodsList( savedCardsComposeView, savedMethods, - // On card click - trigger recaching savedCard -> handleRecacheClick(savedCard), - // On delete click - remove from repository savedCard -> { repository.deletePaymentMethod(savedCard.getToken()); - loadSavedPaymentMethods(); // Reload list + loadSavedPaymentMethods(); Toast.makeText(this, "Card removed", Toast.LENGTH_SHORT).show(); } ); } - + private void handleRecacheClick(SavedPaymentMethod savedCard) { Log.d(TAG, "Recaching card: " + savedCard.getCardType() + " **** " + savedCard.getLastFourDigits()); - - // Create recache config - SDK automatically calculates CVV length from card type + var config = RecacheJavaHelper.createRecacheConfigFull( savedCard.getLastFourDigits(), savedCard.getCardType(), @@ -194,48 +173,31 @@ private void handleRecacheClick(SavedPaymentMethod savedCard) { "Confirm", "Cancel" ); - - // Trigger recaching + RecacheJavaHelper.recachePaymentMethod( - this, // LifecycleOwner + this, sdk, savedCard.getToken(), config, - // Success callback - updatedToken -> { - runOnUiThread(() -> { - // Display the updated token - tokenText.setText(updatedToken); - tokenCard.setVisibility(View.VISIBLE); - - Toast.makeText(this, - "CVV recached successfully!", - Toast.LENGTH_LONG).show(); - - }); - }, - // Error callback - errorMessage -> { - runOnUiThread(() -> { - Toast.makeText(this, - "Recaching failed: " + errorMessage, - Toast.LENGTH_LONG).show(); - - Log.e(TAG, "Recaching failed: " + errorMessage); - }); - } + updatedToken -> runOnUiThread(() -> { + tokenText.setText(updatedToken); + tokenCard.setVisibility(View.VISIBLE); + Toast.makeText(this, "CVV recached successfully!", Toast.LENGTH_LONG).show(); + }), + errorMessage -> runOnUiThread(() -> { + Toast.makeText(this, "Recaching failed: " + errorMessage, Toast.LENGTH_LONG).show(); + Log.e(TAG, "Recaching failed: " + errorMessage); + }) ); } @Override protected void onDestroy() { super.onDestroy(); - // Clear state when activity is finishing to prevent stale messages - // when navigating back to form list if (configHelper != null && isFinishing()) { configHelper.clearStateOnFinish(); Log.d(TAG, "Cleared state on activity finish"); } Log.d(TAG, "Hosted fields activity destroyed"); } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/spreedly/example/JavaHostedFieldsWrapper.kt b/app/src/main/java/com/spreedly/example/JavaHostedFieldsWrapper.kt index ba6a3c5..c698c46 100644 --- a/app/src/main/java/com/spreedly/example/JavaHostedFieldsWrapper.kt +++ b/app/src/main/java/com/spreedly/example/JavaHostedFieldsWrapper.kt @@ -1,25 +1,120 @@ package com.spreedly.example +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import java.util.function.Consumer +import kotlinx.coroutines.launch +import com.spreedly.example.qa.FieldStateInspectorCard +import com.spreedly.example.qa.JavaHostedFieldsSampleConfig import com.spreedly.example.viewmodel.ConfigurationChangeAwareViewModel import com.spreedly.hostedfields.HostedFieldsJavaHelper +import com.spreedly.hostedfields.models.HostedFieldStateListener import com.spreedly.sdk.Spreedly -/** - * App-specific wrapper that adds ViewModel-based configuration-change awareness - * on top of the SDK's [HostedFieldsJavaHelper]. - * - * The ViewModel parameter is retained so the Activity can manage SDK lifecycle - * across configuration changes; the actual form layout delegates to the SDK. - */ object JavaHostedFieldsWrapper { @JvmStatic - @Suppress("UNUSED_PARAMETER") + fun setupMerchantMaskToggle(composeView: ComposeView, sdk: Spreedly, onAutofillChanged: Runnable) { + composeView.setContent { + val hostedCardDisplayState by sdk.hostedCardDisplayState + Column(modifier = Modifier.fillMaxWidth()) { + MerchantMaskToggleBar( + sdk = sdk, + hostedCardDisplayState = hostedCardDisplayState, + ) + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "enableAutofill", + style = MaterialTheme.typography.bodySmall, + ) + androidx.compose.material3.Switch( + checked = JavaHostedFieldsSampleConfig.enableAutofill, + onCheckedChange = { + JavaHostedFieldsSampleConfig.enableAutofill = it + onAutofillChanged.run() + }, + ) + } + } + } + } + + @JvmStatic fun setupHostedFieldsCompose( composeView: ComposeView, sdk: Spreedly, viewModel: ConfigurationChangeAwareViewModel, ) { - HostedFieldsJavaHelper.setupContent(composeView, sdk) + val listener = + HostedFieldStateListener { state -> + viewModel.onHostedFieldState(state) + } + HostedFieldsJavaHelper.setupContentWithInspector( + composeView, + sdk, + listener, + { + HostedFieldsInspectorSlot(sdk = sdk, viewModel = viewModel) + }, + onFieldValidated = { type, valid -> viewModel.onHostedFieldValidation(type, valid) }, + enableAutofill = JavaHostedFieldsSampleConfig.enableAutofill, + ) + } + + @JvmStatic + fun observePaymentToken( + lifecycleOwner: LifecycleOwner, + viewModel: ConfigurationChangeAwareViewModel, + onNonEmptyToken: Consumer, + ) { + lifecycleOwner.lifecycleScope.launch { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.paymentToken.collect { token -> + if (token.isNotEmpty()) { + onNonEmptyToken.accept(token) + } + } + } + } + } + + @Composable + private fun HostedFieldsInspectorSlot( + sdk: Spreedly, + viewModel: ConfigurationChangeAwareViewModel, + ) { + val hostedCardDisplayState by sdk.hostedCardDisplayState + val inspectorUiState by viewModel.inspectorUiState.collectAsState() + LaunchedEffect(hostedCardDisplayState) { + viewModel.fieldStateInspector.refreshMismatch(hostedCardDisplayState) + } + FieldStateInspectorCard( + uiState = inspectorUiState, + hostedCardDisplayState = hostedCardDisplayState, + modifier = Modifier.fillMaxWidth(), + ) } } diff --git a/app/src/main/java/com/spreedly/example/JavaPaymentActivity.java b/app/src/main/java/com/spreedly/example/JavaPaymentActivity.java index 98889a2..fcfad37 100644 --- a/app/src/main/java/com/spreedly/example/JavaPaymentActivity.java +++ b/app/src/main/java/com/spreedly/example/JavaPaymentActivity.java @@ -37,6 +37,7 @@ public class JavaPaymentActivity extends AppCompatActivity { private TextView buttonText; private CardView tokenCard; private TextView tokenText; + private ComposeView expressConfigComposeView; private ComposeView composeBottomSheet; private ComposeView recacheComposeView; private ComposeView savedCardsComposeView; @@ -91,6 +92,7 @@ private void initializeViews() { buttonText = findViewById(R.id.java_button_text); tokenCard = findViewById(R.id.java_token_card); tokenText = findViewById(R.id.java_token_text); + expressConfigComposeView = findViewById(R.id.java_express_config_compose_view); composeBottomSheet = findViewById(R.id.java_compose_bottom_sheet); recacheComposeView = findViewById(R.id.recache_compose_view); savedCardsComposeView = findViewById(R.id.saved_cards_compose_view); @@ -104,8 +106,11 @@ private void initializeViews() { private void setupComposeBottomSheet() { try { - // Use the Kotlin helper to set up the ComposeView properly - PaymentSheetJavaHelper.setupContent(composeBottomSheet, sdk); + JavaPaymentExpressConfigWrapper.setupExpressConfigBar( + expressConfigComposeView, + this::refreshBottomSheetCompose + ); + refreshBottomSheetCompose(); Log.d(TAG, "ComposeView setup completed with SpreedlyBottomSheet"); } catch (Exception e) { Log.e(TAG, "Failed to setup ComposeView", e); @@ -113,6 +118,10 @@ private void setupComposeBottomSheet() { } } + private void refreshBottomSheetCompose() { + JavaPaymentExpressConfigWrapper.setupBottomSheet(composeBottomSheet, sdk); + } + private void observeViewModelState() { // Monitor ViewModel state changes instead of using SpreedlyHelper to avoid // multiple observers on the same SDK paymentResultFlow which can cause race conditions diff --git a/app/src/main/java/com/spreedly/example/JavaPaymentExpressConfigWrapper.kt b/app/src/main/java/com/spreedly/example/JavaPaymentExpressConfigWrapper.kt new file mode 100644 index 0000000..b061963 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/JavaPaymentExpressConfigWrapper.kt @@ -0,0 +1,45 @@ +package com.spreedly.example + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.dp +import com.spreedly.example.qa.ExpressDisplayConfigBar +import com.spreedly.example.qa.JavaPaymentSampleConfig +import com.spreedly.paymentsheet.PaymentSheetJavaHelper +import com.spreedly.sdk.Spreedly +import com.spreedly.sdk.ui.PaymentSheetConfig + +object JavaPaymentExpressConfigWrapper { + @JvmStatic + fun setupExpressConfigBar(composeView: ComposeView, onConfigChanged: Runnable) { + composeView.setContent { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + ExpressDisplayConfigBar( + enableAutofill = JavaPaymentSampleConfig.enableAutofill, + onEnableAutofillChange = { + JavaPaymentSampleConfig.enableAutofill = it + onConfigChanged.run() + }, + useMaskedFormat = JavaPaymentSampleConfig.useMaskedFormat, + onUseMaskedFormatChange = { + JavaPaymentSampleConfig.useMaskedFormat = it + onConfigChanged.run() + }, + ) + } + } + } + + @JvmStatic + fun setupBottomSheet(composeView: ComposeView, sdk: Spreedly) { + PaymentSheetJavaHelper.setupContent( + composeView, + sdk, + PaymentSheetConfig.Default, + JavaPaymentSampleConfig.displayConfig(), + ) + } +} diff --git a/app/src/main/java/com/spreedly/example/MerchantMaskToggleBar.kt b/app/src/main/java/com/spreedly/example/MerchantMaskToggleBar.kt new file mode 100644 index 0000000..e084f16 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/MerchantMaskToggleBar.kt @@ -0,0 +1,76 @@ +package com.spreedly.example + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.spreedly.app.R +import com.spreedly.sdk.Spreedly +import com.spreedly.sdk.ui.CardNumberFormat +import com.spreedly.sdk.ui.HostedCardDisplayState + +@Composable +fun MerchantMaskToggleBar( + sdk: Spreedly, + hostedCardDisplayState: HostedCardDisplayState, + modifier: Modifier = Modifier, +) { + val formats = + listOf( + CardNumberFormat.PRETTY to stringResource(R.string.sample_format_pretty), + CardNumberFormat.PLAIN to stringResource(R.string.sample_format_plain), + CardNumberFormat.MASKED to stringResource(R.string.sample_format_masked), + ) + val selectedIndex = formats.indexOfFirst { it.first == hostedCardDisplayState.cardNumberFormat } + .coerceAtLeast(0) + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.sample_card_display_format_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + formats.forEachIndexed { index, (format, label) -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index = index, count = formats.size), + selected = selectedIndex == index, + onClick = { sdk.setNumberFormat(format) }, + ) { + Text(label) + } + } + } + Text( + text = stringResource(R.string.sample_mask_toggle_caption), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + ) + TextButton(onClick = { sdk.toggleMask() }) { + Text(stringResource(R.string.sample_toggle_mask_button)) + } + Text( + text = + stringResource( + R.string.sample_pan_masked_policy, + hostedCardDisplayState.panMasked.toString(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp), + ) + } +} diff --git a/app/src/main/java/com/spreedly/example/TraditionalActivity.kt b/app/src/main/java/com/spreedly/example/TraditionalActivity.kt index 59013ff..163d76e 100644 --- a/app/src/main/java/com/spreedly/example/TraditionalActivity.kt +++ b/app/src/main/java/com/spreedly/example/TraditionalActivity.kt @@ -19,7 +19,13 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import com.spreedly.app.R import com.spreedly.example.viewmodel.ConfigurationChangeAwareViewModel +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.spreedly.example.qa.ExpressDisplayConfigBar import com.spreedly.paymentsheet.SpreedlyBottomSheet +import com.spreedly.sdk.ui.CardNumberFormat +import com.spreedly.sdk.ui.PaymentSheetDisplayConfig import kotlinx.coroutines.launch @@ -115,7 +121,30 @@ class TraditionalActivity : AppCompatActivity() { val isInitializing by viewModel.isInitializing.collectAsState() if (!isInitializing) { - SpreedlyBottomSheet(sdk = viewModel.sdk) + var sheetEnableAutofill by remember { mutableStateOf(true) } + var sheetUseMaskedFormat by remember { mutableStateOf(false) } + val displayConfig = + PaymentSheetDisplayConfig( + enableAutofill = sheetEnableAutofill, + cardNumberFormat = + if (sheetUseMaskedFormat) { + CardNumberFormat.MASKED + } else { + CardNumberFormat.PRETTY + }, + ) + androidx.compose.foundation.layout.Column { + ExpressDisplayConfigBar( + enableAutofill = sheetEnableAutofill, + onEnableAutofillChange = { sheetEnableAutofill = it }, + useMaskedFormat = sheetUseMaskedFormat, + onUseMaskedFormatChange = { sheetUseMaskedFormat = it }, + ) + SpreedlyBottomSheet( + sdk = viewModel.sdk, + displayConfig = displayConfig, + ) + } } } } diff --git a/app/src/main/java/com/spreedly/example/api/SpreedlyPurchaseAPIClient.kt b/app/src/main/java/com/spreedly/example/api/SpreedlyPurchaseAPIClient.kt index bd88442..3011d3f 100644 --- a/app/src/main/java/com/spreedly/example/api/SpreedlyPurchaseAPIClient.kt +++ b/app/src/main/java/com/spreedly/example/api/SpreedlyPurchaseAPIClient.kt @@ -95,9 +95,10 @@ constructor( request: T, errorContext: String = "Purchase", ): SpreedlyPurchaseResponse = try { + val requestBody = purchaseJson.encodeToString(request) val response = client.post(url) { contentType(ContentType.Application.Json) - setBody(purchaseJson.encodeToString(request)) + setBody(requestBody) } val responseBody = response.bodyAsText() if (!response.status.isSuccess()) { @@ -230,6 +231,7 @@ constructor( apmTypes: List, redirectUrl: String, callbackUrl: String = DEFAULT_CALLBACK_URL, + radarSessionId: String? = null, ): SpreedlyPurchaseResponse = executePurchaseRequest( url = "$BASE_URL$PURCHASE_PATH", request = StripeAPMPurchaseTransactionRequest( @@ -240,6 +242,11 @@ constructor( redirectUrl = redirectUrl, callbackUrl = callbackUrl, paymentMethod = StripeAPMPaymentMethod(apmTypes = apmTypes), + gatewaySpecificFields = radarSessionId?.let { + StripeGatewaySpecificFields( + stripePaymentIntents = StripeRadarFields(radarSessionId = it), + ) + }, ), ), errorContext = "Stripe APM purchase", diff --git a/app/src/main/java/com/spreedly/example/api/SpreedlyPurchaseModels.kt b/app/src/main/java/com/spreedly/example/api/SpreedlyPurchaseModels.kt index 1f0652f..28db069 100644 --- a/app/src/main/java/com/spreedly/example/api/SpreedlyPurchaseModels.kt +++ b/app/src/main/java/com/spreedly/example/api/SpreedlyPurchaseModels.kt @@ -225,6 +225,8 @@ data class StripeAPMPurchaseTransactionBody( val callbackUrl: String = DEFAULT_CALLBACK_URL, @SerialName("payment_method") val paymentMethod: StripeAPMPaymentMethod, + @SerialName("gateway_specific_fields") + val gatewaySpecificFields: StripeGatewaySpecificFields? = null, ) @Serializable @@ -235,6 +237,18 @@ data class StripeAPMPaymentMethod( val apmTypes: List, ) +@Serializable +data class StripeGatewaySpecificFields( + @SerialName("stripe_payment_intents") + val stripePaymentIntents: StripeRadarFields, +) + +@Serializable +data class StripeRadarFields( + @SerialName("radar_session_id") + val radarSessionId: String, +) + // ============================================================================ // Braintree-specific models // ============================================================================ diff --git a/app/src/main/java/com/spreedly/example/qa/FieldStateInspectorCard.kt b/app/src/main/java/com/spreedly/example/qa/FieldStateInspectorCard.kt new file mode 100644 index 0000000..f5b1e0c --- /dev/null +++ b/app/src/main/java/com/spreedly/example/qa/FieldStateInspectorCard.kt @@ -0,0 +1,301 @@ +package com.spreedly.example.qa + +import android.annotation.SuppressLint +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.spreedly.hostedfields.models.HostedFieldState +import com.spreedly.sdk.ui.HostedCardDisplayState + +@Composable +fun FieldStateInspectorCard( + uiState: FieldStateInspectorUiState, + hostedCardDisplayState: HostedCardDisplayState, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + RoundedCornerShape(12.dp), + ) + .padding(16.dp) + .testTag("field-state-inspector-card"), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = INSPECTOR_TITLE, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = INSPECTOR_CAPTION, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = INSPECTOR_WIRING_CAPTION, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag("custom-form-wiring-readout"), + ) + GlobalDisplayPanel(hostedCardDisplayState = hostedCardDisplayState) + uiState.mismatchMessage?.let { mismatch -> + Text( + text = mismatch, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.testTag("custom-form-snapshot-global-mismatch"), + ) + } + Text( + text = uiState.lastEventSummary, + style = MaterialTheme.typography.bodySmall, + color = + if (uiState.lastEventSummary.contains("PAN_MASK_CHANGED")) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.testTag("custom-form-last-event-readout"), + ) + EventLogSection(eventLog = uiState.eventLog) + CardSnapshotPanel(state = uiState.cardSnapshot) + CvvSnapshotPanel(state = uiState.cvvSnapshot) + if (uiState.aggregateReadout.isNotEmpty()) { + Text( + text = uiState.aggregateReadout, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag("custom-form-aggregate-validation-readout"), + ) + } + Text( + text = uiState.onChangeReadout, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag("custom-form-onchange-readout"), + ) + } +} + +@Composable +@SuppressLint("ComposeUnstableCollections") +private fun EventLogSection(eventLog: List) { + var expanded by rememberSaveable { mutableStateOf(false) } + Column(modifier = Modifier.testTag("custom-form-event-log")) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .testTag("custom-form-event-log-toggle"), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = EVENT_LOG_TITLE, + style = MaterialTheme.typography.labelMedium, + ) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded) "Collapse event log" else "Expand event log", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (expanded) { + Column( + modifier = + Modifier + .padding(top = 6.dp) + .testTag("custom-form-event-log-entries"), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (eventLog.isEmpty()) { + Text( + text = "No events yet.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + eventLog.forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun GlobalDisplayPanel(hostedCardDisplayState: HostedCardDisplayState) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.25f), + RoundedCornerShape(8.dp), + ) + .padding(10.dp) + .testTag("custom-form-global-display-readout"), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = GLOBAL_DISPLAY_TITLE, + style = MaterialTheme.typography.titleSmall, + ) + InspectorRow("Format", cardNumberFormatLabel(hostedCardDisplayState.cardNumberFormat)) + InspectorRow("panMasked", logYesNo(hostedCardDisplayState.panMasked)) + InspectorRow("cvvDisplayMasked", logYesNo(hostedCardDisplayState.cvvDisplayMasked)) + } +} + +@Composable +private fun CardSnapshotPanel(state: HostedFieldState?) { + SnapshotPanelContainer(title = CARD_PANEL_TITLE) { + if (state == null) { + InspectorEmptyHint(CARD_EMPTY_HINT) + } else { + SnapshotPanelContent(state = state, isCardNumber = true) + } + } +} + +@Composable +private fun CvvSnapshotPanel(state: HostedFieldState?) { + SnapshotPanelContainer( + title = CVV_PANEL_TITLE, + testTag = "custom-form-cvc-field-state-inspector", + ) { + if (state == null) { + InspectorEmptyHint(CVV_EMPTY_HINT) + } else { + SnapshotPanelContent(state = state, isCardNumber = false) + } + } +} + +@Composable +private fun SnapshotPanelContainer( + title: String, + testTag: String = "custom-form-field-state-inspector", + content: @Composable () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.04f), + RoundedCornerShape(8.dp), + ) + .padding(10.dp) + .testTag(testTag), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = title, style = MaterialTheme.typography.titleSmall) + content() + } +} + +@Composable +private fun SnapshotPanelContent( + state: HostedFieldState, + isCardNumber: Boolean, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + InspectorRow("Event", hostedFieldEventLabel(state.eventType)) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + InspectorRow("Valid", logYesNo(state.isValid)) + InspectorRow("Focused", logYesNo(state.isFocused)) + InspectorRow("Empty", logYesNo(state.isEmpty)) + } + if (isCardNumber) { + HorizontalDivider() + Text( + text = "PAN display (from snapshot)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + InspectorRow( + "Format", + state.panDisplayFormat?.let { cardNumberFormatLabel(it) } ?: "—", + ) + InspectorRow( + "Policy masked", + state.panDisplayPolicyMasked?.let { logYesNo(it) } ?: "—", + ) + InspectorRow("Digits hidden", logYesNo(state.isPanMasked)) + } + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + InspectorRow( + "Brand", + cardSchemeRawValue(state.cardScheme) ?: "—", + ) + InspectorRow( + "PAN digit count", + state.numberLength?.toString() ?: "0", + ) + InspectorRow("IIN", state.iin ?: "—") + } + } else { + InspectorRow("CVV digit count", state.cvvLength?.toString() ?: "0") + } + } +} + +@Composable +private fun InspectorRow( + label: String, + value: String, +) { + Column { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text(text = value, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun InspectorEmptyHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/app/src/main/java/com/spreedly/example/qa/FieldStateInspectorController.kt b/app/src/main/java/com/spreedly/example/qa/FieldStateInspectorController.kt new file mode 100644 index 0000000..55afd31 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/qa/FieldStateInspectorController.kt @@ -0,0 +1,127 @@ +package com.spreedly.example.qa + +import com.spreedly.hostedfields.models.HostedFieldState +import com.spreedly.sdk.Spreedly +import com.spreedly.sdk.models.FormFieldType +import com.spreedly.sdk.ui.HostedCardDisplayState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.time.Clock +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +class FieldStateInspectorController( + private val sdk: Spreedly, + private val clock: Clock = Clock.systemDefaultZone(), +) { + private val _uiState = MutableStateFlow(FieldStateInspectorUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var aggregateFields: List Boolean>> = emptyList() + private var isFormValidProvider: () -> Boolean = { false } + + private val timeFormatter = + DateTimeFormatter.ofPattern("HH:mm:ss", Locale.US).withZone(ZoneId.systemDefault()) + + fun configureAggregate( + fields: List Boolean>>, + isFormValid: () -> Boolean, + ) { + this.aggregateFields = fields + this.isFormValidProvider = isFormValid + refreshAggregate() + } + + fun onFieldStateChanged(state: HostedFieldState) { + when (state.fieldType) { + is FormFieldType.CARD -> { + _uiState.update { it.copy(cardSnapshot = state) } + appendEventLog(state) + refreshMismatchFromSnapshots() + } + is FormFieldType.CVV -> { + _uiState.update { it.copy(cvvSnapshot = state) } + appendEventLog(state) + } + else -> Unit + } + } + + fun logOpaqueFieldChange(fieldType: FormFieldType) { + when (fieldType) { + is FormFieldType.CARD -> + _uiState.update { + it.copy(onChangeReadout = "onChange: card number (opaque — not logged)") + } + is FormFieldType.CVV -> + _uiState.update { + it.copy(onChangeReadout = "onChange: CVC (opaque — not logged)") + } + else -> Unit + } + } + + fun logNonSensitiveFieldChange(fieldType: FormFieldType, value: String) { + logOnChangeReadout(hostedFieldDisplayName(fieldType), value) + } + + fun logOnChangeReadout(label: String, value: String) { + val display = if (value.isEmpty()) "(empty)" else value + _uiState.update { + it.copy(onChangeReadout = "onChange: $label = \"$display\"") + } + } + + fun refreshMismatch(global: HostedCardDisplayState) { + val mismatch = + globalDisplayMismatchMessage( + card = _uiState.value.cardSnapshot, + globalFormat = global.cardNumberFormat, + globalPanMasked = global.panMasked, + ) + _uiState.update { it.copy(mismatchMessage = mismatch) } + } + + fun resetInspector() { + _uiState.value = FieldStateInspectorUiState() + refreshAggregate() + } + + private fun refreshMismatchFromSnapshots() { + refreshMismatch(sdk.hostedCardDisplayState.value) + } + + private fun appendEventLog(state: HostedFieldState) { + val fieldLabel = hostedFieldDisplayName(state.fieldType) + val event = hostedFieldEventLabel(state.eventType) + val time = timeFormatter.format(clock.instant()) + val line = "$event · $fieldLabel · $time" + _uiState.update { current -> + val log = (listOf(line) + current.eventLog).take(5) + current.copy( + lastEventSummary = "Last event: $line", + eventLog = log, + ) + } + } + + private fun refreshAggregate() { + if (aggregateFields.isEmpty()) return + val allValid = isFormValidProvider() + val invalid = + aggregateFields.filter { (_, isValid) -> !isValid() }.map { (name, _) -> name } + val invalidText = + if (invalid.isEmpty()) { + "none" + } else { + invalid.joinToString(", ") + } + val registered = sdk.getRegisteredFieldCount() + val readout = + "Form valid: ${logYesNo(allValid)} · invalid: $invalidText · registered: $registered" + _uiState.update { it.copy(aggregateReadout = readout) } + } +} diff --git a/app/src/main/java/com/spreedly/example/qa/FieldStateInspectorUiState.kt b/app/src/main/java/com/spreedly/example/qa/FieldStateInspectorUiState.kt new file mode 100644 index 0000000..9d74ba9 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/qa/FieldStateInspectorUiState.kt @@ -0,0 +1,18 @@ +package com.spreedly.example.qa + +import com.spreedly.hostedfields.models.HostedFieldState + +data class FieldStateInspectorUiState( + val cardSnapshot: HostedFieldState? = null, + val cvvSnapshot: HostedFieldState? = null, + val lastEventSummary: String = "Last event: —", + val eventLog: List = emptyList(), + val aggregateReadout: String = "", + val onChangeReadout: String = DEFAULT_ON_CHANGE_READOUT, + val mismatchMessage: String? = null, +) { + companion object { + const val DEFAULT_ON_CHANGE_READOUT = + "onChange: edit a field to see values (card/CVC stay opaque)." + } +} diff --git a/app/src/main/java/com/spreedly/example/qa/HostedFieldInspectorCopy.kt b/app/src/main/java/com/spreedly/example/qa/HostedFieldInspectorCopy.kt new file mode 100644 index 0000000..463fc4f --- /dev/null +++ b/app/src/main/java/com/spreedly/example/qa/HostedFieldInspectorCopy.kt @@ -0,0 +1,13 @@ +package com.spreedly.example.qa + +internal const val INSPECTOR_TITLE = "Field state inspector" +internal const val INSPECTOR_CAPTION = + "Updates from onFieldStateChange. Use snapshot fields — not hostedCardDisplayState in the callback." +internal const val INSPECTOR_WIRING_CAPTION = + "PAN + CVC follow setNumberFormat / toggleMask (iframe parity)" +internal const val GLOBAL_DISPLAY_TITLE = "Global hostedCardDisplayState" +internal const val EVENT_LOG_TITLE = "Event log (last 5)" +internal const val CARD_PANEL_TITLE = "Card number" +internal const val CVV_PANEL_TITLE = "CVC" +internal const val CARD_EMPTY_HINT = "Type a card number or change the format picker above." +internal const val CVV_EMPTY_HINT = "Type a security code. isPanMasked is not used on CVC events." diff --git a/app/src/main/java/com/spreedly/example/qa/HostedFieldInspectorMappings.kt b/app/src/main/java/com/spreedly/example/qa/HostedFieldInspectorMappings.kt new file mode 100644 index 0000000..95de5a8 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/qa/HostedFieldInspectorMappings.kt @@ -0,0 +1,57 @@ +package com.spreedly.example.qa + +import com.spreedly.hostedfields.models.HostedFieldEventType +import com.spreedly.sdk.models.CardScheme +import com.spreedly.sdk.models.FormFieldType +import com.spreedly.sdk.ui.CardNumberFormat + +internal fun hostedFieldDisplayName(fieldType: FormFieldType): String = + when (fieldType) { + is FormFieldType.CARD -> "Card number" + is FormFieldType.CVV -> "Security code" + is FormFieldType.EXPIRY_DATE -> "MM/YY" + is FormFieldType.MONTH -> "MM" + is FormFieldType.YEAR, is FormFieldType.YEAR_SECONDARY -> "YY" + is FormFieldType.NAME -> "Full Name" + else -> fieldType.toString() + } + +internal fun hostedFieldEventLabel(eventType: HostedFieldEventType): String = + when (eventType) { + HostedFieldEventType.INPUT -> "INPUT" + HostedFieldEventType.FOCUS -> "FOCUS" + HostedFieldEventType.BLUR -> "BLUR" + HostedFieldEventType.VALIDATION -> "VALIDATION" + HostedFieldEventType.PAN_MASK_CHANGED -> "PAN_MASK_CHANGED" + } + +internal fun cardNumberFormatLabel(format: CardNumberFormat): String = + when (format) { + CardNumberFormat.PRETTY -> "Pretty" + CardNumberFormat.PLAIN -> "Plain" + CardNumberFormat.MASKED -> "Masked" + } + +internal fun logYesNo(value: Boolean): String = if (value) "yes" else "no" + +internal fun cardSchemeRawValue(scheme: CardScheme?): String? = + scheme?.takeIf { it != CardScheme.UNKNOWN }?.name?.lowercase() + +internal fun globalDisplayMismatchMessage( + card: com.spreedly.hostedfields.models.HostedFieldState?, + globalFormat: CardNumberFormat, + globalPanMasked: Boolean, +): String? { + if (card == null) return null + card.panDisplayFormat?.let { snapFormat -> + if (snapFormat != globalFormat) { + return "Snapshot format (${cardNumberFormatLabel(snapFormat)}) ≠ global (${cardNumberFormatLabel(globalFormat)})" + } + } + card.panDisplayPolicyMasked?.let { policy -> + if (policy != globalPanMasked) { + return "Snapshot policy masked (${logYesNo(policy)}) ≠ global panMasked (${logYesNo(globalPanMasked)})" + } + } + return null +} diff --git a/app/src/main/java/com/spreedly/example/qa/HostedFieldStateInspector.kt b/app/src/main/java/com/spreedly/example/qa/HostedFieldStateInspector.kt new file mode 100644 index 0000000..b21cc9d --- /dev/null +++ b/app/src/main/java/com/spreedly/example/qa/HostedFieldStateInspector.kt @@ -0,0 +1,127 @@ +package com.spreedly.example.qa + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.spreedly.hostedfields.models.HostedFieldState +import com.spreedly.sdk.ui.HostedCardDisplayState + +@Composable +fun HostedFieldStateInspector( + uiState: FieldStateInspectorUiState, + hostedCardDisplayState: HostedCardDisplayState, + modifier: Modifier = Modifier, +) { + FieldStateInspectorCard( + uiState = uiState, + hostedCardDisplayState = hostedCardDisplayState, + modifier = modifier, + ) +} + +@Deprecated("Use HostedFieldStateInspector(uiState, hostedCardDisplayState)") +@Composable +fun HostedFieldStateInspector( + cardState: HostedFieldState?, + cvvState: HostedFieldState?, + modifier: Modifier = Modifier, + onClear: (() -> Unit)? = null, +) { + FieldStateInspectorCard( + uiState = + FieldStateInspectorUiState( + cardSnapshot = cardState, + cvvSnapshot = cvvState, + ), + hostedCardDisplayState = HostedCardDisplayState(), + modifier = modifier, + ) +} + +@Composable +fun HeadlessHostedFieldsConfigCard( + enableAutofill: Boolean, + onEnableAutofillChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "CARD/CVV read display via sdk on SPLTextField", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = "enableAutofill (SPLTextField)", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + androidx.compose.material3.Switch( + checked = enableAutofill, + onCheckedChange = onEnableAutofillChange, + ) + } + } +} + +@Deprecated("Use HeadlessHostedFieldsConfigCard") +@Composable +fun HeadlessHostedFieldsConfigBar( + enableAutofill: Boolean, + onEnableAutofillChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + HeadlessHostedFieldsConfigCard( + enableAutofill = enableAutofill, + onEnableAutofillChange = onEnableAutofillChange, + modifier = modifier, + ) +} + +@Composable +fun ExpressDisplayConfigBar( + enableAutofill: Boolean, + onEnableAutofillChange: (Boolean) -> Unit, + useMaskedFormat: Boolean, + onUseMaskedFormatChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = "PaymentSheetDisplayConfig (sheet seed; global setNumberFormat overrides after open)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = "enableAutofill", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + androidx.compose.material3.Switch( + checked = enableAutofill, + onCheckedChange = onEnableAutofillChange, + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = "cardNumberFormat MASKED seed", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + androidx.compose.material3.Switch( + checked = useMaskedFormat, + onCheckedChange = onUseMaskedFormatChange, + ) + } + } +} diff --git a/app/src/main/java/com/spreedly/example/qa/JavaHostedFieldStateHolder.kt b/app/src/main/java/com/spreedly/example/qa/JavaHostedFieldStateHolder.kt new file mode 100644 index 0000000..abb6c3c --- /dev/null +++ b/app/src/main/java/com/spreedly/example/qa/JavaHostedFieldStateHolder.kt @@ -0,0 +1,42 @@ +package com.spreedly.example.qa + +import com.spreedly.hostedfields.models.HostedFieldState +import com.spreedly.sdk.models.FormFieldType +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object JavaHostedFieldStateHolder { + private val cardLatest = AtomicReference(null) + private val cvvLatest = AtomicReference(null) + private val _revision = MutableStateFlow(0) + val revision: StateFlow = _revision.asStateFlow() + + @JvmStatic + fun update(state: HostedFieldState) { + when (state.fieldType) { + is FormFieldType.CARD -> cardLatest.set(state) + is FormFieldType.CVV -> cvvLatest.set(state) + else -> Unit + } + _revision.value += 1 + } + + @JvmStatic + fun getCard(): HostedFieldState? = cardLatest.get() + + @JvmStatic + fun getCvv(): HostedFieldState? = cvvLatest.get() + + @Deprecated("Use getCard() or getCvv()") + @JvmStatic + fun get(): HostedFieldState? = getCvv() ?: getCard() + + @JvmStatic + fun clear() { + cardLatest.set(null) + cvvLatest.set(null) + _revision.value += 1 + } +} diff --git a/app/src/main/java/com/spreedly/example/qa/JavaHostedFieldsSampleConfig.kt b/app/src/main/java/com/spreedly/example/qa/JavaHostedFieldsSampleConfig.kt new file mode 100644 index 0000000..20b2233 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/qa/JavaHostedFieldsSampleConfig.kt @@ -0,0 +1,6 @@ +package com.spreedly.example.qa + +object JavaHostedFieldsSampleConfig { + @JvmField + var enableAutofill: Boolean = true +} diff --git a/app/src/main/java/com/spreedly/example/qa/JavaPaymentSampleConfig.kt b/app/src/main/java/com/spreedly/example/qa/JavaPaymentSampleConfig.kt new file mode 100644 index 0000000..f33e1f9 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/qa/JavaPaymentSampleConfig.kt @@ -0,0 +1,24 @@ +package com.spreedly.example.qa + +import com.spreedly.sdk.ui.CardNumberFormat +import com.spreedly.sdk.ui.PaymentSheetDisplayConfig + +object JavaPaymentSampleConfig { + @JvmField + var enableAutofill: Boolean = true + + @JvmField + var useMaskedFormat: Boolean = false + + @JvmStatic + fun displayConfig(): PaymentSheetDisplayConfig = + PaymentSheetDisplayConfig( + enableAutofill = enableAutofill, + cardNumberFormat = + if (useMaskedFormat) { + CardNumberFormat.MASKED + } else { + CardNumberFormat.PRETTY + }, + ) +} diff --git a/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutPaymentResultFilter.kt b/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutPaymentResultFilter.kt new file mode 100644 index 0000000..88ecf96 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutPaymentResultFilter.kt @@ -0,0 +1,9 @@ +package com.spreedly.example.screens.basiccheckout + +import com.spreedly.sdk.ui.PaymentResult + +internal const val CLIENT_SIDE_FORM_VALIDATION_FAILURE_MESSAGE = + "All required fields are not valid" + +internal fun PaymentResult.Failed.isClientSideFormValidationFailure(): Boolean = + message == CLIENT_SIDE_FORM_VALIDATION_FAILURE_MESSAGE diff --git a/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutScreen.kt b/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutScreen.kt index d152f1b..467773a 100644 --- a/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutScreen.kt @@ -41,11 +41,15 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -62,15 +66,22 @@ 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 androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.sp +import com.spreedly.example.MerchantMaskToggleBar +import com.spreedly.example.qa.FieldStateInspectorCard +import com.spreedly.example.qa.HeadlessHostedFieldsConfigCard +import com.spreedly.example.ui.components.CardBrandTrailingIcon +import com.spreedly.example.ui.components.FieldStyleOverrideCard import com.spreedly.example.ui.components.SavedPaymentMethodsList import com.spreedly.example.ui.components.RetokenizeCard +import com.spreedly.example.ui.components.ThemeConfigurationCard +import com.spreedly.example.ui.components.ThemeConfigurationStyle import com.spreedly.example.ui.theme.Spacing import com.spreedly.example.viewmodel.basicCheckoutViewModel import com.spreedly.hostedfields.ui.SPLTextField -import com.spreedly.sdk.AdditionalField import com.spreedly.sdk.models.FormFieldType -import com.spreedly.sdk.ui.CustomFieldsConfig import com.spreedly.sdk.ui.PaymentProcessingResult import com.spreedly.paymentsheet.recache.SpreedlyRecacheUI import com.spreedly.security.secureScreen @@ -93,6 +104,7 @@ fun SimpleInputField( keyboardType: KeyboardType = KeyboardType.Text, capitalization: KeyboardCapitalization = KeyboardCapitalization.Words, imeAction: ImeAction = ImeAction.Default, + onFocusChanged: ((Boolean) -> Unit)? = null, ) { val focusManager = LocalFocusManager.current var isFocused by remember { mutableStateOf(false) } @@ -133,7 +145,10 @@ fun SimpleInputField( color = borderColor, shape = MaterialTheme.shapes.small, ).padding(Spacing.sm) - .onFocusChanged { isFocused = it.isFocused }, + .onFocusChanged { fc -> + isFocused = fc.isFocused + onFocusChanged?.invoke(fc.isFocused) + }, textStyle = TextStyle( fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurface, @@ -181,53 +196,77 @@ fun BasicCheckoutScreen( viewModel: BasicCheckoutViewModel = basicCheckoutViewModel(), ) { val sdk = viewModel.sdk + val hostedCardDisplayState by sdk.hostedCardDisplayState + val inspectorUiState by viewModel.inspectorUiState.collectAsState() val snackbarHostState = viewModel.snackbarHostState - val coroutineScope = rememberCoroutineScope() val scrollState = rememberScrollState() val isInitializing by viewModel.isInitializing.collectAsState() val isProcessing by viewModel.isProcessing.collectAsState() val paymentToken by viewModel.paymentToken.collectAsState() val savedPaymentMethods by viewModel.savedPaymentMethods.collectAsState() + val nameError by viewModel.nameError.collectAsState() + val emailError by viewModel.emailError.collectAsState() + val isFormValid by viewModel.isFormValid.collectAsState() + val useCustomTheme by viewModel.useCustomTheme.collectAsState() + val selectedThemePreset by viewModel.selectedThemePreset.collectAsState() + val fieldOverrideTarget by viewModel.fieldOverrideTarget.collectAsState() + val fieldStyleOverrides by viewModel.fieldStyleOverrides.collectAsState() + val isDarkMode = isSystemInDarkTheme() + + LaunchedEffect(useCustomTheme, selectedThemePreset, isDarkMode) { + viewModel.applyThemeToSdk(isDarkMode) + } - // Custom fields for the new pattern - handled manually - var fullName by rememberSaveable { mutableStateOf("") } - var email by rememberSaveable { mutableStateOf("") } - var shouldRetainPaymentMethod by remember { mutableStateOf(false) } - - // Simple validation states - var nameError by rememberSaveable { mutableStateOf(null) } - var emailError by rememberSaveable { mutableStateOf(null) } + val cardFieldConfig = + remember(useCustomTheme, selectedThemePreset, fieldOverrideTarget, fieldStyleOverrides, isDarkMode) { + viewModel.resolveSplFieldConfig(FormFieldType.CARD(true), isDarkMode) + } + val expiryFieldConfig = + remember(useCustomTheme, selectedThemePreset, fieldOverrideTarget, fieldStyleOverrides, isDarkMode) { + viewModel.resolveSplFieldConfig(FormFieldType.EXPIRY_DATE(true), isDarkMode) + } + val cvvFieldConfig = + remember(useCustomTheme, selectedThemePreset, fieldOverrideTarget, fieldStyleOverrides, isDarkMode) { + viewModel.resolveSplFieldConfig(FormFieldType.CVV(true), isDarkMode) + } - // SDK initialization and payment result handling is now managed by ViewModel + val coroutineScope = rememberCoroutineScope() - // Simple validation functions - fun validateName(): Boolean { - nameError = when { - fullName.isBlank() -> "Name is required" - fullName.length < 2 -> "Name must be at least 2 characters" - else -> null - } - return nameError == null + var fullName by rememberSaveable { mutableStateOf("") } + var email by rememberSaveable { mutableStateOf("") } + var shouldRetainPaymentMethod by rememberSaveable { mutableStateOf(false) } + var eligibleForCardUpdater by rememberSaveable { mutableStateOf(false) } + var enableAutofill by rememberSaveable { mutableStateOf(true) } + var forceMaskOnLifecycleStop by rememberSaveable { mutableStateOf(true) } + var attemptedSubmit by rememberSaveable { mutableStateOf(false) } + var nameWasFocused by rememberSaveable { mutableStateOf(false) } + var emailWasFocused by rememberSaveable { mutableStateOf(false) } + var nameHasEverChanged by rememberSaveable { mutableStateOf(false) } + var emailHasEverChanged by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(hostedCardDisplayState) { + viewModel.fieldStateInspector.refreshMismatch(hostedCardDisplayState) } - fun validateEmail(): Boolean { - emailError = when { - email.isBlank() -> "Email is required" - !email.matches(Regex("^[A-Za-z0-9+_.-]+@(.+)$")) -> "Invalid email format" - else -> null + LaunchedEffect(viewModel) { + viewModel.checkoutEvent.collect { result -> + when (result) { + is PaymentProcessingResult.Processing -> { + fullName = "" + email = "" + viewModel.updateNameValidity("") + viewModel.updateEmailValidity("") + viewModel.onCheckoutFieldsClearedBySdk() + viewModel.clearCustomFieldInputErrors() + viewModel.setProcessing(true) + viewModel.startPaymentPolling() + } + is PaymentProcessingResult.ValidationFailed -> Unit + } } - return emailError == null } - fun isFormValid(): Boolean = validateName() && validateEmail() - - // Pure check without side effects -- safe to call during composition - fun isFormFilledOut(): Boolean = - fullName.isNotBlank() && fullName.length >= 2 && - email.isNotBlank() && email.matches(Regex("^[A-Za-z0-9+_.-]+@(.+)$")) - - // Only sensitive fields require validation via SDK val formFields = listOf( FormFieldType.CARD(true), FormFieldType.EXPIRY_DATE(true), @@ -290,6 +329,33 @@ fun BasicCheckoutScreen( modifier = Modifier.padding(bottom = Spacing.xxxl), ) + ThemeConfigurationCard( + useCustomTheme = useCustomTheme, + selectedPreset = selectedThemePreset, + onUseCustomThemeChange = viewModel::setUseCustomTheme, + onPresetSelected = viewModel::setThemePreset, + onResetTheme = viewModel::resetThemeConfiguration, + style = ThemeConfigurationStyle.SWATCH, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.md) + .padding(bottom = Spacing.md), + ) + + FieldStyleOverrideCard( + selectedTarget = fieldOverrideTarget, + overrides = fieldStyleOverrides, + onTargetSelected = viewModel::setFieldOverrideTarget, + onOverridesChange = viewModel::updateFieldStyleOverrides, + onClearOverrides = viewModel::clearFieldStyleOverrides, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.md) + .padding(bottom = Spacing.lg), + ) + // Saved Payment Methods Section if (savedPaymentMethods.isNotEmpty()) { SavedPaymentMethodsList( @@ -340,39 +406,91 @@ fun BasicCheckoutScreen( .padding(bottom = Spacing.sm), ) - // SPL Card Number Field - SPLTextField( - label = "Card Number", - formFieldType = FormFieldType.CARD(true), - config = CustomFieldsConfig.Default, - value = sdk.paymentState.value.cardNumber.value, - onChange = { sdk.callbacks.onCardNumberChange(it, true) }, + MerchantMaskToggleBar( + sdk = sdk, + hostedCardDisplayState = hostedCardDisplayState, + modifier = Modifier.padding(bottom = Spacing.sm), ) - Spacer(modifier = Modifier.height(Spacing.sm)) + HeadlessHostedFieldsConfigCard( + enableAutofill = enableAutofill, + onEnableAutofillChange = { enableAutofill = it }, + modifier = Modifier.padding(bottom = Spacing.sm), + ) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = Spacing.sm), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - // SPL Expiry Date Field + Text( + text = "forceMaskOnLifecycleStop (CARD)", + style = MaterialTheme.typography.bodySmall, + ) + androidx.compose.material3.Switch( + checked = forceMaskOnLifecycleStop, + onCheckedChange = { forceMaskOnLifecycleStop = it }, + ) + } + + // SPL Card Number Field + key(useCustomTheme, selectedThemePreset, fieldOverrideTarget, fieldStyleOverrides) { + SPLTextField( + label = "Card Number", + formFieldType = FormFieldType.CARD(true), + config = cardFieldConfig, + value = sdk.paymentState.value.cardNumber.value, + onChange = { + sdk.callbacks.onCardNumberChange(it, true) + viewModel.fieldStateInspector.logOpaqueFieldChange(FormFieldType.CARD(true)) + }, + trailingIcon = { scheme -> CardBrandTrailingIcon(scheme) }, + onFieldStateChange = { viewModel.onFieldStateUpdate(it) }, + onValidationChange = { + viewModel.onHostedFieldValidation(FormFieldType.CARD(true), it) + }, + enableAutofill = enableAutofill, + forceMaskOnLifecycleStop = forceMaskOnLifecycleStop, + sdk = sdk, + ) + } + + Spacer(modifier = Modifier.height(Spacing.sm)) + + key(useCustomTheme, selectedThemePreset, fieldOverrideTarget, fieldStyleOverrides) { SPLTextField( label = "MM/YY", formFieldType = FormFieldType.EXPIRY_DATE(true), - config = CustomFieldsConfig.Default, + config = expiryFieldConfig, value = sdk.paymentState.value.expirationDate.value, onChange = { sdk.callbacks.onExpirationDateChange(it, true) }, - modifier = Modifier.weight(1f), + onValidationChange = { + viewModel.onHostedFieldValidation(FormFieldType.EXPIRY_DATE(true), it) + }, + modifier = Modifier.fillMaxWidth(), ) + } + + Spacer(modifier = Modifier.height(Spacing.sm)) - // SPL CVV Field + key(useCustomTheme, selectedThemePreset, fieldOverrideTarget, fieldStyleOverrides) { SPLTextField( - label = "CVV", + label = "Security Code (CVC)", formFieldType = FormFieldType.CVV(true), - config = CustomFieldsConfig.Default, + config = cvvFieldConfig, value = sdk.paymentState.value.securityCode.value, - onChange = { sdk.callbacks.onSecurityCodeChange(it, true) }, - modifier = Modifier.weight(1f), + onChange = { + sdk.callbacks.onSecurityCodeChange(it, true) + viewModel.fieldStateInspector.logOpaqueFieldChange(FormFieldType.CVV(true)) + }, + onFieldStateChange = { viewModel.onFieldStateUpdate(it) }, + onValidationChange = { + viewModel.onHostedFieldValidation(FormFieldType.CVV(true), it) + }, + enableAutofill = enableAutofill, + sdk = sdk, ) } @@ -394,13 +512,29 @@ fun BasicCheckoutScreen( SimpleInputField( label = "Full Name", value = fullName, - onValueChange = { fullName = it }, + onValueChange = { + fullName = it + if (it.isNotEmpty()) nameHasEverChanged = true + if (nameHasEverChanged || attemptedSubmit) { + viewModel.validateName(it) + } else { + viewModel.clearNameError() + viewModel.updateNameValidity(it) + } + }, isRequired = true, isError = nameError != null, errorMessage = nameError, keyboardType = KeyboardType.Text, capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next, + onFocusChanged = { focused -> + if (focused) { + nameWasFocused = true + } else if (nameWasFocused) { + viewModel.validateName(fullName) + } + }, ) Spacer(modifier = Modifier.height(Spacing.sm)) @@ -409,13 +543,29 @@ fun BasicCheckoutScreen( SimpleInputField( label = "Email", value = email, - onValueChange = { email = it }, + onValueChange = { + email = it + if (it.isNotEmpty()) emailHasEverChanged = true + if (emailHasEverChanged || attemptedSubmit) { + viewModel.validateEmail(it) + } else { + viewModel.clearEmailError() + viewModel.updateEmailValidity(it) + } + }, isRequired = true, isError = emailError != null, errorMessage = emailError, keyboardType = KeyboardType.Email, capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Done, + onFocusChanged = { focused -> + if (focused) { + emailWasFocused = true + } else if (emailWasFocused) { + viewModel.validateEmail(email) + } + }, ) Spacer(modifier = Modifier.height(Spacing.md)) @@ -446,64 +596,66 @@ fun BasicCheckoutScreen( Spacer(modifier = Modifier.height(Spacing.xs)) - val isButtonDisabled = isInitializing || isProcessing || !isFormFilledOut() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { eligibleForCardUpdater = !eligibleForCardUpdater } + .padding(vertical = Spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = eligibleForCardUpdater, + onCheckedChange = { eligibleForCardUpdater = it }, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.primary, + uncheckedColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + Spacer(modifier = Modifier.width(Spacing.xs)) + Text( + text = "Eligible for card updater", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(modifier = Modifier.height(Spacing.xs)) + + val isButtonDisabled = isInitializing || isProcessing || !isFormValid // Custom Payment Button using new pattern Button( onClick = { - if (isFormValid()) { + attemptedSubmit = true + val nameOk = viewModel.validateName(fullName) + val emailOk = viewModel.validateEmail(email) + if (!nameOk || !emailOk) { + return@Button + } + if (!isFormValid) { + coroutineScope.launch { + snackbarHostState.showSnackbar("Fix invalid fields before paying.") + } + return@Button + } + if (!sdk.areAllFieldsValid(formFields)) { coroutineScope.launch { - try { - Log.d(TAG, "Starting payment processing...") - - // Create additional fields map using new pattern - val additionalFields = mapOf( - AdditionalField.FULL_NAME to fullName.split(" ").firstOrNull().orEmpty(), - AdditionalField.EMAIL to email, - ) - - // Use the new iOS-style createCreditCard method - val result = sdk.createCreditCard( - formFields = formFields, // Only sensitive fields - additionalFields = additionalFields, // Custom fields passed directly - metadata = mapOf( - "checkout_type" to "basic_with_custom_fields", - "timestamp" to "${System.currentTimeMillis()}", - "pattern" to "ios_style", - ), - retainOnSuccess = shouldRetainPaymentMethod, - ) - - Log.d(TAG, "Payment processing result received") - - when (result) { - is PaymentProcessingResult.Processing -> { - Log.d(TAG, "Payment processing started") - fullName = "" - email = "" - nameError = null - emailError = null - viewModel.setProcessing(true) - viewModel.startPaymentPolling() - } - - is PaymentProcessingResult.ValidationFailed -> { - Log.d( - TAG, - "Validation failed for fields: ${result.invalidFields}", - ) - viewModel.setProcessing(false) // Ensure processing state is reset - snackbarHostState - .showSnackbar("Validation failed. Please check your information.") - } - } - } catch (e: Exception) { - Log.d(TAG, "Error during payment processing: ${e::class.simpleName}") - viewModel.setProcessing(false) - snackbarHostState.showSnackbar("Payment processing error") - } + snackbarHostState.showSnackbar( + "Please fix card field errors before proceeding", + ) } + return@Button } + Log.d(TAG, "Starting payment processing...") + viewModel.launchSubmitCheckout( + SubmitCheckoutParams( + formFields = formFields, + fullName = fullName, + email = email, + shouldRetainPaymentMethod = shouldRetainPaymentMethod, + eligibleForCardUpdater = eligibleForCardUpdater.takeIf { it }, + ), + ) }, modifier = Modifier .fillMaxWidth() @@ -531,7 +683,7 @@ fun BasicCheckoutScreen( text = when { isInitializing -> "Initializing..." isProcessing -> "Processing Payment..." - !isFormFilledOut() -> "Complete All Fields" + !isFormValid -> "Complete All Fields" else -> "Pay with Custom Button" }, color = MaterialTheme.colorScheme.onPrimary, @@ -544,8 +696,28 @@ fun BasicCheckoutScreen( paymentToken = paymentToken, onRetokenize = { viewModel.reinitialize() }, ) + + Spacer(modifier = Modifier.height(Spacing.md)) + + TextButton( + onClick = { viewModel.performFullPaymentReset() }, + modifier = Modifier + .fillMaxWidth() + .testTag("basic-checkout-reset-payment-state-button"), + ) { + Text("resetPaymentState()") + } + + Spacer(modifier = Modifier.height(Spacing.lg)) + + FieldStateInspectorCard( + uiState = inspectorUiState, + hostedCardDisplayState = hostedCardDisplayState, + modifier = Modifier.fillMaxWidth(), + ) } } + Spacer(modifier = Modifier.height(Spacing.xl)) // Info Section Row( diff --git a/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutViewModel.kt b/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutViewModel.kt index 03b9d19..7f7e43b 100644 --- a/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutViewModel.kt +++ b/app/src/main/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutViewModel.kt @@ -9,17 +9,32 @@ import com.spreedly.app.BuildConfig import com.spreedly.example.AuthService import com.spreedly.example.models.SavedPaymentMethod import com.spreedly.example.repository.PaymentMethodRepository +import com.spreedly.example.qa.FieldStateInspectorController import com.spreedly.example.utils.PaymentResultHandler import com.spreedly.example.utils.SdkSessionManager -import com.spreedly.sdk.SpreedlyErrorMessages +import com.spreedly.example.ui.theme.SampleThemePreset +import com.spreedly.example.ui.theme.SplFieldStyleOverrides +import com.spreedly.example.ui.theme.SplFieldTarget +import com.spreedly.example.ui.theme.ThemeConfigurationController +import com.spreedly.example.utils.isCvvFormRequirementMet +import com.spreedly.hostedfields.models.HostedFieldState +import com.spreedly.result.Result +import com.spreedly.sdk.AdditionalField import com.spreedly.sdk.Spreedly +import com.spreedly.sdk.SpreedlyErrorMessages import com.spreedly.sdk.SpreedlyNetworkError +import com.spreedly.sdk.models.FormFieldType import com.spreedly.sdk.models.RecacheConfig import com.spreedly.sdk.models.SavedCardInfo import com.spreedly.sdk.models.ScreenPresentationMode +import com.spreedly.sdk.models.paymentMethodUpdatedAt +import com.spreedly.sdk.ui.PaymentProcessingResult +import com.spreedly.validation.EmailValidator import com.spreedly.validation.ValidationParameter import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @@ -27,14 +42,13 @@ import kotlinx.coroutines.launch /** * ViewModel for BasicCheckoutScreen managing payment state and SDK initialization. */ -class BasicCheckoutViewModel(private val context: Context) : ViewModel() { - // Spreedly SDK instance - survives configuration changes via ViewModel - val sdk = Spreedly() - - // SnackbarHostState for showing messages +class BasicCheckoutViewModel( + private val context: Context, + val sdk: Spreedly = Spreedly(), + private val skipAutoInitializeSdk: Boolean = false, +) : ViewModel() { val snackbarHostState = SnackbarHostState() - // UI State private val _isInitializing = MutableStateFlow(true) val isInitializing: StateFlow = _isInitializing.asStateFlow() @@ -44,88 +58,265 @@ class BasicCheckoutViewModel(private val context: Context) : ViewModel() { private val _paymentToken = MutableStateFlow("") val paymentToken: StateFlow = _paymentToken.asStateFlow() - // Track when payment completed to prevent race conditions private var lastPaymentCompletedTime = 0L - // Shared helpers private val sdkSessionManager = SdkSessionManager(AuthService()) private val paymentMethodRepository = PaymentMethodRepository(context) private val paymentResultHandler = PaymentResultHandler(paymentMethodRepository) private var paymentResultJob: Job? = null private var initJob: Job? = null - // Saved payment methods private val _savedPaymentMethods = MutableStateFlow>(emptyList()) val savedPaymentMethods: StateFlow> = _savedPaymentMethods.asStateFlow() - // Initialize SDK on ViewModel creation - init { - initializeSDK() - loadSavedPaymentMethods() - fetchPaymentMethodsFromBackend() + val fieldStateInspector = FieldStateInspectorController(sdk) + val inspectorUiState = fieldStateInspector.uiState + val themeConfiguration = ThemeConfigurationController() + val useCustomTheme = themeConfiguration.useCustomTheme + val selectedThemePreset = themeConfiguration.selectedPreset + val fieldOverrideTarget = themeConfiguration.fieldOverrideTarget + val fieldStyleOverrides = themeConfiguration.fieldOverrides + + private val _cardValid = MutableStateFlow(false) + private val _cvvValid = MutableStateFlow(false) + private val _expiryValid = MutableStateFlow(false) + private val _nameValid = MutableStateFlow(false) + private val _emailValid = MutableStateFlow(false) + + private val _nameError = MutableStateFlow(null) + val nameError: StateFlow = _nameError.asStateFlow() + + private val _emailError = MutableStateFlow(null) + val emailError: StateFlow = _emailError.asStateFlow() + + private val _isFormValid = MutableStateFlow(false) + val isFormValid: StateFlow = _isFormValid.asStateFlow() + + private val _checkoutEvent = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + ) + val checkoutEvent: SharedFlow = _checkoutEvent + + fun onFieldStateUpdate(state: HostedFieldState) { + fieldStateInspector.onFieldStateChanged(state) } - private fun initializeSDK() { - initJob = viewModelScope.launch { - _isInitializing.value = true - sdkSessionManager.initializeSdk(sdk, context.applicationContext, BuildConfig.ENVIRONMENT_KEY) - .fold( - onSuccess = { - Log.d(TAG, "BasicCheckoutViewModel: SDK initialized successfully") - observePaymentResults() - }, - onFailure = { e -> - Log.d(TAG, "BasicCheckoutViewModel: SDK initialization failed: ${e::class.simpleName}") - snackbarHostState.showSnackbar("SDK initialization failed") - }, - ) + fun onHostedFieldValidation(fieldType: FormFieldType, isValid: Boolean) { + when (fieldType) { + is FormFieldType.CARD -> _cardValid.value = isValid + is FormFieldType.CVV -> _cvvValid.value = isValid + is FormFieldType.EXPIRY_DATE -> _expiryValid.value = isValid + else -> Unit + } + refreshInspectorAggregate() + } + + fun onCheckoutFieldsClearedBySdk() { + _cardValid.value = false + _cvvValid.value = false + _expiryValid.value = false + refreshInspectorAggregate() + } + + fun performFullPaymentReset() { + sdk.resetPaymentState() + fieldStateInspector.resetInspector() + _cardValid.value = false + _cvvValid.value = false + _expiryValid.value = false + _nameValid.value = false + _emailValid.value = false + clearCustomFieldInputErrors() + refreshInspectorAggregate() + } + + private fun computeIsFormValid(): Boolean = + _cardValid.value && + isCvvFormRequirementMet(_cvvValid.value) && + _expiryValid.value && + _nameValid.value && + _emailValid.value + + private fun refreshInspectorAggregate() { + _isFormValid.value = computeIsFormValid() + fieldStateInspector.configureAggregate( + fields = + listOf( + "Card number" to { _cardValid.value }, + "Security code (CVC)" to { isCvvFormRequirementMet(_cvvValid.value) }, + "MM/YY" to { _expiryValid.value }, + "Full Name" to { _nameValid.value }, + "Email" to { _emailValid.value }, + ), + isFormValid = ::computeIsFormValid, + ) + fieldStateInspector.refreshMismatch(sdk.hostedCardDisplayState.value) + } + + fun updateNameValidity(name: String) { + _nameValid.value = isNameValueValid(name) + refreshInspectorAggregate() + } + + fun updateEmailValidity(email: String) { + _emailValid.value = isEmailValueValid(email) + refreshInspectorAggregate() + } + + private fun isNameValueValid(name: String): Boolean = + name.isNotBlank() && name.length >= 2 + + private fun isEmailValueValid(email: String): Boolean = + email.isNotBlank() && EmailValidator.isValid(email) + + fun clearCustomFieldInputErrors() { + _nameError.value = null + _emailError.value = null + } + + fun clearNameError() { + _nameError.value = null + } + + fun clearEmailError() { + _emailError.value = null + } + + fun validateName(name: String): Boolean { + _nameError.value = + when { + name.isBlank() -> "Name is required" + name.length < 2 -> "Name must be at least 2 characters" + else -> null + } + _nameValid.value = isNameValueValid(name) + fieldStateInspector.logNonSensitiveFieldChange(FormFieldType.NAME(true), name) + refreshInspectorAggregate() + return _nameValid.value + } + + fun validateEmail(email: String): Boolean { + _emailError.value = + when { + email.isBlank() -> "Email is required" + !EmailValidator.isValid(email) -> "Invalid email format" + else -> null + } + _emailValid.value = isEmailValueValid(email) + fieldStateInspector.logOnChangeReadout("Email", email) + refreshInspectorAggregate() + return _emailValid.value + } + + suspend fun submitCheckout(params: SubmitCheckoutParams): PaymentProcessingResult { + val additionalFields = + mapOf( + AdditionalField.FULL_NAME to params.fullName.split(" ").firstOrNull().orEmpty(), + AdditionalField.EMAIL to params.email, + ) + return sdk.createCreditCard( + formFields = params.formFields, + additionalFields = additionalFields, + metadata = + mapOf( + "checkout_type" to "basic_with_custom_fields", + "timestamp" to "${System.currentTimeMillis()}", + "pattern" to "ios_style", + ), + retainOnSuccess = params.shouldRetainPaymentMethod, + eligibleForCardUpdater = params.eligibleForCardUpdater?.takeIf { it }, + ) + } + + fun launchSubmitCheckout(params: SubmitCheckoutParams) { + viewModelScope.launch { + try { + val result = submitCheckout(params) + _checkoutEvent.emit(result) + } catch (e: Exception) { + Log.d(TAG, "BasicCheckoutViewModel: Error during payment processing: ${e::class.simpleName}") + _isProcessing.value = false + snackbarHostState.showSnackbar("Payment processing error") + } + } + } + + init { + refreshInspectorAggregate() + if (skipAutoInitializeSdk) { _isInitializing.value = false + } else { + initializeSDK() + loadSavedPaymentMethods() + fetchPaymentMethodsFromBackend() } } + private fun initializeSDK() { + initJob = + viewModelScope.launch { + _isInitializing.value = true + sdkSessionManager.initializeSdk(sdk, context.applicationContext, BuildConfig.ENVIRONMENT_KEY) + .fold( + onSuccess = { + Log.d(TAG, "BasicCheckoutViewModel: SDK initialized successfully") + observePaymentResults() + }, + onFailure = { e -> + Log.d(TAG, "BasicCheckoutViewModel: SDK initialization failed: ${e::class.simpleName}") + snackbarHostState.showSnackbar("SDK initialization failed") + }, + ) + _isInitializing.value = false + } + } + private fun observePaymentResults() { - paymentResultJob = paymentResultHandler.observeResults( - sdk = sdk, - scope = viewModelScope, - onCompleted = { result -> - Log.d(TAG, "BasicCheckoutViewModel: Payment completed") - _isProcessing.value = false - _paymentToken.value = result.token - lastPaymentCompletedTime = System.currentTimeMillis() - - paymentResultHandler.retainIfNeeded(result).fold( - onSuccess = { retained -> - if (retained) { - fetchPaymentMethodsFromBackend() - snackbarHostState.showSnackbar("Payment method saved for future use!") - } else { - snackbarHostState.showSnackbar("Payment method created successfully!") - } - }, - onFailure = { e -> - snackbarHostState.showSnackbar("Payment created but failed to save") - }, - ) - }, - onFailed = { result -> - Log.d(TAG, "BasicCheckoutViewModel: Payment failed: ${result.errorType}") - _isProcessing.value = false - _paymentToken.value = "" - snackbarHostState.showSnackbar("Error creating payment method") - }, - onCanceled = { - Log.d(TAG, "BasicCheckoutViewModel: Payment canceled") - _isProcessing.value = false - snackbarHostState.showSnackbar("Payment canceled") - }, - ) + paymentResultJob = + paymentResultHandler.observeResults( + sdk = sdk, + scope = viewModelScope, + onCompleted = { result -> + Log.d(TAG, "BasicCheckoutViewModel: Payment completed") + _isProcessing.value = false + _paymentToken.value = result.token + lastPaymentCompletedTime = System.currentTimeMillis() + paymentResultHandler.retainIfNeeded(result).fold( + onSuccess = { retained -> + if (retained) { + fetchPaymentMethodsFromBackend() + snackbarHostState.showSnackbar("Payment method saved for future use!") + } else { + snackbarHostState.showSnackbar("Payment method created successfully!") + } + }, + onFailure = { e -> + snackbarHostState.showSnackbar("Payment created but failed to save") + }, + ) + }, + onFailed = { result -> + Log.d(TAG, "BasicCheckoutViewModel: Payment failed: ${result.errorType}") + _isProcessing.value = false + _paymentToken.value = "" + if (!result.isClientSideFormValidationFailure()) { + snackbarHostState.showSnackbar("Error creating payment method") + } + }, + onCanceled = { + Log.d(TAG, "BasicCheckoutViewModel: Payment canceled") + _isProcessing.value = false + snackbarHostState.showSnackbar("Payment canceled") + }, + ) } fun setProcessing(processing: Boolean) { Log.d(TAG, "BasicCheckoutViewModel: setProcessing called with: $processing") if (processing) { - // Check if payment completed recently (within last 1000ms) - race condition protection val timeSinceCompletion = System.currentTimeMillis() - lastPaymentCompletedTime if (timeSinceCompletion < 1000 && _paymentToken.value.isNotEmpty()) { Log.d( @@ -152,11 +343,10 @@ class BasicCheckoutViewModel(private val context: Context) : ViewModel() { initializeSDK() } - // Backup timeout mechanism in case payment result flow fails fun startPaymentPolling() { Log.d(TAG, "BasicCheckoutViewModel: Starting payment timeout protection") viewModelScope.launch { - kotlinx.coroutines.delay(30000) // 30-second timeout + kotlinx.coroutines.delay(30000) if (_isProcessing.value) { Log.d(TAG, "BasicCheckoutViewModel: Payment timeout reached - resetting state") _isProcessing.value = false @@ -165,13 +355,9 @@ class BasicCheckoutViewModel(private val context: Context) : ViewModel() { } } - /** - * Load saved payment methods from the repository. - */ fun loadSavedPaymentMethods() { viewModelScope.launch { try { - // Load saved payment methods (no mock data) val methods = paymentMethodRepository.getSavedPaymentMethods() _savedPaymentMethods.value = methods Log.d(TAG, "BasicCheckoutViewModel: Loaded ${methods.size} saved payment methods") @@ -182,9 +368,6 @@ class BasicCheckoutViewModel(private val context: Context) : ViewModel() { } } - /** - * Fetch payment methods from backend and update local cache. - */ private fun fetchPaymentMethodsFromBackend() { viewModelScope.launch { try { @@ -195,7 +378,6 @@ class BasicCheckoutViewModel(private val context: Context) : ViewModel() { }, onFailure = { e -> Log.d(TAG, "BasicCheckoutViewModel: Error fetching payment methods: ${e::class.simpleName}") - // Keep using cached data }, ) } catch (e: Exception) { @@ -204,65 +386,58 @@ class BasicCheckoutViewModel(private val context: Context) : ViewModel() { } } - /** - * Recache a saved payment method by showing the SDK's CVV input UI. - * - * @param savedCard The saved payment method to recache - */ fun recacheSavedPaymentMethod(savedCard: SavedPaymentMethod) { viewModelScope.launch { try { _isProcessing.value = true - // Create recaching configuration - val config = RecacheConfig( - recachePresentationMode = ScreenPresentationMode.bottomSheet, - cardInfo = SavedCardInfo( - lastFourDigits = savedCard.lastFourDigits, - cardType = savedCard.cardType, - cardholderName = savedCard.cardholderName, - ), - labelText = "CVV", - placeholderText = "123", - buttonText = "Confirm", - cancelButtonText = "Cancel", - ) + val config = + RecacheConfig( + recachePresentationMode = ScreenPresentationMode.bottomSheet, + cardInfo = + SavedCardInfo( + lastFourDigits = savedCard.lastFourDigits, + cardType = savedCard.cardType, + cardholderName = savedCard.cardholderName, + ), + labelText = "CVV", + placeholderText = "123", + buttonText = "Confirm", + cancelButtonText = "Cancel", + ) sdk.setParam(ValidationParameter.ALLOW_BLANK_NAME, true) - // Call SDK's recaching method - val result = sdk.recachePaymentMethod( - paymentMethodToken = savedCard.token, - config = config, - ) + val result = + sdk.recachePaymentMethod( + paymentMethodToken = savedCard.token, + config = config, + ) _isProcessing.value = false when (val recacheResult = result) { - is com.spreedly.result.Result.Success -> { + is Result.Success -> { val response = recacheResult.data - if (response.transaction.succeeded) { - _paymentToken.value = response.transaction.paymentMethod.token - Log.d( - TAG, - "BasicCheckoutViewModel: Recached " + - "token: ${response.transaction.paymentMethod.token}", - ) - - // Call retain API after successful recache to refresh retention - retainAfterRecache(response.transaction.paymentMethod.token) - } else { - snackbarHostState.showSnackbar("Recaching failed: ${response.transaction.message}") - } + _paymentToken.value = response.transaction.paymentMethod.token + Log.d( + TAG, + "BasicCheckoutViewModel: Recached " + + "token: ${response.transaction.paymentMethod.token}", + ) + snackbarHostState.showSnackbar( + "CVV updated. Updated at: ${response.paymentMethodUpdatedAt}", + ) + retainAfterRecache(response.transaction.paymentMethod.token) } - is com.spreedly.result.Result.Error -> { - // Don't show error message if user cancelled + is Result.Error -> { if (recacheResult.error != SpreedlyNetworkError.USER_CANCELLED) { - val errorMessage = SpreedlyErrorMessages.getUserFriendlyMessage( - error = recacheResult.error, - defaultMessage = "Failed to update payment method. Please try again.", - ) + val errorMessage = + SpreedlyErrorMessages.getUserFriendlyMessage( + error = recacheResult.error, + defaultMessage = "Failed to update payment method. Please try again.", + ) snackbarHostState.showSnackbar(errorMessage) Log.d(TAG, "BasicCheckoutViewModel: Recaching error: ${recacheResult.error.safeDescription()}") } else { @@ -278,9 +453,6 @@ class BasicCheckoutViewModel(private val context: Context) : ViewModel() { } } - /** - * Call retain API after successful recache to refresh retention period. - */ private fun retainAfterRecache(token: String) { viewModelScope.launch { try { @@ -301,11 +473,6 @@ class BasicCheckoutViewModel(private val context: Context) : ViewModel() { } } - /** - * Delete a saved payment method. - * - * @param token The token of the payment method to delete - */ fun deleteSavedPaymentMethod(token: String) { viewModelScope.launch { try { @@ -319,6 +486,39 @@ class BasicCheckoutViewModel(private val context: Context) : ViewModel() { } } + fun setUseCustomTheme(enabled: Boolean) { + themeConfiguration.setUseCustomTheme(enabled) + } + + fun setThemePreset(preset: SampleThemePreset) { + themeConfiguration.setPreset(preset) + } + + fun resetThemeConfiguration() { + themeConfiguration.setUseCustomTheme(false) + } + + fun setFieldOverrideTarget(target: SplFieldTarget) { + themeConfiguration.setFieldOverrideTarget(target) + } + + fun updateFieldStyleOverrides(overrides: SplFieldStyleOverrides) { + themeConfiguration.updateFieldOverrides(overrides) + } + + fun clearFieldStyleOverrides() { + themeConfiguration.clearFieldOverrides() + } + + fun resolveSplFieldConfig( + formFieldType: FormFieldType, + isDarkMode: Boolean, + ) = themeConfiguration.resolveFieldConfig(formFieldType, isDarkMode) + + fun applyThemeToSdk(isDarkMode: Boolean) { + themeConfiguration.applyGlobalTheme(sdk, isDarkMode) + } + private companion object { private const val TAG = "BasicCheckoutViewModel" } diff --git a/app/src/main/java/com/spreedly/example/screens/basiccheckout/SubmitCheckoutParams.kt b/app/src/main/java/com/spreedly/example/screens/basiccheckout/SubmitCheckoutParams.kt new file mode 100644 index 0000000..ab64180 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/screens/basiccheckout/SubmitCheckoutParams.kt @@ -0,0 +1,11 @@ +package com.spreedly.example.screens.basiccheckout + +import com.spreedly.sdk.models.FormFieldType + +data class SubmitCheckoutParams( + val formFields: List, + val fullName: String, + val email: String, + val shouldRetainPaymentMethod: Boolean, + val eligibleForCardUpdater: Boolean?, +) diff --git a/app/src/main/java/com/spreedly/example/screens/bottomsheet/BottomSheetPaymentScreen.kt b/app/src/main/java/com/spreedly/example/screens/bottomsheet/BottomSheetPaymentScreen.kt index c6f0894..06ed84a 100644 --- a/app/src/main/java/com/spreedly/example/screens/bottomsheet/BottomSheetPaymentScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/bottomsheet/BottomSheetPaymentScreen.kt @@ -37,8 +37,11 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,7 +54,12 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.spreedly.example.ui.theme.Spacing +import com.spreedly.example.MerchantMaskToggleBar +import com.spreedly.example.qa.ExpressDisplayConfigBar +import com.spreedly.sdk.ui.CardNumberFormat +import com.spreedly.sdk.ui.PaymentSheetDisplayConfig +import com.spreedly.example.ui.components.ThemeConfigurationCard +import com.spreedly.example.ui.components.ThemeConfigurationStyle import com.spreedly.example.viewmodel.bottomSheetPaymentViewModel import com.spreedly.paymentsheet.SpreedlyBottomSheet import com.spreedly.sdk.ui.ConfigurableFormField @@ -67,15 +75,39 @@ fun BottomSheetPaymentScreen( viewModel: BottomSheetPaymentViewModel = bottomSheetPaymentViewModel(), ) { val sdk = viewModel.sdk + val hostedCardDisplayState by sdk.hostedCardDisplayState val snackbarHostState = viewModel.snackbarHostState val isInitializing by viewModel.isInitializing.collectAsState() val token by viewModel.token.collectAsState() val isProcessing by viewModel.isProcessing.collectAsState() + val useCustomTheme by viewModel.useCustomTheme.collectAsState() + val selectedThemePreset by viewModel.selectedThemePreset.collectAsState() + val isDarkMode = isSystemInDarkTheme() + val paymentSheetConfig = + remember(useCustomTheme, selectedThemePreset, isDarkMode) { + viewModel.resolvePaymentSheetConfig(isDarkMode) + } + + LaunchedEffect(useCustomTheme, selectedThemePreset, isDarkMode) { + viewModel.applyThemeToSdk(isDarkMode) + } var allowBlankName by rememberSaveable { mutableStateOf(false) } var allowBlankDate by rememberSaveable { mutableStateOf(false) } var allowExpiredDate by rememberSaveable { mutableStateOf(false) } + var sheetEnableAutofill by rememberSaveable { mutableStateOf(true) } + var sheetUseMaskedFormat by rememberSaveable { mutableStateOf(false) } + val sheetDisplayConfig = + PaymentSheetDisplayConfig( + enableAutofill = sheetEnableAutofill, + cardNumberFormat = + if (sheetUseMaskedFormat) { + CardNumberFormat.MASKED + } else { + CardNumberFormat.PRETTY + }, + ) LaunchedEffect(allowBlankName) { if (!isInitializing) { @@ -164,6 +196,20 @@ fun BottomSheetPaymentScreen( modifier = Modifier.padding(bottom = 48.dp), ) + ThemeConfigurationCard( + useCustomTheme = useCustomTheme, + selectedPreset = selectedThemePreset, + onUseCustomThemeChange = viewModel::setUseCustomTheme, + onPresetSelected = viewModel::setThemePreset, + onResetTheme = viewModel::resetThemeConfiguration, + style = ThemeConfigurationStyle.BUTTON, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + ) + // Main Card Card( modifier = @@ -449,6 +495,22 @@ fun BottomSheetPaymentScreen( } } + Spacer(modifier = Modifier.height(16.dp)) + + MerchantMaskToggleBar( + sdk = sdk, + hostedCardDisplayState = hostedCardDisplayState, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + ExpressDisplayConfigBar( + enableAutofill = sheetEnableAutofill, + onEnableAutofillChange = { sheetEnableAutofill = it }, + useMaskedFormat = sheetUseMaskedFormat, + onUseMaskedFormatChange = { sheetUseMaskedFormat = it }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + Spacer(modifier = Modifier.height(24.dp)) // Info Section @@ -478,7 +540,8 @@ fun BottomSheetPaymentScreen( SpreedlyBottomSheet( sdk = sdk, - config = PaymentSheetConfig.Default, + config = paymentSheetConfig, + displayConfig = sheetDisplayConfig, nameFieldDisplayMode = NameFieldDisplayMode.SEPARATE_FIELDS, allowBlankName = allowBlankName, allowBlankDate = allowBlankDate, diff --git a/app/src/main/java/com/spreedly/example/screens/bottomsheet/BottomSheetPaymentViewModel.kt b/app/src/main/java/com/spreedly/example/screens/bottomsheet/BottomSheetPaymentViewModel.kt index a1fded8..b050445 100644 --- a/app/src/main/java/com/spreedly/example/screens/bottomsheet/BottomSheetPaymentViewModel.kt +++ b/app/src/main/java/com/spreedly/example/screens/bottomsheet/BottomSheetPaymentViewModel.kt @@ -10,6 +10,8 @@ import com.spreedly.example.AuthService import com.spreedly.example.repository.PaymentMethodRepository import com.spreedly.example.utils.PaymentResultHandler import com.spreedly.example.utils.SdkSessionManager +import com.spreedly.example.ui.theme.SampleThemePreset +import com.spreedly.example.ui.theme.ThemeConfigurationController import com.spreedly.sdk.Spreedly import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -45,6 +47,9 @@ class BottomSheetPaymentViewModel(private val context: Context) : ViewModel() { private val sdkSessionManager = SdkSessionManager(AuthService()) private val paymentMethodRepository = PaymentMethodRepository(context) private val paymentResultHandler = PaymentResultHandler(paymentMethodRepository) + val themeConfiguration = ThemeConfigurationController() + val useCustomTheme = themeConfiguration.useCustomTheme + val selectedThemePreset = themeConfiguration.selectedPreset // Initialize SDK on ViewModel creation init { @@ -219,6 +224,25 @@ class BottomSheetPaymentViewModel(private val context: Context) : ViewModel() { } } + fun setUseCustomTheme(enabled: Boolean) { + themeConfiguration.setUseCustomTheme(enabled) + } + + fun setThemePreset(preset: SampleThemePreset) { + themeConfiguration.setPreset(preset) + } + + fun resetThemeConfiguration() { + themeConfiguration.setUseCustomTheme(false) + } + + fun resolvePaymentSheetConfig(isDarkMode: Boolean) = + themeConfiguration.resolvePaymentSheetConfig(isDarkMode) + + fun applyThemeToSdk(isDarkMode: Boolean) { + themeConfiguration.applyGlobalTheme(sdk, isDarkMode) + } + private companion object { private const val TAG = "BottomSheetPaymentViewModel" } diff --git a/app/src/main/java/com/spreedly/example/screens/customcheckout/CheckoutWithAdditionalFieldsScreen.kt b/app/src/main/java/com/spreedly/example/screens/customcheckout/CheckoutWithAdditionalFieldsScreen.kt index d7ccbcc..03abc64 100644 --- a/app/src/main/java/com/spreedly/example/screens/customcheckout/CheckoutWithAdditionalFieldsScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/customcheckout/CheckoutWithAdditionalFieldsScreen.kt @@ -289,6 +289,7 @@ fun CheckoutWithAdditionalFieldsScreen( onImeAction = { handleFieldSubmit(AdditionalFieldType.CARD_NUMBER) }, imeAction = getImeAction(AdditionalFieldType.CARD_NUMBER), shouldFocus = focusedFieldType == AdditionalFieldType.CARD_NUMBER, + sdk = sdk, ) Spacer(modifier = Modifier.height(12.dp)) @@ -317,6 +318,7 @@ fun CheckoutWithAdditionalFieldsScreen( onImeAction = { handleFieldSubmit(AdditionalFieldType.CVV) }, imeAction = getImeAction(AdditionalFieldType.CVV), shouldFocus = focusedFieldType == AdditionalFieldType.CVV, + sdk = sdk, ) Spacer(modifier = Modifier.height(12.dp)) diff --git a/app/src/main/java/com/spreedly/example/screens/customcheckout/CheckoutWithAdditionalFieldsViewModel.kt b/app/src/main/java/com/spreedly/example/screens/customcheckout/CheckoutWithAdditionalFieldsViewModel.kt index 04278f1..d3b958f 100644 --- a/app/src/main/java/com/spreedly/example/screens/customcheckout/CheckoutWithAdditionalFieldsViewModel.kt +++ b/app/src/main/java/com/spreedly/example/screens/customcheckout/CheckoutWithAdditionalFieldsViewModel.kt @@ -11,8 +11,10 @@ import com.spreedly.example.models.SavedPaymentMethod import com.spreedly.example.repository.PaymentMethodRepository import com.spreedly.example.utils.PaymentResultHandler import com.spreedly.example.utils.SdkSessionManager +import com.spreedly.result.Result import com.spreedly.sdk.SpreedlyErrorMessages import com.spreedly.sdk.Spreedly +import com.spreedly.sdk.models.paymentMethodUpdatedAt import com.spreedly.sdk.models.RecacheConfig import com.spreedly.sdk.models.SavedCardInfo import com.spreedly.sdk.models.ScreenPresentationMode @@ -202,22 +204,19 @@ class CheckoutWithAdditionalFieldsViewModel(private val context: Context) : View ) when (val recacheResult = result) { - is com.spreedly.result.Result.Success -> { + is Result.Success -> { val response = recacheResult.data - if (response.transaction.succeeded) { - _token.value = response.transaction.paymentMethod.token - Log.d(TAG, - "CheckoutWithAdditionalFieldsViewModel: " + + _token.value = response.transaction.paymentMethod.token + Log.d(TAG, + "CheckoutWithAdditionalFieldsViewModel: " + "Recached token: ${response.transaction.paymentMethod.token}", - ) - - // Call retain API after successful recache to refresh retention - retainAfterRecache(response.transaction.paymentMethod.token) - } else { - snackbarHostState.showSnackbar("Recaching failed: ${response.transaction.message}") - } + ) + snackbarHostState.showSnackbar( + "CVV updated. Updated at: ${response.paymentMethodUpdatedAt}", + ) + retainAfterRecache(response.transaction.paymentMethod.token) } - is com.spreedly.result.Result.Error -> { + is Result.Error -> { // Don't show error message if user cancelled if (recacheResult.error != com.spreedly.sdk.SpreedlyNetworkError.USER_CANCELLED) { val errorMessage = SpreedlyErrorMessages.getUserFriendlyMessage( diff --git a/app/src/main/java/com/spreedly/example/screens/customizedcheckout/CustomisedCheckoutScreen.kt b/app/src/main/java/com/spreedly/example/screens/customizedcheckout/CustomisedCheckoutScreen.kt index eb075b1..3bba4e0 100644 --- a/app/src/main/java/com/spreedly/example/screens/customizedcheckout/CustomisedCheckoutScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/customizedcheckout/CustomisedCheckoutScreen.kt @@ -200,6 +200,7 @@ fun CustomisedCheckoutScreen( config = config, value = sdk.paymentState.value.cardNumber.value, onChange = { sdk.callbacks.onCardNumberChange(it, true) }, + sdk = sdk, ) } } @@ -257,6 +258,7 @@ fun CustomisedCheckoutScreen( config = config, value = sdk.paymentState.value.securityCode.value, onChange = { sdk.callbacks.onSecurityCodeChange(it) }, + sdk = sdk, ) } } diff --git a/app/src/main/java/com/spreedly/example/screens/customtextfields/CustomTextFieldsScreen.kt b/app/src/main/java/com/spreedly/example/screens/customtextfields/CustomTextFieldsScreen.kt index 5676871..df77f3a 100644 --- a/app/src/main/java/com/spreedly/example/screens/customtextfields/CustomTextFieldsScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/customtextfields/CustomTextFieldsScreen.kt @@ -62,15 +62,20 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.ui.platform.testTag import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection @@ -85,13 +90,19 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.spreedly.example.ui.components.CardBrandTrailingIcon +import com.spreedly.example.ui.components.FieldStyleOverrideCard import com.spreedly.example.ui.components.RetokenizeCard +import com.spreedly.example.ui.components.ThemeConfigurationCard +import com.spreedly.example.ui.components.ThemeConfigurationStyle +import com.spreedly.example.MerchantMaskToggleBar +import com.spreedly.example.qa.FieldStateInspectorCard +import com.spreedly.example.qa.HeadlessHostedFieldsConfigCard import com.spreedly.example.ui.theme.Spacing import com.spreedly.example.viewmodel.customTextFieldsViewModel import com.spreedly.hostedfields.ui.SPLTextField import com.spreedly.sdk.AdditionalField import com.spreedly.sdk.models.FormFieldType -import com.spreedly.sdk.ui.CustomFieldsConfig import com.spreedly.sdk.ui.PaymentProcessingResult import com.spreedly.security.secureScreen import com.spreedly.validation.formx.FormInput @@ -359,6 +370,13 @@ fun CustomTextFieldsScreen( viewModel: CustomTextFieldsViewModel = customTextFieldsViewModel(), ) { val sdk = viewModel.sdk + val hostedCardDisplayState by sdk.hostedCardDisplayState + val inspectorUiState by viewModel.inspectorUiState.collectAsState() + var enableAutofill by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(hostedCardDisplayState) { + viewModel.fieldStateInspector.refreshMismatch(hostedCardDisplayState) + } val snackbarHostState = viewModel.snackbarHostState val coroutineScope = rememberCoroutineScope() val scrollState = rememberScrollState() @@ -376,6 +394,29 @@ fun CustomTextFieldsScreen( val cityInput by viewModel.cityInput.collectAsState() val stateInput by viewModel.stateInput.collectAsState() val zipCodeInput by viewModel.zipCodeInput.collectAsState() + val isFormValid by viewModel.isFormValid.collectAsState() + val useCustomTheme by viewModel.useCustomTheme.collectAsState() + val selectedThemePreset by viewModel.selectedThemePreset.collectAsState() + val fieldOverrideTarget by viewModel.fieldOverrideTarget.collectAsState() + val fieldStyleOverrides by viewModel.fieldStyleOverrides.collectAsState() + val isDarkMode = isSystemInDarkTheme() + + LaunchedEffect(useCustomTheme, selectedThemePreset, isDarkMode) { + viewModel.applyThemeToSdk(isDarkMode) + } + + val cardFieldConfig = + remember(useCustomTheme, selectedThemePreset, fieldOverrideTarget, fieldStyleOverrides, isDarkMode) { + viewModel.resolveSplFieldConfig(FormFieldType.CARD(true), isDarkMode) + } + val expiryFieldConfig = + remember(useCustomTheme, selectedThemePreset, fieldOverrideTarget, fieldStyleOverrides, isDarkMode) { + viewModel.resolveSplFieldConfig(FormFieldType.EXPIRY_DATE(true), isDarkMode) + } + val cvvFieldConfig = + remember(useCustomTheme, selectedThemePreset, fieldOverrideTarget, fieldStyleOverrides, isDarkMode) { + viewModel.resolveSplFieldConfig(FormFieldType.CVV(true), isDarkMode) + } // Define which SPL fields are required for payment processing // Only card-related fields use SPL for security, custom fields handle their own validation @@ -426,19 +467,6 @@ fun CustomTextFieldsScreen( null -> null } - /** - * Validates all custom form fields. - * - * This demonstrates how to combine multiple custom field validations. - * The payment button will be disabled until both SPL fields (handled by SDK) - * and custom fields (handled here) are all valid. - */ - fun isCustomFormValid(): Boolean = nameInput.isValid && - addressInput.isValid && - cityInput.isValid && - stateInput.isValid && - zipCodeInput.isValid - Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, containerColor = MaterialTheme.colorScheme.background, @@ -496,6 +524,33 @@ fun CustomTextFieldsScreen( modifier = Modifier.padding(bottom = 48.dp), ) + ThemeConfigurationCard( + useCustomTheme = useCustomTheme, + selectedPreset = selectedThemePreset, + onUseCustomThemeChange = viewModel::setUseCustomTheme, + onPresetSelected = viewModel::setThemePreset, + onResetTheme = viewModel::resetThemeConfiguration, + style = ThemeConfigurationStyle.SWATCH, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp), + ) + + FieldStyleOverrideCard( + selectedTarget = fieldOverrideTarget, + overrides = fieldStyleOverrides, + onTargetSelected = viewModel::setFieldOverrideTarget, + onOverridesChange = viewModel::updateFieldStyleOverrides, + onClearOverrides = viewModel::clearFieldStyleOverrides, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + ) + // Main Card Card( modifier = @@ -529,42 +584,79 @@ fun CustomTextFieldsScreen( .padding(bottom = 12.dp), ) - // SPL Card Number Field - SPLTextField( - label = "Card Number", - formFieldType = FormFieldType.CARD(true), - config = CustomFieldsConfig.Default, - value = sdk.paymentState.value.cardNumber.value, - onChange = { sdk.callbacks.onCardNumberChange(it, true) }, + MerchantMaskToggleBar( + sdk = sdk, + hostedCardDisplayState = hostedCardDisplayState, + modifier = Modifier.padding(bottom = 12.dp), + ) + + HeadlessHostedFieldsConfigCard( + enableAutofill = enableAutofill, + onEnableAutofillChange = { enableAutofill = it }, + modifier = Modifier.padding(bottom = 12.dp), ) + key(useCustomTheme, selectedThemePreset, fieldOverrideTarget, fieldStyleOverrides) { + SPLTextField( + label = "Card Number", + formFieldType = FormFieldType.CARD(true), + config = cardFieldConfig, + value = sdk.paymentState.value.cardNumber.value, + onChange = { + sdk.callbacks.onCardNumberChange(it, true) + viewModel.fieldStateInspector.logOpaqueFieldChange(FormFieldType.CARD(true)) + }, + trailingIcon = { scheme -> CardBrandTrailingIcon(scheme) }, + onFieldStateChange = { viewModel.onFieldStateUpdate(it) }, + onValidationChange = { + viewModel.onHostedFieldValidation(FormFieldType.CARD(true), it) + }, + enableAutofill = enableAutofill, + sdk = sdk, + ) + } + Spacer(modifier = Modifier.height(12.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - // SPL Expiry Date Field - SPLTextField( - label = "MM/YY", - formFieldType = FormFieldType.EXPIRY_DATE(true), - config = CustomFieldsConfig.Default, - value = sdk.paymentState.value.expirationDate.value, - onChange = { sdk.callbacks.onExpirationDateChange(it, true) }, - modifier = Modifier.weight(1f), - imeAction = ImeAction.Next, - ) + key(useCustomTheme, selectedThemePreset, fieldOverrideTarget, fieldStyleOverrides) { + SPLTextField( + label = "MM/YY", + formFieldType = FormFieldType.EXPIRY_DATE(true), + config = expiryFieldConfig, + value = sdk.paymentState.value.expirationDate.value, + onChange = { sdk.callbacks.onExpirationDateChange(it, true) }, + onValidationChange = { + viewModel.onHostedFieldValidation(FormFieldType.EXPIRY_DATE(true), it) + }, + modifier = Modifier.weight(1f), + imeAction = ImeAction.Next, + ) + } - // SPL CVV Field - SPLTextField( - label = "CVV", - formFieldType = FormFieldType.CVV(true), - config = CustomFieldsConfig.Default, - value = sdk.paymentState.value.securityCode.value, - onChange = { sdk.callbacks.onSecurityCodeChange(it, true) }, - modifier = Modifier.weight(1f), - imeAction = ImeAction.Next, - ) + key(useCustomTheme, selectedThemePreset, fieldOverrideTarget, fieldStyleOverrides) { + SPLTextField( + label = "Security Code (CVC)", + formFieldType = FormFieldType.CVV(true), + config = cvvFieldConfig, + value = sdk.paymentState.value.securityCode.value, + onChange = { + sdk.callbacks.onSecurityCodeChange(it, true) + viewModel.fieldStateInspector.logOpaqueFieldChange(FormFieldType.CVV(true)) + }, + onFieldStateChange = { viewModel.onFieldStateUpdate(it) }, + onValidationChange = { + viewModel.onHostedFieldValidation(FormFieldType.CVV(true), it) + }, + modifier = Modifier.weight(1f), + imeAction = ImeAction.Next, + enableAutofill = enableAutofill, + sdk = sdk, + ) + } } Spacer(modifier = Modifier.height(24.dp)) @@ -722,13 +814,26 @@ fun CustomTextFieldsScreen( Spacer(modifier = Modifier.height(8.dp)) - val isButtonDisabled = isInitializing || isProcessing || !isCustomFormValid() + val isButtonDisabled = isInitializing || isProcessing || !isFormValid // Custom Payment Button - uses sdk.processPayment() instead of CheckoutButton Button( onClick = { + if (!isFormValid) { + coroutineScope.launch { + snackbarHostState.showSnackbar("Fix invalid fields before paying.") + } + return@Button + } + if (!sdk.areAllFieldsValid(formFields)) { + coroutineScope.launch { + snackbarHostState.showSnackbar( + "Please fix card field errors before proceeding", + ) + } + return@Button + } coroutineScope.launch { - // NEW PATTERN: Pass additional fields directly like iOS val additionalFields = mapOf( AdditionalField.FULL_NAME to nameInput.value, AdditionalField.ADDRESS_LINE_1 to addressInput.value, @@ -737,7 +842,6 @@ fun CustomTextFieldsScreen( AdditionalField.ZIP_CODE to zipCodeInput.value, ) - // Process payment with SPL field validation and additional fields val result = sdk.createCreditCard( formFields = formFields, // Only SPL fields are validated by SDK additionalFields = additionalFields, // Pass other fields directly @@ -753,6 +857,7 @@ fun CustomTextFieldsScreen( is PaymentProcessingResult.Processing -> { Log.d(TAG, "Payment processing started") viewModel.resetFormFields() + viewModel.onCheckoutFieldsClearedBySdk() viewModel.setProcessing(true) viewModel.startPaymentPolling() } @@ -789,7 +894,7 @@ fun CustomTextFieldsScreen( text = when { isInitializing -> "Initializing..." isProcessing -> "Processing Payment..." - !isCustomFormValid() -> "Complete All Fields" + !isFormValid -> "Complete All Fields" else -> "Pay Now - Custom Fields" }, color = MaterialTheme.colorScheme.onPrimary, @@ -802,6 +907,26 @@ fun CustomTextFieldsScreen( paymentToken = paymentToken, onRetokenize = { viewModel.reinitialize() }, ) + + Spacer(modifier = Modifier.height(12.dp)) + + androidx.compose.material3.TextButton( + onClick = { viewModel.performFullPaymentReset() }, + modifier = + Modifier + .fillMaxWidth() + .testTag("custom-text-fields-reset-payment-state-button"), + ) { + Text("resetPaymentState()") + } + + Spacer(modifier = Modifier.height(24.dp)) + + FieldStateInspectorCard( + uiState = inspectorUiState, + hostedCardDisplayState = hostedCardDisplayState, + modifier = Modifier.fillMaxWidth(), + ) } } diff --git a/app/src/main/java/com/spreedly/example/screens/customtextfields/CustomTextFieldsViewModel.kt b/app/src/main/java/com/spreedly/example/screens/customtextfields/CustomTextFieldsViewModel.kt index 30fca46..10c47dc 100644 --- a/app/src/main/java/com/spreedly/example/screens/customtextfields/CustomTextFieldsViewModel.kt +++ b/app/src/main/java/com/spreedly/example/screens/customtextfields/CustomTextFieldsViewModel.kt @@ -8,9 +8,17 @@ import androidx.lifecycle.viewModelScope import com.spreedly.app.BuildConfig import com.spreedly.example.AuthService import com.spreedly.example.repository.PaymentMethodRepository +import com.spreedly.example.qa.FieldStateInspectorController import com.spreedly.example.utils.PaymentResultHandler import com.spreedly.example.utils.SdkSessionManager +import com.spreedly.example.ui.theme.SampleThemePreset +import com.spreedly.example.ui.theme.SplFieldStyleOverrides +import com.spreedly.example.ui.theme.SplFieldTarget +import com.spreedly.example.ui.theme.ThemeConfigurationController +import com.spreedly.example.utils.isCvvFormRequirementMet +import com.spreedly.hostedfields.models.HostedFieldState import com.spreedly.sdk.Spreedly +import com.spreedly.sdk.models.FormFieldType import com.spreedly.sdk.ui.PaymentResult import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -23,14 +31,22 @@ import kotlinx.coroutines.launch * This ViewModel handles both the Spreedly SDK instance and the custom form field states * that cannot be saved using rememberSaveable due to their complex nature. */ -class CustomTextFieldsViewModel(private val context: Context) : ViewModel() { - // Spreedly SDK instance - survives configuration changes via ViewModel +class CustomTextFieldsViewModel( + private val context: Context, + private val skipAutoInitializeSdk: Boolean = false, +) : ViewModel() { val sdk = Spreedly() - // SnackbarHostState for showing messages val snackbarHostState = SnackbarHostState() - // UI State + val fieldStateInspector = FieldStateInspectorController(sdk) + val inspectorUiState = fieldStateInspector.uiState + val themeConfiguration = ThemeConfigurationController() + val useCustomTheme = themeConfiguration.useCustomTheme + val selectedThemePreset = themeConfiguration.selectedPreset + val fieldOverrideTarget = themeConfiguration.fieldOverrideTarget + val fieldStyleOverrides = themeConfiguration.fieldOverrides + private val _isInitializing = MutableStateFlow(true) val isInitializing: StateFlow = _isInitializing.asStateFlow() @@ -40,7 +56,6 @@ class CustomTextFieldsViewModel(private val context: Context) : ViewModel() { private val _paymentToken = MutableStateFlow("") val paymentToken: StateFlow = _paymentToken.asStateFlow() - // Custom form field states - managed in ViewModel to survive configuration changes private val _nameInput = MutableStateFlow(NameInput("")) val nameInput: StateFlow = _nameInput.asStateFlow() @@ -56,19 +71,28 @@ class CustomTextFieldsViewModel(private val context: Context) : ViewModel() { private val _zipCodeInput = MutableStateFlow(ZipCodeInput("")) val zipCodeInput: StateFlow = _zipCodeInput.asStateFlow() - // Track when payment completed to prevent race conditions + private val _cardValid = MutableStateFlow(false) + private val _cvvValid = MutableStateFlow(false) + private val _expiryValid = MutableStateFlow(false) + + private val _isFormValid = MutableStateFlow(false) + val isFormValid: StateFlow = _isFormValid.asStateFlow() + private var lastPaymentCompletedTime = 0L - // Payment method repository for retention private val paymentMethodRepository = PaymentMethodRepository(context) private val sdkSessionManager = SdkSessionManager(AuthService()) private val paymentResultHandler = PaymentResultHandler(paymentMethodRepository) private var paymentResultJob: Job? = null private var initJob: Job? = null - // Initialize SDK on ViewModel creation init { - initializeSDK() + refreshInspectorAggregate() + if (skipAutoInitializeSdk) { + _isInitializing.value = false + } else { + initializeSDK() + } } private fun initializeSDK() { @@ -81,7 +105,7 @@ class CustomTextFieldsViewModel(private val context: Context) : ViewModel() { observePaymentResults() _isInitializing.value = false }, - onFailure = { e -> + onFailure = { snackbarHostState.showSnackbar("Failed to initialize SDK") _isInitializing.value = false }, @@ -120,7 +144,6 @@ class CustomTextFieldsViewModel(private val context: Context) : ViewModel() { fun setProcessing(processing: Boolean) { if (processing) { - // Check if payment completed recently (within last 1000ms) - race condition protection val timeSinceCompletion = System.currentTimeMillis() - lastPaymentCompletedTime if (timeSinceCompletion < 1000 && _paymentToken.value.isNotEmpty()) { return @@ -131,7 +154,38 @@ class CustomTextFieldsViewModel(private val context: Context) : ViewModel() { fun clearToken() { _paymentToken.value = "" - lastPaymentCompletedTime = 0L // Reset completion tracking + lastPaymentCompletedTime = 0L + } + + fun onFieldStateUpdate(state: HostedFieldState) { + fieldStateInspector.onFieldStateChanged(state) + } + + fun onHostedFieldValidation(fieldType: FormFieldType, isValid: Boolean) { + when (fieldType) { + is FormFieldType.CARD -> _cardValid.value = isValid + is FormFieldType.CVV -> _cvvValid.value = isValid + is FormFieldType.EXPIRY_DATE -> _expiryValid.value = isValid + else -> Unit + } + refreshInspectorAggregate() + } + + fun onCheckoutFieldsClearedBySdk() { + _cardValid.value = false + _cvvValid.value = false + _expiryValid.value = false + refreshInspectorAggregate() + } + + fun performFullPaymentReset() { + sdk.resetPaymentState() + fieldStateInspector.resetInspector() + _cardValid.value = false + _cvvValid.value = false + _expiryValid.value = false + resetFormFields() + refreshInspectorAggregate() } fun reinitialize() { @@ -141,10 +195,6 @@ class CustomTextFieldsViewModel(private val context: Context) : ViewModel() { initializeSDK() } - /** - * Handle payment retention after successful payment creation. - * Calls the backend retain API. - */ private fun handlePaymentRetention(result: PaymentResult.Completed) { viewModelScope.launch { try { @@ -154,8 +204,8 @@ class CustomTextFieldsViewModel(private val context: Context) : ViewModel() { Log.d(TAG, "CustomTextFieldsViewModel: Payment method retained successfully") snackbarHostState.showSnackbar("Payment method saved for future use!") }, - onFailure = { e -> - Log.d(TAG, "CustomTextFieldsViewModel: Error retaining payment method: ${e::class.simpleName}") + onFailure = { + Log.d(TAG, "CustomTextFieldsViewModel: Error retaining payment method") snackbarHostState.showSnackbar("Payment created but failed to save") }, ) @@ -165,28 +215,36 @@ class CustomTextFieldsViewModel(private val context: Context) : ViewModel() { } } - // Form field update methods fun updateNameInput(value: String) { _nameInput.value = NameInput(value, false) + fieldStateInspector.logOnChangeReadout("Full Name", value) + refreshInspectorAggregate() } fun updateAddressInput(value: String) { _addressInput.value = AddressInput(value, false) + fieldStateInspector.logOnChangeReadout("Address Line 1", value) + refreshInspectorAggregate() } fun updateCityInput(value: String) { _cityInput.value = CityInput(value, false) + fieldStateInspector.logOnChangeReadout("City", value) + refreshInspectorAggregate() } fun updateStateInput(value: String) { _stateInput.value = StateInput(value, false) + fieldStateInspector.logOnChangeReadout("State", value) + refreshInspectorAggregate() } fun updateZipCodeInput(value: String) { _zipCodeInput.value = ZipCodeInput(value, false) + fieldStateInspector.logOnChangeReadout("Zip Code", value) + refreshInspectorAggregate() } - // Reset all form fields fun resetFormFields() { _nameInput.value = NameInput("") _addressInput.value = AddressInput("") @@ -195,10 +253,9 @@ class CustomTextFieldsViewModel(private val context: Context) : ViewModel() { _zipCodeInput.value = ZipCodeInput("") } - // Backup timeout mechanism in case payment result flow fails fun startPaymentPolling() { viewModelScope.launch { - kotlinx.coroutines.delay(30000) // 30-second timeout + kotlinx.coroutines.delay(30000) if (_isProcessing.value) { _isProcessing.value = false snackbarHostState.showSnackbar("Payment processing timeout") @@ -206,6 +263,68 @@ class CustomTextFieldsViewModel(private val context: Context) : ViewModel() { } } + private fun computeIsFormValid(): Boolean = + _cardValid.value && + isCvvFormRequirementMet(_cvvValid.value) && + _expiryValid.value && + _nameInput.value.isValid && + _addressInput.value.isValid && + _cityInput.value.isValid && + _stateInput.value.isValid && + _zipCodeInput.value.isValid + + private fun refreshInspectorAggregate() { + _isFormValid.value = computeIsFormValid() + fieldStateInspector.configureAggregate( + fields = + listOf( + "Card number" to { _cardValid.value }, + "Security code (CVC)" to { isCvvFormRequirementMet(_cvvValid.value) }, + "MM/YY" to { _expiryValid.value }, + "Full Name" to { _nameInput.value.isValid }, + "Address Line 1" to { _addressInput.value.isValid }, + "City" to { _cityInput.value.isValid }, + "State" to { _stateInput.value.isValid }, + "Zip Code" to { _zipCodeInput.value.isValid }, + ), + isFormValid = ::computeIsFormValid, + ) + fieldStateInspector.refreshMismatch(sdk.hostedCardDisplayState.value) + } + + fun setUseCustomTheme(enabled: Boolean) { + themeConfiguration.setUseCustomTheme(enabled) + } + + fun setThemePreset(preset: SampleThemePreset) { + themeConfiguration.setPreset(preset) + } + + fun resetThemeConfiguration() { + themeConfiguration.setUseCustomTheme(false) + } + + fun setFieldOverrideTarget(target: SplFieldTarget) { + themeConfiguration.setFieldOverrideTarget(target) + } + + fun updateFieldStyleOverrides(overrides: SplFieldStyleOverrides) { + themeConfiguration.updateFieldOverrides(overrides) + } + + fun clearFieldStyleOverrides() { + themeConfiguration.clearFieldOverrides() + } + + fun resolveSplFieldConfig( + formFieldType: FormFieldType, + isDarkMode: Boolean, + ) = themeConfiguration.resolveFieldConfig(formFieldType, isDarkMode) + + fun applyThemeToSdk(isDarkMode: Boolean) { + themeConfiguration.applyGlobalTheme(sdk, isDarkMode) + } + private companion object { private const val TAG = "CustomTextFieldsViewModel" } diff --git a/app/src/main/java/com/spreedly/example/screens/flexibleexpiry/FlexibleExpiryScreen.kt b/app/src/main/java/com/spreedly/example/screens/flexibleexpiry/FlexibleExpiryScreen.kt index fd475df..63b9e2c 100644 --- a/app/src/main/java/com/spreedly/example/screens/flexibleexpiry/FlexibleExpiryScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/flexibleexpiry/FlexibleExpiryScreen.kt @@ -414,6 +414,7 @@ fun FlexibleExpiryScreen( config = CustomFieldsConfig.Default, value = sdk.paymentState.value.cardNumber.value, onChange = { sdk.callbacks.onCardNumberChange(it, true) }, + sdk = sdk, ) Spacer(modifier = Modifier.height(12.dp)) @@ -489,6 +490,7 @@ fun FlexibleExpiryScreen( config = CustomFieldsConfig.Default, value = sdk.paymentState.value.securityCode.value, onChange = { sdk.callbacks.onSecurityCodeChange(it) }, + sdk = sdk, ) Spacer(modifier = Modifier.height(12.dp)) diff --git a/app/src/main/java/com/spreedly/example/screens/recachingshowcase/RecachingShowcaseScreen.kt b/app/src/main/java/com/spreedly/example/screens/recachingshowcase/RecachingShowcaseScreen.kt index dc1bbb9..df608e5 100644 --- a/app/src/main/java/com/spreedly/example/screens/recachingshowcase/RecachingShowcaseScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/recachingshowcase/RecachingShowcaseScreen.kt @@ -68,6 +68,7 @@ import com.spreedly.app.R import com.spreedly.example.ui.components.RetokenizeCard import com.spreedly.example.screens.basiccheckout.SimpleInputField import com.spreedly.example.ui.components.SavedPaymentMethodsList +import com.spreedly.example.ui.theme.SampleThemePreset import com.spreedly.example.ui.theme.Spacing import com.spreedly.example.viewmodel.recachingShowcaseViewModel import com.spreedly.hostedfields.ui.SPLTextField @@ -315,20 +316,20 @@ fun RecachingShowcaseScreen( ) { ThemePresetChip( text = stringResource(R.string.theme_default), - isSelected = selectedThemePreset == ThemePreset.DEFAULT, - onClick = { viewModel.updateThemePreset(ThemePreset.DEFAULT) }, + isSelected = selectedThemePreset == SampleThemePreset.DEFAULT, + onClick = { viewModel.updateThemePreset(SampleThemePreset.DEFAULT) }, color = Color(0xFF757575), ) ThemePresetChip( text = stringResource(R.string.theme_blue), - isSelected = selectedThemePreset == ThemePreset.BLUE, - onClick = { viewModel.updateThemePreset(ThemePreset.BLUE) }, + isSelected = selectedThemePreset == SampleThemePreset.BLUE, + onClick = { viewModel.updateThemePreset(SampleThemePreset.BLUE) }, color = Color(0xFF1976D2), ) ThemePresetChip( text = stringResource(R.string.theme_green), - isSelected = selectedThemePreset == ThemePreset.GREEN, - onClick = { viewModel.updateThemePreset(ThemePreset.GREEN) }, + isSelected = selectedThemePreset == SampleThemePreset.GREEN, + onClick = { viewModel.updateThemePreset(SampleThemePreset.GREEN) }, color = Color(0xFF388E3C), ) } @@ -339,14 +340,14 @@ fun RecachingShowcaseScreen( ) { ThemePresetChip( text = stringResource(R.string.theme_purple), - isSelected = selectedThemePreset == ThemePreset.PURPLE, - onClick = { viewModel.updateThemePreset(ThemePreset.PURPLE) }, + isSelected = selectedThemePreset == SampleThemePreset.PURPLE, + onClick = { viewModel.updateThemePreset(SampleThemePreset.PURPLE) }, color = Color(0xFF7B1FA2), ) ThemePresetChip( text = stringResource(R.string.theme_dark), - isSelected = selectedThemePreset == ThemePreset.DARK, - onClick = { viewModel.updateThemePreset(ThemePreset.DARK) }, + isSelected = selectedThemePreset == SampleThemePreset.DARK, + onClick = { viewModel.updateThemePreset(SampleThemePreset.DARK) }, color = Color(0xFF212121), ) // Spacer to balance the row @@ -546,6 +547,7 @@ fun RecachingShowcaseScreen( config = CustomFieldsConfig.Default, value = sdk.paymentState.value.cardNumber.value, onChange = { sdk.callbacks.onCardNumberChange(it, true) }, + sdk = sdk, ) Spacer(modifier = Modifier.height(12.dp)) @@ -574,6 +576,7 @@ fun RecachingShowcaseScreen( onChange = { sdk.callbacks.onSecurityCodeChange(it, true) }, modifier = Modifier.weight(1f), imeAction = ImeAction.Next, + sdk = sdk, ) } diff --git a/app/src/main/java/com/spreedly/example/screens/recachingshowcase/RecachingShowcaseViewModel.kt b/app/src/main/java/com/spreedly/example/screens/recachingshowcase/RecachingShowcaseViewModel.kt index ef94af4..7a69106 100644 --- a/app/src/main/java/com/spreedly/example/screens/recachingshowcase/RecachingShowcaseViewModel.kt +++ b/app/src/main/java/com/spreedly/example/screens/recachingshowcase/RecachingShowcaseViewModel.kt @@ -3,22 +3,23 @@ package com.spreedly.example.screens.recachingshowcase import android.content.Context import android.util.Log import androidx.compose.material3.SnackbarHostState -import androidx.compose.ui.graphics.Color +import com.spreedly.example.ui.theme.SampleThemePreset +import com.spreedly.example.ui.theme.SampleThemePresets +import com.spreedly.ui.theme.SpreedlyTheme import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.spreedly.app.BuildConfig import com.spreedly.example.AuthService import com.spreedly.example.models.SavedPaymentMethod import com.spreedly.example.repository.PaymentMethodRepository -import com.spreedly.example.utils.PaymentResultHandler import com.spreedly.example.utils.SdkSessionManager import com.spreedly.sdk.SpreedlyErrorMessages import com.spreedly.result.Result import com.spreedly.sdk.Spreedly +import com.spreedly.sdk.models.paymentMethodUpdatedAt import com.spreedly.sdk.models.RecacheConfig import com.spreedly.sdk.models.SavedCardInfo import com.spreedly.sdk.models.ScreenPresentationMode -import com.spreedly.sdk.ui.PaymentResult import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -45,27 +46,20 @@ class RecachingShowcaseViewModel(private val context: Context) : ViewModel() { private val _paymentToken = MutableStateFlow("") val paymentToken: StateFlow = _paymentToken.asStateFlow() - // Track when payment completed to prevent race conditions - private var lastPaymentCompletedTime = 0L - // Saved payment methods private val paymentMethodRepository = PaymentMethodRepository(context) private val sdkSessionManager = SdkSessionManager(AuthService()) - private val paymentResultHandler = PaymentResultHandler(paymentMethodRepository) private val _savedPaymentMethods = MutableStateFlow>(emptyList()) val savedPaymentMethods: StateFlow> = _savedPaymentMethods.asStateFlow() - private var paymentResultJob: Job? = null private var initJob: Job? = null - - // Recaching customization state private val _presentationMode = MutableStateFlow(ScreenPresentationMode.bottomSheet) val presentationMode: StateFlow = _presentationMode.asStateFlow() private val _customThemeEnabled = MutableStateFlow(false) val customThemeEnabled: StateFlow = _customThemeEnabled.asStateFlow() - private val _selectedThemePreset = MutableStateFlow(ThemePreset.DEFAULT) - val selectedThemePreset: StateFlow = _selectedThemePreset.asStateFlow() + private val _selectedThemePreset = MutableStateFlow(SampleThemePreset.DEFAULT) + val selectedThemePreset: StateFlow = _selectedThemePreset.asStateFlow() // Initialize SDK on ViewModel creation init { @@ -91,7 +85,6 @@ class RecachingShowcaseViewModel(private val context: Context) : ViewModel() { .fold( onSuccess = { Log.d(TAG,"RecachingShowcaseViewModel: SDK initialized successfully") - observePaymentResults() }, onFailure = { e -> Log.d(TAG,"RecachingShowcaseViewModel: Failed to fetch auth parameters: ${e::class.simpleName}") @@ -106,60 +99,17 @@ class RecachingShowcaseViewModel(private val context: Context) : ViewModel() { } } - private fun observePaymentResults() { - paymentResultJob = paymentResultHandler.observeResults( - sdk = sdk, - scope = viewModelScope, - onCompleted = { result -> - _isProcessing.value = false - _paymentToken.value = result.token - lastPaymentCompletedTime = System.currentTimeMillis() - if (result.shouldRetain) { - handlePaymentRetention(result) - } else { - snackbarHostState.showSnackbar("Payment method created successfully!") - } - }, - onFailed = { - Log.d(TAG, "RecachingShowcaseViewModel: Payment failed: ${it.errorType}") - _isProcessing.value = false - _paymentToken.value = "" - snackbarHostState.showSnackbar("Error creating payment method") - }, - onCanceled = { - Log.d(TAG,"RecachingShowcaseViewModel: Payment canceled") - _isProcessing.value = false - // Don't show snackbar - user cancellation is a normal action, not an error - }, - ) - } - fun setProcessing(processing: Boolean) { Log.d(TAG,"RecachingShowcaseViewModel: setProcessing called with: $processing") - - if (processing) { - // Check if payment completed recently (within last 1000ms) - race condition protection - val timeSinceCompletion = System.currentTimeMillis() - lastPaymentCompletedTime - if (timeSinceCompletion < 1000 && _paymentToken.value.isNotEmpty()) { - Log.d(TAG, - "RecachingShowcaseViewModel: Ignoring setProcessing(true) - " + - "payment completed ${timeSinceCompletion}ms ago", - ) - return - } - } - _isProcessing.value = processing } fun clearToken() { _paymentToken.value = "" - lastPaymentCompletedTime = 0L } fun reinitialize() { initJob?.cancel() - paymentResultJob?.cancel() clearToken() initializeSDK() } @@ -216,31 +166,6 @@ class RecachingShowcaseViewModel(private val context: Context) : ViewModel() { } } - /** - * Handle payment retention after successful payment creation. - * Calls the backend retain API and refreshes the payment methods list. - */ - private fun handlePaymentRetention(result: PaymentResult.Completed) { - viewModelScope.launch { - try { - Log.d(TAG,"RecachingShowcaseViewModel: Retaining payment method") - paymentResultHandler.retainIfNeeded(result).fold( - onSuccess = { - Log.d(TAG,"RecachingShowcaseViewModel: Payment method retained successfully") - fetchPaymentMethodsFromBackend() - snackbarHostState.showSnackbar("Payment method created and saved!") - }, - onFailure = { e -> - Log.d(TAG,"RecachingShowcaseViewModel: Error retaining payment method: ${e::class.simpleName}") - snackbarHostState.showSnackbar("Payment created but failed to save") - }, - ) - } catch (e: Exception) { - Log.d(TAG,"RecachingShowcaseViewModel: Exception during retention: ${e::class.simpleName}") - } - } - } - /** * Update the presentation mode for recaching UI. */ @@ -260,7 +185,7 @@ class RecachingShowcaseViewModel(private val context: Context) : ViewModel() { /** * Update the selected theme preset. */ - fun updateThemePreset(preset: ThemePreset) { + fun updateThemePreset(preset: SampleThemePreset) { _selectedThemePreset.value = preset Log.d(TAG,"RecachingShowcaseViewModel: Theme preset updated to $preset") } @@ -269,57 +194,12 @@ class RecachingShowcaseViewModel(private val context: Context) : ViewModel() { * Build the SpreedlyTheme based on the selected preset. * Used to pass theme to SpreedlyRecacheUI at the view level. */ - fun buildSpreedlyTheme(): com.spreedly.ui.theme.SpreedlyTheme? { - if (!_customThemeEnabled.value) return null - - return when (_selectedThemePreset.value) { - ThemePreset.DEFAULT -> null - ThemePreset.BLUE -> com.spreedly.ui.theme.SpreedlyTheme( - colors = com.spreedly.ui.theme.SpreedlyColors( - text = Color(0xFF1976D2), - textSecondary = Color(0xFF424242), - surface = Color(0xFFE3F2FD), - primary = Color(0xFF1976D2), - error = Color(0xFFD32F2F), - background = Color.White, - border = Color(0xFFBDBDBD), - ), - ) - ThemePreset.GREEN -> com.spreedly.ui.theme.SpreedlyTheme( - colors = com.spreedly.ui.theme.SpreedlyColors( - text = Color(0xFF388E3C), - textSecondary = Color(0xFF424242), - surface = Color(0xFFE8F5E9), - primary = Color(0xFF388E3C), - error = Color(0xFFD32F2F), - background = Color.White, - border = Color(0xFFBDBDBD), - ), - ) - ThemePreset.PURPLE -> com.spreedly.ui.theme.SpreedlyTheme( - colors = com.spreedly.ui.theme.SpreedlyColors( - text = Color(0xFF7B1FA2), - textSecondary = Color(0xFF424242), - surface = Color(0xFFF3E5F5), - primary = Color(0xFF7B1FA2), - error = Color(0xFFD32F2F), - background = Color.White, - border = Color(0xFFBDBDBD), - ), - ) - ThemePreset.DARK -> com.spreedly.ui.theme.SpreedlyTheme( - colors = com.spreedly.ui.theme.SpreedlyColors( - text = Color(0xFFFFFFFF), - textSecondary = Color(0xFFBDBDBD), - surface = Color(0xFF424242), - primary = Color(0xFF212121), - error = Color(0xFFEF5350), - background = Color(0xFF303030), - border = Color(0xFF616161), - ), - ) - } - } + fun buildSpreedlyTheme(): SpreedlyTheme? = + SampleThemePresets.resolveTheme( + preset = _selectedThemePreset.value, + isDarkMode = false, + useCustomTheme = _customThemeEnabled.value, + ) /** * Recache a saved payment method by showing the SDK's CVV input UI with custom configuration. @@ -359,18 +239,15 @@ class RecachingShowcaseViewModel(private val context: Context) : ViewModel() { when (result) { is Result.Success -> { val response = result.data - if (response.transaction.succeeded) { - _paymentToken.value = response.transaction.paymentMethod.token - Log.d(TAG, - "RecachingShowcaseViewModel: Recached " + - "token: ${response.transaction.paymentMethod.token.take(8)}...", - ) - - // Call retain API after successful recache to refresh retention - retainAfterRecache(response.transaction.paymentMethod.token) - } else { - snackbarHostState.showSnackbar("Recaching failed: ${response.transaction.message}") - } + _paymentToken.value = response.transaction.paymentMethod.token + Log.d(TAG, + "RecachingShowcaseViewModel: Recached " + + "token: ${response.transaction.paymentMethod.token.take(8)}...", + ) + snackbarHostState.showSnackbar( + "CVV recached. Updated at: ${response.paymentMethodUpdatedAt}", + ) + retainAfterRecache(response.transaction.paymentMethod.token) } is Result.Error -> { // Don't show error message if user cancelled @@ -439,14 +316,3 @@ class RecachingShowcaseViewModel(private val context: Context) : ViewModel() { private const val TAG = "RecachingShowcaseViewModel" } } - -/** - * Theme presets for recaching UI customization. - */ -enum class ThemePreset { - DEFAULT, - BLUE, - GREEN, - PURPLE, - DARK, -} diff --git a/app/src/main/java/com/spreedly/example/screens/reusablebottomsheet/ReusableBottomSheetPaymentScreen.kt b/app/src/main/java/com/spreedly/example/screens/reusablebottomsheet/ReusableBottomSheetPaymentScreen.kt index 97704a0..9b70e95 100644 --- a/app/src/main/java/com/spreedly/example/screens/reusablebottomsheet/ReusableBottomSheetPaymentScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/reusablebottomsheet/ReusableBottomSheetPaymentScreen.kt @@ -40,7 +40,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -59,9 +62,13 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import com.spreedly.example.ui.theme.Spacing +import com.spreedly.example.ui.components.ThemeConfigurationCard +import com.spreedly.example.ui.components.ThemeConfigurationStyle import com.spreedly.example.viewmodel.reusableBottomSheetPaymentViewModel +import com.spreedly.example.qa.ExpressDisplayConfigBar import com.spreedly.paymentsheet.SpreedlyBottomSheet +import com.spreedly.sdk.ui.CardNumberFormat +import com.spreedly.sdk.ui.PaymentSheetDisplayConfig import com.spreedly.sdk.ui.ConfigurableFormField import com.spreedly.sdk.ui.NameFieldDisplayMode import com.spreedly.sdk.ui.OptionalFieldType @@ -84,10 +91,39 @@ fun ReusableBottomSheetPaymentScreen( val isProcessing by viewModel.isProcessing.collectAsState() val paymentCount by viewModel.paymentCount.collectAsState() val tokenHistory by viewModel.tokenHistory.collectAsState() + val useCustomTheme by viewModel.useCustomTheme.collectAsState() + val selectedThemePreset by viewModel.selectedThemePreset.collectAsState() + val isDarkMode = isSystemInDarkTheme() + val defaultPaymentSheetConfig = + PaymentSheetConfig( + primaryColor = MaterialTheme.colorScheme.tertiary, + fieldBackgroundColor = MaterialTheme.colorScheme.surfaceVariant, + formBackgroundColor = MaterialTheme.colorScheme.surface, + ) + val paymentSheetConfig = + remember(useCustomTheme, selectedThemePreset, isDarkMode) { + viewModel.resolvePaymentSheetConfig(isDarkMode, defaultPaymentSheetConfig) + } + + LaunchedEffect(useCustomTheme, selectedThemePreset, isDarkMode) { + viewModel.applyThemeToSdk(isDarkMode) + } var allowBlankName by rememberSaveable { mutableStateOf(false) } var allowBlankDate by rememberSaveable { mutableStateOf(false) } var allowExpiredDate by rememberSaveable { mutableStateOf(false) } + var sheetEnableAutofill by rememberSaveable { mutableStateOf(true) } + var sheetUseMaskedFormat by rememberSaveable { mutableStateOf(false) } + val sheetDisplayConfig = + PaymentSheetDisplayConfig( + enableAutofill = sheetEnableAutofill, + cardNumberFormat = + if (sheetUseMaskedFormat) { + CardNumberFormat.MASKED + } else { + CardNumberFormat.PRETTY + }, + ) LaunchedEffect(allowBlankName) { if (!isInitializing) { @@ -203,6 +239,19 @@ fun ReusableBottomSheetPaymentScreen( modifier = Modifier.padding(bottom = 16.dp), ) + ThemeConfigurationCard( + useCustomTheme = useCustomTheme, + selectedPreset = selectedThemePreset, + onUseCustomThemeChange = viewModel::setUseCustomTheme, + onPresetSelected = viewModel::setThemePreset, + onResetTheme = viewModel::resetThemeConfiguration, + style = ThemeConfigurationStyle.BUTTON, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) + // Payment Counter if (paymentCount > 0) { Card( @@ -788,6 +837,38 @@ fun ReusableBottomSheetPaymentScreen( } } + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Display Options", + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + ), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + shape = RoundedCornerShape(12.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), + ) { + ExpressDisplayConfigBar( + enableAutofill = sheetEnableAutofill, + onEnableAutofillChange = { sheetEnableAutofill = it }, + useMaskedFormat = sheetUseMaskedFormat, + onUseMaskedFormatChange = { sheetUseMaskedFormat = it }, + modifier = Modifier.padding(16.dp), + ) + } + Spacer(modifier = Modifier.height(24.dp)) // Emergency close button if bottom sheet is stuck open @@ -886,11 +967,8 @@ fun ReusableBottomSheetPaymentScreen( SpreedlyBottomSheet( sdk = sdk, - config = PaymentSheetConfig( - primaryColor = MaterialTheme.colorScheme.tertiary, - fieldBackgroundColor = MaterialTheme.colorScheme.surfaceVariant, - formBackgroundColor = MaterialTheme.colorScheme.surface, - ), + config = paymentSheetConfig, + displayConfig = sheetDisplayConfig, nameFieldDisplayMode = NameFieldDisplayMode.SEPARATE_FIELDS, allowBlankName = allowBlankName, allowBlankDate = allowBlankDate, diff --git a/app/src/main/java/com/spreedly/example/screens/reusablebottomsheet/ReusableBottomSheetPaymentViewModel.kt b/app/src/main/java/com/spreedly/example/screens/reusablebottomsheet/ReusableBottomSheetPaymentViewModel.kt index 63df702..d54164b 100644 --- a/app/src/main/java/com/spreedly/example/screens/reusablebottomsheet/ReusableBottomSheetPaymentViewModel.kt +++ b/app/src/main/java/com/spreedly/example/screens/reusablebottomsheet/ReusableBottomSheetPaymentViewModel.kt @@ -10,6 +10,8 @@ import com.spreedly.example.AuthService import com.spreedly.example.repository.PaymentMethodRepository import com.spreedly.example.utils.PaymentResultHandler import com.spreedly.example.utils.SdkSessionManager +import com.spreedly.example.ui.theme.SampleThemePreset +import com.spreedly.example.ui.theme.ThemeConfigurationController import com.spreedly.sdk.Spreedly import com.spreedly.sdk.models.BinMetadata import com.spreedly.sdk.models.Metadata @@ -17,6 +19,7 @@ import com.spreedly.sdk.models.PaymentMethodDetails import com.spreedly.sdk.models.PaymentMethodResponse import com.spreedly.sdk.models.Transaction import com.spreedly.sdk.ui.PaymentResult +import com.spreedly.sdk.ui.PaymentSheetConfig import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -60,6 +63,9 @@ class ReusableBottomSheetPaymentViewModel(private val context: Context) : ViewMo private val paymentMethodRepository = PaymentMethodRepository(context) private val sdkSessionManager = SdkSessionManager(AuthService()) private val paymentResultHandler = PaymentResultHandler(paymentMethodRepository) + val themeConfiguration = ThemeConfigurationController() + val useCustomTheme = themeConfiguration.useCustomTheme + val selectedThemePreset = themeConfiguration.selectedPreset // Track if we've initialized the SDK at least once private var hasInitialized = false @@ -419,6 +425,30 @@ class ReusableBottomSheetPaymentViewModel(private val context: Context) : ViewMo } } + fun setUseCustomTheme(enabled: Boolean) { + themeConfiguration.setUseCustomTheme(enabled) + } + + fun setThemePreset(preset: SampleThemePreset) { + themeConfiguration.setPreset(preset) + } + + fun resetThemeConfiguration() { + themeConfiguration.setUseCustomTheme(false) + } + + fun resolvePaymentSheetConfig( + isDarkMode: Boolean, + fallbackConfig: PaymentSheetConfig, + ): PaymentSheetConfig { + val themedConfig = themeConfiguration.resolvePaymentSheetConfig(isDarkMode) + return themedConfig ?: fallbackConfig + } + + fun applyThemeToSdk(isDarkMode: Boolean) { + themeConfiguration.applyGlobalTheme(sdk, isDarkMode) + } + private companion object { private const val TAG = "ReusableBottomSheetPaymentViewModel" } diff --git a/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMAppearanceSection.kt b/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMAppearanceSection.kt new file mode 100644 index 0000000..c4bc12f --- /dev/null +++ b/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMAppearanceSection.kt @@ -0,0 +1,280 @@ +package com.spreedly.example.screens.stripeapmpayment + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +@SuppressLint("ComposeModifierMissing") +@Composable +fun StripeAPMAppearanceSection( + useCustomAppearance: Boolean, + onUseCustomAppearanceChange: (Boolean) -> Unit, + primaryColor: Color, + onPrimaryColorChange: (Color) -> Unit, + backgroundColor: Color, + onBackgroundColorChange: (Color) -> Unit, + buttonBackgroundColor: Color, + onButtonBackgroundColorChange: (Color) -> Unit, + buttonTextColor: Color, + onButtonTextColorChange: (Color) -> Unit, + cornerRadiusDp: Float, + onCornerRadiusDpChange: (Float) -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "PaymentSheet appearance", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.testTag(StripeApmPaymentTestTags.APPEARANCE_SECTION_TITLE), + ) + Text( + text = "Colors map to StripeAPMAppearanceConfig and are applied when PaymentSheet opens.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Customize PaymentSheet appearance", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Switch( + checked = useCustomAppearance, + onCheckedChange = onUseCustomAppearanceChange, + enabled = enabled, + modifier = Modifier.testTag(StripeApmPaymentTestTags.USE_CUSTOM_APPEARANCE_TOGGLE), + ) + } + AnimatedVisibility(visible = useCustomAppearance) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + StripeAppearanceColorPickerRow( + title = "Primary", + color = primaryColor, + onColorChange = onPrimaryColorChange, + enabled = enabled, + testTag = StripeApmPaymentTestTags.PRIMARY_COLOR_PICKER, + ) + StripeAppearanceColorPickerRow( + title = "Background", + color = backgroundColor, + onColorChange = onBackgroundColorChange, + enabled = enabled, + testTag = StripeApmPaymentTestTags.BACKGROUND_COLOR_PICKER, + ) + StripeAppearanceColorPickerRow( + title = "Pay button background", + color = buttonBackgroundColor, + onColorChange = onButtonBackgroundColorChange, + enabled = enabled, + testTag = StripeApmPaymentTestTags.BUTTON_BACKGROUND_COLOR_PICKER, + ) + StripeAppearanceColorPickerRow( + title = "Pay button text", + color = buttonTextColor, + onColorChange = onButtonTextColorChange, + enabled = enabled, + testTag = StripeApmPaymentTestTags.BUTTON_TEXT_COLOR_PICKER, + ) + StripeAppearanceCornerRadiusRow( + cornerRadiusDp = cornerRadiusDp, + onCornerRadiusDpChange = onCornerRadiusDpChange, + enabled = enabled, + ) + } + } + } + } +} + +@Composable +private fun StripeAppearanceColorPickerRow( + title: String, + color: Color, + onColorChange: (Color) -> Unit, + enabled: Boolean, + testTag: String, +) { + var showDialog by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "Pick a color for ${title.lowercase()} in the Stripe PaymentSheet" } + .testTag(testTag), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .background(color) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp)) + .clickable(enabled = enabled) { showDialog = true }, + ) + } + + if (showDialog) { + StripeAppearanceColorDialog( + title = title, + selectedColor = color, + onColorSelected = { + onColorChange(it) + showDialog = false + }, + onDismiss = { showDialog = false }, + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun StripeAppearanceColorDialog( + title: String, + selectedColor: Color, + onColorSelected: (Color) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Select $title") }, + text = { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + StripeApmAppearancePresetColors.forEach { preset -> + val isSelected = preset.toArgb() == selectedColor.toArgb() + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(preset) + .border( + width = if (isSelected) 3.dp else 1.dp, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + shape = CircleShape, + ) + .clickable { onColorSelected(preset) }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Done") + } + }, + ) +} + +@Composable +private fun StripeAppearanceCornerRadiusRow( + cornerRadiusDp: Float, + onCornerRadiusDpChange: (Float) -> Unit, + enabled: Boolean, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(StripeApmPaymentTestTags.CORNER_RADIUS_STEPPER) + .semantics { contentDescription = "Corner radius for Stripe PaymentSheet" }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = "Corner radius", style = MaterialTheme.typography.bodyLarge) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "${cornerRadiusDp.toInt()} pt", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 8.dp), + ) + IconButton( + onClick = { onCornerRadiusDpChange((cornerRadiusDp - 1f).coerceAtLeast(0f)) }, + enabled = enabled && cornerRadiusDp > 0f, + ) { + Text(text = "−", style = MaterialTheme.typography.titleLarge) + } + IconButton( + onClick = { onCornerRadiusDpChange((cornerRadiusDp + 1f).coerceAtMost(24f)) }, + enabled = enabled && cornerRadiusDp < 24f, + ) { + Text(text = "+", style = MaterialTheme.typography.titleLarge) + } + } + } +} + +private val StripeApmAppearancePresetColors: List = listOf( + DefaultStripeApmPrimaryColor, + Color(0xFF6750A4), + Color(0xFF007AFF), + Color(0xFF34C759), + Color(0xFFFF9500), + Color(0xFFFF3B30), + Color(0xFF000000), + Color(0xFFFFFFFF), + Color(0xFF8E8E93), + Color(0xFF5AC8FA), + Color(0xFFFF2D55), + Color(0xFF30B0C7), +) diff --git a/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentScreen.kt b/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentScreen.kt index 9f03657..d24b20a 100644 --- a/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentScreen.kt @@ -2,14 +2,17 @@ package com.spreedly.example.screens.stripeapmpayment import android.annotation.SuppressLint import android.app.Activity +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -22,14 +25,24 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +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.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -64,6 +77,26 @@ fun StripeAPMPaymentScreen( val selectedApmTypes by viewModel.selectedApmTypes.collectAsStateWithLifecycle() val errorMessage by viewModel.errorMessage.collectAsStateWithLifecycle() val successMessage by viewModel.successMessage.collectAsStateWithLifecycle() + val radarEnabled by viewModel.radarEnabled.collectAsStateWithLifecycle() + val radarState by viewModel.radarState.collectAsStateWithLifecycle() + val radarSessionId by viewModel.radarSessionId.collectAsStateWithLifecycle() + + val controlsEnabled = stage == StripeAPMPaymentViewModel.Stage.IDLE + + var useCustomAppearance by rememberSaveable { mutableStateOf(true) } + var appearancePrimaryArgb by rememberSaveable { + mutableIntStateOf(DefaultStripeApmPrimaryColor.toArgb()) + } + var appearanceBackgroundArgb by rememberSaveable { + mutableIntStateOf(DefaultStripeApmBackgroundColor.toArgb()) + } + var appearanceButtonBackgroundArgb by rememberSaveable { + mutableIntStateOf(DefaultStripeApmPrimaryColor.toArgb()) + } + var appearanceButtonTextArgb by rememberSaveable { + mutableIntStateOf(Color.White.toArgb()) + } + var appearanceCornerRadius by rememberSaveable { mutableFloatStateOf(10f) } // onResume handling for Stripe APM checkout is done in MainActivity.onResume() via // SpreedlyStripeAPMCheckout.finalizeIfActive() to avoid LifecycleOwner registration @@ -114,6 +147,15 @@ fun StripeAPMPaymentScreen( }, ) + Spacer(modifier = Modifier.height(8.dp)) + + RadarToggleRow( + enabled = radarEnabled, + radarState = radarState, + radarSessionId = radarSessionId, + onToggle = viewModel::toggleRadar, + ) + Spacer(modifier = Modifier.height(16.dp)) Text( @@ -127,7 +169,25 @@ fun StripeAPMPaymentScreen( selectedProviders = selectedApmTypes, getLabel = { it.displayName }, onProviderToggled = viewModel::toggleApmType, - enabled = stage == StripeAPMPaymentViewModel.Stage.IDLE, + enabled = controlsEnabled, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + StripeAPMAppearanceSection( + useCustomAppearance = useCustomAppearance, + onUseCustomAppearanceChange = { useCustomAppearance = it }, + primaryColor = Color(appearancePrimaryArgb), + onPrimaryColorChange = { appearancePrimaryArgb = it.toArgb() }, + backgroundColor = Color(appearanceBackgroundArgb), + onBackgroundColorChange = { appearanceBackgroundArgb = it.toArgb() }, + buttonBackgroundColor = Color(appearanceButtonBackgroundArgb), + onButtonBackgroundColorChange = { appearanceButtonBackgroundArgb = it.toArgb() }, + buttonTextColor = Color(appearanceButtonTextArgb), + onButtonTextColorChange = { appearanceButtonTextArgb = it.toArgb() }, + cornerRadiusDp = appearanceCornerRadius, + onCornerRadiusDpChange = { appearanceCornerRadius = it }, + enabled = controlsEnabled, ) Spacer(modifier = Modifier.height(24.dp)) @@ -162,7 +222,15 @@ fun StripeAPMPaymentScreen( onClick = { val activity = context as? Activity if (activity != null) { - viewModel.startPayment(activity) + val appearance = buildStripeAppearanceConfig( + useCustomAppearance = useCustomAppearance, + primaryColor = Color(appearancePrimaryArgb), + backgroundColor = Color(appearanceBackgroundArgb), + buttonBackgroundColor = Color(appearanceButtonBackgroundArgb), + buttonTextColor = Color(appearanceButtonTextArgb), + cornerRadiusDp = appearanceCornerRadius, + ) + viewModel.startPayment(activity, appearance) } }, modifier = Modifier @@ -217,6 +285,69 @@ fun StripeAPMPaymentScreen( } } +@Composable +private fun RadarToggleRow( + enabled: Boolean, + radarState: StripeAPMPaymentViewModel.RadarState, + radarSessionId: String?, + onToggle: (Boolean) -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Stripe Radar", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Switch(checked = enabled, onCheckedChange = onToggle) + } + + if (enabled) { + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + when (radarState) { + StripeAPMPaymentViewModel.RadarState.IDLE -> {} + StripeAPMPaymentViewModel.RadarState.COLLECTING -> { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + ) + Spacer(Modifier.width(6.dp)) + Text( + text = "Collecting device data...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + StripeAPMPaymentViewModel.RadarState.SUCCESS -> { + Text( + text = radarSessionId ?: "", + style = MaterialTheme.typography.labelSmall + .copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + StripeAPMPaymentViewModel.RadarState.FAILED -> { + Text( + text = "Collection failed -- payment will proceed without Radar", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } +} + private fun formatEurPrice(cents: Int): String { val amount = cents / 100.0 val format = NumberFormat.getCurrencyInstance(Locale.GERMANY) diff --git a/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentViewModel.kt b/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentViewModel.kt index 9dc6730..65bded6 100644 --- a/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentViewModel.kt +++ b/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentViewModel.kt @@ -15,7 +15,10 @@ import com.spreedly.example.utils.SdkSessionManager import com.spreedly.sdk.Spreedly import com.spreedly.sdk.ui.PaymentResult import com.spreedly.stripe.SpreedlyStripeAPMCheckout +import com.spreedly.stripe.StripeAPMAppearanceConfig import com.spreedly.stripe.StripeAPMConfig +import com.spreedly.striperadar.SpreedlyStripeRadar +import com.spreedly.striperadar.StripeRadarConfig import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -71,6 +74,17 @@ class StripeAPMPaymentViewModel( private val _selectedApmTypes = MutableStateFlow(setOf(apmTypes.first())) val selectedApmTypes: StateFlow> = _selectedApmTypes.asStateFlow() + private val _radarEnabled = MutableStateFlow(false) + val radarEnabled: StateFlow = _radarEnabled.asStateFlow() + + private val _radarSessionId = MutableStateFlow(null) + val radarSessionId: StateFlow = _radarSessionId.asStateFlow() + + private val _radarState = MutableStateFlow(RadarState.IDLE) + val radarState: StateFlow = _radarState.asStateFlow() + + enum class RadarState { IDLE, COLLECTING, SUCCESS, FAILED } + val products = listOf( Product("Sunglasses", "Premium UV protection", 4400, "🕶️"), Product("Watch", "Swiss precision", 19900, "⌚"), @@ -91,9 +105,6 @@ class StripeAPMPaymentViewModel( initializeSdkOnScreenLoad() } - /** - * Initializes the SDK when the screen loads, so there's no delay when tapping Pay. - */ private fun initializeSdkOnScreenLoad() { viewModelScope.launch { val initialized = initializeSdkIfNeeded() @@ -103,6 +114,29 @@ class StripeAPMPaymentViewModel( } } + fun toggleRadar(enabled: Boolean) { + _radarEnabled.value = enabled + if (enabled) { + collectRadarSession() + } else { + _radarSessionId.value = null + _radarState.value = RadarState.IDLE + } + } + + private fun collectRadarSession() { + val publishableKey = BuildConfig.STRIPE_PUBLISHABLE_KEY + if (publishableKey.isBlank()) return + + _radarState.value = RadarState.COLLECTING + viewModelScope.launch { + val config = StripeRadarConfig(publishableKey = publishableKey) + val sessionId = SpreedlyStripeRadar.createRadarSession(config, context) + _radarSessionId.value = sessionId + _radarState.value = if (sessionId != null) RadarState.SUCCESS else RadarState.FAILED + } + } + /** * Starts collecting from paymentResultFlow. Must only be called after the SDK is initialized, * since paymentResultFlow delegates to paymentManager which throws if not initialized. @@ -147,7 +181,10 @@ class StripeAPMPaymentViewModel( ) } - fun startPayment(activity: Activity) { + fun startPayment( + activity: Activity, + appearance: StripeAPMAppearanceConfig? = null, + ) { val product = _selectedProduct.value if (product == null) { _errorMessage.value = "Please select a product" @@ -177,6 +214,7 @@ class StripeAPMPaymentViewModel( currencyCode = "EUR", apmTypes = apmTypeIds, redirectUrl = SpreedlyPurchaseAPIClient.redirectUrl(context, "stripe/checkout"), + radarSessionId = if (_radarEnabled.value) _radarSessionId.value else null, ) val transaction = response.transaction @@ -218,7 +256,7 @@ class StripeAPMPaymentViewModel( ) _stage.value = Stage.CHECKOUT - SpreedlyStripeAPMCheckout.present(config, activity) + SpreedlyStripeAPMCheckout.present(config, activity, appearance) } catch (e: Exception) { _errorMessage.value = "Pending purchase failed" _stage.value = Stage.IDLE diff --git a/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeApmAppearanceBuilder.kt b/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeApmAppearanceBuilder.kt new file mode 100644 index 0000000..be40c7a --- /dev/null +++ b/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeApmAppearanceBuilder.kt @@ -0,0 +1,45 @@ +package com.spreedly.example.screens.stripeapmpayment + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.spreedly.stripe.StripeAPMAppearanceConfig + +/** + * Builds [StripeAPMAppearanceConfig] from the sample app's PaymentSheet appearance QA controls. + * Mirrors iOS `makeStripeAppearance()` in `StripeAPMPaymentFlowView`. + */ +fun buildStripeAppearanceConfig( + useCustomAppearance: Boolean, + primaryColor: Color, + backgroundColor: Color, + buttonBackgroundColor: Color, + buttonTextColor: Color, + cornerRadiusDp: Float, +): StripeAPMAppearanceConfig? { + if (!useCustomAppearance) { + return null + } + return StripeAPMAppearanceConfig( + shapes = StripeAPMAppearanceConfig.Shapes( + cornerRadiusDp = cornerRadiusDp, + borderStrokeWidthDp = 1f, + selectedBorderStrokeWidthDp = null, + ), + colors = StripeAPMAppearanceConfig.Colors( + primary = primaryColor.toArgb(), + surface = backgroundColor.toArgb(), + ), + primaryButton = StripeAPMAppearanceConfig.PrimaryButton( + background = buttonBackgroundColor.toArgb(), + onBackground = buttonTextColor.toArgb(), + cornerRadiusDp = cornerRadiusDp, + heightDp = 52f, + ), + ) +} + +/** Default primary / pay-button color (iOS `Color(.systemIndigo)`). */ +val DefaultStripeApmPrimaryColor: Color = Color(0xFF5856D6) + +/** Default PaymentSheet background (iOS `Color(.systemBackground)` on light). */ +val DefaultStripeApmBackgroundColor: Color = Color(0xFFFFFBFE) diff --git a/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeApmPaymentTestTags.kt b/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeApmPaymentTestTags.kt new file mode 100644 index 0000000..423cb52 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeApmPaymentTestTags.kt @@ -0,0 +1,14 @@ +package com.spreedly.example.screens.stripeapmpayment + +/** + * UI test tags aligned with iOS `SPLAccessibility.StripeAPMPayment` identifiers. + */ +object StripeApmPaymentTestTags { + const val APPEARANCE_SECTION_TITLE = "stripe-apm-payment-appearance-section-title" + const val USE_CUSTOM_APPEARANCE_TOGGLE = "stripe-apm-payment-use-custom-appearance-toggle" + const val PRIMARY_COLOR_PICKER = "stripe-apm-payment-primary-color-picker" + const val BACKGROUND_COLOR_PICKER = "stripe-apm-payment-background-color-picker" + const val BUTTON_BACKGROUND_COLOR_PICKER = "stripe-apm-payment-button-background-color-picker" + const val BUTTON_TEXT_COLOR_PICKER = "stripe-apm-payment-button-text-color-picker" + const val CORNER_RADIUS_STEPPER = "stripe-apm-payment-corner-radius-stepper" +} diff --git a/app/src/main/java/com/spreedly/example/ui/components/CardBrandTrailingIcon.kt b/app/src/main/java/com/spreedly/example/ui/components/CardBrandTrailingIcon.kt new file mode 100644 index 0000000..fce5822 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/ui/components/CardBrandTrailingIcon.kt @@ -0,0 +1,39 @@ +package com.spreedly.example.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.spreedly.app.R +import com.spreedly.sdk.models.CardScheme + +@Composable +fun CardBrandTrailingIcon( + scheme: CardScheme?, + modifier: Modifier = Modifier, +) { + when (scheme) { + CardScheme.VISA -> + Image( + painter = painterResource(R.drawable.ic_card_brand_visa), + contentDescription = "Visa", + modifier = modifier.size(36.dp), + ) + + null, CardScheme.UNKNOWN -> Unit + + else -> + Text( + text = scheme.name, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = modifier, + ) + } +} diff --git a/app/src/main/java/com/spreedly/example/ui/components/FieldStyleOverrideCard.kt b/app/src/main/java/com/spreedly/example/ui/components/FieldStyleOverrideCard.kt new file mode 100644 index 0000000..1b1ae32 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/ui/components/FieldStyleOverrideCard.kt @@ -0,0 +1,245 @@ +package com.spreedly.example.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.spreedly.example.ui.theme.SplFieldStyleOverrides +import com.spreedly.example.ui.theme.SplFieldTarget + +private data class OverrideColorOption( + val label: String, + val color: Color?, +) + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun FieldStyleOverrideCard( + selectedTarget: SplFieldTarget, + overrides: SplFieldStyleOverrides, + onTargetSelected: (SplFieldTarget) -> Unit, + onOverridesChange: (SplFieldStyleOverrides) -> Unit, + onClearOverrides: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "Field-Level Style Override", + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + ) + Text( + text = "Style one SPL field while others keep the global theme.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Target field", + style = MaterialTheme.typography.labelMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + SplFieldTarget.entries.forEach { target -> + FilterChip( + selected = selectedTarget == target, + onClick = { onTargetSelected(target) }, + label = { Text(target.label) }, + ) + } + } + + if (selectedTarget != SplFieldTarget.NONE) { + Spacer(modifier = Modifier.height(16.dp)) + OverrideColorRow( + label = "Primary", + selectedColor = overrides.primaryColor, + onColorSelected = { onOverridesChange(overrides.copy(primaryColor = it)) }, + defaultOption = OverrideColorOption("Default", null), + firstAccent = OverrideColorOption("Red", Color(0xFFE53935)), + secondAccent = OverrideColorOption("Teal", Color(0xFF00897B)), + ) + Spacer(modifier = Modifier.height(12.dp)) + OverrideColorRow( + label = "Background", + selectedColor = overrides.fieldBackgroundColor, + onColorSelected = { onOverridesChange(overrides.copy(fieldBackgroundColor = it)) }, + defaultOption = OverrideColorOption("Default", null), + firstAccent = OverrideColorOption("Gray", Color(0xFFECEFF1)), + secondAccent = OverrideColorOption("Yellow", Color(0xFFFFF9C4)), + ) + Spacer(modifier = Modifier.height(12.dp)) + OverrideColorRow( + label = "Text", + selectedColor = overrides.textColor, + onColorSelected = { onOverridesChange(overrides.copy(textColor = it)) }, + defaultOption = OverrideColorOption("Default", null), + firstAccent = OverrideColorOption("Navy", Color(0xFF1A237E)), + secondAccent = OverrideColorOption("Black", Color(0xFF212121)), + ) + Spacer(modifier = Modifier.height(12.dp)) + OverrideColorRow( + label = "Placeholder", + selectedColor = overrides.placeholderColor, + onColorSelected = { onOverridesChange(overrides.copy(placeholderColor = it)) }, + defaultOption = OverrideColorOption("Default", null), + firstAccent = OverrideColorOption("Gray", Color(0xFF9E9E9E)), + secondAccent = OverrideColorOption("Blue", Color(0xFF5C6BC0)), + ) + Spacer(modifier = Modifier.height(12.dp)) + OverrideColorRow( + label = "Border", + selectedColor = overrides.borderColor, + onColorSelected = { onOverridesChange(overrides.copy(borderColor = it)) }, + defaultOption = OverrideColorOption("Default", null), + firstAccent = OverrideColorOption("Red", Color(0xFFE53935)), + secondAccent = OverrideColorOption("Orange", Color(0xFFFB8C00)), + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = onClearOverrides) { + Text("Clear field override") + } + } + } + } +} + +@Composable +private fun OverrideColorRow( + label: String, + selectedColor: Color?, + onColorSelected: (Color?) -> Unit, + defaultOption: OverrideColorOption, + firstAccent: OverrideColorOption, + secondAccent: OverrideColorOption, +) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OverrideColorChip( + label = defaultOption.label, + color = defaultOption.color, + isSelected = selectedColor == defaultOption.color, + onClick = { onColorSelected(defaultOption.color) }, + ) + OverrideColorChip( + label = firstAccent.label, + color = firstAccent.color, + isSelected = selectedColor == firstAccent.color, + onClick = { onColorSelected(firstAccent.color) }, + ) + OverrideColorChip( + label = secondAccent.label, + color = secondAccent.color, + isSelected = selectedColor == secondAccent.color, + onClick = { onColorSelected(secondAccent.color) }, + ) + } + } +} + +@Composable +private fun OverrideColorChip( + label: String, + color: Color?, + isSelected: Boolean, + onClick: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick) + .padding(4.dp), + ) { + Box( + modifier = + Modifier + .size(28.dp) + .clip(CircleShape) + .then( + if (color == null) { + Modifier.background(MaterialTheme.colorScheme.surfaceVariant) + } else { + Modifier.background(color) + }, + ).border( + width = if (isSelected) 2.dp else 1.dp, + color = + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) + }, + shape = CircleShape, + ), + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = + if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} diff --git a/app/src/main/java/com/spreedly/example/ui/components/ThemeConfigurationCard.kt b/app/src/main/java/com/spreedly/example/ui/components/ThemeConfigurationCard.kt new file mode 100644 index 0000000..d67010b --- /dev/null +++ b/app/src/main/java/com/spreedly/example/ui/components/ThemeConfigurationCard.kt @@ -0,0 +1,284 @@ +package com.spreedly.example.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.spreedly.example.ui.theme.SampleThemePreset + +enum class ThemeConfigurationStyle { + SWATCH, + BUTTON, +} + +@Composable +fun ThemeConfigurationCard( + useCustomTheme: Boolean, + selectedPreset: SampleThemePreset, + onUseCustomThemeChange: (Boolean) -> Unit, + onPresetSelected: (SampleThemePreset) -> Unit, + onResetTheme: () -> Unit, + style: ThemeConfigurationStyle, + modifier: Modifier = Modifier, + showDarkPreset: Boolean = false, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "Theme Configuration", + style = + MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Use Custom Theme", + style = MaterialTheme.typography.bodyMedium, + ) + Switch( + checked = useCustomTheme, + onCheckedChange = onUseCustomThemeChange, + colors = + SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.primary, + checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Current Theme:", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + when (style) { + ThemeConfigurationStyle.SWATCH -> { + Box( + modifier = + Modifier + .size(22.dp) + .clip(CircleShape) + .background(selectedPreset.swatchColor) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.25f), CircleShape), + ) + Text( + text = selectedPreset.displayName, + style = MaterialTheme.typography.bodyMedium, + color = + if (useCustomTheme) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + ThemeConfigurationStyle.BUTTON -> { + Text( + text = selectedPreset.displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = + if (useCustomTheme) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + + if (useCustomTheme) { + Spacer(modifier = Modifier.height(12.dp)) + val presets = + buildList { + add(SampleThemePreset.BLUE) + add(SampleThemePreset.GREEN) + add(SampleThemePreset.PURPLE) + if (showDarkPreset) { + add(SampleThemePreset.DARK) + } + } + + when (style) { + ThemeConfigurationStyle.SWATCH -> { + Text( + text = "Pick a color:", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + presets.forEach { preset -> + ThemeSwatchButton( + preset = preset, + isSelected = selectedPreset == preset, + onClick = { onPresetSelected(preset) }, + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = onResetTheme) { + Text("Reset to Default") + } + } + ThemeConfigurationStyle.BUTTON -> { + Text( + text = "Custom Theme Colors:", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + presets.forEach { preset -> + ThemeLabelButton( + text = "${preset.displayName} Theme", + accentColor = preset.swatchColor, + isSelected = selectedPreset == preset, + onClick = { onPresetSelected(preset) }, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + } + } +} + +@Composable +private fun ThemeSwatchButton( + preset: SampleThemePreset, + isSelected: Boolean, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .size(48.dp) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = + Modifier + .size(40.dp) + .clip(CircleShape) + .background(preset.swatchColor) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), CircleShape), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(16.dp), + ) + } + } + if (isSelected) { + Box( + modifier = + Modifier + .size(46.dp) + .border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape), + ) + } + } +} + +@Composable +private fun ThemeLabelButton( + text: String, + accentColor: Color, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = + modifier + .clip(RoundedCornerShape(8.dp)) + .background(accentColor.copy(alpha = if (isSelected) 0.2f else 0.1f)) + .border( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) accentColor else accentColor.copy(alpha = 0.3f), + shape = RoundedCornerShape(8.dp), + ).clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = accentColor, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + ) + } +} diff --git a/app/src/main/java/com/spreedly/example/ui/theme/SampleThemePresets.kt b/app/src/main/java/com/spreedly/example/ui/theme/SampleThemePresets.kt new file mode 100644 index 0000000..323eef7 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/ui/theme/SampleThemePresets.kt @@ -0,0 +1,172 @@ +package com.spreedly.example.ui.theme + +import androidx.compose.ui.graphics.Color +import com.spreedly.ui.theme.SpreedlyColors +import com.spreedly.ui.theme.SpreedlyTheme + +enum class SampleThemePreset { + DEFAULT, + BLUE, + GREEN, + PURPLE, + DARK, + ; + + val displayName: String + get() = + when (this) { + DEFAULT -> "Default" + BLUE -> "Blue" + GREEN -> "Green" + PURPLE -> "Purple" + DARK -> "Dark" + } + + val swatchColor: Color + get() = + when (this) { + DEFAULT -> Color(0x73000000) + BLUE -> Color.Blue + GREEN -> Color(0xFF4CAF50) + PURPLE -> Color(0xFF9C27B0) + DARK -> Color(0xFF212121) + } +} + +object SampleThemePresets { + fun resolveTheme( + preset: SampleThemePreset, + isDarkMode: Boolean, + useCustomTheme: Boolean, + ): SpreedlyTheme? { + if (!useCustomTheme || preset == SampleThemePreset.DEFAULT) { + return null + } + return if (isDarkMode) { + buildDarkTheme(preset) + } else { + buildLightTheme(preset) + } + } + + fun buildLightTheme(preset: SampleThemePreset): SpreedlyTheme = + when (preset) { + SampleThemePreset.DEFAULT -> SpreedlyTheme.Default + SampleThemePreset.BLUE -> + SpreedlyTheme( + colors = + SpreedlyColors( + primary = Color.Blue, + secondary = Color.Blue.copy(alpha = 0.7f), + background = Color.White, + surface = Color(0xFFE3F2FD), + text = Color(0xFF1976D2), + textSecondary = Color(0xFF424242), + border = Color.Blue.copy(alpha = 0.3f), + borderFocused = Color.Blue, + error = Color.Red, + placeholder = Color.Gray, + ), + ) + SampleThemePreset.GREEN -> + SpreedlyTheme( + colors = + SpreedlyColors( + primary = Color(0xFF4CAF50), + secondary = Color(0xFF4CAF50).copy(alpha = 0.7f), + background = Color.White, + surface = Color(0xFFE8F5E9), + text = Color(0xFF388E3C), + textSecondary = Color(0xFF424242), + border = Color(0xFF4CAF50).copy(alpha = 0.3f), + borderFocused = Color(0xFF4CAF50), + error = Color.Red, + placeholder = Color.Gray, + ), + ) + SampleThemePreset.PURPLE -> + SpreedlyTheme( + colors = + SpreedlyColors( + primary = Color(0xFF9C27B0), + secondary = Color(0xFF9C27B0).copy(alpha = 0.7f), + background = Color.White, + surface = Color(0xFFF3E5F5), + text = Color(0xFF7B1FA2), + textSecondary = Color(0xFF424242), + border = Color(0xFF9C27B0).copy(alpha = 0.3f), + borderFocused = Color(0xFF9C27B0), + error = Color.Red, + placeholder = Color.Gray, + ), + ) + SampleThemePreset.DARK -> + SpreedlyTheme( + colors = + SpreedlyColors( + text = Color.White, + textSecondary = Color(0xFFBDBDBD), + surface = Color(0xFF424242), + primary = Color(0xFF212121), + error = Color(0xFFEF5350), + background = Color(0xFF303030), + border = Color(0xFF616161), + placeholder = Color(0xFFBDBDBD), + ), + ) + } + + fun buildDarkTheme(preset: SampleThemePreset): SpreedlyTheme = + when (preset) { + SampleThemePreset.DEFAULT -> SpreedlyTheme.Default + SampleThemePreset.BLUE -> + SpreedlyTheme( + colors = + SpreedlyColors( + primary = Color.Blue, + secondary = Color.Blue.copy(alpha = 0.7f), + background = Color.Black, + surface = Color(0xFF1C1C1E), + text = Color.White, + textSecondary = Color.Gray.copy(alpha = 0.8f), + border = Color.Blue.copy(alpha = 0.5f), + borderFocused = Color.Blue, + error = Color.Red, + placeholder = Color.Gray.copy(alpha = 0.8f), + ), + ) + SampleThemePreset.GREEN -> + SpreedlyTheme( + colors = + SpreedlyColors( + primary = Color(0xFF4CAF50), + secondary = Color(0xFF4CAF50).copy(alpha = 0.7f), + background = Color.Black, + surface = Color(0xFF1C1C1E), + text = Color.White, + textSecondary = Color.Gray.copy(alpha = 0.8f), + border = Color(0xFF4CAF50).copy(alpha = 0.5f), + borderFocused = Color(0xFF4CAF50), + error = Color.Red, + placeholder = Color.Gray.copy(alpha = 0.8f), + ), + ) + SampleThemePreset.PURPLE -> + SpreedlyTheme( + colors = + SpreedlyColors( + primary = Color(0xFF9C27B0), + secondary = Color(0xFF9C27B0).copy(alpha = 0.7f), + background = Color.Black, + surface = Color(0xFF1C1C1E), + text = Color.White, + textSecondary = Color.Gray.copy(alpha = 0.8f), + border = Color(0xFF9C27B0).copy(alpha = 0.5f), + borderFocused = Color(0xFF9C27B0), + error = Color.Red, + placeholder = Color.Gray.copy(alpha = 0.8f), + ), + ) + SampleThemePreset.DARK -> buildLightTheme(SampleThemePreset.DARK) + } +} diff --git a/app/src/main/java/com/spreedly/example/ui/theme/SplFieldConfigResolver.kt b/app/src/main/java/com/spreedly/example/ui/theme/SplFieldConfigResolver.kt new file mode 100644 index 0000000..3b6fcba --- /dev/null +++ b/app/src/main/java/com/spreedly/example/ui/theme/SplFieldConfigResolver.kt @@ -0,0 +1,31 @@ +package com.spreedly.example.ui.theme + +import com.spreedly.hostedfields.ui.toCustomFieldsConfig +import com.spreedly.sdk.models.FormFieldType +import com.spreedly.sdk.ui.CustomFieldsConfig +import com.spreedly.ui.theme.SpreedlyTheme + +object SplFieldConfigResolver { + fun resolve( + formFieldType: FormFieldType, + globalTheme: SpreedlyTheme?, + overrideTarget: SplFieldTarget, + overrides: SplFieldStyleOverrides, + ): CustomFieldsConfig? { + val targetType = overrideTarget.formFieldType + val baseConfig = globalTheme?.toCustomFieldsConfig() + + if (targetType == null || !formFieldType.matchesSplFieldTarget(targetType)) { + return baseConfig + } + + val resolvedBase = baseConfig ?: SpreedlyTheme.Default.toCustomFieldsConfig() + return resolvedBase.copy( + primaryColor = overrides.primaryColor ?: resolvedBase.primaryColor, + fieldBackgroundColor = overrides.fieldBackgroundColor ?: resolvedBase.fieldBackgroundColor, + textColor = overrides.textColor ?: resolvedBase.textColor, + placeholderColor = overrides.placeholderColor ?: resolvedBase.placeholderColor, + formBorderColor = overrides.borderColor ?: resolvedBase.formBorderColor, + ) + } +} diff --git a/app/src/main/java/com/spreedly/example/ui/theme/SplFieldStyleOverrides.kt b/app/src/main/java/com/spreedly/example/ui/theme/SplFieldStyleOverrides.kt new file mode 100644 index 0000000..7dfafe0 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/ui/theme/SplFieldStyleOverrides.kt @@ -0,0 +1,37 @@ +package com.spreedly.example.ui.theme + +import androidx.compose.ui.graphics.Color +import com.spreedly.sdk.models.FormFieldType + +enum class SplFieldTarget(val label: String) { + NONE("None"), + CARD_NUMBER("Card Number"), + EXPIRY("Expiry"), + CVV("CVV"), + ; + + val formFieldType: FormFieldType? + get() = + when (this) { + NONE -> null + CARD_NUMBER -> FormFieldType.CARD(true) + EXPIRY -> FormFieldType.EXPIRY_DATE(true) + CVV -> FormFieldType.CVV(true) + } +} + +data class SplFieldStyleOverrides( + val primaryColor: Color? = null, + val fieldBackgroundColor: Color? = null, + val textColor: Color? = null, + val placeholderColor: Color? = null, + val borderColor: Color? = null, +) + +fun FormFieldType.matchesSplFieldTarget(target: FormFieldType): Boolean = + when { + this is FormFieldType.CARD && target is FormFieldType.CARD -> true + this is FormFieldType.CVV && target is FormFieldType.CVV -> true + this is FormFieldType.EXPIRY_DATE && target is FormFieldType.EXPIRY_DATE -> true + else -> false + } diff --git a/app/src/main/java/com/spreedly/example/ui/theme/ThemeConfigurationController.kt b/app/src/main/java/com/spreedly/example/ui/theme/ThemeConfigurationController.kt new file mode 100644 index 0000000..11fae50 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/ui/theme/ThemeConfigurationController.kt @@ -0,0 +1,94 @@ +package com.spreedly.example.ui.theme + +import com.spreedly.sdk.Spreedly +import com.spreedly.sdk.models.FormFieldType +import com.spreedly.sdk.ui.CustomFieldsConfig +import com.spreedly.sdk.ui.PaymentSheetConfig +import com.spreedly.ui.theme.SpreedlyTheme +import com.spreedly.ui.theme.toPaymentSheetConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class ThemeConfigurationController { + private val _useCustomTheme = MutableStateFlow(false) + val useCustomTheme: StateFlow = _useCustomTheme.asStateFlow() + + private val _selectedPreset = MutableStateFlow(SampleThemePreset.DEFAULT) + val selectedPreset: StateFlow = _selectedPreset.asStateFlow() + + private val _fieldOverrideTarget = MutableStateFlow(SplFieldTarget.NONE) + val fieldOverrideTarget: StateFlow = _fieldOverrideTarget.asStateFlow() + + private val _fieldOverrides = MutableStateFlow(SplFieldStyleOverrides()) + val fieldOverrides: StateFlow = _fieldOverrides.asStateFlow() + + fun setUseCustomTheme(enabled: Boolean) { + _useCustomTheme.value = enabled + if (!enabled) { + _selectedPreset.value = SampleThemePreset.DEFAULT + clearFieldOverrides() + } else if (_selectedPreset.value == SampleThemePreset.DEFAULT) { + _selectedPreset.value = SampleThemePreset.BLUE + } + } + + fun setPreset(preset: SampleThemePreset) { + _selectedPreset.value = preset + _useCustomTheme.value = true + } + + fun setFieldOverrideTarget(target: SplFieldTarget) { + _fieldOverrideTarget.value = target + if (target == SplFieldTarget.NONE) { + _fieldOverrides.value = SplFieldStyleOverrides() + } + } + + fun updateFieldOverrides(overrides: SplFieldStyleOverrides) { + _fieldOverrides.value = overrides + } + + fun clearFieldOverrides() { + _fieldOverrideTarget.value = SplFieldTarget.NONE + _fieldOverrides.value = SplFieldStyleOverrides() + } + + fun resolveTheme(isDarkMode: Boolean): SpreedlyTheme? = + SampleThemePresets.resolveTheme( + preset = _selectedPreset.value, + isDarkMode = isDarkMode, + useCustomTheme = _useCustomTheme.value, + ) + + fun resolveFieldConfig( + formFieldType: FormFieldType, + isDarkMode: Boolean, + ): CustomFieldsConfig? { + if (!_useCustomTheme.value && _fieldOverrideTarget.value == SplFieldTarget.NONE) { + return null + } + return SplFieldConfigResolver.resolve( + formFieldType = formFieldType, + globalTheme = resolveTheme(isDarkMode), + overrideTarget = _fieldOverrideTarget.value, + overrides = _fieldOverrides.value, + ) + } + + fun resolvePaymentSheetConfig(isDarkMode: Boolean): PaymentSheetConfig? { + val theme = resolveTheme(isDarkMode) + return theme?.toPaymentSheetConfig() + } + + fun applyGlobalTheme( + sdk: Spreedly, + isDarkMode: Boolean, + ) { + if (!_useCustomTheme.value) { + sdk.setGlobalTheme(SpreedlyTheme.Default) + return + } + resolveTheme(isDarkMode)?.let { sdk.setGlobalTheme(it) } + } +} diff --git a/app/src/main/java/com/spreedly/example/utils/CvvFormValidity.kt b/app/src/main/java/com/spreedly/example/utils/CvvFormValidity.kt new file mode 100644 index 0000000..ca809eb --- /dev/null +++ b/app/src/main/java/com/spreedly/example/utils/CvvFormValidity.kt @@ -0,0 +1,7 @@ +package com.spreedly.example.utils + +import com.spreedly.sdk.utils.CardNumberContext +import com.spreedly.sdk.utils.isCvvOptionalForCardScheme + +internal fun isCvvFormRequirementMet(cvvFieldValid: Boolean): Boolean = + cvvFieldValid || isCvvOptionalForCardScheme(CardNumberContext.getCardScheme()) diff --git a/app/src/main/java/com/spreedly/example/viewmodel/ConfigurationChangeAwareViewModel.kt b/app/src/main/java/com/spreedly/example/viewmodel/ConfigurationChangeAwareViewModel.kt index 51c6229..f49373a 100644 --- a/app/src/main/java/com/spreedly/example/viewmodel/ConfigurationChangeAwareViewModel.kt +++ b/app/src/main/java/com/spreedly/example/viewmodel/ConfigurationChangeAwareViewModel.kt @@ -8,8 +8,12 @@ import androidx.lifecycle.viewModelScope import com.spreedly.app.BuildConfig import com.spreedly.example.AuthService import com.spreedly.example.repository.PaymentMethodRepository +import com.spreedly.example.qa.FieldStateInspectorController import com.spreedly.example.utils.PaymentResultHandler import com.spreedly.example.utils.SdkSessionManager +import com.spreedly.example.utils.isCvvFormRequirementMet +import com.spreedly.hostedfields.models.HostedFieldState +import com.spreedly.sdk.models.FormFieldType import com.spreedly.sdk.Spreedly import com.spreedly.sdk.ui.PaymentResult import kotlinx.coroutines.Job @@ -38,6 +42,14 @@ class ConfigurationChangeAwareViewModel(private val context: Context) : ViewMode val snackbarHostState = SnackbarHostState() + val fieldStateInspector = FieldStateInspectorController(sdk) + val inspectorUiState = fieldStateInspector.uiState + + private val _cardValid = MutableStateFlow(false) + private val _cvvValid = MutableStateFlow(false) + private val _expiryValid = MutableStateFlow(false) + private val _nameValid = MutableStateFlow(false) + // UI State private val _isInitializing = MutableStateFlow(true) val isInitializing: StateFlow = _isInitializing.asStateFlow() @@ -56,9 +68,55 @@ class ConfigurationChangeAwareViewModel(private val context: Context) : ViewMode // Initialize SDK on ViewModel creation init { + refreshInspectorAggregate() initializeSDK() } + fun onHostedFieldState(state: HostedFieldState) { + fieldStateInspector.onFieldStateChanged(state) + onHostedFieldValidation(state.fieldType, state.isValid) + } + + fun onHostedFieldValidation(fieldType: FormFieldType, isValid: Boolean) { + when (fieldType) { + is FormFieldType.CARD -> _cardValid.value = isValid + is FormFieldType.CVV -> _cvvValid.value = isValid + is FormFieldType.EXPIRY_DATE -> _expiryValid.value = isValid + is FormFieldType.NAME -> _nameValid.value = isValid + else -> Unit + } + refreshInspectorAggregate() + } + + fun performFullPaymentReset() { + sdk.resetPaymentState() + fieldStateInspector.resetInspector() + _cardValid.value = false + _cvvValid.value = false + _expiryValid.value = false + _nameValid.value = false + refreshInspectorAggregate() + } + + private fun refreshInspectorAggregate() { + fieldStateInspector.configureAggregate( + fields = + listOf( + "Card number" to { _cardValid.value }, + "Security code (CVC)" to { isCvvFormRequirementMet(_cvvValid.value) }, + "MM/YY" to { _expiryValid.value }, + "Name on Card" to { _nameValid.value }, + ), + isFormValid = { + _cardValid.value && + isCvvFormRequirementMet(_cvvValid.value) && + _expiryValid.value && + _nameValid.value + }, + ) + fieldStateInspector.refreshMismatch(sdk.hostedCardDisplayState.value) + } + private fun initializeSDK() { initJob = viewModelScope.launch { _isInitializing.value = true diff --git a/app/src/main/res/drawable-nodpi/ic_card_brand_visa.png b/app/src/main/res/drawable-nodpi/ic_card_brand_visa.png new file mode 100644 index 0000000..0b3630e Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_card_brand_visa.png differ diff --git a/app/src/main/res/layout/activity_java_hosted_fields.xml b/app/src/main/res/layout/activity_java_hosted_fields.xml index efbdd95..f53c892 100644 --- a/app/src/main/res/layout/activity_java_hosted_fields.xml +++ b/app/src/main/res/layout/activity_java_hosted_fields.xml @@ -74,6 +74,13 @@ android:padding="24dp" android:gravity="center_horizontal"> + + + + + Payment SDK Java Hosted Fields Demo Enter your payment details + Card display format (setNumberFormat) + Pretty + Plain + Masked + Pretty: grouped digits, last-4 blur when unfocused. Plain: all digits visible. Masked: every digit • while typing. toggleMask() cycles mask (first tap from Pretty → full masked). + toggleMask() + hostedCardDisplayState.panMasked: %1$s + HostedFieldState.isPanMasked (card): %1$s + HostedFieldState.isPanMasked (card): — Success Payment Token Generated token_placeholder diff --git a/app/src/test/java/com/spreedly/example/qa/FieldStateInspectorCardTest.kt b/app/src/test/java/com/spreedly/example/qa/FieldStateInspectorCardTest.kt new file mode 100644 index 0000000..999b25a --- /dev/null +++ b/app/src/test/java/com/spreedly/example/qa/FieldStateInspectorCardTest.kt @@ -0,0 +1,37 @@ +package com.spreedly.example.qa + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import com.spreedly.sdk.ui.CardNumberFormat +import com.spreedly.sdk.ui.HostedCardDisplayState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class FieldStateInspectorCardTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `renders inspector card and wiring readout test tags`() { + composeRule.setContent { + FieldStateInspectorCard( + uiState = FieldStateInspectorUiState(), + hostedCardDisplayState = + HostedCardDisplayState( + cardNumberFormat = CardNumberFormat.MASKED, + panMasked = true, + ), + ) + } + composeRule.onNodeWithTag("field-state-inspector-card").assertIsDisplayed() + composeRule.onNodeWithTag("custom-form-wiring-readout").assertIsDisplayed() + composeRule.onNodeWithTag("custom-form-event-log-toggle").assertExists() + } +} diff --git a/app/src/test/java/com/spreedly/example/qa/FieldStateInspectorControllerTest.kt b/app/src/test/java/com/spreedly/example/qa/FieldStateInspectorControllerTest.kt new file mode 100644 index 0000000..8475ce4 --- /dev/null +++ b/app/src/test/java/com/spreedly/example/qa/FieldStateInspectorControllerTest.kt @@ -0,0 +1,99 @@ +package com.spreedly.example.qa + +import com.spreedly.hostedfields.models.HostedFieldEventType +import com.spreedly.hostedfields.models.HostedFieldState +import com.spreedly.sdk.Spreedly +import com.spreedly.sdk.models.CardScheme +import com.spreedly.sdk.models.FormFieldType +import com.spreedly.sdk.ui.CardNumberFormat +import com.spreedly.sdk.ui.HostedCardDisplayState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class FieldStateInspectorControllerTest { + + private fun cardState( + eventType: HostedFieldEventType = HostedFieldEventType.INPUT, + panDisplayFormat: CardNumberFormat? = CardNumberFormat.MASKED, + panDisplayPolicyMasked: Boolean? = true, + ) = HostedFieldState( + fieldType = FormFieldType.CARD(true), + eventType = eventType, + isFocused = true, + isValid = false, + isEmpty = false, + cardScheme = CardScheme.VISA, + numberLength = 4, + cvvLength = null, + isPanMasked = true, + iin = null, + panDisplayFormat = panDisplayFormat, + panDisplayPolicyMasked = panDisplayPolicyMasked, + ) + + @Test + fun `onFieldStateChanged stores CARD snapshot and appends event log`() { + val controller = FieldStateInspectorController(Spreedly()) + controller.onFieldStateChanged(cardState(eventType = HostedFieldEventType.FOCUS)) + val ui = controller.uiState.value + assertEquals(CardScheme.VISA, ui.cardSnapshot?.cardScheme) + assertTrue(ui.lastEventSummary.contains("FOCUS")) + assertEquals(1, ui.eventLog.size) + } + + @Test + fun `configureAggregate reflects invalid fields and registered count`() { + val controller = FieldStateInspectorController(Spreedly()) + controller.configureAggregate( + fields = + listOf( + "Card number" to { false }, + "Security code (CVC)" to { true }, + ), + isFormValid = { false }, + ) + val readout = controller.uiState.value.aggregateReadout + assertTrue(readout.contains("Form valid: no")) + assertTrue(readout.contains("Card number")) + assertTrue(readout.contains("registered:")) + } + + @Test + fun `refreshMismatch reports format drift`() { + val controller = FieldStateInspectorController(Spreedly()) + controller.onFieldStateChanged( + cardState( + panDisplayFormat = CardNumberFormat.PRETTY, + panDisplayPolicyMasked = false, + ), + ) + controller.refreshMismatch( + HostedCardDisplayState( + cardNumberFormat = CardNumberFormat.MASKED, + panMasked = true, + ), + ) + assertNotNull(controller.uiState.value.mismatchMessage) + assertTrue(controller.uiState.value.mismatchMessage!!.contains("Snapshot format")) + } + + @Test + fun `resetInspector clears snapshots and event log`() { + val controller = FieldStateInspectorController(Spreedly()) + controller.onFieldStateChanged(cardState()) + controller.resetInspector() + val ui = controller.uiState.value + assertNull(ui.cardSnapshot) + assertNull(ui.cvvSnapshot) + assertEquals("Last event: —", ui.lastEventSummary) + assertTrue(ui.eventLog.isEmpty()) + } +} diff --git a/app/src/test/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutPaymentResultFilterTest.kt b/app/src/test/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutPaymentResultFilterTest.kt new file mode 100644 index 0000000..3f50160 --- /dev/null +++ b/app/src/test/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutPaymentResultFilterTest.kt @@ -0,0 +1,53 @@ +package com.spreedly.example.screens.basiccheckout + +import com.spreedly.sdk.ui.PaymentResult +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BasicCheckoutPaymentResultFilterTest { + + @Test + fun `isClientSideFormValidationFailure true for exact SDK message`() { + val failed = + PaymentResult.Failed( + errorType = PaymentResult.Failed.ErrorType.UNKNOWN_ERROR, + message = CLIENT_SIDE_FORM_VALIDATION_FAILURE_MESSAGE, + originalError = Exception(CLIENT_SIDE_FORM_VALIDATION_FAILURE_MESSAGE), + ) + assertTrue(failed.isClientSideFormValidationFailure()) + } + + @Test + fun `isClientSideFormValidationFailure false for trailing space`() { + val failed = + PaymentResult.Failed( + errorType = PaymentResult.Failed.ErrorType.UNKNOWN_ERROR, + message = "${CLIENT_SIDE_FORM_VALIDATION_FAILURE_MESSAGE} ", + originalError = Exception("x"), + ) + assertFalse(failed.isClientSideFormValidationFailure()) + } + + @Test + fun `isClientSideFormValidationFailure false for wrong casing`() { + val failed = + PaymentResult.Failed( + errorType = PaymentResult.Failed.ErrorType.UNKNOWN_ERROR, + message = CLIENT_SIDE_FORM_VALIDATION_FAILURE_MESSAGE.uppercase(), + originalError = Exception("x"), + ) + assertFalse(failed.isClientSideFormValidationFailure()) + } + + @Test + fun `isClientSideFormValidationFailure false for network style message`() { + val failed = + PaymentResult.Failed( + errorType = PaymentResult.Failed.ErrorType.UNKNOWN_ERROR, + message = "Connection refused", + originalError = Exception("x"), + ) + assertFalse(failed.isClientSideFormValidationFailure()) + } +} diff --git a/app/src/test/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutViewModelFieldStateTest.kt b/app/src/test/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutViewModelFieldStateTest.kt new file mode 100644 index 0000000..6737100 --- /dev/null +++ b/app/src/test/java/com/spreedly/example/screens/basiccheckout/BasicCheckoutViewModelFieldStateTest.kt @@ -0,0 +1,139 @@ +package com.spreedly.example.screens.basiccheckout + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.spreedly.hostedfields.models.HostedFieldEventType +import com.spreedly.hostedfields.models.HostedFieldState +import com.spreedly.sdk.Spreedly +import com.spreedly.sdk.models.CardScheme +import com.spreedly.sdk.models.FormFieldType +import com.spreedly.sdk.utils.CardNumberContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class BasicCheckoutViewModelFieldStateTest { + + @Before + fun setUp() { + CardNumberContext.clear() + } + + @After + fun tearDown() { + CardNumberContext.clear() + } + + private fun cardState() = + HostedFieldState( + fieldType = FormFieldType.CARD(true), + eventType = HostedFieldEventType.INPUT, + isFocused = true, + isValid = true, + isEmpty = false, + cardScheme = CardScheme.VISA, + numberLength = 16, + cvvLength = null, + isPanMasked = true, + iin = null, + ) + + @Test + fun `onFieldStateUpdate routes CARD to inspector`() { + val context = ApplicationProvider.getApplicationContext() + val vm = BasicCheckoutViewModel(context, Spreedly(), skipAutoInitializeSdk = true) + vm.onFieldStateUpdate(cardState()) + assertEquals(CardScheme.VISA, vm.inspectorUiState.value.cardSnapshot?.cardScheme) + } + + @Test + fun `performFullPaymentReset clears inspector snapshots`() { + val context = ApplicationProvider.getApplicationContext() + val vm = BasicCheckoutViewModel(context, Spreedly(), skipAutoInitializeSdk = true) + vm.onFieldStateUpdate(cardState()) + vm.performFullPaymentReset() + assertNull(vm.inspectorUiState.value.cardSnapshot) + } + + @Test + fun `isFormValid is false until all hosted and custom fields are valid`() { + val context = ApplicationProvider.getApplicationContext() + val vm = BasicCheckoutViewModel(context, Spreedly(), skipAutoInitializeSdk = true) + assertFalse(vm.isFormValid.value) + vm.onHostedFieldValidation(FormFieldType.CARD(true), true) + vm.onHostedFieldValidation(FormFieldType.CVV(true), true) + vm.onHostedFieldValidation(FormFieldType.EXPIRY_DATE(true), true) + assertFalse(vm.isFormValid.value) + vm.updateNameValidity("Jane Doe") + vm.updateEmailValidity("jane@example.com") + assertTrue(vm.isFormValid.value) + } + + @Test + fun `performFullPaymentReset clears isFormValid`() { + val context = ApplicationProvider.getApplicationContext() + val vm = BasicCheckoutViewModel(context, Spreedly(), skipAutoInitializeSdk = true) + vm.onHostedFieldValidation(FormFieldType.CARD(true), true) + vm.onHostedFieldValidation(FormFieldType.CVV(true), true) + vm.onHostedFieldValidation(FormFieldType.EXPIRY_DATE(true), true) + vm.updateNameValidity("Jane Doe") + vm.updateEmailValidity("jane@example.com") + assertTrue(vm.isFormValid.value) + vm.performFullPaymentReset() + assertFalse(vm.isFormValid.value) + } + + @Test + fun `validateName sets error when cleared after entry`() { + val context = ApplicationProvider.getApplicationContext() + val vm = BasicCheckoutViewModel(context, Spreedly(), skipAutoInitializeSdk = true) + assertTrue(vm.validateName("Jo")) + assertNull(vm.nameError.value) + assertFalse(vm.validateName("")) + assertEquals("Name is required", vm.nameError.value) + } + + @Test + fun `validateEmail sets error when cleared after entry`() { + val context = ApplicationProvider.getApplicationContext() + val vm = BasicCheckoutViewModel(context, Spreedly(), skipAutoInitializeSdk = true) + assertTrue(vm.validateEmail("jane@example.com")) + assertNull(vm.emailError.value) + assertFalse(vm.validateEmail("")) + assertEquals("Email is required", vm.emailError.value) + } + + @Test + fun `isFormValid true when CVV untouched and card scheme has optional CVV`() { + val context = ApplicationProvider.getApplicationContext() + val vm = BasicCheckoutViewModel(context, Spreedly(), skipAutoInitializeSdk = true) + CardNumberContext.setCardScheme("117515279008103") + vm.onHostedFieldValidation(FormFieldType.CARD(true), true) + vm.onHostedFieldValidation(FormFieldType.CVV(true), false) + vm.onHostedFieldValidation(FormFieldType.EXPIRY_DATE(true), true) + vm.updateNameValidity("Jane Doe") + vm.updateEmailValidity("jane@example.com") + assertTrue(vm.isFormValid.value) + } + + @Test + fun `onHostedFieldValidation updates aggregate readout`() { + val context = ApplicationProvider.getApplicationContext() + val vm = BasicCheckoutViewModel(context, Spreedly(), skipAutoInitializeSdk = true) + vm.onHostedFieldValidation(FormFieldType.CARD(true), true) + vm.onHostedFieldValidation(FormFieldType.CVV(true), false) + val readout = vm.inspectorUiState.value.aggregateReadout + assertTrue(readout.contains("Security code (CVC)")) + assertNotNull(readout) + } +} diff --git a/app/src/test/java/com/spreedly/example/screens/braintreepayment/BraintreePaymentViewModelTest.kt b/app/src/test/java/com/spreedly/example/screens/braintreepayment/BraintreePaymentViewModelTest.kt index 97a45c5..b7cca0f 100644 --- a/app/src/test/java/com/spreedly/example/screens/braintreepayment/BraintreePaymentViewModelTest.kt +++ b/app/src/test/java/com/spreedly/example/screens/braintreepayment/BraintreePaymentViewModelTest.kt @@ -422,7 +422,7 @@ class BraintreePaymentViewModelTest { startPaymentResultObserver(viewModel) // When - paymentResultFlow.value = PaymentResult.Canceled + paymentResultFlow.value = PaymentResult.Canceled() advanceUntilIdle() // Then - default payment type is PayPal @@ -468,7 +468,7 @@ class BraintreePaymentViewModelTest { startPaymentResultObserver(viewModel) // When - paymentResultFlow.value = PaymentResult.Canceled + paymentResultFlow.value = PaymentResult.Canceled() advanceUntilIdle() // Then @@ -540,7 +540,7 @@ class BraintreePaymentViewModelTest { startPaymentResultObserver(viewModel) // When - no currentTransactionToken set - paymentResultFlow.value = PaymentResult.Canceled + paymentResultFlow.value = PaymentResult.Canceled() advanceUntilIdle() // Then - confirm not called, but canceled message still shown diff --git a/app/src/test/java/com/spreedly/example/screens/customtextfields/CustomTextFieldsViewModelFieldStateTest.kt b/app/src/test/java/com/spreedly/example/screens/customtextfields/CustomTextFieldsViewModelFieldStateTest.kt new file mode 100644 index 0000000..cbc86b2 --- /dev/null +++ b/app/src/test/java/com/spreedly/example/screens/customtextfields/CustomTextFieldsViewModelFieldStateTest.kt @@ -0,0 +1,89 @@ +package com.spreedly.example.screens.customtextfields + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.spreedly.hostedfields.models.HostedFieldEventType +import com.spreedly.hostedfields.models.HostedFieldState +import com.spreedly.sdk.models.CardScheme +import com.spreedly.sdk.models.FormFieldType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class CustomTextFieldsViewModelFieldStateTest { + + @Test + fun `onFieldStateUpdate routes CARD to inspector`() { + val context = ApplicationProvider.getApplicationContext() + val vm = CustomTextFieldsViewModel(context, skipAutoInitializeSdk = true) + vm.onFieldStateUpdate( + HostedFieldState( + fieldType = FormFieldType.CARD(true), + eventType = HostedFieldEventType.INPUT, + isFocused = true, + isValid = true, + isEmpty = false, + cardScheme = CardScheme.VISA, + numberLength = 16, + cvvLength = null, + isPanMasked = true, + iin = "411111", + ), + ) + assertEquals(16, vm.inspectorUiState.value.cardSnapshot?.numberLength) + } + + @Test + fun `performFullPaymentReset clears inspector`() { + val context = ApplicationProvider.getApplicationContext() + val vm = CustomTextFieldsViewModel(context, skipAutoInitializeSdk = true) + vm.onFieldStateUpdate( + HostedFieldState( + fieldType = FormFieldType.CVV(true), + eventType = HostedFieldEventType.BLUR, + isFocused = false, + isValid = true, + isEmpty = false, + cardScheme = null, + numberLength = null, + cvvLength = 3, + isPanMasked = true, + iin = null, + ), + ) + vm.performFullPaymentReset() + assertNull(vm.inspectorUiState.value.cvvSnapshot) + } + + @Test + fun `isFormValid requires hosted fields and custom address fields`() { + val context = ApplicationProvider.getApplicationContext() + val vm = CustomTextFieldsViewModel(context, skipAutoInitializeSdk = true) + assertFalse(vm.isFormValid.value) + vm.updateNameInput("Jane Doe") + vm.updateAddressInput("123 Main St") + vm.updateCityInput("Springfield") + vm.updateStateInput("CA") + vm.updateZipCodeInput("12345") + assertFalse(vm.isFormValid.value) + vm.onHostedFieldValidation(FormFieldType.CARD(true), true) + vm.onHostedFieldValidation(FormFieldType.CVV(true), true) + vm.onHostedFieldValidation(FormFieldType.EXPIRY_DATE(true), true) + assertTrue(vm.isFormValid.value) + } + + @Test + fun `custom field updates refresh aggregate`() { + val context = ApplicationProvider.getApplicationContext() + val vm = CustomTextFieldsViewModel(context, skipAutoInitializeSdk = true) + vm.updateNameInput("Jane Doe") + assertTrue(vm.inspectorUiState.value.onChangeReadout.contains("Full Name")) + } +} diff --git a/app/src/test/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentViewModelTest.kt b/app/src/test/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentViewModelTest.kt index 624cac4..30dd6d0 100644 --- a/app/src/test/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentViewModelTest.kt +++ b/app/src/test/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentViewModelTest.kt @@ -407,7 +407,7 @@ class StripeAPMPaymentViewModelTest { startPaymentResultObserver(viewModel) // When - paymentResultFlow.value = PaymentResult.Canceled + paymentResultFlow.value = PaymentResult.Canceled() advanceUntilIdle() // Then - default selected APM is iDEAL diff --git a/app/src/test/java/com/spreedly/example/screens/stripeapmpayment/StripeApmAppearanceBuilderTest.kt b/app/src/test/java/com/spreedly/example/screens/stripeapmpayment/StripeApmAppearanceBuilderTest.kt new file mode 100644 index 0000000..4994091 --- /dev/null +++ b/app/src/test/java/com/spreedly/example/screens/stripeapmpayment/StripeApmAppearanceBuilderTest.kt @@ -0,0 +1,50 @@ +package com.spreedly.example.screens.stripeapmpayment + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StripeApmAppearanceBuilderTest { + + @Test + fun `buildStripeAppearanceConfig returns null when custom appearance disabled`() { + val result = buildStripeAppearanceConfig( + useCustomAppearance = false, + primaryColor = Color.Red, + backgroundColor = Color.White, + buttonBackgroundColor = Color.Blue, + buttonTextColor = Color.White, + cornerRadiusDp = 10f, + ) + assertNull(result) + } + + @Test + fun `buildStripeAppearanceConfig maps colors shapes and primary button`() { + val primary = Color(0xFF5856D6) + val surface = Color(0xFFFFFBFE) + val buttonBg = Color(0xFF5856D6) + val buttonText = Color.White + + val result = buildStripeAppearanceConfig( + useCustomAppearance = true, + primaryColor = primary, + backgroundColor = surface, + buttonBackgroundColor = buttonBg, + buttonTextColor = buttonText, + cornerRadiusDp = 10f, + ) + + requireNotNull(result) + assertEquals(10f, result.shapes?.cornerRadiusDp) + assertEquals(1f, result.shapes?.borderStrokeWidthDp) + assertEquals(primary.toArgb(), result.colors?.primary) + assertEquals(surface.toArgb(), result.colors?.surface) + assertEquals(buttonBg.toArgb(), result.primaryButton?.background) + assertEquals(buttonText.toArgb(), result.primaryButton?.onBackground) + assertEquals(10f, result.primaryButton?.cornerRadiusDp) + assertEquals(52f, result.primaryButton?.heightDp) + } +} diff --git a/app/src/test/java/com/spreedly/example/ui/theme/SampleThemeConfigurationTest.kt b/app/src/test/java/com/spreedly/example/ui/theme/SampleThemeConfigurationTest.kt new file mode 100644 index 0000000..25a4565 --- /dev/null +++ b/app/src/test/java/com/spreedly/example/ui/theme/SampleThemeConfigurationTest.kt @@ -0,0 +1,134 @@ +package com.spreedly.example.ui.theme + +import androidx.compose.ui.graphics.Color +import com.spreedly.sdk.models.FormFieldType +import com.spreedly.ui.theme.SpreedlyColors +import com.spreedly.ui.theme.SpreedlyTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class SampleThemePresetsTest { + @Test + fun `should return null when custom theme is disabled`() { + val theme = + SampleThemePresets.resolveTheme( + preset = SampleThemePreset.BLUE, + isDarkMode = false, + useCustomTheme = false, + ) + + assertNull(theme) + } + + @Test + fun `should return blue light theme when blue preset selected in light mode`() { + val theme = + SampleThemePresets.resolveTheme( + preset = SampleThemePreset.BLUE, + isDarkMode = false, + useCustomTheme = true, + ) + + assertEquals(Color.Blue, theme?.colors?.primary) + assertEquals(Color(0xFFE3F2FD), theme?.colors?.surface) + } + + @Test + fun `should return blue dark theme when blue preset selected in dark mode`() { + val theme = + SampleThemePresets.resolveTheme( + preset = SampleThemePreset.BLUE, + isDarkMode = true, + useCustomTheme = true, + ) + + assertEquals(Color(0xFF1C1C1E), theme?.colors?.surface) + assertEquals(Color.White, theme?.colors?.text) + } +} + +class SplFieldConfigResolverTest { + private val globalTheme = + SpreedlyTheme( + colors = + SpreedlyColors( + primary = Color.Blue, + surface = Color.White, + text = Color.Black, + border = Color.Gray, + placeholder = Color.LightGray, + ), + ) + + @Test + fun `should return global config for non-target fields`() { + val config = + SplFieldConfigResolver.resolve( + formFieldType = FormFieldType.EXPIRY_DATE(true), + globalTheme = globalTheme, + overrideTarget = SplFieldTarget.CARD_NUMBER, + overrides = SplFieldStyleOverrides(primaryColor = Color.Red), + ) + + assertEquals(Color.Blue, config?.primaryColor) + } + + @Test + fun `should apply overrides on target field while keeping other properties`() { + val config = + SplFieldConfigResolver.resolve( + formFieldType = FormFieldType.CARD(true), + globalTheme = globalTheme, + overrideTarget = SplFieldTarget.CARD_NUMBER, + overrides = + SplFieldStyleOverrides( + primaryColor = Color.Red, + placeholderColor = Color.Magenta, + ), + ) + + assertEquals(Color.Red, config?.primaryColor) + assertEquals(Color.Magenta, config?.placeholderColor) + assertEquals(Color.White, config?.fieldBackgroundColor) + } + + @Test + fun `should use default base when only field override is enabled`() { + val config = + SplFieldConfigResolver.resolve( + formFieldType = FormFieldType.CVV(true), + globalTheme = null, + overrideTarget = SplFieldTarget.CVV, + overrides = SplFieldStyleOverrides(textColor = Color.Red), + ) + + assertEquals(Color.Red, config?.textColor) + } +} + +class ThemeConfigurationControllerTest { + @Test + fun `should clear field overrides when custom theme disabled`() { + val controller = ThemeConfigurationController() + controller.setFieldOverrideTarget(SplFieldTarget.CVV) + controller.updateFieldOverrides(SplFieldStyleOverrides(textColor = Color.Red)) + + controller.setUseCustomTheme(false) + + assertEquals(SplFieldTarget.NONE, controller.fieldOverrideTarget.value) + assertEquals(SplFieldStyleOverrides(), controller.fieldOverrides.value) + } + + @Test + fun `should resolve payment sheet config only when custom theme enabled`() { + val controller = ThemeConfigurationController() + + assertNull(controller.resolvePaymentSheetConfig(isDarkMode = false)) + + controller.setUseCustomTheme(true) + + assertNotNull(controller.resolvePaymentSheetConfig(isDarkMode = false)) + } +} diff --git a/app/src/test/java/com/spreedly/example/utils/CvvFormValidityTest.kt b/app/src/test/java/com/spreedly/example/utils/CvvFormValidityTest.kt new file mode 100644 index 0000000..8d44246 --- /dev/null +++ b/app/src/test/java/com/spreedly/example/utils/CvvFormValidityTest.kt @@ -0,0 +1,36 @@ +package com.spreedly.example.utils + +import com.spreedly.sdk.utils.CardNumberContext +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class CvvFormValidityTest { + + @Before + fun setUp() { + CardNumberContext.clear() + } + + @After + fun tearDown() { + CardNumberContext.clear() + } + + @Test + fun `should treat CVV as met when field invalid but card scheme has optional CVV`() { + CardNumberContext.setCardScheme("6018280000000006") + + assertTrue(isCvvFormRequirementMet(cvvFieldValid = false)) + } + + @Test + fun `should require valid CVV when card scheme requires CVV`() { + CardNumberContext.setCardScheme("4111111111111111") + + assertFalse(isCvvFormRequirementMet(cvvFieldValid = false)) + assertTrue(isCvvFormRequirementMet(cvvFieldValid = true)) + } +} diff --git a/app/src/test/java/com/spreedly/example/utils/PaymentResultHandlerTest.kt b/app/src/test/java/com/spreedly/example/utils/PaymentResultHandlerTest.kt index 36131fd..bd1d622 100644 --- a/app/src/test/java/com/spreedly/example/utils/PaymentResultHandlerTest.kt +++ b/app/src/test/java/com/spreedly/example/utils/PaymentResultHandlerTest.kt @@ -88,7 +88,7 @@ class PaymentResultHandlerTest { onCanceled = { canceled = true }, ) - resultFlow.emit(PaymentResult.Canceled) + resultFlow.emit(PaymentResult.Canceled()) assertTrue(canceled) job.cancel() } diff --git a/build.gradle.kts b/build.gradle.kts index bb98b71..46eb05d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,6 +25,22 @@ tasks.withType().configureEach { apiValidation { ignoredProjects += listOf("app") + ignoredClasses += listOf("com.spreedly.sdk.BuildConfig") +} + +tasks.register("refreshLegacyAbiDumps") { + group = "verification" + description = + "Rebuild build/kotlin/abi-legacy dumps from compiled classes. " + + "Use with -PspreedlyRefreshAbiDumps so only dumps bypass the build cache (compile stays cached). " + + "To update committed api/*.api files, run updateLegacyAbi instead." + dependsOn( + subprojects + .filter { sub -> + sub.layout.projectDirectory.file("api/${sub.name}.api").asFile.exists() + } + .map { "${it.path}:dumpLegacyAbi" }, + ) } diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2cbf0f5..41a9518 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,82 @@ All notable changes to the Spreedly Android SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] - 2026-06-03 + +### Breaking Changes + +- **`SPLTextField`** (`hostedfields`) — **CARD** and **CVV** require non-null `sdk`; display follows `Spreedly.hostedCardDisplayState`. Removed composable parameters that duplicated SDK display state (`observeHostedCardDisplayState`, `hostedCardDisplayState`, `cardNumberFormat`, `cvvDisplayMasked`). Recompile consumers and pass `sdk` on every CARD/CVV field. +- **`SPLTextField` Compose signature** (`hostedfields`) — new parameters (`trailingIcon`, `onFieldStateChange`, `onFocusChanged`, `enableAutofill`) change the Compose-generated method name. Recompile consumers against the new `hostedfields` AAR. +- **`PaymentSheet`** (`paymentsheet`) — requires `sdk: Spreedly`; removed `hostedCardDisplayState` parameter (use SDK state only). +- **`PaymentSheetConfig` data class** (`payments-core`) — new properties `enableAutofill` (`Boolean`, default `true`) and `cardNumberFormat` (`CardNumberFormat`, default `PRETTY`). Java callers using positional constructors or `copy()` must add the two new trailing arguments. Kotlin callers using named parameters are unaffected. +- **`HostedFieldsJavaHelper` / `HostedFieldsForm`** (`hostedfields`) — removed `cardNumberFormat` from hosted-fields Java setup overloads and form content; CARD/CVV use `sdk` on `SPLTextField` for display. +- **CVV recache result delivery** (`payments-core`) — Recache outcomes are delivered only on `recacheResultFlow` and from the `recachePaymentMethod` suspend return, not on `paymentResultFlow`. Removed `PaymentResult.isRecacheMirrorEvent()` and `PaymentResult.Completed.paymentMethodUpdatedAt`. Use `PaymentMethodResponse.paymentMethodUpdatedAt` from the recache `Result`. Matches iframe (separate `recache` vs `paymentMethod` events). +- **`PaymentResult.Canceled`** (`payments-core`) — now a `data class` with optional `message: String?` and `paymentMethodType: String?` (both default `null`). Kotlin `when` branches must use `is PaymentResult.Canceled`. Emit sites must use `PaymentResult.Canceled()`. Java callers use `instanceof`. Binary-incompatible change; requires recompilation. +- **`BraintreeAPMActivity.Companion.createIntent()`** (`braintree`) — removed; use `SpreedlyBraintreeAPMCheckout.present()` or `BraintreeAPMJavaHelper.presentCheckout()` instead. + +### Added + +- **Express core field copy** (`payments-core`, `paymentsheet`, `hostedfields`) — `PaymentSheetCoreFieldLabels` and `PaymentSheetCoreFieldCopyResolver` for optional card number, CVV, and expiration label and hint text on `SpreedlyBottomSheet` / `PaymentSheet` (iOS `DropInCoreFieldLabels` parity). `SPLTextField` supports custom hint text for express pass-through. Java: `PaymentSheetJavaHelper.createDefaultCoreFieldLabels()` and `setupContent` overloads with `coreFieldLabels`. Defaults unchanged when omitted. +- **Iframe-style PAN display** (`payments-core`, `paymentsheet`, `hostedfields`) — `CardNumberFormat` (`PRETTY`, `PLAIN`, `MASKED`), `HostedCardDisplayState`, `Spreedly.hostedCardDisplayState` (observable Compose `State`), `setNumberFormat` (enum or iframe-style string aliases `prettyFormat` / `plainFormat` / `maskedFormat`), and `toggleMask` (coupled CARD + CVV). Mask control is merchant-owned UI outside the fields (iframe parity). +- **Lifecycle mask overlay** (`hostedfields`) — `SPLTextField.forceMaskOnLifecycleStop` (default `true`): temporary visual mask on `ON_STOP` when PAN was revealed. Clipboard copy/cut disabled for CARD when revealed. +- **`SPLTextField.onFieldStateChange` / `HostedFieldState`** (`hostedfields`) — typed merchant-safe field snapshots (`INPUT`, `FOCUS`, `BLUR`, `VALIDATION`, `PAN_MASK_CHANGED`): `cardScheme`, `numberLength`, `cvvLength` (digit counts only — no raw PAN/CVV), `isValid`, `isEmpty`, `isFocused`, `isPanMasked`, `iin`. Java: `HostedFieldStateListener` overloads on `HostedFieldsJavaHelper.setupContent`. +- **`SPLTextField.onFocusChanged`** (`hostedfields`) — optional `((Boolean) -> Unit)?` for focus/blur callbacks. +- **`SPLTextField.trailingIcon`** (`hostedfields`) — optional composable slot for CARD fields (e.g., card brand logo). +- **`Spreedly.areAllFieldsValid(formFields)`** (`payments-core`) — aggregate validation gate over `FormFieldType` list. `@MainThread`. +- **`Spreedly.getRegisteredFieldCount()`** (`payments-core`) — count of mounted hosted field instances for pay-button gating. +- **`Spreedly.resetPaymentFormPreservingDisplayConfig()`** (`payments-core`) — clears payment field values and validation without resetting `hostedCardDisplayState` (preserves mask/format state). +- **`EmailValidator`** (`payments-core`) — `isValid(email)` for merchant email fields (rejects single-label domains). +- **`eligibleForCardUpdater`** — optional parameter on `createCreditCard` / `createPaymentMethod`; JSON key `eligible_for_card_updater` sent only when non-null. +- **Hosted fields autofill** (`hostedfields`, `payments-core`) — `SPLTextField(enableAutofill = true)`, `HostedFieldsJavaHelper.setupContent(..., enableAutofill)`, `PaymentSheetConfig.enableAutofill`. CARD uses `KeyboardType.Number` when autofill is on (password keyboard suppresses OS autofill). +- **`PaymentSheetDisplayConfig`** (`payments-core`) — express checkout display options (`enableAutofill`, `cardNumberFormat`). Optional `displayConfig` on `SpreedlyBottomSheet` and `PaymentSheet` (`null` falls back to `PaymentSheetConfig` via `PaymentSheetDisplayConfig.resolve`). Java: `PaymentSheetJavaHelper.createDefaultDisplayConfig()` and `setupContent` overloads with optional `displayConfig`. +- **`HostedFieldState.iin`** (`hostedfields`) — merchant-safe IIN prefix on card field snapshots (iframe `inputProperties` parity); always computed when six or more digits are present. +- **`HostedFieldState.panDisplayFormat` / `panDisplayPolicyMasked`** (`hostedfields`) — card-number snapshot of global display format and `panMasked` policy at emission time. +- **`HostedFieldsJavaHelper.setupContentWithInspector`** (`hostedfields`) — Java setup with optional inspector slot; CARD/CVV-only field-state listener on the inspector path. +- **`PaymentResult.Completed.paymentMethodType`** (`payments-core`) — optional field identifying the payment type (e.g., "paypal", "venmo") on successful Braintree APM flows. +- **`PaymentResult.Completed.venmoUsername`** (`payments-core`) — optional Venmo username returned on successful Venmo payments. +- **Braintree transaction status models** (`payments-core`) — `BraintreeLineItem`, `BraintreeShippingAddress`, and new fields on `TransactionStatus` (`enablePaylaterButton`, `billingAgreementDescription`, `lineItems`, `shippingAddress`, `locale`) for iFrame parity. +- **Braintree PayPal vault & checkout_with_vault flows** (`braintree`) — `launchPayPal()` now builds `PayPalVaultRequest` or `PayPalCheckoutRequest` with billing agreement, shipping address, line items, and locale from the transaction status. `paypal_flow_type` values `checkout`, `vault`, and `checkout_with_vault` are all supported. +- **Braintree PayPal device-data gating** (`braintree`) — device data collection is skipped for plain `checkout` flows (matching iFrame behavior). Vault and `checkout_with_vault` flows still collect device data. +- **Braintree Venmo `multi_use` / `single_use` mapping** (`braintree`) — `venmo_flow_type` from the transaction status is mapped to `VenmoPaymentMethodUsage.MULTI_USE` or `SINGLE_USE`. `multi_use` also sets `shouldVault = true`. +- **Braintree cancel messages** (`braintree`) — `PaymentResult.Canceled` now carries `message` (e.g., "User canceled PayPal payment") and `paymentMethodType` (e.g., "paypal", "venmo") on Braintree cancel events. +- **`SpreedlyBraintreeAPMCheckout.inferPaymentType()`** (`braintree`) — public helper to determine `BraintreeAPMPaymentType` from a `TransactionStatus.paymentMethodType` string, with Java-friendly helper in `BraintreeAPMJavaHelper`. +- **Client token 24h validation** (`braintree`) — `present()` now validates that the Braintree client token (`createdAt`) is within 24 hours and rejects stale tokens with `PaymentResult.Failed`. +- **Payment type mismatch validation** (`braintree`) — `present()` checks that the config's `paymentType` matches the transaction's `paymentMethodType` and fails early on mismatch. +- **Recache accessors and helpers** (`payments-core`) — `PaymentMethodTransactionTypes.RECACHE_SENSITIVE_DATA`, `PaymentMethodResponse.paymentMethodUpdatedAt`, `PaymentMethodResponse.recacheToken`. +- **Stripe APM PaymentSheet appearance** (`stripe`) — optional `StripeAPMAppearanceConfig` on `SpreedlyStripeAPMCheckout.present(config, activity, appearance)` and `StripeAPMJavaHelper.presentCheckout(config, activity, appearance)` maps to Stripe `PaymentSheet.Appearance`. See `docs/development/STRIPE_22_8_APPEARANCE_FIELD_MATRIX.md`. +- **Stripe Radar module** (`stripe-radar`) — device data collection for fraud detection via Stripe Radar sessions. See `docs/guides/stripe-radar.md`. +- **Legacy iframe migration guide** — `docs/guides/migration/from-legacy.md` with iframe-to-Android mapping tables. + +### Deprecated + +- **`PaymentSheetConfig.enableAutofill` / `cardNumberFormat`** (`payments-core`) — use `PaymentSheetDisplayConfig` on express checkout composables instead. Deprecated properties remain for this release (binary compatible). + +### Changed + +- **PAN/CVV display transforms** (`hostedfields`) — `MASKED` and `PLAIN` use full-bullet display (iframe `maskedFormat` style); `PRETTY` keeps focus-based gradual reveal (not identical to iframe `prettyFormat` — see migration guide). +- **MASKED PAN and CVV mask character** (`hostedfields`) — full-mask display now uses `*` instead of `•` for iframe parity. PRETTY gradual-reveal still uses bullets in `MaskedCardNumberVisualTransformation`. +- **Hosted card display transitions** (`payments-core`) — `setNumberFormat(PRETTY)` unmasks the PAN only; **CVV mask is preserved** from the prior state (e.g. MASKED → PRETTY keeps CVV masked). `PLAIN` and `MASKED` still couple PAN and CVV (both unmasked or both masked). `toggleMask()` toggles between masked and plain. Default / full `resetPaymentState()` uses PRETTY with `panMasked = false` and `cvvDisplayMasked = false`; successful `createCreditCard` does not reset display. +- **`resetPaymentState()` display reset** (`payments-core`) — Clears form values and resets `hostedCardDisplayState` to defaults. Re-apply `setNumberFormat` / `toggleMask` after reset if you use a non-default mask. See [migration guide](guides/migration/from-legacy.md#tokenization-reset-and-listeners). +- **Post-tokenize hosted display** (`payments-core`, `paymentsheet`) — Successful `createCreditCard` and express sheet open call `resetPaymentFormPreservingDisplayConfig()` so `hostedCardDisplayState` (mask/format) is preserved. Full `resetPaymentState()` still clears display for merchant clean-slate resets. +- **Express autofill** (`paymentsheet`) — autofill is controlled by `PaymentSheetDisplayConfig.enableAutofill` only; removed the in-sheet `showsAutofillToggle` QA switch from `PaymentSheet` / `SpreedlyBottomSheet`. +- **`AppTextField`** (`payments-core`) — when `autofillContentType` is null, the field now sets autofill semantics to `ContentDataType.None` so the OS does not treat it as a generic autofillable text target (reduces unwanted autofill UI when autofill is intentionally off). +- **`SpreedlyTheme.toPaymentSheetConfig()`** (`payments-core`) — documents and maps **colors only** for payment sheets; initial autofill / card-number format for express use `PaymentSheetDisplayConfig` or legacy `PaymentSheetConfig` fields when `displayConfig` is null. +- **Default form colors in dark mode** — Built-in `SpreedlyTheme.Dark` is now selected automatically when no global theme is set. `setGlobalTheme(SpreedlyTheme.Default)` resets to that auto-switching behavior. Payment sheets and hosted fields no longer show white field backgrounds on dark system themes. +- **Gateway-specific 3DS** (`threeds`) — Challenge and redirect polling now time out after **10 minutes** (previously 15 minutes). Braintree purchases that return `device_fingerprint` go straight to the challenge step when `challenge_url` or `challenge_form_embed_url` is on the status; if both are missing, the SDK returns **Challenge URL missing**. Device fingerprint completion is detected via status polling (up to ~10 seconds). +- **Recache suspend return** (`payments-core`) — `recachePaymentMethod` returns `Result.Error` when `transaction.succeeded` is false (including some HTTP 200 failure bodies). + +### Fixed + +- **Braintree APM `client_token` expiry** (`braintree`) — Parse `transaction.context` for `client_token` and `created_at`; skip expiry when `created_at` is missing or unparseable so checkout is not blocked when status fields are only in context. Fixes HC-1480. +- **Separate expiry month field** (`hostedfields`) — Month field no longer pads a lone `0` to `00`, so merchants can backspace and clear the field while typing. Live month input: digits only, max 2 characters, no zero-padding until autofill or payment submission. Months 10–12 can be typed digit-by-digit (e.g. `1` then `2` → `12`). Fixes HC-1508. +- **Validation param revalidation** (`payments-core`, `hostedfields`) — Enabling `allowExpiredDate` (or `allowBlankDate`) now clears stale expiry errors on separate month/year fields without requiring another keystroke. Fixes HC-1508. +- **Recache optional CVV** (`payments-core`, `paymentsheet`) — ROUTEX, UATP, and Tarjeta D recache now accept empty or 1–3 digit numeric CVV (hosted-fields parity). Rejects 4+ digits on optional cards. Fixes HC-1506 / HC-1504. +- **Recache Confirm button** (`paymentsheet`) — Confirm stays disabled until CVV passes card-type validation (not merely non-empty). +- **Separate expiry fields card scan** (`hostedfields`) — YY year field now keeps the last two digits when autofill supplies a four-digit year (e.g. `2030` → `30`). Single-digit months from scan are zero-padded (e.g. `4` → `04`). Fixes HC-1505. +- **Optional CVV submit validation** (`hostedfields`) — `SPLTextField` re-publishes CVV validation when the card scheme changes so ROUTEX / Tarjeta D / UATP cards accept empty CVV. Fixes HC-1496. +- **Expiry autofill** (`hostedfields`) — card scan and Wallet autofill values such as `06/30`, `06/2030`, and compact `0630` are parsed into `EXPIRY_DATE` (MMYY) and separate month/year fields; combined values route to the peer field. +- **Global theme on hosted fields** (`hostedfields`, `paymentsheet`) — `SPLTextField` and `PaymentSheet` re-read `GlobalThemeManager` when the global theme changes instead of caching an empty config in `remember(config)`. + ## [1.0.1] - 2026-05-08 ### Added @@ -25,6 +101,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Braintree APM client_token expiry** (`braintree`) -- Parse `transaction.context` for `client_token` and `created_at`; skip expiry when `created_at` is missing or unparseable so checkout is not blocked when status fields are only in context (HC-1480). + - **Apache-2.0 LICENSE in published artifacts** -- each AAR now embeds `META-INF/LICENSE` inside `classes.jar`, ensuring compliance with Apache 2.0 Section 4 distribution requirements. ### Changed @@ -262,7 +340,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > `checkout-threeds`. Merchants need to add `com.spreedly:checkout-threeds` for 3DS support. > See [README.md](../README.md) for current integration instructions. -### 🚨 BREAKING CHANGES +### Breaking Changes This release introduces a new multi-module publishing structure that replaces the single fat AAR. This is a **breaking change** that requires dependency updates. @@ -283,40 +361,40 @@ implementation("com.spreedly:checkout-paymentsheet:0.7.1") ### Added -- 📦 **Multi-Module Publishing**: SDK now published as three separate modules +- **Multi-Module Publishing**: SDK now published as three separate modules - `checkout-payments-core`: Core payment processing, API client, and 3DS support - `checkout-hostedfields`: Secure individual payment field components - `checkout-paymentsheet`: Pre-built payment sheet UI -- 🔗 **Proper Dependency Management**: Transitive dependencies (Ktor, Compose) now correctly exposed via `api` scope -- 📚 **Bundled Core Modules**: All internal core modules now bundled into `payments-core` for cleaner architecture +- **Proper Dependency Management**: Transitive dependencies (Ktor, Compose) now correctly exposed via `api` scope +- **Bundled Core Modules**: All internal core modules now bundled into `payments-core` for cleaner architecture ### Changed -- ⚡ **Artifact ID Change**: Changed from `checkout-android` to module-specific IDs -- 🏗️ **Build System**: Replaced fat AAR with standard multi-module publishing -- 📦 **Forter SDK**: Now required as explicit dependency (not bundled) +- **Artifact ID Change**: Changed from `checkout-android` to module-specific IDs +- **Build System**: Replaced fat AAR with standard multi-module publishing +- **Forter SDK**: Now required as explicit dependency (not bundled) ### Fixed -- 🐛 **Resources$NotFoundException**: Fixed string resource issues by properly bundling all resources into `payments-core` -- 🔧 **NoClassDefFoundError**: Fixed Ktor dependency issues with proper `api` scope -- 📦 **POM Generation**: Fixed dependency declarations in published POMs +- **Resources$NotFoundException**: Fixed string resource issues by properly bundling all resources into `payments-core` +- **NoClassDefFoundError**: Fixed Ktor dependency issues with proper `api` scope +- **POM Generation**: Fixed dependency declarations in published POMs ### Removed -- 🗑️ **Fat AAR**: Removed single fat AAR in favor of modular approach -- 🗑️ **Bundled Forter**: Forter SDK no longer bundled, must be added explicitly +- **Fat AAR**: Removed single fat AAR in favor of modular approach +- **Bundled Forter**: Forter SDK no longer bundled, must be added explicitly ## [0.7.0] - 2024-12-30 ### Added -- 💾 **Save Card for Future Payments**: Added `shouldRetain` property to `PaymentResult.Completed` to indicate whether users want to save their payment method +- **Save Card for Future Payments**: Added `shouldRetain` property to `PaymentResult.Completed` to indicate whether users want to save their payment method - New `retainOnSuccess` parameter in payment creation methods (`createPaymentMethod`, `createCreditCard`) - `SaveCardCheckbox` composable component for allowing users to opt-in to saving cards - Automatic extraction of `retained` value from API response to `PaymentResult` - Comprehensive documentation on secure token storage best practices -- 🎯 **Focus Tracking Callback**: Added `onFocus` callback parameter to `SPLTextField` and `AppTextField` +- **Focus Tracking Callback**: Added `onFocus` callback parameter to `SPLTextField` and `AppTextField` - Triggered when user taps into a field (field gains focus) - Enables custom focus management and field tracking - Complements existing `shouldFocus` parameter for complete focus control @@ -324,49 +402,47 @@ implementation("com.spreedly:checkout-paymentsheet:0.7.1") ### Security -- 🔒 **Payment Token Storage**: Added guidance on secure payment token storage using encrypted SharedPreferences and Android Keystore +- **Payment Token Storage**: Added guidance on secure payment token storage using encrypted SharedPreferences and Android Keystore ## [0.0.3] - 2025-08-25 ### Added -- 🔧 **AuthService in App Module**: Added `AuthService` class in app module to handle authentication +- **AuthService in App Module**: Added `AuthService` class in app module to handle authentication parameter retrieval from backend -- ♿ **Accessibility Enhancements**: Enhanced UI components with comprehensive accessibility support +- **Accessibility Enhancements**: Enhanced UI components with comprehensive accessibility support - Added content descriptions for all interactive elements - Implemented semantic properties for screen readers - Improved navigation and focus management ### Changed -- 🔄 **BREAKING CHANGE**: SDK initialization now requires authentication parameters to be fetched by +- **BREAKING CHANGE**: SDK initialization now requires authentication parameters to be fetched by the app - Removed `suspend fun init(environmentKey: String, config: PaymentSheetConfig)` from SDK - Apps must now implement their own `AuthService` to fetch auth parameters from backend - Updated all example activities to use new initialization pattern - Updated `SpreedlyHelper.initializeSdkWithRemoteAuth()` to handle auth parameter fetching -### Deprecated - ### Removed -- ❌ **AuthParamsService**: Removed from SDK core - authentication API calls now handled by app layer -- ❌ **Suspend init method**: Removed `suspend fun init(environmentKey, config)` - replaced with +- **AuthParamsService**: Removed from SDK core - authentication API calls now handled by app layer +- **Suspend init method**: Removed `suspend fun init(environmentKey, config)` - replaced with explicit auth parameter passing -- ❌ **Auth-related tests**: Removed SDK tests for authentication parameter fetching since it's now +- **Auth-related tests**: Removed SDK tests for authentication parameter fetching since it's now app responsibility ### Fixed -- 🐛 **Card Number Visual Transformation**: Fixed cursor positioning bug in card number formatting +- **Card Number Visual Transformation**: Fixed cursor positioning bug in card number formatting - Resolved crash when typing 4th digit in unknown card types - Improved offset mapping for dynamic card number formatting - Enhanced handling of space positions in formatted card numbers -- 🎨 **Code Formatting**: Applied trailing comma formatting in SPLTextField component +- **Code Formatting**: Applied trailing comma formatting in SPLTextField component ### Security -- 🔒 **Improved Security**: Authentication parameters are now fetched by the app, giving developers +- **Improved Security**: Authentication parameters are now fetched by the app, giving developers full control over the authentication flow ## [0.0.1] - 2025-08-08 @@ -376,10 +452,10 @@ implementation("com.spreedly:checkout-paymentsheet:0.7.1") ### Added -- 🎉 **Initial Release** of Spreedly Android SDK -- 💳 **Core Payment Processing** with secure tokenization -- 🎨 **Modern UI Components** built with Jetpack Compose -- 🏗️ **Modular Architecture** with separate modules: +- **Initial Release** of Spreedly Android SDK +- **Core Payment Processing** with secure tokenization +- **Modern UI Components** built with Jetpack Compose +- **Modular Architecture** with separate modules: - `payments-core`: Core payment processing (includes analytics, networking, validation, result types, security, and UI) - `paymentsheet`: Pre-built payment sheet UI components - `hostedfields`: Secure payment field components @@ -389,19 +465,19 @@ implementation("com.spreedly:checkout-paymentsheet:0.7.1") ### Features -- ✅ **Secure Payment Field Components** +- **Secure Payment Field Components** - Card number validation with real-time formatting - Expiry date validation (month/year) - CVV validation with card type detection - Address field validation -- ✅ **Payment Sheet Integration** +- **Payment Sheet Integration** - Bottom sheet payment form - Inline payment forms - Customizable styling and theming - Support for additional billing fields -- ✅ **Form Validation** +- **Form Validation** - Real-time validation feedback - Comprehensive validator classes: - `CardNumberValidator`: Validates card numbers with Luhn algorithm @@ -411,33 +487,33 @@ implementation("com.spreedly:checkout-paymentsheet:0.7.1") - `ExpiryDateValidator`: Validates expiry date formats - `RequiredValidator`: Validates required fields -- ✅ **Network Layer** +- **Network Layer** - Ktor-based HTTP client - Robust error handling - Request/response logging - Timeout configuration -- ✅ **Security Features** +- **Security Features** - Secure tokenization of payment data - PCI-compliant payment field handling - Data encryption utilities ### Technical Features -- 🔧 **Build System** +- **Build System** - Gradle Kotlin DSL with convention plugins - Multi-module architecture - Build flavors (development/production) - Version catalog for dependency management -- 🧪 **Testing & Quality** +- **Testing & Quality** - Comprehensive unit test coverage - Code coverage reporting with Kover - Lint checks with ktlint and Android Lint - Compose-specific lint checks (Slack compose-lint-checks) - Automated code formatting with Spotless -- 🚀 **CI/CD Pipeline** +- **CI/CD Pipeline** - GitHub Actions workflows for CI/CD - Automated testing and building - Code quality checks with coverage reporting @@ -446,13 +522,13 @@ implementation("com.spreedly:checkout-paymentsheet:0.7.1") ### Developer Experience -- 📖 **Documentation** +- **Documentation** - Comprehensive README with integration guides - API documentation - Code examples and usage patterns - Workflow documentation -- 🛠️ **Development Tools** +- **Development Tools** - Custom lint checks and rules - Code generation scripts - Coverage report generation @@ -460,6 +536,8 @@ implementation("com.spreedly:checkout-paymentsheet:0.7.1") ### Installation +> **Note**: This snippet reflects the original 0.0.1 single-module artifact. See `README.md` for current multi-module installation (0.7.1+). + ```kotlin // In settings.gradle.kts dependencyResolutionManagement { @@ -479,11 +557,9 @@ dependencyResolutionManagement { } } -// In app/build.gradle.kts +// In app/build.gradle.kts (OBSOLETE — see README.md for current dependencies) dependencies { - implementation("com.spreedly:checkout-android:0.0.1") { - exclude(group = "checkout-android-sdk.core") - } + implementation("com.spreedly:checkout-android:0.0.1") } ``` diff --git a/docs/README.md b/docs/README.md index 16501d3..f3967f7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ | [ACH Bank Account](guides/ach-bank-account.md) | Tokenize bank accounts with pre-built UI, custom layout, or headless flow | | [Offsite Payments](guides/offsite-payments.md) | PayPal, Pix, Boleto via Chrome Custom Tabs | | [Stripe APM](guides/stripe-apm.md) | iDEAL, Bancontact, EPS, P24, SEPA via Stripe | +| [Stripe Radar](guides/stripe-radar.md) | Device fingerprinting for fraud detection via Stripe Radar | | [Braintree APM](guides/braintree-apm.md) | PayPal and Venmo via Braintree | | [3DS Global](guides/3ds-global.md) | Forter-based 3D Secure authentication (`checkout-threeds`) | | [3DS Gateway-Specific](guides/3ds-gateway-specific.md) | Gateway-managed 3DS via Chrome Custom Tabs (`checkout-threeds`) | diff --git a/docs/guides/3ds-gateway-specific.md b/docs/guides/3ds-gateway-specific.md index 364793d..1abd42e 100644 --- a/docs/guides/3ds-gateway-specific.md +++ b/docs/guides/3ds-gateway-specific.md @@ -145,7 +145,7 @@ The SDK uses Auth Tab for all 3DS browser flows on Chrome 137+. Auth Tab provide After device fingerprinting completes, the SDK emits `gatewaySpecific3DSTriggerCompletionFlow`. At that point the merchant must call their backend `/complete` endpoint. Two approaches exist for handling the response: -**Explicit finalization (recommended)** — Pass the `/complete` response to `GatewaySpecific3DSIntegration.finalizeTransaction()`. The SDK processes it immediately with no polling overhead. Matches iOS SDK behavior. +**Explicit finalization (recommended)** — Pass the `/complete` response to `GatewaySpecific3DSIntegration.finalizeTransaction()`. The SDK processes it immediately without additional status polling. **Automatic polling (default)** — Skip `finalizeTransaction()`. The SDK polls `checkTransactionStatus()` every 2 seconds for up to 2 minutes. Also acts as a fallback if explicit finalization fails. @@ -162,11 +162,11 @@ flowchart TD SDK -->|challenge| CH["Auth Tab / CCT
(3DS challenge page)"] SDK -->|redirect| RD["Auth Tab / CCT
(external redirect)"] - FP -->|"polling / postMessage"| Trigger[onTriggerCompletion] + FP -->|"polling (~10s)"| Trigger[onTriggerCompletion] Trigger --> Complete["Merchant calls /complete"] Complete --> Explicit["Explicit: finalizeTransaction()"] - Complete --> Automatic["Automatic: SDK polls"] + Complete --> Automatic["Automatic: SDK polls (up to 2 min)"] Explicit --> CompletionCheck{Result?} Automatic --> CompletionCheck @@ -174,8 +174,8 @@ flowchart TD CompletionCheck -->|"succeeded / failed"| FinalResult[ThreeDSChallengeResult] CompletionCheck -->|"pending + challenge"| CH - CH -->|"polling (2s, max 15min)"| ChallengeResult["succeeded / failed"] - RD -->|"polling (2s, max 15min)"| RedirectResult["succeeded / failed"] + CH -->|"polling (2s, max 10min)"| ChallengeResult["succeeded / failed"] + RD -->|"polling (2s, max 10min)"| RedirectResult["succeeded / failed"] ChallengeResult --> FinalResult RedirectResult --> FinalResult @@ -228,11 +228,21 @@ Headers: When `required_action` is `"device_fingerprint"`, the SDK: -1. Opens the `device_fingerprint_form_embed_url` in an Auth Tab (or CCT fallback) -2. Polls for completion (10 × 1-second intervals) or listens for a Worldpay `postMessage` -3. Emits `gatewaySpecific3DSTriggerCompletionFlow` once fingerprinting completes +1. Opens the `device_fingerprint_form_embed_url` in an Auth Tab (or Chrome Custom Tab fallback) +2. Polls the transaction status about once per second for up to **10 seconds** +3. Emits `gatewaySpecific3DSTriggerCompletionFlow` when fingerprinting is complete -The merchant must then call the backend `/complete` endpoint. The response determines the next step: +Fingerprint completion is detected through **status polling**, not through a custom WebView or JavaScript bridge in your app. + +### Braintree + +When the purchase status has `gateway_type` **braintree** and `required_action` **device_fingerprint**, the SDK opens the **challenge** step directly instead of the fingerprint tab. Your purchase response must include a `challenge_url` or `challenge_form_embed_url`. If neither is present, the flow ends with **Challenge URL missing**. + +### Worldpay and other gateways + +For gateways such as Worldpay, the fingerprint tab runs as described above. Allow up to **10 seconds** for the SDK to detect completion and emit `gatewaySpecific3DSTriggerCompletionFlow` before you call your backend `/complete` endpoint. + +After fingerprinting, call your backend `/complete` endpoint. The response determines the next step: - **`succeeded` or `failed`** — SDK emits a final result via `threeDSChallengeResultFlow` - **`pending` with `required_action = "challenge"`** — SDK launches the challenge @@ -243,10 +253,16 @@ The merchant must then call the backend `/complete` endpoint. The response deter Challenges are displayed in an Auth Tab (or CCT fallback). The SDK launches the tab automatically — no manual view embedding is needed. -Once launched, the SDK emits `gatewaySpecific3DSChallengeReadyFlow` and begins polling for a terminal state (every 2 seconds, up to 15 minutes). +Once launched, the SDK emits `gatewaySpecific3DSChallengeReadyFlow` and polls every **2 seconds** until the transaction reaches a terminal state or **10 minutes** elapse. When `required_action` is `"challenge"` from the initial purchase (e.g., $30.05 test amount), the SDK skips device fingerprinting and launches the challenge directly. +If challenge or redirect polling runs for **10 minutes** without a terminal status, the SDK reports a challenge timeout failure. + +### App backgrounding + +If the user backgrounds your app during fingerprinting, challenge, or the post-fingerprint wait, status polling generally continues. When they return, the SDK presents results through the same flows. + --- ## API Reference @@ -313,7 +329,19 @@ The SDK declares the `REORDER_TASKS` permission in its manifest (auto-merged). T **Symptom:** Error after 2 minutes — "Timeout waiting for /complete.json API call" -Verify your backend processes the `/complete` call and that `gatewaySpecific3DSTriggerCompletionFlow` is subscribed. +Verify your backend processes the `/complete` call, that `gatewaySpecific3DSTriggerCompletionFlow` is subscribed, and that you call `finalizeTransaction()` with the `/complete` response when possible. + +### Challenge URL Missing (Braintree) + +**Symptom:** Flow fails immediately with **Challenge URL missing** after a Braintree purchase returns `device_fingerprint`. + +Ensure the purchase status includes `challenge_url` or `challenge_form_embed_url`. The SDK opens the challenge step directly for Braintree and does not run the fingerprint tab when those URLs are required for the next step. + +### Challenge Polling Timeout + +**Symptom:** Error after **10 minutes** of challenge or redirect polling. + +The user may need to complete authentication in the browser tab, or the transaction may be stuck on the gateway. Check transaction status on your backend and retry or cancel the payment as your product requires. ### Complete API Returns 404 diff --git a/docs/guides/custom-payment-forms.md b/docs/guides/custom-payment-forms.md index ea4ae45..dd49f21 100644 --- a/docs/guides/custom-payment-forms.md +++ b/docs/guides/custom-payment-forms.md @@ -26,6 +26,8 @@ SPL fields enforce security automatically: - Card number fields block copy, cut, and text selection (paste is allowed) - CVV fields block all clipboard operations and text selection +**`SPLTextField` callbacks:** `onChange` receives **AES-encrypted** ciphertext for `FormFieldType.CARD`, `FormFieldType.CVV`, and `FormFieldType.ACCOUNT_NUMBER` only (`FormFieldType.shouldEncrypt()`); all other field types receive **raw** processed text in `onChange`. Do **not** log encrypted strings or treat them as display digits. Use **`onFieldStateChange(HostedFieldState)?`** for iframe-style observability: digit **counts** (`numberLength` / `cvvLength`), `cardScheme`, `isValid`, focus/blur via `HostedFieldEventType`, without parsing ciphertext. Use `onValidationChange` / `hasValidationError` / submit results for gating checkout. Kotlin/Java samples: [Migration from legacy](migration/from-legacy.md#hostedfieldstate--kotlin-samples-compose). + For initial SDK setup, see [getting-started.md](getting-started.md). ## Setup @@ -109,31 +111,40 @@ For styling guidance, see [theme-and-styling.md](theme-and-styling.md). ## SPL Text Fields -Use `SPLTextField` (from the `:hostedfields` module) with `FormFieldType` (from `com.spreedly.sdk.models`) for all sensitive card data. Each field requires a `formFieldType` and an `onChange` callback. +Use `SPLTextField` (from the `:hostedfields` module) with `FormFieldType` (from `com.spreedly.sdk.models`) for all sensitive card data. Each field requires a `formFieldType` and an `onChange` callback. **CARD** and **CVV** fields also require the same `sdk: Spreedly` instance used for `setNumberFormat` / `toggleMask` (display follows `sdk.hostedCardDisplayState`). ### Single Expiry Field ```kotlin -SPLTextField( - formFieldType = FormFieldType.EXPIRY_DATE(), - label = "Expiry Date (MM/YY)", - value = expiryValue, - onChange = { expiryValue = it }, -) +@Composable +fun CardFieldsSection(sdk: Spreedly) { + var expiryValue by remember { mutableStateOf("") } + var cardValue by remember { mutableStateOf("") } + var cvvValue by remember { mutableStateOf("") } -SPLTextField( - formFieldType = FormFieldType.CARD(true), - label = "Card Number", - value = cardValue, - onChange = { cardValue = it }, -) + SPLTextField( + formFieldType = FormFieldType.EXPIRY_DATE(), + label = "Expiry Date (MM/YY)", + value = expiryValue, + onChange = { expiryValue = it }, + ) -SPLTextField( - formFieldType = FormFieldType.CVV(true), - label = "CVV", - value = cvvValue, - onChange = { cvvValue = it }, -) + SPLTextField( + formFieldType = FormFieldType.CARD(true), + sdk = sdk, + label = "Card Number", + value = cardValue, + onChange = { cardValue = it }, + ) + + SPLTextField( + formFieldType = FormFieldType.CVV(true), + sdk = sdk, + label = "CVV", + value = cvvValue, + onChange = { cvvValue = it }, + ) +} ``` ### Separate Month and Year Fields diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index be25c29..b1e005c 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -127,7 +127,7 @@ private fun handleApiError(error: PaymentResult.Failed) { ## Common Error Scenarios -### 1. Account Inactive Error (402 Payment Required) +### 1. Account Inactive Error (422 Unprocessable Entity) **What it means:** You're trying to use real card numbers with a test gateway. diff --git a/docs/guides/express-checkout.md b/docs/guides/express-checkout.md index 579292c..a680fa0 100644 --- a/docs/guides/express-checkout.md +++ b/docs/guides/express-checkout.md @@ -347,6 +347,7 @@ The bottom sheet auto-dismisses on `Completed`, `Canceled`, and API/network `Fai | `sdk` | `Spreedly` | *(required)* | SDK instance | | `modifier` | `Modifier` | `Modifier` | Compose modifier | | `config` | `PaymentSheetConfig?` | `null` | Color and styling configuration. Falls back to global theme if null | +| `displayConfig` | `PaymentSheetDisplayConfig?` | `null` | Express display (`enableAutofill`, initial `cardNumberFormat`). `null` reads legacy fields from the resolved `PaymentSheetConfig` (same as 1.1.0). Non-null uses that object as-is (no field-level merge). iOS: `CardFormDropInDisplayConfig` | | `borderRadius` | `Dp` | `8.dp` | Corner radius for form elements | | `fieldShape` | `Shape` | `RoundedCornerShape(8.dp)` | Shape for input fields | | `nameFieldDisplayMode` | `NameFieldDisplayMode` | `SINGLE_FIELD` | How cardholder name is displayed | @@ -358,6 +359,39 @@ The bottom sheet auto-dismisses on `Completed`, `Canceled`, and API/network `Fai | `showSavePaymentCheckbox` | `Boolean` | `true` | Show "Save card" checkbox | | `savePaymentCheckboxLabel` | `String` | `"Save payment information for future use"` | Checkbox label text | | `savePaymentCheckboxDefaultChecked` | `Boolean` | `false` | Whether the checkbox starts checked | +| `coreFieldLabels` | `PaymentSheetCoreFieldLabels?` | `null` | Optional core card field label and placeholder overrides (iOS `DropInCoreFieldLabels` parity). `null` keeps SDK defaults | + +### Core field copy (`PaymentSheetCoreFieldLabels`) + +Override labels and placeholders for card number, CVV, and expiration fields only. Name and address labels use existing `additionalFields` / name-mode behavior and are not part of this config. + +```kotlin +import com.spreedly.sdk.ui.PaymentSheetCoreFieldLabels + +SpreedlyBottomSheet( + sdk = sdk, + coreFieldLabels = PaymentSheetCoreFieldLabels( + cardNumberTitle = "Card number", + cardNumberPlaceholder = "1234 5678 9012 3456", + cvcTitle = "CVC", + expirationDateTitle = "Expiry", + expirationDatePlaceholder = "MM/YY", + ), +) +``` + +Java: + +```java +PaymentSheetJavaHelper.setupContent( + composeView, + sdk, + PaymentSheetJavaHelper.createDefaultCoreFieldLabels(), // or your overrides + PaymentSheetJavaHelper.createDefaultDisplayConfig() +); +``` + +Blank or null properties fall back to SDK defaults. For CVV, leave `cvcPlaceholder` unset to keep optional-CVV hint behavior when applicable. ### NameFieldDisplayMode @@ -442,6 +476,13 @@ SpreedlyBottomSheet(sdk = sdk) | `textColor` | Input text content | | `disabledTextColor` | Text when fields are disabled | | `iconColor` | Icons and trailing elements | +| `placeholderColor` | Hint / placeholder tint in input fields | +| `enableAutofill` | *(deprecated)* — prefer **`PaymentSheetDisplayConfig`** on **`SpreedlyBottomSheet`** / **`PaymentSheet`**. When `displayConfig` is `null`, this property still applies (1.1.0 path) | +| `cardNumberFormat` | *(deprecated)* — same migration as `enableAutofill` | + +**Express display (preferred):** pass **`displayConfig = PaymentSheetDisplayConfig(...)`** on **`SpreedlyBottomSheet`** (or **`PaymentSheet`**). **`SpreedlyTheme.toPaymentSheetConfig()`** maps colors only; it does not set express autofill or initial card format — use **`displayConfig`** or legacy **`PaymentSheetConfig`** fields when **`displayConfig`** is null. + +**Mask / reveal outside the sheet:** `SpreedlyBottomSheet` does not include a mask control. To match the iframe, add a Switch or button **outside** the sheet and call **`sdk.setNumberFormat(CardNumberFormat.PLAIN)`** / **`PRETTY`** or **`sdk.toggleMask()`** while the sheet is open. CARD and CVV inside the sheet already observe **`sdk.hostedCardDisplayState`**. Opening a fresh sheet or **`resetPaymentState()`** resets display state to defaults (`PRETTY`, masked PAN/CVV). Re-apply **`setNumberFormat`** / **`toggleMask`** after reset or successful tokenization if you use a non-default mask. For full design system documentation, see [Theme & Styling](theme-and-styling.md). @@ -602,6 +643,10 @@ For detailed error handling patterns, see [Error Handling](error-handling.md). - `config` parameter overrides the global theme. If you pass `PaymentSheetConfig()` (all defaults / `Color.Unspecified`), the global theme fills in. If you pass explicit colors, those take precedence. - `PaymentSheetConfig.fromTheme()` must be called inside a `@Composable` function to access `MaterialTheme` colors. +### Express autofill / card format not updating + +- **`sdk.setConfig(PaymentSheetConfig(...))`** updates colors and validation wiring used by the sheet, but express **field autofill** and **initial card number format** are driven by the composable **`displayConfig`** / legacy **`PaymentSheetConfig`** fields resolved when **`SpreedlyBottomSheet`** / **`PaymentSheet`** run — not by `setConfig` alone. Pass **`displayConfig`** on the composable (or keep using deprecated **`PaymentSheetConfig.enableAutofill`** / **`cardNumberFormat`** with **`displayConfig = null`**). + ### Form resets unexpectedly - The bottom sheet clears form state each time it opens, unless `sdk.shouldPreserveState()` is configured. This is by design for fresh payment sessions. diff --git a/docs/guides/focus-management.md b/docs/guides/focus-management.md index b366013..016ffda 100644 --- a/docs/guides/focus-management.md +++ b/docs/guides/focus-management.md @@ -6,6 +6,10 @@ The Spreedly Android SDK provides programmatic focus control for text fields thr The parameter defaults to `false`, so existing integrations require no changes. +**CARD and CVV fields** require the same `sdk: Spreedly` instance passed to `SPLTextField` (hosted display follows `sdk.hostedCardDisplayState`). Examples below include `sdk = sdk` on CARD/CVV fields. + +**Focus vs blur:** `SPLTextField` exposes `onFocus` when the field **gains** focus, and `onFocusChanged(Boolean)?` when focus **enters or leaves** the field (blur is `false`). Prefer `onFocusChanged` if you need blur-aware UI (e.g. hiding ancillary widgets). Internal `SpreedlyEvent.HostedFieldInteraction` telemetry is **not** a supported merchant API unless product documents otherwise. + Key capabilities: - **Programmatic focus control** from parent components or external frameworks @@ -30,10 +34,11 @@ Key capabilities: ```kotlin import androidx.compose.runtime.* import com.spreedly.hostedfields.ui.SPLTextField +import com.spreedly.sdk.Spreedly import com.spreedly.sdk.models.FormFieldType @Composable -fun PaymentForm() { +fun PaymentForm(sdk: Spreedly) { var cardNumber by remember { mutableStateOf("") } var cvv by remember { mutableStateOf("") } var focusedField by remember { mutableStateOf(null) } @@ -41,6 +46,7 @@ fun PaymentForm() { Column { SPLTextField( formFieldType = FormFieldType.CARD(), + sdk = sdk, label = "Card Number", value = cardNumber, onChange = { cardNumber = it }, @@ -49,6 +55,7 @@ fun PaymentForm() { SPLTextField( formFieldType = FormFieldType.CVV(), + sdk = sdk, label = "CVV", value = cvv, onChange = { cvv = it }, @@ -70,7 +77,7 @@ enum class PaymentFieldType { } @Composable -fun PaymentFormWithAutoFocus() { +fun PaymentFormWithAutoFocus(sdk: Spreedly) { var fullName by remember { mutableStateOf("") } var cardNumber by remember { mutableStateOf("") } var expiryMonth by remember { mutableStateOf("") } @@ -115,6 +122,7 @@ fun PaymentFormWithAutoFocus() { SPLTextField( formFieldType = FormFieldType.CARD(), + sdk = sdk, label = "Card Number", value = cardNumber, onChange = { cardNumber = it }, @@ -137,6 +145,7 @@ fun PaymentFormWithAutoFocus() { SPLTextField( formFieldType = FormFieldType.CVV(), + sdk = sdk, label = "CVV", value = cvv, onChange = { cvv = it }, @@ -161,12 +170,14 @@ fun focusField(fieldName: String) { @Composable fun ReactNativePaymentForm( + sdk: Spreedly, focusedField: State ) { var cardNumber by remember { mutableStateOf("") } SPLTextField( formFieldType = FormFieldType.CARD(), + sdk = sdk, label = "Card Number", value = cardNumber, onChange = { cardNumber = it }, @@ -179,7 +190,7 @@ fun ReactNativePaymentForm( ```kotlin @Composable -fun SmartPaymentForm() { +fun SmartPaymentForm(sdk: Spreedly) { var cardNumber by remember { mutableStateOf("") } var cvv by remember { mutableStateOf("") } @@ -199,6 +210,7 @@ fun SmartPaymentForm() { Column { SPLTextField( formFieldType = FormFieldType.CARD(), + sdk = sdk, label = "Card Number", value = cardNumber, onChange = { cardNumber = it }, @@ -208,6 +220,7 @@ fun SmartPaymentForm() { SPLTextField( formFieldType = FormFieldType.CVV(), + sdk = sdk, label = "CVV", value = cvv, onChange = { cvv = it }, @@ -247,15 +260,21 @@ fun SPLTextField( onValidationChange: ((Boolean) -> Unit)? = null, shouldFocus: Boolean = false, onFocus: (() -> Unit)? = null, + onFocusChanged: ((Boolean) -> Unit)? = null, + onFieldStateChange: ((com.spreedly.hostedfields.models.HostedFieldState) -> Unit)? = null, + sdk: Spreedly? = null, ) ``` -`formFieldType` and `onChange` are required. `FormFieldType` is located at `com.spreedly.sdk.models.FormFieldType`. +`formFieldType` and `onChange` are required. **`sdk` is required for CARD and CVV** fields (optional for other types). `FormFieldType` is located at `com.spreedly.sdk.models.FormFieldType`. | Parameter | Description | |---|---| +| `sdk` | Required for **CARD** and **CVV** — display follows `sdk.hostedCardDisplayState`. Optional for other field types. | | `shouldFocus` | When `true`, the field programmatically requests focus. Useful for external focus control from React Native or dynamic focus management. | | `onFocus` | Callback invoked when the field gains focus (e.g., user taps the field). Use this to track which field is currently active. | +| `onFocusChanged` | Optional callback when focus **enters or leaves** the field (`true` = focused, `false` = blurred). Prefer this over `onFocus` when you need blur-aware UI. Same **`1.1.0`** minor as `onFieldStateChange` / `HostedFieldState`. | +| `onFieldStateChange` | Optional **`HostedFieldState`** stream (`INPUT`, `FOCUS`, `BLUR`, `VALIDATION`, `PAN_MASK_CHANGED`) with `cardScheme`, digit counts, **`iin`** (IIN prefix only), `isPanMasked`, and `isValid` — merchant-safe; **no** raw PAN/CVV. | ### AppTextField @@ -269,11 +288,12 @@ fun AppTextField( modifier: Modifier = Modifier, // ... other parameters ... shouldFocus: Boolean = false, + onFocusChanged: ((Boolean) -> Unit)? = null, onFocus: (() -> Unit)? = null, ) ``` -`shouldFocus` and `onFocus` behave the same as on `SPLTextField`. `SPLTextField` passes these through to `AppTextField` internally. +`shouldFocus`, `onFocusChanged`, and `onFocus` behave the same as on `SPLTextField`. `SPLTextField` passes these through to `AppTextField` internally. ### Focus Tracking with onFocus @@ -281,7 +301,7 @@ The `onFocus` callback lets you track when users tap into fields: ```kotlin @Composable -fun PaymentFormWithTracking() { +fun PaymentFormWithTracking(sdk: Spreedly) { var currentField by remember { mutableStateOf(null) } var cardNumber by remember { mutableStateOf("") } var cvv by remember { mutableStateOf("") } @@ -289,6 +309,7 @@ fun PaymentFormWithTracking() { Column { SPLTextField( formFieldType = FormFieldType.CARD(true), + sdk = sdk, label = "Card Number", value = cardNumber, onChange = { cardNumber = it }, @@ -299,6 +320,7 @@ fun PaymentFormWithTracking() { SPLTextField( formFieldType = FormFieldType.CVV(true), + sdk = sdk, label = "CVV", value = cvv, onChange = { cvv = it }, @@ -319,13 +341,14 @@ Use both parameters for complete focus control — programmatic focus plus user- ```kotlin @Composable -fun SmartFocusForm() { +fun SmartFocusForm(sdk: Spreedly) { var focusedField by remember { mutableStateOf(FormFieldType.CARD(true)) } SPLTextField( formFieldType = FormFieldType.CARD(true), + sdk = sdk, onChange = { }, label = "Card Number", shouldFocus = focusedField == FormFieldType.CARD(true), @@ -337,6 +360,7 @@ fun SmartFocusForm() { SPLTextField( formFieldType = FormFieldType.CVV(true), + sdk = sdk, onChange = { }, label = "CVV", shouldFocus = focusedField == FormFieldType.CVV(true), @@ -356,6 +380,7 @@ var focusedField by remember { mutableStateOf(null) } SPLTextField( formFieldType = FormFieldType.CARD(), + sdk = sdk, onChange = { }, shouldFocus = focusedField == "thisField" ) @@ -371,11 +396,13 @@ val focusedField = remember { mutableStateOf("cardNumber") } SPLTextField( formFieldType = FormFieldType.CARD(), + sdk = sdk, onChange = { }, shouldFocus = focusedField.value == "cardNumber" ) SPLTextField( formFieldType = FormFieldType.CVV(), + sdk = sdk, onChange = { }, shouldFocus = focusedField.value == "cvv" ) @@ -388,6 +415,7 @@ Use `onValidationChange` to auto-advance focus when a field becomes valid: ```kotlin SPLTextField( formFieldType = FormFieldType.CARD(), + sdk = sdk, value = cardNumber, onChange = { cardNumber = it }, onValidationChange = { valid -> @@ -420,6 +448,7 @@ fun submitForm() { ```kotlin SPLTextField( formFieldType = FormFieldType.CARD(), + sdk = sdk, value = cardNumber, onChange = { cardNumber = it }, imeAction = ImeAction.Next, @@ -435,6 +464,7 @@ Focus control is independent of theme configuration. See the [Theme and Styling] ```kotlin SPLTextField( formFieldType = FormFieldType.CARD(), + sdk = sdk, onChange = { }, config = CustomFieldsConfig( primaryColor = Color.Blue, @@ -485,6 +515,7 @@ var shouldFocus by remember { mutableStateOf(false) } SPLTextField( formFieldType = FormFieldType.CARD(), + sdk = sdk, onChange = { }, shouldFocus = shouldFocus ) diff --git a/docs/guides/migration/from-legacy.md b/docs/guides/migration/from-legacy.md index 41fb592..e7e5490 100644 --- a/docs/guides/migration/from-legacy.md +++ b/docs/guides/migration/from-legacy.md @@ -38,12 +38,12 @@ See the [README compatibility table](../../../README.md#compatibility) for minim | Area | Old SDK / WebView | Checkout SDK | |------|-------------------|--------------| -| **Language** | Java (old SDK) / JavaScript (WebView) | Kotlin (Java helpers available for every module) | +| **Language** | Java (old SDK) / JavaScript (WebView) | Kotlin (Java helpers for most modules; `stripe-radar` uses `SpreedlyStripeRadar`) | | **UI** | XML Views (`SecureForm`, `SecureCreditCardField`) / WebView | Jetpack Compose (`SPLTextField`, `SpreedlyBottomSheet`) | | **Async model** | RxJava `Single` / JS callbacks | Coroutines (`suspend fun`) + `SharedFlow` | | **Authentication** | API secret stored on-device: `SpreedlyClient.newInstance("key", "secret", true)` | Server-generated signed params per session (nonce, signature, certificateToken, timestamp) -- no secret on device | | **PCI scope** | Merchant code constructs `CreditCardInfo` with raw card data / WebView handles it in JS | Sensitive data flows exclusively through SDK secure components (`SPLTextField` / `sdk.callbacks`) -- never in merchant code | -| **Dependencies** | `com.spreedly:client` / `express` / `securewidgets` | Multi-module: `checkout-payments-core` / `checkout-hostedfields` / `checkout-paymentsheet` + optional `checkout-threeds`, `checkout-braintree-apm`, `checkout-stripe-apm` | +| **Dependencies** | `com.spreedly:client` / `express` / `securewidgets` | Multi-module: `checkout-payments-core` / `checkout-hostedfields` / `checkout-paymentsheet` + optional `checkout-threeds`, `checkout-braintree-apm`, `checkout-stripe-apm`, `checkout-stripe-radar` | | **Result delivery** | `onActivityResult` with `EXTRA_PAYMENT_METHOD_TOKEN` / JS bridge | `sdk.paymentResultFlow: SharedFlow` | --- @@ -154,7 +154,11 @@ dependencyResolutionManagement { password = "your_github_personal_access_token" // needs read:packages scope } } - maven { url = uri("https://mobile-sdks.forter.com/android") } + // When using checkout-threeds (Global or Gateway-Specific 3DS): + // maven { + // url = uri("https://mobile-sdks.forter.com/android") + // credentials { ... } // see ../3ds-global.md#step-1-add-maven-repository + // } google() mavenCentral() } @@ -164,20 +168,23 @@ dependencyResolutionManagement { ```kotlin // app/build.gradle.kts dependencies { - // Core (required) + // Typical card UI (payments-core is always required) implementation("com.spreedly:checkout-payments-core:$spreedlyVersion") - implementation("com.spreedly:checkout-hostedfields:$spreedlyVersion") - implementation("com.spreedly:checkout-paymentsheet:$spreedlyVersion") + implementation("com.spreedly:checkout-hostedfields:$spreedlyVersion") // SPLTextField / custom forms + implementation("com.spreedly:checkout-paymentsheet:$spreedlyVersion") // pre-built bottom sheet - // 3DS (add if you need 3D Secure authentication) - implementation("com.spreedly:checkout-threeds:$spreedlyVersion") + // 3DS — uncomment when using Global or Gateway-Specific 3DS + // implementation("com.spreedly:checkout-threeds:$spreedlyVersion") - // Alternative Payment Methods (add as needed) - implementation("com.spreedly:checkout-braintree-apm:$spreedlyVersion") // PayPal, Venmo - implementation("com.spreedly:checkout-stripe-apm:$spreedlyVersion") // iDEAL, Bancontact, EPS, P24, SEPA + // Optional — add only the flows you use + // implementation("com.spreedly:checkout-braintree-apm:$spreedlyVersion") // PayPal, Venmo + // implementation("com.spreedly:checkout-stripe-apm:$spreedlyVersion") // iDEAL, Bancontact, EPS, P24, SEPA + // implementation("com.spreedly:checkout-stripe-radar:$spreedlyVersion") // Stripe Radar fraud sessions } ``` +See the [README installation notes](../../../README.md#installation-notes) for when each module and repository is required. + ### ProGuard / R8 If you use custom ProGuard rules, add: @@ -293,8 +300,8 @@ Build your own payment form layout using secure `SPLTextField` composables for s | Old SDK (SecureForm / XML) | New SDK (Compose) | |---|---| -| `SecureCreditCardField` (`@id/spreedly_credit_card_number`) | `SPLTextField(formFieldType = FormFieldType.CARD(true), onChange = { ... })` | -| `SecureTextField` (`@id/spreedly_cvv`) | `SPLTextField(formFieldType = FormFieldType.CVV(true), onChange = { ... })` | +| `SecureCreditCardField` (`@id/spreedly_credit_card_number`) | `SPLTextField(formFieldType = FormFieldType.CARD(true), sdk = sdk, onChange = { ... })` | +| `SecureTextField` (`@id/spreedly_cvv`) | `SPLTextField(formFieldType = FormFieldType.CVV(true), sdk = sdk, onChange = { ... })` | | `SecureExpirationDate` (`@id/spreedly_cc_expiration_date`) | `SPLTextField(formFieldType = FormFieldType.EXPIRY_DATE(true), onChange = { ... })` or separate `MONTH()` / `YEAR()` fields | | `TextInputLayout` (`@id/spreedly_full_name`) | Standard Compose `TextField` + `sdk.callbacks.onNameOnCardChange(value, isRequired = true)` | | `TextInputLayout` (`@id/spreedly_first_name`) | Standard Compose `TextField` + `sdk.callbacks.onFirstNameChange(value, isRequired = true)` | @@ -304,7 +311,7 @@ Build your own payment form layout using secure `SPLTextField` composables for s | WebView (Hosted Fields JS) | New SDK (Compose) | |---|---| -| `
` + `inAppElements()` | `SPLTextField(formFieldType = FormFieldType.CARD(true), onChange = { ... })` | +| `
` + `inAppElements()` | `SPLTextField(formFieldType = FormFieldType.CARD(true), sdk = sdk, onChange = { ... })` | | JS field change callbacks | `SPLTextField`'s `onChange: (String) -> Unit` lambda | #### Kotlin example @@ -320,6 +327,7 @@ fun CustomPaymentForm(sdk: Spreedly) { Column { SPLTextField( formFieldType = FormFieldType.CARD(true), + sdk = sdk, label = "Card Number", value = cardValue, onChange = { cardValue = it }, @@ -332,6 +340,7 @@ fun CustomPaymentForm(sdk: Spreedly) { ) SPLTextField( formFieldType = FormFieldType.CVV(true), + sdk = sdk, label = "CVV", value = cvvValue, onChange = { cvvValue = it }, @@ -525,7 +534,7 @@ See the [Error Handling guide](../error-handling.md) for the full list of `Spree ## Java interop -The old SDK was Java. The new SDK is Kotlin-first with Jetpack Compose, but every module provides a Java helper class: +The old SDK was Java. The new SDK is Kotlin-first with Jetpack Compose; most payment modules provide a Java helper class (`stripe-radar` uses the `SpreedlyStripeRadar` Kotlin API instead): | Module | Java Helper | Purpose | |--------|-------------|---------| @@ -551,13 +560,16 @@ The Checkout SDK includes features not available in the old SDK or WebView appro | 3D Secure (Forter global) | [3DS Global](../3ds-global.md) | | 3D Secure (gateway-specific) | [3DS Gateway-Specific](../3ds-gateway-specific.md) | | Offsite payments (PayPal, Pix, Boleto, OXXO, NuPay) | [Offsite Payments](../offsite-payments.md) | -| Stripe APM (iDEAL, Bancontact, EPS, P24, SEPA) | [Stripe APM](../stripe-apm.md) | +| Stripe APM (iDEAL, Bancontact, EPS, P24, SEPA) and PaymentSheet appearance | [Stripe APM](../stripe-apm.md) | | Braintree APM (PayPal, Venmo) | [Braintree APM](../braintree-apm.md) | | CVV recaching for saved cards | [Recaching](../recaching.md) | +| Stripe Radar fraud sessions | [Stripe Radar](../stripe-radar.md) | | Theming and dark mode | [Theme and Styling](../theme-and-styling.md) | | Screen capture protection | [Security](../security.md) | | Built-in telemetry (Datadog) | [Datadog Integration](../../development/DATADOG_INTEGRATION.md) | +For gateway-specific 3DS timing, Braintree behavior, and `/complete` handling, see [3DS Gateway-Specific](../3ds-gateway-specific.md). + --- ## Concept mapping reference @@ -580,8 +592,8 @@ Quick-reference table for translating between old and new APIs. | `EXTRA_PAYMENT_METHOD_TOKEN` | `PaymentResult.Completed.token` | Via `paymentResultFlow` | | `PaymentOptions` | `SpreedlyBottomSheet` parameters + `PaymentSheetConfig` | | | `SecureForm` | `SPLTextField` composables + `sdk.callbacks` | Or `HostedFieldsJavaHelper` | -| `SecureCreditCardField` | `SPLTextField(formFieldType = FormFieldType.CARD(true))` | | -| `SecureTextField` (CVV) | `SPLTextField(formFieldType = FormFieldType.CVV(true))` | | +| `SecureCreditCardField` | `SPLTextField(formFieldType = FormFieldType.CARD(true), sdk = sdk)` | | +| `SecureTextField` (CVV) | `SPLTextField(formFieldType = FormFieldType.CVV(true), sdk = sdk)` | | | `SecureExpirationDate` | `SPLTextField(formFieldType = FormFieldType.EXPIRY_DATE(true))` | Or separate `MONTH()` / `YEAR()` | | `createCreditCardPaymentMethod()` (RxJava) | `sdk.createCreditCard(formFields, ...)` (suspend) | Returns `PaymentProcessingResult`; final result on `paymentResultFlow` | | `createBankAccountPaymentMethod()` (RxJava) | `sdk.createBankAccount(formFields, ...)` (suspend) | Returns `PaymentProcessingResult`; final result on `paymentResultFlow` | @@ -602,6 +614,319 @@ Quick-reference table for translating between old and new APIs. --- +## Public API inventory (merchant-facing) + +Complete checklist of SDK APIs available to merchants. Use this to confirm you've wired everything your integration needs. + +### SDK-level (`Spreedly` instance) + +| API | Purpose | +|-----|---------| +| `sdk.init(SpreedlySDKInitOptions(…))` | Configure signed auth for the session | +| `sdk.isInitialized` | `true` after `init` succeeds — not proof of credentials alone | +| `sdk.expressCheckout()` | Show the pre-built payment bottom sheet | +| `sdk.createCreditCard(formFields, metadata, additionalFields)` | Tokenize card (suspend) — final result on `paymentResultFlow` | +| `sdk.createBankAccount(formFields, metadata, additionalFields)` | Tokenize bank account (suspend) | +| `sdk.createPaymentMethod(formFields, metadata, additionalFields)` | Generic tokenize (suspend) | +| `sdk.recachePaymentMethod(token, config)` | CVV recache — result on `recacheResultFlow` and suspend return | +| `sdk.paymentResultFlow` | `SharedFlow` — tokenization, APM, offsite results | +| `sdk.recacheResultFlow` | `SharedFlow>` — recache only (not mirrored on `paymentResultFlow`) | +| `sdk.threeDSChallengeResultFlow` | `SharedFlow` — 3DS challenge outcomes | +| `sdk.hasValidationError(FormFieldType)` | Per-field pre-submit check | +| `sdk.areAllFieldsValid(List)` | Aggregate validation gate (`@MainThread`) | +| `sdk.getRegisteredFieldCount()` | Count of mounted `SPLTextField` instances — use for pay-button gating (1.1.0+) | +| `sdk.setNumberFormat(CardNumberFormat)` | Set PAN layout: `PRETTY` / `PLAIN` / `MASKED` — coupled CVV | +| `sdk.setNumberFormat(type: String)` | Iframe-style string aliases: `"prettyFormat"` / `"plainFormat"` / `"maskedFormat"` | +| `sdk.toggleMask()` | Toggle plain ↔ masked (PAN + CVV coupled; main thread) | +| `sdk.hostedCardDisplayState` | Read-only `State` — observe `cardNumberFormat`, `panMasked`, `cvvDisplayMasked` | +| `sdk.resetPaymentState()` | Full reset: form values, validation, scheme context, and `hostedCardDisplayState` defaults | +| `sdk.resetPaymentFormPreservingDisplayConfig()` | Form-only reset: clears values + validation but keeps mask/format (called automatically on `createCreditCard` success) | +| `sdk.preservePaymentStateOnNextShow()` | Skip the next bottom-sheet form reset on open (use for config change / rotation) | +| `sdk.shouldPreserveState()` | Consume one-shot preserve flag (advanced; sheet uses internally) | +| `sdk.setParam(ValidationParameter, Boolean)` | Set validation behavior flags (see table below) | +| `sdk.callbacks` | Field change handlers for custom forms: `onNameOnCardChange`, `onFirstNameChange`, `onLastNameChange`, etc. | + +#### `ValidationParameter` values + +| Parameter | Default | Effect | +|-----------|---------|--------| +| `ALLOW_BLANK_NAME` | `false` | Skip name field validation | +| `ALLOW_BLANK_DATE` | `false` | Skip expiry date validation | +| `ALLOW_EXPIRED_DATE` | `false` | Accept past expiry dates | +| `ALLOW_INTERNATIONAL_ZIP_CODES` | `true` | `true` = international postal codes; `false` = US numeric ZIP only | + +### Headless `SPLTextField` (Compose) + +| Parameter / Callback | Purpose | +|----------------------|---------| +| `formFieldType` | Which field: `CARD(required)`, `CVV(required)`, `EXPIRY_DATE(required)`, `MONTH()`, `YEAR()`, etc. | +| `sdk` | **Required** for `CARD` and `CVV` — display follows `sdk.hostedCardDisplayState`. Optional for other field types. | +| `onChange` | Value updates (AES-encrypted for CARD/CVV/ACCOUNT_NUMBER; raw text for others) | +| `onFieldStateChange` | `HostedFieldState` snapshots — scheme, `iin`, digit lengths, validity, focus, mask state (1.1.0+) | +| `onValidationChange` | Per-field `Boolean` validity | +| `onFocus` | Field gained focus | +| `onFocusChanged` | Focus enter (`true`) / blur (`false`) (1.1.0+) | +| `shouldFocus` | Programmatic focus request | +| `trailingIcon` | Custom composable for CARD brand artwork (e.g., card logo drawable) | +| `forceMaskOnLifecycleStop` | Lifecycle mask overlay on `ON_STOP` (default `true`) | +| `enableAutofill` | OS autofill hints (default `true`; set `false` for legacy `toggleAutoComplete` off) | +| `imeAction` / `onImeAction` | IME "Next" / "Done" and keyboard submit | +| `config: CustomFieldsConfig` | Per-field visual overrides (colors, borders) | + +#### `HostedFieldState` properties (on `onFieldStateChange`) + +| Property | Type | Notes | +|----------|------|-------| +| `fieldType` | `FormFieldType` | Which field emitted | +| `eventType` | `HostedFieldEventType` | `INPUT`, `FOCUS`, `BLUR`, `VALIDATION`, `PAN_MASK_CHANGED` | +| `isFocused` | `Boolean` | | +| `isValid` | `Boolean` | Current validation (includes combined expiry) | +| `isEmpty` | `Boolean` | | +| `cardScheme` | `CardScheme?` | Detected brand (CARD fields) | +| `iin` | `String?` | 6–8 digit issuer prefix (CARD; `null` when <6 digits; omitted from `toString()`) | +| `numberLength` | `Int?` | Digit count for CARD | +| `cvvLength` | `Int?` | Digit count for CVV | +| `isPanMasked` | `Boolean` | Whether PAN is visually masked | +| `panDisplayFormat` | `CardNumberFormat?` | Snapshot of global format at emission (CARD only) | +| `panDisplayPolicyMasked` | `Boolean?` | Snapshot of global `panMasked` policy (CARD only) | + +### Express Checkout (`SpreedlyBottomSheet` / `PaymentSheet`) + +| Parameter | Purpose | +|-----------|---------| +| `sdk` | SDK instance (required) | +| `nameFieldDisplayMode` | `SINGLE_FIELD` / `SEPARATE_FIELDS` | +| `yearFormat` | Expiry year: 2-digit or 4-digit | +| `additionalFields` | Optional address fields (`ConfigurableFormField` list) | +| `showSavePaymentCheckbox` / `savePaymentCheckboxLabel` | Save card opt-in UI | +| `allowBlankName` / `allowBlankDate` / `allowExpiredDate` | Validation relaxation | +| `config: PaymentSheetConfig` | Colors and legacy display (deprecated for `displayConfig`) | +| `displayConfig: PaymentSheetDisplayConfig` | `enableAutofill`, `cardNumberFormat` (seeds sheet on open) | +| `coreFieldLabels: PaymentSheetCoreFieldLabels?` | Core card field labels/placeholders (iOS `DropInCoreFieldLabels` parity) | + +**Not available on Express** (use headless `SPLTextField` if needed): +- `onFieldStateChange` per-field callbacks +- Custom `trailingIcon` on CARD +- Per-field `onFocusChanged` +- Granular `onInputLength` / digit counts during typing + +### `EmailValidator` + +| API | Purpose | +|-----|---------| +| `EmailValidator.isValid(String)` | JVM-safe email format check (not auto-wired into fields) | + +--- + +## Legacy iFrame (JavaScript) 1:1 mapping + +If you integrated **Spreedly iFrame v1** (`iframe-v1.min.js`) inside a WebView or a mobile browser, use this table to find the Checkout **Android** pattern. Official legacy references: [iFrame UI](https://developer.spreedly.com/docs/iframe-ui), [iFrame events](https://developer.spreedly.com/docs/iframe-events). + +**Status** in the third column: + +- **Parity** — Supported with an Android-first API or pattern. +- **Platform** — Same outcome via Android/Compose idioms (not a JS-shaped API). +- **Gap** — No equivalent today; read **Notes** for what to do instead. +- **N/A** — Web-only or not meaningful on Android. + +### Card-brand display during typing + +Legacy `fieldEvent` / `inputProperties.cardType` let merchants swap **icons** and drive BIN UX while typing. + +**Android today** + +- Use **`SPLTextField.onFieldStateChange`** with **`com.spreedly.hostedfields.models.HostedFieldState`** — `cardScheme` is populated for `FormFieldType.CARD` on **`INPUT`**, **`VALIDATION`**, and focus-related events (typed `CardScheme`; map to your drawable assets). +- **`HostedFieldState.iin`** — merchant-safe issuer prefix (6 or 8 digits per scheme; `null` when fewer than six digits on the card field). Use for BIN-level UX while typing; it is omitted from `HostedFieldState.toString()` for logging safety. +- The SDK **does not** ship drawable assets for brands. By default, `CardScheme.name` is shown as **Text** in the trailing slot when the scheme is known. To replace that with an icon, pass **`trailingIcon = { scheme -> … }`** on **`SPLTextField`** (CARD fields only; `null` keeps the default text). +- `CardNumberContext` exists for **internal** CVV-length coupling and is **not** a supported merchant subscription API (`payments-core/.../CardNumberContext.kt`). + +### Safe field-state observability + +| Signal | Merchant-visible? | Notes | +|--------|--------------------|--------| +| `SPLTextField.onFieldStateChange(HostedFieldState)?` | Yes (**1.1.0+**) | **Preferred** iframe-style channel: `INPUT`, `FOCUS`, `BLUR`, `VALIDATION`, `PAN_MASK_CHANGED`; includes `cardScheme`, `numberLength` / `cvvLength` (**digit counts only**), `iin` (**issuer prefix**, 1.1.0+), `isValid`, `isEmpty`, `isFocused`, `isPanMasked`. | +| `SPLTextField.onChange` | Yes, but value is **AES-encrypted** only for `CARD`, `CVV`, and `ACCOUNT_NUMBER` (`FormFieldType.shouldEncrypt()`); other types pass **raw** text | Do **not** log ciphertext or treat it as display digits; use `onFieldStateChange` for lengths, not ciphertext parsing. | +| `onValidationChange(Boolean)?` | Yes | Per-field validity; also reflected in `HostedFieldState.isValid` on **`VALIDATION`** (and other events). | +| `onFocus` | Yes | Fires when the field **gains** focus. | +| `onFocusChanged(Boolean)?` | Yes (**1.1.0+**) | `true`/`false` for focus and blur (blur is `false`). | +| `hasValidationError(FormFieldType)` | Yes | Pre-submit validation helper on `Spreedly`. | +| `areAllFieldsValid(List)` | Yes | Aggregate helper: `true` when none of the listed fields report `hasValidationError`. Must run on the **main thread** (`@MainThread`); avoid calling every frame during recomposition because **CVV** checks decrypt ciphertext — prefer `onValidationChange` for live UI. | +| `com.spreedly.validation.EmailValidator` | Yes | JVM-safe `isValid(String)` for merchant-collected email before tokenize (not auto-wired into `SPLTextField`). | +| `PaymentProcessingResult.ValidationFailed` | Yes | Submit-time invalid field list. | +| `SpreedlyEvent.HostedFieldInteraction` (Datadog) | **Internal telemetry** | **Not** a supported merchant integration API unless product explicitly documents it. | + +### HostedFieldState — Kotlin samples (Compose) + +Merchant-owned **icons** (legacy iframe used `cardType` metadata; same pattern on Android): + +```kotlin +var cardBrandIcon by remember { mutableStateOf(null) } + +SPLTextField( + formFieldType = FormFieldType.CARD(true), + sdk = sdk, + value = cardNumber, + onChange = { cardNumber = it }, + onFieldStateChange = { state -> + if (state.fieldType is FormFieldType.CARD) { + cardBrandIcon = state.cardScheme + } + }, +) +// Map cardBrandIcon to painterResource(R.drawable.ic_visa) etc. — do not log state if it could +// ever include sensitive fields; HostedFieldState is PAN-safe by design. +``` + +Safe **length** gating (do not parse ciphertext from `onChange`): + +```kotlin +var digitCount by remember { mutableStateOf(0) } + +SPLTextField( + formFieldType = FormFieldType.CARD(true), + sdk = sdk, + value = cardNumber, + onChange = { cardNumber = it }, + onFieldStateChange = { state -> + state.numberLength?.let { digitCount = it } + }, +) +``` + +Pre-submit **block** using `HostedFieldState.isValid` (mirrors `onValidationChange`; use either or both): + +```kotlin +var hostedCardValid by remember { mutableStateOf(false) } + +SPLTextField( + formFieldType = FormFieldType.CARD(true), + sdk = sdk, + value = cardNumber, + onChange = { cardNumber = it }, + onFieldStateChange = { state -> + if (state.eventType == HostedFieldEventType.VALIDATION || + state.eventType == HostedFieldEventType.INPUT + ) { + hostedCardValid = state.isValid + } + }, +) +// Gate checkout: if (!hostedCardValid || sdk.hasValidationError(...)) return +``` + +**Java** — use non-null listener overloads so overload resolution is unambiguous (`setupContent(composeView, sdk, listener)` or `setupContent(composeView, sdk, config, listener)`): + +```java +HostedFieldsJavaHelper.setupContent(composeView, sdk, state -> { + CardScheme scheme = state.getCardScheme(); + Integer len = state.getNumberLength(); + // Drive UI from getters; do not log raw payment data. +}); +``` + +### External mask toggle (iframe / iOS pattern) + +The iframe has **no eye icon** inside the number or CVV iframes. Mask/reveal is **merchant-owned UI** outside the fields, calling **`Spreedly.setNumberFormat(...)`** or **`Spreedly.toggleMask()`** on the main thread. The Spreedly example app uses this pattern (not the in-field SDK toggle). + +> **PRETTY is not iframe `prettyFormat`.** On the web, `prettyFormat` keeps grouped digits visible when the field loses focus. On Android, `CardNumberFormat.PRETTY` applies **blur masking** (last-four style) when unfocused while `panMasked` is true. For digits that stay visible like iframe `prettyFormat`, use `CardNumberFormat.PLAIN`. For full bullets on every digit, use `MASKED` or `toggleMask()`. + +> **Multiple CARD fields:** One **`Spreedly.hostedCardDisplayState`** applies to the whole SDK instance. Every **CARD** and **CVV** `SPLTextField` that receives the same **`sdk`** instance shares that snapshot (mask/format), unlike separate iframes with per-embed state. + +**Internal:** `SpreedlyUIController.setHostedCardDisplayState` is SDK-internal and unused by merchant UIs — use **`Spreedly.setNumberFormat`** / **`toggleMask`** and collect **`hostedCardDisplayState`** instead. + +**Required wiring** — pass the same **`sdk`** on **CARD** and **CVV** fields (display follows **`sdk.hostedCardDisplayState`** automatically): + +```kotlin +SPLTextField( + formFieldType = FormFieldType.CARD(true), + sdk = sdk, + label = "Card Number", + value = cardNumber, + onChange = { cardNumber = it }, +) + +SPLTextField( + formFieldType = FormFieldType.CVV(true), + sdk = sdk, + label = "CVV", + value = cvv, + onChange = { cvv = it }, +) + +// Merchant UI: all three iframe formats (see sample app MerchantMaskToggleBar) +sdk.setNumberFormat(CardNumberFormat.PRETTY) // prettyFormat — grouped digits +sdk.setNumberFormat(CardNumberFormat.PLAIN) // plainFormat — reveal PAN + CVV +sdk.setNumberFormat(CardNumberFormat.MASKED) // maskedFormat — full * on every digit + +// iframe toggleMask() — coupled PAN + CVV; from PRETTY, first call → MASKED; second → PLAIN +Button(onClick = { sdk.toggleMask() }) { Text("toggleMask()") } +``` + +**Java `HostedFieldState`:** positional constructor includes **`isPanMasked`** as the **9th** parameter; handle **`PAN_MASK_CHANGED`** in `switch` statements. + +**`SPLTextField.forceMaskOnLifecycleStop`** (default `true`) — Android lifecycle overlay on `ON_STOP` when PAN was revealed; does not replace `toggleMask()` / controller state. See CHANGELOG. + +### Initialization and configuration + +| If you previously used… | Use this in Android SDK… | Notes | Status | +|---------------------------|---------------------------|-------|--------| +| `Spreedly.init(environmentKey, { nonce, signature, certificateToken, timestamp, numberEl, cvvEl, … })` | `Spreedly()` + `sdk.init(SpreedlySDKInitOptions(…))` | No DOM ids — add `SPLTextField` composables instead of iframe containers. | Platform | +| `Spreedly.on('ready', fn)` | Run field setup **after** `init` succeeds and your composable is active | There is no global `ready` callback; `sdk.isInitialized` reflects completion. | Platform | +| `Spreedly.setFieldType('number'\|'cvv', 'text'\|'tel'\|'number')` | Default keyboards from `SPLTextField` / `FormFieldType` | Card/CVV use digit-safe keyboards internally. | Platform | +| `Spreedly.setLabel(field, text)` | `label = "…"` on `SPLTextField` | Label drives visible text and accessibility on native fields. | Parity | +| `Spreedly.setTitle(field, text)` | Optional helper text / custom semantics | No iframe `title` attribute — use native patterns. | N/A — Android native | +| `Spreedly.setPlaceholder(field, text)` | Hint/placeholder via field config + `placeholderColor` in theme (`CustomFieldsConfig` / `SpreedlyColors`) | See [Theme and Styling](../theme-and-styling.md) and [CHANGELOG](../../CHANGELOG.md) for `placeholderColor`. | Parity | +| `Spreedly.setStyle(field, css)` / `setStyle('placeholder', css)` | `SpreedlyTheme` / `MaterialTheme` + `CustomFieldsConfig` | Map CSS to Compose tokens; placeholder color has first-class support. | Platform | +| `Spreedly.setNumberFormat('prettyFormat' \| 'plainFormat' \| 'maskedFormat')` | Prefer **`Spreedly.setNumberFormat(CardNumberFormat)`** (or the string overload above). **`PaymentSheetDisplayConfig.cardNumberFormat`** on **`SpreedlyBottomSheet`** / **`PaymentSheet`** (or deprecated **`PaymentSheetConfig.cardNumberFormat`** when **`displayConfig`** is null) seeds the controller when it is still default after the sheet opens. Headless **`SPLTextField`**: pass **`sdk`** on CARD/CVV — display follows **`sdk.hostedCardDisplayState`** | See **PRETTY vs iframe prettyFormat** above. | Partial | +| `Spreedly.toggleMask()` | **`Spreedly.toggleMask()`** on the main thread (coupled CARD + CVV when both fields use the same **`sdk`**). Prefer merchant UI outside fields (see **External mask toggle** above). From default **`PRETTY`**, first **`toggleMask()`** applies **`MASKED`**; second reveals **`PLAIN`**. | | Partial | +| `Spreedly.transferFocus(field)` | `shouldFocus = true` on the target `SPLTextField` | See [Focus management](../focus-management.md). | Parity | +| `Spreedly.toggleAutoComplete()` | **`PaymentSheetDisplayConfig(enableAutofill = false)`** on **`SpreedlyBottomSheet`** / **`PaymentSheet`** (or deprecated **`PaymentSheetConfig(enableAutofill = false)`** when **`displayConfig`** is null) / `SPLTextField(..., enableAutofill = false)` / `HostedFieldsJavaHelper.setupContent(..., enableAutofill = false)` | Default is on (`true`). Set `false` to suppress autofill hints on hosted fields. | N/A | +| `Spreedly.setRequiredAttribute(field)` | `FormFieldType.CARD(true)`, `CVV(true)`, etc. | `required` flags on `FormFieldType`. | Parity | +| `Spreedly.setParam(name, value)` | `sdk.setParam(ValidationParameter.…, value)` | Typed `ValidationParameter` enum: `ALLOW_BLANK_NAME`, `ALLOW_BLANK_DATE`, `ALLOW_EXPIRED_DATE`, `ALLOW_INTERNATIONAL_ZIP_CODES`. | Parity | + +### Validation and field state + +| If you previously used… | Use this in Android SDK… | Notes | Status | +|---------------------------|---------------------------|-------|--------| +| `Spreedly.validate()` + `validation` event (`inputProperties`) | `sdk.hasValidationError` / `sdk.areAllFieldsValid` before submit; `onValidationChange` on each `SPLTextField`; `createCreditCard` → `PaymentProcessingResult.ValidationFailed` | Per-field booleans + optional aggregate; **no** single `inputProperties` object. Use `HostedFieldState` for typing-time scheme, lengths, and `iin`. | Parity | +| `fieldEvent` with `input` + `inputProperties.cardType` / `validNumber` / `validCvv` | `onFieldStateChange { HostedFieldState }` on `SPLTextField` (plus `onChange` / `onValidationChange` if you split concerns) | **`HostedFieldState`** bundles scheme, digit **lengths**, `iin`, validity, focus, mask state, and event type — **no** separate `luhnValid` bit. Built-in **icons** are still merchant assets. | Platform | +| `inputProperties.iin` (6–8 digits while typing) | `HostedFieldState.iin` on CARD field snapshots | Merchant-safe prefix only (not full PAN); `null` when fewer than six digits. Omitted from `toString()`. | Parity | +| `inputProperties.numberLength` / `cvvLength` | `HostedFieldState.numberLength` / `cvvLength` (**int digit counts**, from `handleValueChange` pre-encrypt) | Do **not** confuse with ciphertext in `onChange`. | Platform | +| `fieldEvent` `focus` / `blur` | `HostedFieldState` with `HostedFieldEventType.FOCUS` / `BLUR`, or `onFocus` / `onFocusChanged` | Same semantics; blur-driven errors still use “user edited” + forced validation. | Platform | +| `fieldEvent` `mouseover` / `mouseout` | (skip on phone) | Desktop-only hover. | N/A | +| `inputProperties.luhnValid` | Use `HostedFieldState.isValid` | No separate Luhn bit; `isValid` covers format + Luhn + length. | Platform | +| `setValue('number' \| 'cvv', …)` | Not exposed | Sensitive fields accept user input and autofill only — no programmatic `setValue`. | Gap | +| Enter / Tab / Escape from iframe | `ImeAction` + `onImeAction`; **`FocusRequester`** / `focusProperties` / `Modifier.focusOrder` between fields; `onFocusChanged` | Tab order across fields is **merchant-owned** in Compose — wire explicit focus chains (e.g. card → expiry → CVV) and test with a hardware keyboard. Escape is not a mobile soft-keyboard concept. | Platform | + +### Tokenization, reset, and listeners + +| If you previously used… | Use this in Android SDK… | Notes | Status | +|---------------------------|---------------------------|-------|--------| +| `Spreedly.tokenizeCreditCard()` | `sdk.createCreditCard(…)` / `createPaymentMethod(…)` + collect `sdk.paymentResultFlow` | Optional **`eligibleForCardUpdater`** argument on `createCreditCard` / `createPaymentMethod` maps to JSON `eligible_for_card_updater` when non-null; omitted when `null` (matches API `encodeDefaults = false`). Alternatively set `"eligible_for_card_updater"` in **`metadata`** as `"true"` / `"false"` if you prefer the metadata-only path. | Parity | +| `Spreedly.reload()` | `sdk.resetPaymentState()` clears form values + validation display + scheme context + **`hostedCardDisplayState`** defaults; fetch **new** signed auth and call **`sdk.init(SpreedlySDKInitOptions(...))`** again for a fresh signing bundle. | Unlike iframe `reload()` (DOM remount), Android keeps one `Spreedly` instance — **init** rotates signing; `resetPaymentState` alone does not. Successful **`createCreditCard`** calls **`resetPaymentFormPreservingDisplayConfig()`** (fields only, mask/format kept — iOS parity). **`createPaymentMethod`** does not auto-reset. Use **`resetPaymentState()`** when you need display wiped. For rotation / config changes on express sheets, call **`preservePaymentStateOnNextShow()`** before the sheet re-opens to skip the next form reset. | Platform | +| `Spreedly.removeHandlers()` / `off` | Cancel coroutine collectors (`viewModelScope` / `lifecycleScope`) | No global event bus on Android. | Platform | + +### Recache, errors, 3DS, telemetry + +| If you previously used… | Use this in Android SDK… | Notes | Status | +|---------------------------|---------------------------|-------|--------| +| `recacheReady` | Add `SpreedlyRecacheUI` to composition + `init` before calling `recachePaymentMethod` | There is **no** `recacheReady` event name — readiness is structural. | Platform | +| `recache` / `recache` success | `recachePaymentMethod`, `recacheResultFlow`, and suspend `Result` only (not `paymentResultFlow`) | See [Recaching](../recaching.md). | Parity | +| `errors` (array of `{ attribute, key, message }`) | `PaymentResult.Failed` + `validationErrors` / `SpreedlyNetworkError` | Map `errorKey` to UI. See [Error handling](../error-handling.md). | Parity | +| `paymentMethod` | `PaymentResult.Completed` on `paymentResultFlow` | Tokenization and other payment flows only — not CVV recache. Recache uses `recacheResultFlow` / suspend return. | Parity | +| `3ds:status` | `threeDSChallengeResultFlow` + gateway-specific APIs | Typed results instead of one string event. See [3DS Global](../3ds-global.md). | Platform | +| `fraud:token` | Stripe Radar (`checkout-stripe-radar`) / Braintree device data | Different fraud integrations; not a drop-in `fraud:token`. | Gap | +| `consoleError` | (no merchant hook) | Use Spreedly support + your own crash reporting for **app** code. | N/A | + +### Release notes and docs + +| If you previously used… | Use this in Android SDK… | Notes | Status | +|---------------------------|---------------------------|-------|--------| +| iframe `feed.xml` / web changelog habits | [CHANGELOG](../../CHANGELOG.md) + GitHub Packages release | Optional RSS/XML feed is **not** published from this repo today. | Gap | +| TypeScript / `@types` | Kotlin sources + Dokka (`./gradlew generateApiDocs`) | Types ship with the library; use IDE or generated HTML. | Platform | + +--- + ## Verification checklist - [ ] Build succeeds: `./gradlew assembleRelease` diff --git a/docs/guides/offsite-payments.md b/docs/guides/offsite-payments.md index 12d7ef3..3bb08cb 100644 --- a/docs/guides/offsite-payments.md +++ b/docs/guides/offsite-payments.md @@ -321,8 +321,7 @@ private fun observePaymentResults() { val state = result.state // e.g., "gateway_processing_failed" handleFailure(message, state) } - PaymentResult.Canceled -> { - handleCancellation() + is PaymentResult.Canceled -> { handleCancellation() } PaymentResult.Initial -> { // Initial state - no action needed @@ -1468,7 +1467,7 @@ viewModelScope.launch { when (result) { is PaymentResult.Completed -> { /* token, state */ } is PaymentResult.Failed -> { /* errorType, message, state */ } - PaymentResult.Canceled -> { /* user canceled */ } + is PaymentResult.Canceled -> { /* user canceled */ } PaymentResult.Initial -> { /* initial state */ } } } diff --git a/docs/guides/recaching.md b/docs/guides/recaching.md index 08e0ad9..139fd09 100644 --- a/docs/guides/recaching.md +++ b/docs/guides/recaching.md @@ -66,6 +66,8 @@ fun CheckoutScreen() { } ``` +Before calling `recachePaymentMethod`, initialize the SDK with `Spreedly.init(SpreedlySDKInitOptions(...))`. Keep `SpreedlyRecacheUI` in the composition tree whenever recache may run so the CVV UI can be presented. + ### 2. Trigger Recaching ```kotlin @@ -122,8 +124,8 @@ suspend fun recachePaymentMethod( - `config`: UI and display configuration **Returns:** -- `Result.Success`: Contains updated payment method token -- `Result.Error`: Contains error details +- `Result.Success`: Contains the updated payment method; use `transaction.paymentMethod.token` or `recacheToken` / `paymentMethodUpdatedAt` extensions on the response +- `Result.Error`: Validation, network, or API failure — including when the API returns HTTP 200 with `transaction.succeeded == false` (normalized to `Error`) **Throws:** - `IllegalStateException`: If SDK is not initialized @@ -211,6 +213,34 @@ viewModelScope.launch { } ``` +If you also collect `paymentResultFlow` for tokenization on the same `Spreedly` instance, recache outcomes **do not** appear there. Use **`recacheResultFlow`** or the **`recachePaymentMethod`** return value for recache only (iframe `recache` event parity). `paymentResultFlow` remains for tokenization, APM, and offsite flows. + +### PaymentMethodResponse accessors + +Extensions on `PaymentMethodResponse` after a successful recache: + +| API | Description | +|-----|-------------| +| `paymentMethodUpdatedAt` | ISO-8601 timestamp from `transaction.paymentMethod.updatedAt` | +| `recacheToken` | Payment method token after recache (same value as `transaction.paymentMethod.token`) | +| `PaymentMethodTransactionTypes.RECACHE_SENSITIVE_DATA` | String constant `RecacheSensitiveData` — typical `transaction.transactionType` for recache responses | + +```kotlin +when (val result = spreedly.recachePaymentMethod(token, config)) { + is Result.Success -> { + val updatedAt = result.data.paymentMethodUpdatedAt + val pmToken = result.data.recacheToken + } + is Result.Error -> { /* handle SpreedlyNetworkError */ } +} +``` + +--- + +### Recache and paymentResultFlow + +CVV recache is delivered **only** on **`recacheResultFlow`** and from the **`recachePaymentMethod`** suspend return as `Result`. It is **not** mirrored to **`paymentResultFlow`** (unlike tokenization, which completes on `paymentResultFlow` as `PaymentResult`). + --- ## Validation Parameters @@ -676,12 +706,7 @@ suspend fun recacheWithErrorHandling( return when (result) { is Result.Success -> { - if (result.data.transaction.succeeded) { - result.data.transaction.paymentMethod.token - } else { - showError("Recaching failed: ${result.data.transaction.message}") - null - } + result.data.transaction.paymentMethod.token } is Result.Error -> { val errorMessage = when (val error = result.error) { @@ -828,7 +853,8 @@ spreedly.recacheResultFlow.collect { result -> } Choose based on your use case: - Use **direct result** for simple, one-time operations -- Use **flow** for reactive UI updates or when you need to observe results in multiple places +- Use **`recacheResultFlow`** for reactive UI updates or when you need to observe results in multiple places +- **`paymentResultFlow`** is for tokenization and other payment flows only — not for recache outcomes --- @@ -858,12 +884,14 @@ RecacheConfig config = RecacheJavaHelper.createRecacheConfig( ); RecacheJavaHelper.recachePaymentMethod( this, sdk, paymentMethodToken, config, - token -> Log.d("Recache", "Recache succeeded"), + (token, updatedAt) -> Log.d("Recache", "token=" + token + " updatedAt=" + updatedAt), error -> Log.e("Recache", "Recache failed") ); ``` -See `RecacheJavaHelper` for full parameter options and `createRecacheConfigFull()` for custom button text and labels. +The `BiConsumer` second argument is the ISO-8601 `updatedAt` timestamp when present (same value as `PaymentMethodResponse.paymentMethodUpdatedAt` in Kotlin). + +See `RecacheJavaHelper` for the `Consumer` token-only overload, full parameter options, and `createRecacheConfigFull()` for custom button text and labels. --- diff --git a/docs/guides/stripe-apm.md b/docs/guides/stripe-apm.md index 7018015..68b820b 100644 --- a/docs/guides/stripe-apm.md +++ b/docs/guides/stripe-apm.md @@ -291,6 +291,7 @@ val clientSecret = transaction.gatewaySpecificResponseFields ```kotlin import com.spreedly.stripe.StripeAPMConfig +import com.spreedly.stripe.StripeAPMAppearanceConfig import com.spreedly.stripe.SpreedlyStripeAPMCheckout val config = StripeAPMConfig( @@ -303,6 +304,19 @@ val config = StripeAPMConfig( SpreedlyStripeAPMCheckout.present(config, activity) ``` +Optional **PaymentSheet appearance** (colors, shapes, typography, primary button) maps to Stripe +`PaymentSheet.Appearance` via [StripeAPMAppearanceConfig](../../stripe/src/main/java/com/spreedly/stripe/StripeAPMAppearanceConfig.kt). +Field support is documented in [STRIPE_22_8_APPEARANCE_FIELD_MATRIX](../development/STRIPE_22_8_APPEARANCE_FIELD_MATRIX.md). + +```kotlin +val appearance = StripeAPMAppearanceConfig( + shapes = StripeAPMAppearanceConfig.Shapes(cornerRadiusDp = 12f, borderStrokeWidthDp = 1f), + colors = StripeAPMAppearanceConfig.Colors(primary = 0xFF6750A4.toInt()), + typography = StripeAPMAppearanceConfig.Typography(fontSizeScaleFactor = 1.05, fontFamilyResId = null), +) +SpreedlyStripeAPMCheckout.present(config, activity, appearance) +``` + The SDK will: 1. Validate the config (fail fast if any required field is blank) @@ -369,7 +383,7 @@ class StripeAPMPaymentViewModel( merchantDisplayName = "Example Store", ) - SpreedlyStripeAPMCheckout.present(config, activity) + SpreedlyStripeAPMCheckout.present(config, activity, appearance) } catch (e: Exception) { _errorMessage.value = e.message @@ -405,8 +419,10 @@ StripeAPMJavaHelper.startPaymentResultMonitoring( } ); -// Present the Stripe PaymentSheet +// Present the Stripe PaymentSheet (optional third argument: StripeAPMAppearanceConfig) StripeAPMJavaHelper.presentCheckout(config, this); +// Or with appearance: +// StripeAPMJavaHelper.presentCheckout(config, this, appearance); // Check status boolean active = StripeAPMJavaHelper.isCheckoutActive(); @@ -466,7 +482,10 @@ with `state = "pending"` — treat this as a soft success and confirm via your b ### PaymentResult.Canceled -Emitted when the user dismisses the Stripe PaymentSheet without completing payment. +Emitted when the user dismisses the Stripe PaymentSheet without completing payment. Use +`paymentResultFlow` / `PaymentResult.Canceled` to detect user dismissals. `ApmCheckoutCompleted` +telemetry records `success = false` for cancel as well, so do not infer cancel from telemetry +alone. --- @@ -622,7 +641,7 @@ Singleton (`object`) for presenting Stripe APM checkout via the native Stripe Pa **Package:** `com.spreedly.stripe` -#### `present(config: StripeAPMConfig, activity: Activity)` +#### `present(config: StripeAPMConfig, activity: Activity, appearance: StripeAPMAppearanceConfig? = null)` Launch the Stripe PaymentSheet for APM checkout. Validates the config, launches a transparent `StripeAPMActivity`, and presents the PaymentSheet. @@ -631,11 +650,15 @@ Launch the Stripe PaymentSheet for APM checkout. Validates the config, launches - `config` — The Stripe APM configuration built from the purchase response - `activity` — The Activity context to launch from +- `appearance` — Optional styling mapped to Stripe `PaymentSheet.Appearance` (omit for defaults) ```kotlin SpreedlyStripeAPMCheckout.present(config, activity) +SpreedlyStripeAPMCheckout.present(config, activity, appearance) ``` +If a checkout is already active, the SDK logs a warning and still starts the new session. + --- #### `finalizeIfActive()` @@ -705,11 +728,16 @@ fun startPaymentResultMonitoring( ) ``` -#### `presentCheckout(config: StripeAPMConfig, activity: Activity)` +#### `presentCheckout(config: StripeAPMConfig, activity: Activity, appearance: StripeAPMAppearanceConfig? = null)` ```kotlin @JvmStatic -fun presentCheckout(config: StripeAPMConfig, activity: Activity) +@JvmOverloads +fun presentCheckout( + config: StripeAPMConfig, + activity: Activity, + appearance: StripeAPMAppearanceConfig? = null, +) ``` #### `isCheckoutActive(): Boolean` diff --git a/docs/guides/stripe-radar.md b/docs/guides/stripe-radar.md new file mode 100644 index 0000000..442d15d --- /dev/null +++ b/docs/guides/stripe-radar.md @@ -0,0 +1,291 @@ +# Stripe Radar Integration Guide + +A practical guide for collecting Stripe Radar device data on Android using the Spreedly SDK's +`checkout-stripe-radar` module. The SDK creates a Radar session and returns a session ID for your +backend to attach to Spreedly purchase or authorization requests. + +## Table of Contents + +- [Introduction](#introduction) +- [Stripe Radar vs Other SDK Features](#stripe-radar-vs-other-sdk-features) +- [Prerequisites](#prerequisites) +- [Project Setup](#project-setup) +- [How Stripe Radar Works](#how-stripe-radar-works) +- [Backend Requirements](#backend-requirements) +- [Kotlin Integration](#kotlin-integration) +- [Java Integration](#java-integration) +- [Stripe Connect (Optional)](#stripe-connect-optional) +- [Error Handling](#error-handling) +- [Testing](#testing) +- [Troubleshooting](#troubleshooting) +- [API Reference](#api-reference) +- [Additional Resources](#additional-resources) + +--- + +## Introduction + +### What is Stripe Radar in this SDK? + +Stripe Radar uses device signals to help detect fraud. The `:stripe-radar` module exposes +**Radar Session creation only**: your app obtains a `radar_session_id` from Stripe via the Stripe +Android SDK, then passes that ID to Spreedly on the **next** API call (typically the Stripe Payment +Intents purchase your backend creates for Stripe APM). + +### Key Points + +- **Not a checkout flow** — no Payment Sheet, no `paymentResultFlow` emission from Radar alone. +- **Pre-payment helper** — call before or while preparing the purchase; send the session ID with the purchase body. +- **Separate artifact** — `checkout-stripe-radar`; keeps Stripe Radar dependency isolated from apps that do not need it. +- **Coexists with Stripe APM** — uses an explicit `Stripe` instance per call and does **not** set Stripe's global `PaymentConfiguration`, so it pairs cleanly with `:stripe` (PaymentSheet). + +--- + +## Stripe Radar vs Other SDK Features + +| Feature | Stripe Radar (this guide) | Stripe APM | Braintree APM | +|---------|---------------------------|------------|---------------| +| **Purpose** | Device fingerprint / Radar session ID | Present Payment Sheet for iDEAL, Bancontact, etc. | PayPal / Venmo native checkout | +| **Primary output** | `radar_session_id` (string or null) | Pending transaction + `client_secret` | Nonce + `device_data` (confirm flow) | +| **Maven artifact** | `checkout-stripe-radar` | `checkout-stripe-apm` | `checkout-braintree-apm` | +| **Typical timing** | Before backend creates Stripe PI purchase | After backend returns pending purchase | After backend returns pending purchase | +| **Gateway-specific field** | `stripe_payment_intents.radar_session_id` | (none from Radar) | Braintree-specific blocks | + +Radar complements Stripe APM: collect a session ID early, include it in the same pending purchase request your backend sends for Payment Sheet flows. + +--- + +## Prerequisites + +1. **Spreedly SDK** installed and initialized per [Getting Started](getting-started.md). +2. **Stripe publishable key** (`pk_test_...` or `pk_live_...`) aligned with your Stripe Payment Intents gateway in Spreedly. +3. **Radar Session** enabled for your Stripe account (Stripe Dashboard / Radar settings — confirm with Stripe docs for your account type). +4. **Minimum toolchain** — See the [Compatibility table](../../README.md#compatibility) in the root README. + +--- + +## Project Setup + +### Maven Repository + +If not already configured, add GitHub Packages as described in [Getting Started — Install](getting-started.md#1-install). + +### Gradle Dependency + +Add the Radar module alongside modules you already use (for example Stripe APM): + +```kotlin +dependencies { + implementation("com.spreedly:checkout-stripe-radar:$spreedlyVersion") + // Often paired with Stripe APM: + // implementation("com.spreedly:checkout-stripe-apm:$spreedlyVersion") +} +``` + +The `:stripe-radar` module depends on `payments-core` as `api` and bundles the Stripe Android SDK for Radar Session APIs — you do not add a separate `com.stripe:stripe-android` line for Radar alone. + +### Keys + +Keep publishable keys out of source control. The demo app uses `BuildConfig` fields wired from `apikeys.properties`; mirror that pattern in your app. + +--- + +## How Stripe Radar Works + +```mermaid +sequenceDiagram + participant App as Merchant_App + participant RadarSDK as SpreedlyStripeRadar + participant Stripe as Stripe_Android_SDK + participant Backend as Merchant_Backend + participant SpreedlyAPI as Spreedly_API + + App->>RadarSDK: createRadarSession(config, context) + RadarSDK->>Stripe: Create Radar Session + Stripe-->>RadarSDK: session id + RadarSDK-->>App: radar_session_id or null + App->>Backend: Create purchase with radar_session_id + Backend->>SpreedlyAPI: POST purchase.json gateway_specific_fields + SpreedlyAPI-->>Backend: Pending transaction etc. + Backend-->>App: Response for Payment Sheet if Stripe APM +``` + +--- + +## Backend Requirements + +Your backend should accept an optional Radar session ID from the app and send it on the Spreedly +purchase (or authorization) under **`gateway_specific_fields.stripe_payment_intents.radar_session_id`**. + +Example shape (aligned with the demo app's Kotlin models in `SpreedlyPurchaseModels.kt`): + +```json +{ + "transaction": { + "amount": 4400, + "currency_code": "EUR", + "channel": "app", + "redirect_url": "your-app-scheme://stripe/checkout", + "callback_url": "https://your-backend.example/webhooks/spreedly", + "payment_method": { + "payment_method_type": "stripe_apm", + "apm_types": ["ideal", "bancontact"] + }, + "gateway_specific_fields": { + "stripe_payment_intents": { + "radar_session_id": "rse_xxxxxxxxxxxxx" + } + } + } +} +``` + +Omit `gateway_specific_fields` entirely when you do not have a session ID (optional Radar). + +For general Stripe APM purchase fields and redirects, see [Stripe APM — Backend Requirements](stripe-apm.md#backend-requirements). + +--- + +## Kotlin Integration + +### Step 1: Build Configuration + +```kotlin +import com.spreedly.striperadar.StripeRadarConfig + +val config = StripeRadarConfig( + publishableKey = publishableKey, + // stripeAccount = "acct_xxx", // optional — Stripe Connect +) +``` + +### Step 2: Collect Session (suspend) + +Use any coroutine scope appropriate for your layer (`viewModelScope`, `lifecycleScope`, etc.): + +```kotlin +import com.spreedly.striperadar.SpreedlyStripeRadar + +val sessionId: String? = SpreedlyStripeRadar.createRadarSession(config, context) +``` + +### Step 3: Send Session ID to Your Backend + +Pass `sessionId` into the API that creates the Spreedly transaction so your server can include +`gateway_specific_fields.stripe_payment_intents.radar_session_id` when present. + +### Demo App Reference + +The sample Stripe APM screen collects Radar when the user opts in, stores the ID in state, and passes it into the purchase client: + +- [`StripeAPMPaymentViewModel.kt`](../../app/src/main/java/com/spreedly/example/screens/stripeapmpayment/StripeAPMPaymentViewModel.kt) — `toggleRadar`, `collectRadarSession`, `radarSessionId` passed to `stripeAPMPurchase(...)`. +- [`SpreedlyPurchaseAPIClient.kt`](../../app/src/main/java/com/spreedly/example/api/SpreedlyPurchaseAPIClient.kt) — maps `radarSessionId` into `StripeGatewaySpecificFields`. + +--- + +## Java Integration + +Use the callback overload; it invokes the callback on the **main** thread and returns a **Kotlin** +`Job` you can cancel. + +```java +import com.spreedly.striperadar.SpreedlyStripeRadar; +import com.spreedly.striperadar.StripeRadarConfig; +import kotlin.Unit; +import kotlinx.coroutines.Job; + +StripeRadarConfig config = new StripeRadarConfig(publishableKey, null); + +Job job = SpreedlyStripeRadar.INSTANCE.createRadarSession( + config, + context, + radarSessionId -> { + if (radarSessionId != null) { + // Send radarSessionId to your backend + } + return Unit.INSTANCE; + } +); +``` + +--- + +## Stripe Connect (Optional) + +If you collect on behalf of a connected account, pass the Stripe Connect account ID: + +```kotlin +StripeRadarConfig( + publishableKey = publishableKey, + stripeAccount = "acct_xxxxxxxxxxxxx", +) +``` + +--- + +## Error Handling + +- **`createRadarSession` returns `null` on failure** — it does not throw for Stripe/SDK failures; failures still emit a Spreedly analytics event (`device_data_collected` with `success = false` and an `error_type` when available). +- **Degraded path** — If Radar is optional for your risk policy, omit `gateway_specific_fields` when `sessionId` is null and proceed with the purchase. +- **Never block checkout indefinitely** — collect Radar before showing pay UI or with a timeout policy your product team agrees on. + +--- + +## Testing + +1. Use **`pk_test_...`** and your Spreedly test environment. +2. Confirm Radar Session creation returns a non-null ID on a supported device/emulator. +3. Verify your backend receives the ID and Spreedly accepts the purchase payload (inspect transaction JSON for `gateway_specific_fields.stripe_payment_intents.radar_session_id`). + +--- + +## Troubleshooting + +| Symptom | Likely cause | What to check | +|---------|----------------|---------------| +| Always `null` session ID | Invalid or blank publishable key | Key matches Stripe account for the gateway; non-empty string | +| Always `null` session ID | Radar Session not enabled | Stripe Dashboard / account capabilities | +| Purchase rejected after adding field | Wrong nesting or key name | Must be `gateway_specific_fields.stripe_payment_intents.radar_session_id` | +| Works on Stripe APM without Radar | N/A | Radar is optional; omit block when no session | + +--- + +## API Reference + +### `SpreedlyStripeRadar` + +**Package:** `com.spreedly.striperadar` + +| API | Description | +|-----|-------------| +| `suspend fun createRadarSession(config: StripeRadarConfig, context: Context): String?` | Suspending Radar Session creation; returns session ID or `null`. | +| `fun createRadarSession(config: StripeRadarConfig, context: Context, callback: (String?) -> Unit): Job` | Same behavior; callback always runs on the main thread; returns cancellable `Job`. | + +#### Nested Objects + +| Object | Constant | Value | +|--------|----------|-------| +| `Keys` | `RADAR_SESSION_ID` | `"radar_session_id"` — JSON field name for gateway-specific maps | +| `Analytics` | `PROVIDER` | `"stripe_radar"` — telemetry provider tag | + +### `StripeRadarConfig` + +```kotlin +data class StripeRadarConfig( + val publishableKey: String, + val stripeAccount: String? = null, +) +``` + +| Property | Required | Description | +|----------|----------|-------------| +| `publishableKey` | Yes | Stripe publishable key (`pk_test_...` / `pk_live_...`). | +| `stripeAccount` | No | Stripe Connect account ID when collecting on behalf of a connected account. | + +--- + +## Additional Resources + +- [Stripe APM Integration Guide](stripe-apm.md) — Pending purchase + Payment Sheet flow where Radar session IDs are commonly attached. +- [Getting Started](getting-started.md) — Install and SDK initialization. +- [Stripe Radar Session documentation](https://stripe.com/docs/radar/radar-session) — Product behavior on the Stripe side (confirm latest Stripe docs for your integration). diff --git a/docs/guides/theme-and-styling.md b/docs/guides/theme-and-styling.md index 2b22d08..7fd2de9 100644 --- a/docs/guides/theme-and-styling.md +++ b/docs/guides/theme-and-styling.md @@ -295,7 +295,7 @@ The theme ships with light and dark color schemes. Always reference colors, typo |------|-------| | Background | Gray900 | | Surface | Gray800 | -| Primary | Blue500 | +| Primary | Blue600 | | On Background | Gray100 | Never hardcode colors. Use theme accessors: diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 346dcb9..898f78e 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -76,6 +76,10 @@ You're using real card numbers in a test environment. - `config` parameter overrides the global theme. If you pass `PaymentSheetConfig()` with all defaults, the global theme fills in. Explicit colors take precedence. - `PaymentSheetConfig.fromTheme()` must be called inside a `@Composable` function to access `MaterialTheme` colors. +### Express autofill or initial card format ignored + +- **`sdk.setConfig(PaymentSheetConfig(...))`** does not by itself change express **autofill** or **initial `CardNumberFormat`** on the payment sheet UI. Those come from **`PaymentSheetDisplayConfig`** (or legacy **`PaymentSheetConfig`** when **`displayConfig`** is null) on **`SpreedlyBottomSheet`** / **`PaymentSheet`**, resolved when the composable runs. + ### Form resets unexpectedly The bottom sheet clears form state each time it opens, unless `sdk.shouldPreserveState()` is configured. This is by design for fresh payment sessions. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b662632..3a27a86 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -77,7 +77,7 @@ protobuf = "0.9.5" # Protobuf plugin firebaseAppDistribution = "5.1.1" # Firebase App Distribution googleServices = "4.4.4" # Google Services plugin datadog = "3.2.0" # Datadog monitoring SDK -spreedlySdk = "1.0.1" # Spreedly SDK version - first public release +spreedlySdk = "1.1.0" # Spreedly SDK version forter3ds = "2.0.4" stripe = "22.8.1" # Stripe Android SDK for APM (PaymentSheet) - Verify latest at https://github.com/stripe/stripe-android/releases braintree = "5.18.0" # Braintree Android SDK v5 for PayPal/Venmo - Verify latest at https://github.com/braintree/braintree_android/releases diff --git a/gradle/module-catalog.json b/gradle/module-catalog.json index 084b643..e345d87 100644 --- a/gradle/module-catalog.json +++ b/gradle/module-catalog.json @@ -5,6 +5,7 @@ { "gradle": "paymentsheet", "artifact": "checkout-paymentsheet", "description": "Pre-built payment sheet UI", "recommended": true }, { "gradle": "threeds", "artifact": "checkout-threeds", "description": "3D Secure authentication" }, { "gradle": "stripe", "artifact": "checkout-stripe-apm", "description": "Stripe alternative payment methods" }, + { "gradle": "stripe-radar", "artifact": "checkout-stripe-radar", "description": "Stripe Radar fraud detection" }, { "gradle": "braintree", "artifact": "checkout-braintree-apm", "description": "Braintree alternative payment methods" }, { "gradle": "checkout-bom", "artifact": "checkout-bom", "type": "pom", "description": "Bill of Materials" } ]