From f120400cfeec457ef9d47ee4c1ca6802089ab658 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Jul 2026 15:45:49 +0000 Subject: [PATCH 1/3] chore: sync sample from v1.2.0 (1.2.0) --- README.md | 10 +- app/build.gradle.kts | 14 +- .../java/com/spreedly/app/MainActivity.kt | 2 + .../screens/bankaccount/BankAccountScreen.kt | 12 +- .../bankaccount/BankAccountViewModel.kt | 11 +- .../basiccheckout/BasicCheckoutScreen.kt | 27 +- .../screens/common/BankAccountConfigPanel.kt | 207 ++++----- .../CustomTextFieldsScreen.kt | 4 + .../HeadlessAchNameValidity.kt | 38 ++ .../HeadlessBankAccountConfigScreen.kt | 3 + .../HeadlessBankAccountScreen.kt | 402 ++++++++++++++---- .../HeadlessBankAccountViewModel.kt | 20 + .../RecachingShowcaseScreen.kt | 4 + .../ui/theme/SplFieldConfigResolver.kt | 2 +- .../example/viewmodel/ViewModelFactories.kt | 8 +- app/src/main/res/values/strings.xml | 8 +- .../BankAccountViewModelThemeTest.kt | 72 ++++ .../HeadlessAchNameValidityTest.kt | 77 ++++ .../HeadlessBankAccountViewModelThemeTest.kt | 85 ++++ docs/CHANGELOG.md | 17 + docs/guides/ach-bank-account.md | 216 +++++++++- docs/guides/custom-payment-forms.md | 2 + docs/guides/error-handling.md | 23 + docs/guides/express-checkout.md | 2 +- docs/guides/migration/from-legacy.md | 2 +- gradle/libs.versions.toml | 13 +- settings.gradle.kts | 8 +- 27 files changed, 1037 insertions(+), 252 deletions(-) create mode 100644 app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessAchNameValidity.kt create mode 100644 app/src/test/java/com/spreedly/example/screens/bankaccount/BankAccountViewModelThemeTest.kt create mode 100644 app/src/test/java/com/spreedly/example/screens/headlessbankaccount/HeadlessAchNameValidityTest.kt create mode 100644 app/src/test/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountViewModelThemeTest.kt diff --git a/README.md b/README.md index b2903b6..aabb408 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Spreedly Checkout — Android Example -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`). +This sample app demonstrates the [Spreedly Android Checkout SDK](https://github.com/spreedly/checkout-android-sdk) at version **1.2.0** (tag `v1.2.0`). ## Setup @@ -20,10 +20,10 @@ gpr.key=YOUR_GITHUB_TOKEN All SDK modules are resolved from GitHub Packages: ```kotlin -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") +implementation("com.spreedly:checkout-paymentsheet:1.2.0") +implementation("com.spreedly:checkout-braintree-apm:1.2.0") +implementation("com.spreedly:checkout-stripe-apm:1.2.0") +implementation("com.spreedly:checkout-threeds:1.2.0") ``` ## SDK Documentation diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 886f610..154cca1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -130,7 +130,7 @@ android { testOptions { unitTests { - isIncludeAndroidResources = true + isReturnDefaultValues = true } } } @@ -143,11 +143,11 @@ kotlin { dependencies { // ✅ Use paymentsheet which includes payments-core and hosted-fields - 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("com.spreedly:checkout-paymentsheet:1.2.0") + implementation("com.spreedly:checkout-braintree-apm:1.2.0") + implementation("com.spreedly:checkout-stripe-apm:1.2.0") + implementation("com.spreedly:checkout-stripe-radar:1.2.0") + implementation("com.spreedly:checkout-threeds:1.2.0") implementation(libs.kotlinx.serialization.json) implementation(platform(libs.androidx.compose.bom)) @@ -167,6 +167,8 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.material) + implementation(libs.androidx.browser) + // Testing dependencies testImplementation(libs.junit) testImplementation(libs.mockk) diff --git a/app/src/main/java/com/spreedly/app/MainActivity.kt b/app/src/main/java/com/spreedly/app/MainActivity.kt index 6815966..3011d66 100644 --- a/app/src/main/java/com/spreedly/app/MainActivity.kt +++ b/app/src/main/java/com/spreedly/app/MainActivity.kt @@ -209,5 +209,7 @@ fun MainNavHost(bottomSheetViewModel: BottomSheetPaymentViewModel) { onBackClick = { navController.popBackStack() }, ) } + + } } diff --git a/app/src/main/java/com/spreedly/example/screens/bankaccount/BankAccountScreen.kt b/app/src/main/java/com/spreedly/example/screens/bankaccount/BankAccountScreen.kt index 0e14c0e..875d93e 100644 --- a/app/src/main/java/com/spreedly/example/screens/bankaccount/BankAccountScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/bankaccount/BankAccountScreen.kt @@ -45,9 +45,11 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import com.spreedly.example.viewmodel.findActivityOrNull import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.spreedly.app.R @@ -77,13 +79,17 @@ fun BankAccountScreen( val showSheet by viewModel.showSheet.collectAsState() val fieldConfig by viewModel.fieldConfig.collectAsState() val uiConfig by viewModel.uiConfig.collectAsState() + val useCustomTheme by viewModel.useCustomTheme.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { + val activity = LocalContext.current.findActivityOrNull() + DisposableEffect(lifecycleOwner, showSheet) { val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_STOP -> { - if (showSheet) viewModel.onConfigurationChanging() + if (showSheet && activity?.isChangingConfigurations == true) { + viewModel.onConfigurationChanging() + } } Lifecycle.Event.ON_START -> { viewModel.onConfigurationChangeComplete() @@ -187,6 +193,8 @@ fun BankAccountScreen( onFieldConfigChange = { viewModel.updateFieldConfig(it) }, uiConfig = uiConfig, onUiConfigChange = { viewModel.updateUiConfig(it) }, + useCustomTheme = useCustomTheme, + onUseCustomThemeChange = { viewModel.updateUseCustomTheme(it) }, ) Spacer(modifier = Modifier.height(Spacing.lg)) diff --git a/app/src/main/java/com/spreedly/example/screens/bankaccount/BankAccountViewModel.kt b/app/src/main/java/com/spreedly/example/screens/bankaccount/BankAccountViewModel.kt index 3ce3138..94e41f4 100644 --- a/app/src/main/java/com/spreedly/example/screens/bankaccount/BankAccountViewModel.kt +++ b/app/src/main/java/com/spreedly/example/screens/bankaccount/BankAccountViewModel.kt @@ -42,6 +42,9 @@ class BankAccountViewModel(private val context: Context) : ViewModel() { private val _uiConfig = MutableStateFlow(CustomFieldsConfig()) val uiConfig: StateFlow = _uiConfig.asStateFlow() + private val _useCustomTheme = MutableStateFlow(false) + val useCustomTheme: StateFlow = _useCustomTheme.asStateFlow() + private val sdkSessionManager = SdkSessionManager(AuthService()) private val paymentMethodRepository = PaymentMethodRepository(context) private val paymentResultHandler = PaymentResultHandler(paymentMethodRepository) @@ -137,7 +140,6 @@ class BankAccountViewModel(private val context: Context) : ViewModel() { _paymentToken.value = "" viewModelScope.launch { if (initializeForPayment()) { - _isProcessing.value = true _showSheet.value = true } } @@ -169,6 +171,13 @@ class BankAccountViewModel(private val context: Context) : ViewModel() { _uiConfig.value = config } + fun updateUseCustomTheme(enabled: Boolean) { + _useCustomTheme.value = enabled + if (!enabled) { + _uiConfig.value = CustomFieldsConfig() + } + } + private companion object { private const val TAG = "BankAccountViewModel" } 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 467773a..396de21 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 @@ -101,6 +101,8 @@ fun SimpleInputField( isRequired: Boolean = false, isError: Boolean = false, errorMessage: String? = null, + placeholder: String? = null, + showLabel: Boolean = true, keyboardType: KeyboardType = KeyboardType.Text, capitalization: KeyboardCapitalization = KeyboardCapitalization.Words, imeAction: ImeAction = ImeAction.Default, @@ -117,18 +119,20 @@ fun SimpleInputField( val borderWidth = if (isFocused) 2.dp else 1.dp Column(modifier = modifier) { - Row(modifier = Modifier.padding(bottom = Spacing.xxs)) { - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, - ) - if (isRequired) { + if (showLabel) { + Row(modifier = Modifier.padding(bottom = Spacing.xxs)) { Text( - text = "*", + text = label, style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.error, + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, ) + if (isRequired) { + Text( + text = "*", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + ) + } } } @@ -167,7 +171,7 @@ fun SimpleInputField( Box { if (value.isEmpty()) { Text( - text = "Enter $label", + text = placeholder ?: "Enter $label", style = TextStyle( fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), @@ -263,6 +267,9 @@ fun BasicCheckoutScreen( viewModel.startPaymentPolling() } is PaymentProcessingResult.ValidationFailed -> Unit + is PaymentProcessingResult.Rejected, + is PaymentProcessingResult.Failed, + -> Unit } } } diff --git a/app/src/main/java/com/spreedly/example/screens/common/BankAccountConfigPanel.kt b/app/src/main/java/com/spreedly/example/screens/common/BankAccountConfigPanel.kt index 7c33a71..aa33654 100644 --- a/app/src/main/java/com/spreedly/example/screens/common/BankAccountConfigPanel.kt +++ b/app/src/main/java/com/spreedly/example/screens/common/BankAccountConfigPanel.kt @@ -68,6 +68,8 @@ fun BankAccountConfigPanel( onFieldConfigChange: (BankAccountFieldConfig) -> Unit, uiConfig: CustomFieldsConfig, onUiConfigChange: (CustomFieldsConfig) -> Unit, + useCustomTheme: Boolean, + onUseCustomThemeChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { val isDark = isSystemInDarkTheme() @@ -124,31 +126,6 @@ fun BankAccountConfigPanel( modifier = Modifier.padding(bottom = Spacing.sm), ) - val presets = - listOf( - stringResource(R.string.config_preset_default) to BankAccountFieldConfig.Default, - stringResource(R.string.config_preset_minimal) to BankAccountFieldConfig.Minimal, - stringResource(R.string.config_preset_full) to BankAccountFieldConfig.Full, - ) - val selectedPreset = - presets.find { it.second == fieldConfig }?.first ?: "Default" - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - presets.forEachIndexed { index, (label, config) -> - SegmentedButton( - shape = SegmentedButtonDefaults.itemShape( - index = index, - count = presets.size, - ), - onClick = { onFieldConfigChange(config) }, - selected = selectedPreset == label, - ) { - Text(label) - } - } - } - - Spacer(modifier = Modifier.height(Spacing.md)) - Text( text = stringResource(R.string.config_name_display_mode), style = MaterialTheme.typography.labelMedium, @@ -232,91 +209,127 @@ fun BankAccountConfigPanel( modifier = Modifier.padding(bottom = Spacing.sm), ) - Text( - text = stringResource(R.string.config_primary_color), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = Spacing.xxs), - ) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Spacing.xs), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - primaryColors.forEach { color -> - ColorSwatch( - color = color, - selected = uiConfig.primaryColor == color, - onClick = { onUiConfigChange(uiConfig.copy(primaryColor = color)) }, - ) - } + Text( + text = stringResource(R.string.config_use_custom_theme), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Switch( + checked = useCustomTheme, + onCheckedChange = onUseCustomThemeChange, + ) } - Spacer(modifier = Modifier.height(Spacing.md)) + if (useCustomTheme) { + Spacer(modifier = Modifier.height(Spacing.md)) - Text( - text = stringResource(R.string.config_field_background), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = Spacing.xxs), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(Spacing.xs), - ) { - fieldBackgroundColors.forEach { color -> - ColorSwatch( - color = color, - selected = uiConfig.fieldBackgroundColor == color, - onClick = { - val hasCustomBg = color != Color.Transparent + Text( + text = stringResource(R.string.config_primary_color), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = Spacing.xxs), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.xs), + ) { + primaryColors.forEach { color -> + ColorSwatch( + color = color, + selected = uiConfig.primaryColor == color, + onClick = { onUiConfigChange(uiConfig.copy(primaryColor = color)) }, + ) + } + } + + Spacer(modifier = Modifier.height(Spacing.md)) + + Text( + text = stringResource(R.string.config_field_background), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = Spacing.xxs), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.xs), + ) { + fieldBackgroundColors.forEach { color -> + val fieldBackgroundValue = + if (color == Color.Transparent) Color.Unspecified else color + val isDefaultSwatch = color == Color.Transparent + ColorSwatch( + color = color, + selected = + if (isDefaultSwatch) { + uiConfig.fieldBackgroundColor == Color.Unspecified + } else { + uiConfig.fieldBackgroundColor == fieldBackgroundValue + }, + onClick = { + val hasCustomBg = !isDefaultSwatch + onUiConfigChange( + uiConfig.copy( + fieldBackgroundColor = fieldBackgroundValue, + textColor = if (hasCustomBg && isDark) { + Color.White + } else if (hasCustomBg) { + Color.Black + } else { + Color.Unspecified + }, + fieldLabelColor = if (hasCustomBg && isDark) { + Color.LightGray + } else if (hasCustomBg) { + Color.DarkGray + } else { + Color.Unspecified + }, + ), + ) + }, + ) + } + } + + Spacer(modifier = Modifier.height(Spacing.md)) + + Text( + text = stringResource(R.string.config_border_radius), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = Spacing.xxs), + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Slider( + modifier = Modifier.weight(1f), + value = uiConfig.borderRadius.value, + onValueChange = { onUiConfigChange( uiConfig.copy( - fieldBackgroundColor = color, - textColor = if (hasCustomBg && isDark) Color.White - else if (hasCustomBg) Color.Black - else Color.Unspecified, - fieldLabelColor = if (hasCustomBg && isDark) Color.LightGray - else if (hasCustomBg) Color.DarkGray - else Color.Unspecified, + borderRadius = it.dp, + fieldShape = RoundedCornerShape(it.dp), ), ) }, + valueRange = 0f..24f, + ) + Spacer(modifier = Modifier.size(Spacing.sm)) + Text( + text = "${uiConfig.borderRadius.value.toInt()}dp", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - - Spacer(modifier = Modifier.height(Spacing.md)) - - Text( - text = stringResource(R.string.config_border_radius), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(bottom = Spacing.xxs), - ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Slider( - modifier = Modifier.weight(1f), - value = uiConfig.borderRadius.value, - onValueChange = { - onUiConfigChange( - uiConfig.copy( - borderRadius = it.dp, - fieldShape = RoundedCornerShape(it.dp), - ), - ) - }, - valueRange = 0f..24f, - ) - Spacer(modifier = Modifier.size(Spacing.sm)) - Text( - text = "${uiConfig.borderRadius.value.toInt()}dp", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } } @@ -331,6 +344,8 @@ private fun BankAccountConfigPanelPreview() { onFieldConfigChange = {}, uiConfig = CustomFieldsConfig.Default, onUiConfigChange = {}, + useCustomTheme = false, + onUseCustomThemeChange = {}, ) } } 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 df77f3a..1c50693 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 @@ -865,6 +865,10 @@ fun CustomTextFieldsScreen( is PaymentProcessingResult.ValidationFailed -> { Log.d(TAG, "Validation failed for fields: ${result.invalidFields}") } + + is PaymentProcessingResult.Rejected, + is PaymentProcessingResult.Failed, + -> Unit } } }, diff --git a/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessAchNameValidity.kt b/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessAchNameValidity.kt new file mode 100644 index 0000000..7716c06 --- /dev/null +++ b/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessAchNameValidity.kt @@ -0,0 +1,38 @@ +package com.spreedly.example.screens.headlessbankaccount + +import com.spreedly.sdk.ui.isAchNameFieldValid +import com.spreedly.sdk.ui.isAchSeparateNamePairValid + +internal data class HeadlessAchNameValidity( + val fullNameIsValid: Boolean, + val firstNameIsValid: Boolean, + val lastNameIsValid: Boolean, + val bankNameIsValid: Boolean, +) + +internal fun headlessAchNameValidity( + accountHolderName: String, + firstName: String, + lastName: String, + bankName: String, + showBankName: Boolean, + bankNameRequired: Boolean, + allowBlankName: Boolean, +): HeadlessAchNameValidity { + val pairValid = isAchSeparateNamePairValid( + firstName = firstName, + lastName = lastName, + allowBlankName = allowBlankName, + ) + val bankNameIsValid = when { + !showBankName -> true + !bankNameRequired -> true + else -> isAchNameFieldValid(bankName, allowBlankName = false) + } + return HeadlessAchNameValidity( + fullNameIsValid = isAchNameFieldValid(accountHolderName, allowBlankName), + firstNameIsValid = pairValid, + lastNameIsValid = pairValid, + bankNameIsValid = bankNameIsValid, + ) +} diff --git a/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountConfigScreen.kt b/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountConfigScreen.kt index 181494c..ae113df 100644 --- a/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountConfigScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountConfigScreen.kt @@ -58,6 +58,7 @@ fun HeadlessBankAccountConfigScreen( val paymentToken by viewModel.paymentToken.collectAsState() val fieldConfig by viewModel.fieldConfig.collectAsState() val uiConfig by viewModel.uiConfig.collectAsState() + val useCustomTheme by viewModel.useCustomTheme.collectAsState() Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -132,6 +133,8 @@ fun HeadlessBankAccountConfigScreen( onFieldConfigChange = { viewModel.updateFieldConfig(it) }, uiConfig = uiConfig, onUiConfigChange = { viewModel.updateUiConfig(it) }, + useCustomTheme = useCustomTheme, + onUseCustomThemeChange = { viewModel.updateUseCustomTheme(it) }, ) } } diff --git a/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountScreen.kt b/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountScreen.kt index 38c28b7..7628a44 100644 --- a/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountScreen.kt +++ b/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountBalance @@ -37,33 +36,44 @@ import androidx.compose.material3.SnackbarDefaults import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import com.spreedly.app.R import com.spreedly.example.ui.theme.Spacing +import com.spreedly.example.viewmodel.findActivityOrNull import com.spreedly.hostedfields.ui.SPLTextField import com.spreedly.sdk.AdditionalField import com.spreedly.sdk.models.BankAccountHolderType import com.spreedly.sdk.models.BankAccountType import com.spreedly.sdk.models.FormFieldType +import com.spreedly.sdk.ui.BankAccountFieldConfig +import com.spreedly.sdk.ui.CustomFieldsConfig import com.spreedly.sdk.ui.NameFieldDisplayMode import com.spreedly.sdk.ui.PaymentProcessingResult +import com.spreedly.sdk.ui.achCombinedNameLengthErrorMessage +import com.spreedly.sdk.ui.isAchAccountNumberFieldValid +import com.spreedly.sdk.ui.resolveAchTextFieldImeAction +import com.spreedly.sdk.ui.resolveBankAccountNameErrorMessage import com.spreedly.security.secureScreen import com.spreedly.ui.molecules.AppTextField +import com.spreedly.ui.theme.resolveColor +import com.spreedly.ui.theme.resolveEffectiveCustomFieldsConfig import com.spreedly.ui.theme.spreedlySegmentedButtonColors +import com.spreedly.validation.SpreedlyParamsManager +import com.spreedly.validation.validators.NameValidator import androidx.navigation.NavController import kotlinx.coroutines.launch @@ -83,24 +93,110 @@ fun HeadlessBankAccountScreen( val paymentFinished by viewModel.paymentFinished.collectAsState() val fieldConfig by viewModel.fieldConfig.collectAsState() val uiConfig by viewModel.uiConfig.collectAsState() + val bankAccountState by sdk.bankAccountState + val resolvedConfig = resolveEffectiveCustomFieldsConfig(uiConfig) - var accountHolderName by rememberSaveable { mutableStateOf("") } - var nameError by rememberSaveable { mutableStateOf(null) } + var accountHolderName by remember { mutableStateOf("") } + var nameError by remember { mutableStateOf(null) } var accountType by remember { mutableStateOf(BankAccountType.CHECKING) } var accountHolderType by remember { mutableStateOf(BankAccountHolderType.PERSONAL) } - var firstName by rememberSaveable { mutableStateOf("") } - var lastName by rememberSaveable { mutableStateOf("") } - var firstNameError by rememberSaveable { mutableStateOf(null) } - var lastNameError by rememberSaveable { mutableStateOf(null) } + var firstName by remember { mutableStateOf("") } + var lastName by remember { mutableStateOf("") } + var firstNameError by remember { mutableStateOf(null) } + var lastNameError by remember { mutableStateOf(null) } + var bankName by remember { mutableStateOf("") } + var bankNameError by remember { mutableStateOf(null) } + + var routingNumberIsValid by remember { mutableStateOf(false) } + var accountNumberIsValid by remember { mutableStateOf(false) } + var fullNameIsValid by remember { mutableStateOf(false) } + var firstNameIsValid by remember { mutableStateOf(false) } + var lastNameIsValid by remember { mutableStateOf(false) } + var bankNameIsValid by remember { mutableStateOf(true) } + + fun clearLocalBankAccountFields() { + accountHolderName = "" + nameError = null + firstName = "" + lastName = "" + firstNameError = null + lastNameError = null + bankName = "" + bankNameError = null + accountType = BankAccountType.CHECKING + accountHolderType = BankAccountHolderType.PERSONAL + routingNumberIsValid = false + accountNumberIsValid = false + fullNameIsValid = false + firstNameIsValid = false + lastNameIsValid = false + bankNameIsValid = true + } + + val allowBlankName = SpreedlyParamsManager.allowBlankName + + val lastSyncedFieldConfig = remember { HeadlessFieldConfigSyncHolder() } + SideEffect { + if (lastSyncedFieldConfig.config != fieldConfig) { + sdk.setBankAccountFieldConfig(fieldConfig) + lastSyncedFieldConfig.config = fieldConfig + } + } + + val activity = LocalContext.current.findActivityOrNull() + DisposableEffect(Unit) { + onDispose { + if (activity?.isChangingConfigurations != true) { + clearLocalBankAccountFields() + viewModel.markLocalFieldsClearPending() + sdk.resetBankAccountState() + } + } + } + + LaunchedEffect(Unit) { + if (viewModel.consumeLocalFieldsClearPending()) { + clearLocalBankAccountFields() + } else { + accountHolderName = bankAccountState.accountHolderName.value + firstName = bankAccountState.firstName.value + lastName = bankAccountState.lastName.value + bankName = bankAccountState.bankName.value + accountType = bankAccountState.accountType + accountHolderType = bankAccountState.accountHolderType + } + } + + LaunchedEffect( + accountHolderName, + firstName, + lastName, + bankName, + fieldConfig, + allowBlankName, + bankAccountState.routingNumber.value, + bankAccountState.accountNumber.value, + ) { + val nameValidity = headlessAchNameValidity( + accountHolderName = accountHolderName, + firstName = firstName, + lastName = lastName, + bankName = bankName, + showBankName = fieldConfig.showBankName, + bankNameRequired = fieldConfig.bankNameRequired, + allowBlankName = allowBlankName, + ) + fullNameIsValid = nameValidity.fullNameIsValid + firstNameIsValid = nameValidity.firstNameIsValid + lastNameIsValid = nameValidity.lastNameIsValid + bankNameIsValid = nameValidity.bankNameIsValid + routingNumberIsValid = bankAccountState.routingNumber.isValid + accountNumberIsValid = isAchAccountNumberFieldValid(bankAccountState.accountNumber.value) + } LaunchedEffect(paymentFinished) { if (paymentFinished) { - accountHolderName = "" - nameError = null - firstName = "" - lastName = "" - firstNameError = null - lastNameError = null + clearLocalBankAccountFields() sdk.resetBankAccountState() viewModel.resetPaymentFinished() navController.popBackStack() @@ -114,34 +210,125 @@ fun HeadlessBankAccountScreen( val nameRequiredError = stringResource(R.string.headless_bank_account_holder_name_required) val nameMinLengthError = stringResource(R.string.headless_bank_account_holder_name_min_length) + val nameTooLongError = stringResource(R.string.headless_bank_account_holder_name_too_long) val validationFailedMsg = stringResource(R.string.headless_bank_account_validation_failed) val processingErrorMsg = stringResource(R.string.headless_bank_account_error) - fun validateName(): Boolean { - return when (fieldConfig.nameDisplayMode) { - NameFieldDisplayMode.SINGLE_FIELD -> { - nameError = when { - accountHolderName.isBlank() -> nameRequiredError - accountHolderName.length < 2 -> nameMinLengthError - else -> null - } - nameError == null - } - NameFieldDisplayMode.SEPARATE_FIELDS -> { - firstNameError = if (firstName.isBlank()) nameRequiredError else null - lastNameError = if (lastName.isBlank()) nameRequiredError else null - firstNameError == null && lastNameError == null - } - } + fun hasFieldsAfterName(): Boolean { + return fieldConfig.showBankName || fieldConfig.showAccountType || fieldConfig.showAccountHolderType + } + + fun nameValidationError(validator: NameValidator): String? { + return validator.resolveBankAccountNameErrorMessage( + isValidationForced = true, + requiredMessage = nameRequiredError, + tooShortMessage = nameMinLengthError, + tooLongMessage = nameTooLongError, + ) + } + + fun updateAccountHolderName(value: String) { + accountHolderName = value + sdk.bankAccountCallbacks.onAccountHolderNameChange(value) + val validator = NameValidator(value, isPure = false, minLength = NameValidator.ACH_MIN_LENGTH) + nameError = nameValidationError(validator) + fullNameIsValid = headlessAchNameValidity( + accountHolderName = value, + firstName = firstName, + lastName = lastName, + bankName = bankName, + showBankName = fieldConfig.showBankName, + bankNameRequired = fieldConfig.bankNameRequired, + allowBlankName = allowBlankName, + ).fullNameIsValid + } + + fun updateFirstName(value: String) { + firstName = value + sdk.bankAccountCallbacks.onFirstNameChange(value) + val validator = NameValidator(value, isPure = false, minLength = NameValidator.ACH_MIN_LENGTH) + val combinedError = achCombinedNameLengthErrorMessage() + firstNameError = combinedError ?: nameValidationError(validator) + lastNameError = combinedError + ?: nameValidationError(NameValidator(lastName, isPure = false, minLength = NameValidator.ACH_MIN_LENGTH)) + val nameValidity = headlessAchNameValidity( + accountHolderName = accountHolderName, + firstName = value, + lastName = lastName, + bankName = bankName, + showBankName = fieldConfig.showBankName, + bankNameRequired = fieldConfig.bankNameRequired, + allowBlankName = allowBlankName, + ) + firstNameIsValid = nameValidity.firstNameIsValid + lastNameIsValid = nameValidity.lastNameIsValid } - fun isFormFilledOut(): Boolean { - return when (fieldConfig.nameDisplayMode) { - NameFieldDisplayMode.SINGLE_FIELD -> accountHolderName.isNotBlank() && accountHolderName.length >= 2 - NameFieldDisplayMode.SEPARATE_FIELDS -> firstName.isNotBlank() && lastName.isNotBlank() + fun updateLastName(value: String) { + lastName = value + sdk.bankAccountCallbacks.onLastNameChange(value) + val validator = NameValidator(value, isPure = false, minLength = NameValidator.ACH_MIN_LENGTH) + val combinedError = achCombinedNameLengthErrorMessage() + lastNameError = combinedError ?: nameValidationError(validator) + firstNameError = combinedError + ?: nameValidationError(NameValidator(firstName, isPure = false, minLength = NameValidator.ACH_MIN_LENGTH)) + val nameValidity = headlessAchNameValidity( + accountHolderName = accountHolderName, + firstName = firstName, + lastName = value, + bankName = bankName, + showBankName = fieldConfig.showBankName, + bankNameRequired = fieldConfig.bankNameRequired, + allowBlankName = allowBlankName, + ) + firstNameIsValid = nameValidity.firstNameIsValid + lastNameIsValid = nameValidity.lastNameIsValid + } + + fun updateBankName(value: String) { + bankName = value + sdk.bankAccountCallbacks.onBankNameChange(value, fieldConfig.bankNameRequired) + if (!fieldConfig.showBankName) { + bankNameError = null + bankNameIsValid = true + return + } + if (!fieldConfig.bankNameRequired) { + bankNameError = null + bankNameIsValid = true + return } + val validator = NameValidator(value, isPure = false, minLength = NameValidator.ACH_MIN_LENGTH) + bankNameError = validator.resolveBankAccountNameErrorMessage( + isValidationForced = true, + requiredMessage = nameRequiredError, + tooShortMessage = nameMinLengthError, + tooLongMessage = nameTooLongError, + requireNonBlank = true, + ) + bankNameIsValid = headlessAchNameValidity( + accountHolderName = accountHolderName, + firstName = firstName, + lastName = lastName, + bankName = value, + showBankName = fieldConfig.showBankName, + bankNameRequired = fieldConfig.bankNameRequired, + allowBlankName = allowBlankName, + ).bankNameIsValid } + val isFormValid = routingNumberIsValid && + accountNumberIsValid && + when (fieldConfig.nameDisplayMode) { + NameFieldDisplayMode.SINGLE_FIELD -> fullNameIsValid + NameFieldDisplayMode.SEPARATE_FIELDS -> firstNameIsValid && lastNameIsValid + } && + if (fieldConfig.showBankName && fieldConfig.bankNameRequired) { + bankNameIsValid + } else { + true + } + Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) { data -> @@ -244,8 +431,9 @@ fun HeadlessBankAccountScreen( label = stringResource(com.spreedly.paymentsheet.R.string.bank_account_routing_number_label), formFieldType = FormFieldType.ROUTING_NUMBER(required = true), config = uiConfig, - value = sdk.bankAccountState.value.routingNumber.value, + value = bankAccountState.routingNumber.value, onChange = { sdk.bankAccountCallbacks.onRoutingNumberChange(it, true) }, + onValidationChange = { routingNumberIsValid = it }, ) Spacer(modifier = Modifier.height(Spacing.sm)) @@ -254,8 +442,9 @@ fun HeadlessBankAccountScreen( label = stringResource(com.spreedly.paymentsheet.R.string.bank_account_account_number_label), formFieldType = FormFieldType.ACCOUNT_NUMBER(required = true), config = uiConfig, - value = sdk.bankAccountState.value.accountNumber.value, + value = bankAccountState.accountNumber.value, onChange = { sdk.bankAccountCallbacks.onAccountNumberChange(it, true) }, + onValidationChange = { accountNumberIsValid = it }, ) Spacer(modifier = Modifier.height(Spacing.lg)) @@ -271,28 +460,26 @@ fun HeadlessBankAccountScreen( .padding(bottom = Spacing.sm), ) - val fieldShape = RoundedCornerShape(uiConfig.borderRadius) + val fieldShape = resolvedConfig.fieldShape when (fieldConfig.nameDisplayMode) { NameFieldDisplayMode.SINGLE_FIELD -> { AppTextField( label = stringResource(R.string.headless_bank_account_holder_name_label), value = accountHolderName, - onValueChange = { accountHolderName = it }, + onValueChange = { updateAccountHolderName(it) }, hint = "", isRequired = true, error = nameError, - backgroundColor = uiConfig.fieldBackgroundColor, - focusedBorderColor = uiConfig.primaryColor, - unfocusedBorderColor = uiConfig.formBorderColor, - labelColor = uiConfig.fieldLabelColor, - textColor = uiConfig.textColor, + backgroundColor = resolvedConfig.fieldBackgroundColor, + focusedBorderColor = resolvedConfig.primaryColor, + unfocusedBorderColor = resolvedConfig.formBorderColor, + labelColor = resolvedConfig.fieldLabelColor, + placeholderColor = resolvedConfig.placeholderColor, + textColor = resolvedConfig.textColor, shape = fieldShape, - imeAction = if (!fieldConfig.showAccountType && !fieldConfig.showAccountHolderType) { - ImeAction.Done - } else { - ImeAction.Default - }, + imeAction = resolveAchTextFieldImeAction(hasFieldsAfterName()), + maxLength = NameValidator.MAX_LENGTH, modifier = Modifier.fillMaxWidth(), ) } @@ -301,15 +488,16 @@ fun HeadlessBankAccountScreen( AppTextField( label = stringResource(R.string.headless_bank_account_first_name_label), value = firstName, - onValueChange = { firstName = it }, + onValueChange = { updateFirstName(it) }, hint = "", isRequired = true, error = firstNameError, - backgroundColor = uiConfig.fieldBackgroundColor, - focusedBorderColor = uiConfig.primaryColor, - unfocusedBorderColor = uiConfig.formBorderColor, - labelColor = uiConfig.fieldLabelColor, - textColor = uiConfig.textColor, + backgroundColor = resolvedConfig.fieldBackgroundColor, + focusedBorderColor = resolvedConfig.primaryColor, + unfocusedBorderColor = resolvedConfig.formBorderColor, + labelColor = resolvedConfig.fieldLabelColor, + placeholderColor = resolvedConfig.placeholderColor, + textColor = resolvedConfig.textColor, shape = fieldShape, modifier = Modifier.weight(1f), ) @@ -317,15 +505,16 @@ fun HeadlessBankAccountScreen( AppTextField( label = stringResource(R.string.headless_bank_account_last_name_label), value = lastName, - onValueChange = { lastName = it }, + onValueChange = { updateLastName(it) }, hint = "", isRequired = true, error = lastNameError, - backgroundColor = uiConfig.fieldBackgroundColor, - focusedBorderColor = uiConfig.primaryColor, - unfocusedBorderColor = uiConfig.formBorderColor, - labelColor = uiConfig.fieldLabelColor, - textColor = uiConfig.textColor, + backgroundColor = resolvedConfig.fieldBackgroundColor, + focusedBorderColor = resolvedConfig.primaryColor, + unfocusedBorderColor = resolvedConfig.formBorderColor, + labelColor = resolvedConfig.fieldLabelColor, + placeholderColor = resolvedConfig.placeholderColor, + textColor = resolvedConfig.textColor, shape = fieldShape, modifier = Modifier.weight(1f), ) @@ -333,6 +522,33 @@ fun HeadlessBankAccountScreen( } } + if (fieldConfig.showBankName) { + Spacer(modifier = Modifier.height(Spacing.md)) + + val bankNameLabel = fieldConfig.bankNameLabel + ?: stringResource(R.string.headless_bank_account_bank_name_label) + AppTextField( + label = bankNameLabel, + value = bankName, + onValueChange = { updateBankName(it) }, + hint = "", + isRequired = fieldConfig.bankNameRequired, + error = bankNameError, + backgroundColor = resolvedConfig.fieldBackgroundColor, + focusedBorderColor = resolvedConfig.primaryColor, + unfocusedBorderColor = resolvedConfig.formBorderColor, + labelColor = resolvedConfig.fieldLabelColor, + placeholderColor = resolvedConfig.placeholderColor, + textColor = resolvedConfig.textColor, + shape = fieldShape, + imeAction = resolveAchTextFieldImeAction( + fieldConfig.showAccountType || fieldConfig.showAccountHolderType, + ), + maxLength = NameValidator.MAX_LENGTH, + modifier = Modifier.fillMaxWidth(), + ) + } + if (fieldConfig.showAccountType) { Spacer(modifier = Modifier.height(Spacing.md)) @@ -349,7 +565,7 @@ fun HeadlessBankAccountScreen( BankAccountType.CHECKING to stringResource(R.string.headless_bank_account_checking), BankAccountType.SAVINGS to stringResource(R.string.headless_bank_account_savings), ) - val segmentedColors = spreedlySegmentedButtonColors(uiConfig.primaryColor) + val segmentedColors = spreedlySegmentedButtonColors(resolvedConfig.primaryColor) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { accountTypes.forEachIndexed { index, type -> SegmentedButton( @@ -383,7 +599,7 @@ fun HeadlessBankAccountScreen( BankAccountHolderType.PERSONAL to stringResource(R.string.headless_bank_account_personal), BankAccountHolderType.BUSINESS to stringResource(R.string.headless_bank_account_business), ) - val holderSegmentedColors = spreedlySegmentedButtonColors(uiConfig.primaryColor) + val holderSegmentedColors = spreedlySegmentedButtonColors(resolvedConfig.primaryColor) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { holderTypes.forEachIndexed { index, type -> SegmentedButton( @@ -403,35 +619,31 @@ fun HeadlessBankAccountScreen( Spacer(modifier = Modifier.height(Spacing.lg)) - val isButtonDisabled = isInitializing || isProcessing || !isFormFilledOut() + val isButtonDisabled = isInitializing || isProcessing || !isFormValid Button( onClick = { - if (validateName()) { - coroutineScope.launch { + if (!isFormValid) return@Button + coroutineScope.launch { try { if (!viewModel.initializeForPayment()) return@launch - when (fieldConfig.nameDisplayMode) { - NameFieldDisplayMode.SINGLE_FIELD -> { - sdk.bankAccountCallbacks.onAccountHolderNameChange(accountHolderName) - } - NameFieldDisplayMode.SEPARATE_FIELDS -> { - sdk.bankAccountCallbacks.onFirstNameChange(firstName) - sdk.bankAccountCallbacks.onLastNameChange(lastName) - } - } sdk.bankAccountCallbacks.onAccountTypeChange(accountType) sdk.bankAccountCallbacks.onAccountHolderTypeChange(accountHolderType) - val additionalFields = when (fieldConfig.nameDisplayMode) { - NameFieldDisplayMode.SINGLE_FIELD -> mapOf( - AdditionalField.FULL_NAME to accountHolderName, - ) - NameFieldDisplayMode.SEPARATE_FIELDS -> mapOf( - AdditionalField.FIRST_NAME to firstName, - AdditionalField.LAST_NAME to lastName, - ) + val additionalFields = buildMap { + when (fieldConfig.nameDisplayMode) { + NameFieldDisplayMode.SINGLE_FIELD -> { + put(AdditionalField.FULL_NAME, accountHolderName) + } + NameFieldDisplayMode.SEPARATE_FIELDS -> { + put(AdditionalField.FIRST_NAME, firstName) + put(AdditionalField.LAST_NAME, lastName) + } + } + if (fieldConfig.showBankName && bankName.isNotBlank()) { + put(AdditionalField.BANK_NAME, bankName) + } } val result = sdk.createBankAccount( @@ -455,6 +667,13 @@ fun HeadlessBankAccountScreen( withDismissAction = true, ) } + is PaymentProcessingResult.Rejected -> { + viewModel.setProcessing(false) + } + is PaymentProcessingResult.Failed -> { + viewModel.setProcessing(true) + viewModel.startPaymentPolling() + } } } catch (e: Exception) { viewModel.setProcessing(false) @@ -463,7 +682,6 @@ fun HeadlessBankAccountScreen( withDismissAction = true, ) } - } } }, modifier = Modifier @@ -472,11 +690,10 @@ fun HeadlessBankAccountScreen( enabled = !isButtonDisabled, shape = MaterialTheme.shapes.medium, colors = ButtonDefaults.buttonColors( - containerColor = if (uiConfig.primaryColor != Color.Unspecified) { - uiConfig.primaryColor - } else { - MaterialTheme.colorScheme.primary - }, + containerColor = resolveColor( + resolvedConfig.primaryColor, + MaterialTheme.colorScheme.primary, + ), disabledContainerColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), ), ) { @@ -496,7 +713,6 @@ fun HeadlessBankAccountScreen( text = when { isInitializing -> stringResource(R.string.headless_bank_account_initializing) isProcessing -> stringResource(R.string.headless_bank_account_processing) - !isFormFilledOut() -> stringResource(R.string.headless_bank_account_fill_fields) else -> stringResource(R.string.headless_bank_account_submit) }, color = MaterialTheme.colorScheme.onPrimary, @@ -528,3 +744,7 @@ fun HeadlessBankAccountScreen( } } +private class HeadlessFieldConfigSyncHolder { + var config: BankAccountFieldConfig? = null +} + diff --git a/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountViewModel.kt b/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountViewModel.kt index 09d7960..b371385 100644 --- a/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountViewModel.kt +++ b/app/src/main/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean class HeadlessBankAccountViewModel(private val context: Context) : ViewModel() { val sdk = Spreedly() @@ -37,6 +38,8 @@ class HeadlessBankAccountViewModel(private val context: Context) : ViewModel() { private val _paymentFinished = MutableStateFlow(false) val paymentFinished: StateFlow = _paymentFinished.asStateFlow() + private val clearLocalFieldsOnNextEntry = AtomicBoolean(false) + private var lastPaymentCompletedTime = 0L private val _fieldConfig = MutableStateFlow(BankAccountFieldConfig.Default) @@ -45,6 +48,9 @@ class HeadlessBankAccountViewModel(private val context: Context) : ViewModel() { private val _uiConfig = MutableStateFlow(CustomFieldsConfig()) val uiConfig: StateFlow = _uiConfig.asStateFlow() + private val _useCustomTheme = MutableStateFlow(false) + val useCustomTheme: StateFlow = _useCustomTheme.asStateFlow() + private val sdkSessionManager = SdkSessionManager(AuthService()) private val paymentMethodRepository = PaymentMethodRepository(context) private val paymentResultHandler = PaymentResultHandler(paymentMethodRepository) @@ -177,6 +183,13 @@ class HeadlessBankAccountViewModel(private val context: Context) : ViewModel() { _paymentFinished.value = false } + fun markLocalFieldsClearPending() { + clearLocalFieldsOnNextEntry.set(true) + } + + fun consumeLocalFieldsClearPending(): Boolean = + clearLocalFieldsOnNextEntry.getAndSet(false) + fun updateFieldConfig(config: BankAccountFieldConfig) { _fieldConfig.value = config } @@ -185,6 +198,13 @@ class HeadlessBankAccountViewModel(private val context: Context) : ViewModel() { _uiConfig.value = config } + fun updateUseCustomTheme(enabled: Boolean) { + _useCustomTheme.value = enabled + if (!enabled) { + _uiConfig.value = CustomFieldsConfig() + } + } + private companion object { private const val TAG = "HeadlessBankAccountVM" private const val PAYMENT_TIMEOUT_MS = 30_000L 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 df608e5..d7ff26d 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 @@ -710,6 +710,10 @@ fun RecachingShowcaseScreen( "check your information.", ) } + + is PaymentProcessingResult.Rejected, + is PaymentProcessingResult.Failed, + -> Unit } } catch (e: Exception) { Log.d( 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 index 3b6fcba..2a5da8d 100644 --- a/app/src/main/java/com/spreedly/example/ui/theme/SplFieldConfigResolver.kt +++ b/app/src/main/java/com/spreedly/example/ui/theme/SplFieldConfigResolver.kt @@ -1,9 +1,9 @@ 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 +import com.spreedly.ui.theme.toCustomFieldsConfig object SplFieldConfigResolver { fun resolve( diff --git a/app/src/main/java/com/spreedly/example/viewmodel/ViewModelFactories.kt b/app/src/main/java/com/spreedly/example/viewmodel/ViewModelFactories.kt index 8ce1ac2..3174364 100644 --- a/app/src/main/java/com/spreedly/example/viewmodel/ViewModelFactories.kt +++ b/app/src/main/java/com/spreedly/example/viewmodel/ViewModelFactories.kt @@ -81,12 +81,17 @@ inline fun viewModelWithContextAndActivity( * Walks up the Context chain until an Activity is found. */ fun Context.findActivity(): Activity { + return findActivityOrNull() + ?: throw IllegalStateException("Could not find Activity in Context chain") +} + +fun Context.findActivityOrNull(): Activity? { var context = this while (context is ContextWrapper) { if (context is Activity) return context context = context.baseContext } - throw IllegalStateException("Could not find Activity in Context chain") + return null } @Composable @@ -168,3 +173,4 @@ fun stripeAPMPaymentViewModel(): StripeAPMPaymentViewModel = viewModelWithContex fun braintreePaymentViewModel(): BraintreePaymentViewModel = viewModelWithContext { context -> BraintreePaymentViewModel(context) } + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index add7674..a36f8bd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -101,6 +101,7 @@ Braintree Payment Braintree PayPal and Venmo payments with native SDK checkout flow + Java Offsite Payment Java implementation of offsite payment flow (PayPal, Sprel) with Chrome Custom Tabs @@ -136,6 +137,7 @@ Account Holder Name Name is required Name must be at least 2 characters + Name must be at most 350 characters Bank Account Information (Secure) Account Details (Custom Handled) Account Type @@ -160,11 +162,9 @@ Payment processing timeout - Field Configuration + Configuration Name Display Mode - Default - Minimal - Full + Use Custom Theme Full Name First + Last Show Bank Name diff --git a/app/src/test/java/com/spreedly/example/screens/bankaccount/BankAccountViewModelThemeTest.kt b/app/src/test/java/com/spreedly/example/screens/bankaccount/BankAccountViewModelThemeTest.kt new file mode 100644 index 0000000..3117865 --- /dev/null +++ b/app/src/test/java/com/spreedly/example/screens/bankaccount/BankAccountViewModelThemeTest.kt @@ -0,0 +1,72 @@ +package com.spreedly.example.screens.bankaccount + +import android.content.Context +import androidx.compose.ui.graphics.Color +import com.spreedly.sdk.ui.CustomFieldsConfig +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class BankAccountViewModelThemeTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockContext: Context + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockContext = mockk(relaxed = true) + every { mockContext.applicationContext } returns mockContext + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `useCustomTheme defaults to false with default uiConfig`() = runTest { + val viewModel = BankAccountViewModel(mockContext) + advanceUntilIdle() + + assertFalse(viewModel.useCustomTheme.value) + assertEquals(CustomFieldsConfig(), viewModel.uiConfig.value) + } + + @Test + fun `useCustomTheme true with swatch selection updates uiConfig primaryColor`() = runTest { + val viewModel = BankAccountViewModel(mockContext) + advanceUntilIdle() + + viewModel.updateUseCustomTheme(true) + viewModel.updateUiConfig(CustomFieldsConfig(primaryColor = Color.Red)) + + assertEquals(Color.Red, viewModel.uiConfig.value.primaryColor) + assertNotEquals(Color.Unspecified, viewModel.uiConfig.value.primaryColor) + } + + @Test + fun `toggle off after customization resets uiConfig to defaults`() = runTest { + val viewModel = BankAccountViewModel(mockContext) + advanceUntilIdle() + + viewModel.updateUseCustomTheme(true) + viewModel.updateUiConfig(CustomFieldsConfig(primaryColor = Color.Red)) + viewModel.updateUseCustomTheme(false) + + assertFalse(viewModel.useCustomTheme.value) + assertEquals(CustomFieldsConfig(), viewModel.uiConfig.value) + } +} diff --git a/app/src/test/java/com/spreedly/example/screens/headlessbankaccount/HeadlessAchNameValidityTest.kt b/app/src/test/java/com/spreedly/example/screens/headlessbankaccount/HeadlessAchNameValidityTest.kt new file mode 100644 index 0000000..dfe0e65 --- /dev/null +++ b/app/src/test/java/com/spreedly/example/screens/headlessbankaccount/HeadlessAchNameValidityTest.kt @@ -0,0 +1,77 @@ +package com.spreedly.example.screens.headlessbankaccount + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HeadlessAchNameValidityTest { + + @Test + fun `headlessAchNameValidity allows blank single name when allowBlankName true`() { + val result = headlessAchNameValidity( + accountHolderName = "", + firstName = "", + lastName = "", + bankName = "", + showBankName = false, + bankNameRequired = false, + allowBlankName = true, + ) + assertTrue(result.fullNameIsValid) + } + + @Test + fun `headlessAchNameValidity allows blank separate pair when allowBlankName true`() { + val result = headlessAchNameValidity( + accountHolderName = "", + firstName = "", + lastName = "", + bankName = "", + showBankName = false, + bankNameRequired = false, + allowBlankName = true, + ) + assertTrue(result.firstNameIsValid) + assertTrue(result.lastNameIsValid) + } + + @Test + fun `headlessAchNameValidity rejects blank when allowBlankName false`() { + val result = headlessAchNameValidity( + accountHolderName = "", + firstName = "", + lastName = "", + bankName = "", + showBankName = false, + bankNameRequired = false, + allowBlankName = false, + ) + assertFalse(result.fullNameIsValid) + assertFalse(result.firstNameIsValid) + assertFalse(result.lastNameIsValid) + } + + @Test + fun `required bank name stays invalid when blank regardless of allowBlankName`() { + val whenAllowBlank = headlessAchNameValidity( + accountHolderName = "Jon Doe", + firstName = "Jon", + lastName = "Doe", + bankName = "", + showBankName = true, + bankNameRequired = true, + allowBlankName = true, + ) + val whenDisallowBlank = headlessAchNameValidity( + accountHolderName = "Jon Doe", + firstName = "Jon", + lastName = "Doe", + bankName = "", + showBankName = true, + bankNameRequired = true, + allowBlankName = false, + ) + assertFalse(whenAllowBlank.bankNameIsValid) + assertFalse(whenDisallowBlank.bankNameIsValid) + } +} diff --git a/app/src/test/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountViewModelThemeTest.kt b/app/src/test/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountViewModelThemeTest.kt new file mode 100644 index 0000000..ec4bf5c --- /dev/null +++ b/app/src/test/java/com/spreedly/example/screens/headlessbankaccount/HeadlessBankAccountViewModelThemeTest.kt @@ -0,0 +1,85 @@ +package com.spreedly.example.screens.headlessbankaccount + +import android.content.Context +import androidx.compose.ui.graphics.Color +import com.spreedly.sdk.ui.CustomFieldsConfig +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class HeadlessBankAccountViewModelThemeTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockContext: Context + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockContext = mockk(relaxed = true) + every { mockContext.applicationContext } returns mockContext + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `useCustomTheme defaults to false with default uiConfig`() = runTest { + val viewModel = HeadlessBankAccountViewModel(mockContext) + advanceUntilIdle() + + assertFalse(viewModel.useCustomTheme.value) + assertEquals(CustomFieldsConfig(), viewModel.uiConfig.value) + } + + @Test + fun `useCustomTheme true with swatch selection updates uiConfig primaryColor`() = runTest { + val viewModel = HeadlessBankAccountViewModel(mockContext) + advanceUntilIdle() + + viewModel.updateUseCustomTheme(true) + viewModel.updateUiConfig(CustomFieldsConfig(primaryColor = Color.Red)) + + assertEquals(Color.Red, viewModel.uiConfig.value.primaryColor) + assertNotEquals(Color.Unspecified, viewModel.uiConfig.value.primaryColor) + } + + @Test + fun `toggle off after customization resets uiConfig to defaults`() = runTest { + val viewModel = HeadlessBankAccountViewModel(mockContext) + advanceUntilIdle() + + viewModel.updateUseCustomTheme(true) + viewModel.updateUiConfig(CustomFieldsConfig(primaryColor = Color.Red)) + viewModel.updateUseCustomTheme(false) + + assertFalse(viewModel.useCustomTheme.value) + assertEquals(CustomFieldsConfig(), viewModel.uiConfig.value) + } + + @Test + fun `consumeLocalFieldsClearPending is true only after mark and only once`() = runTest { + val viewModel = HeadlessBankAccountViewModel(mockContext) + advanceUntilIdle() + + assertFalse(viewModel.consumeLocalFieldsClearPending()) + + viewModel.markLocalFieldsClearPending() + assertTrue(viewModel.consumeLocalFieldsClearPending()) + assertFalse(viewModel.consumeLocalFieldsClearPending()) + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 41a9518..07cf921 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,23 @@ 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.2.0] - 2026-07-22 + +### Added + +- **ACH bank account tokenization** (`paymentsheet`, `payments-core`) — bank account payment support with a pre-built drop-in sheet, a custom-layout composable (`BankAccountSheet(sdk, …)`), and a headless `createBankAccount()` flow. Configure name display mode and optional bank-name / account-type fields via `Spreedly.setBankAccountFieldConfig` / `setBankAccountNameDisplayMode` (and matching `SpreedlyUIController` methods). Routing, account, and name fields validate to iOS parity; in-memory routing and account data is cleared on dismiss, cancel, and API/network failure (SEC-8709), while a configuration change preserves mid-edit values. +- **`PaymentResult.Completed.paymentMethodType`** (`payments-core`) — tokenize success now reports the payment method type (e.g. `"bank_account"`) on `PaymentResult.Completed` from the payment method response. +- **Shared theming helpers** (`payments-core`, `hostedfields`) — `resolveEffectiveCustomFieldsConfig()` and `SpreedlyTheme.toCustomFieldsConfig()` give card and ACH surfaces one theme → field-config mapping (global-theme merge, `fieldShape` synced from `borderRadius`). +- **`CheckoutButton.isFormValid` / `AppTextField.maxLength` / `NameValidator` min length** (`payments-core`) — additive parameters; prior signatures retained via hidden compatibility overloads / `@JvmOverloads`. + +### Breaking + +- **`PaymentProcessingResult`** (`payments-core`) — adds `Rejected(ALREADY_PROCESSING)` and `Failed(UNEXPECTED_ERROR)`. `processPayment()` returns this sealed type, so exhaustive `when` expressions must handle the new branches (or add an `else`). Card flows never return the new values, but the type is shared. + +### Changed + +- **Express checkout guide** (`docs`) — clarifies that card sheet `borderRadius` and `fieldShape` are independent; ACH `CustomFieldsConfig` paths sync shape from radius. + ## [1.1.0] - 2026-06-03 ### Breaking Changes diff --git a/docs/guides/ach-bank-account.md b/docs/guides/ach-bank-account.md index f04701b..068c26f 100644 --- a/docs/guides/ach-bank-account.md +++ b/docs/guides/ach-bank-account.md @@ -6,7 +6,7 @@ Tokenize bank accounts (routing number + account number) via the Spreedly Androi 2. **Custom layout** -- `BankAccountSheet` composable embedded in your own UI 3. **Headless** -- `sdk.createBankAccount()` with SPL text fields and your own form -All three paths produce a Spreedly payment method token through `paymentResultFlow`. +All three paths produce a Spreedly payment method token. The drop-in bottom sheet delivers its result on the `onPaymentResult` callback; the custom-layout and headless paths deliver on `paymentResultFlow`. See [Handling Results](#handling-results). ## Table of Contents @@ -107,7 +107,10 @@ SpreedlyBankAccountBottomSheet( is PaymentResult.Failed -> { // result.message contains the error } - else -> {} + is PaymentResult.Canceled -> { + // user dismissed the sheet (swipe / back) + } + PaymentResult.Initial -> {} } }, ) @@ -117,19 +120,90 @@ SpreedlyBankAccountBottomSheet( ## Custom Layout -Use `BankAccountSheet` to embed the bank account form in your own layout. You provide the submit button. +Use `BankAccountSheet` with an `sdk` parameter so the same `fieldConfig` drives both the form UI and `createBankAccount` validation. Pass `isFormValid` into `CheckoutButton` (or otherwise gate submit until the form is valid). + +Passing `fieldConfig` alone to the state/callbacks overload does **not** configure SDK submission validation. ```kotlin import com.spreedly.paymentsheet.BankAccountSheet import com.spreedly.sdk.models.FormFieldType import com.spreedly.sdk.models.PaymentMethodType +import com.spreedly.sdk.ui.BankAccountFieldConfig import com.spreedly.sdk.ui.CheckoutButton +BankAccountSheet( + sdk = sdk, + fieldConfig = BankAccountFieldConfig.Default, + button = { isFormValid -> + CheckoutButton( + sdk = sdk, + formFields = listOf( + FormFieldType.ROUTING_NUMBER(required = true), + FormFieldType.ACCOUNT_NUMBER(required = true), + ), + paymentMethodType = PaymentMethodType.BANK_ACCOUNT, + isFormValid = isFormValid, + ) + }, +) +``` + +This overload clears in-memory ACH fields when it leaves composition (navigation back, parent `if (show)` becoming false, etc.). Mid-edit values are preserved across configuration change only when a host Activity can be resolved (or is supplied by the drop-in bottom sheet) and reports `isChangingConfigurations`. If no Activity is available, leave-clear is fail-closed (state is reset). Process death does not preserve SDK bank-account state. + +Use the same `allowBlankName` setting for the rendered form and for `createBankAccount` (global `SpreedlyParamsManager` / `setParam`, or the per-call override on submit). + +Blank names may be accepted when `allowBlankName` is enabled. Nonblank names must still satisfy ACH minimum length (2), maximum length (350), and combined first+last length rules. + +### Parameters (`BankAccountSheet(sdk, …)`) + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `sdk` | `Spreedly` | -- | SDK instance; syncs `fieldConfig` and supplies state/callbacks | +| `button` | `@Composable (isFormValid: Boolean) -> Unit` | -- | Your submit button (typically `CheckoutButton`); pass `isFormValid` into `CheckoutButton` | +| `fieldConfig` | `BankAccountFieldConfig` | `Default` | Which optional fields to show (also applied to submission validation) | +| `customFieldsConfig` | `CustomFieldsConfig` | `Default` | Colors, border radius, etc. | +| `borderRadius` | `Dp` | `8.dp` | Surface corner radius | +| `modifier` | `Modifier` | -- | Outer surface modifier | + +### State/callbacks overload (manual config sync) + +If you embed `BankAccountSheet(state, callbacks, fieldConfig, …)` yourself (for example a headless dual-write UI), you **must** sync config **and** clear ACH state on leave (this overload does not auto-clear): + +```kotlin +// Resolve the Activity by unwrapping ContextWrapper layers. `LocalContext.current as? Activity` +// is not reliable — Compose often runs under a ContextThemeWrapper, so the direct cast returns null. +fun Context.findActivity(): Activity? { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + return null +} + +val activity = LocalContext.current.findActivity() + +SideEffect { + sdk.setBankAccountFieldConfig(fieldConfig) +} + +DisposableEffect(Unit) { + onDispose { + // Fail-closed when Activity is missing (treat as true leave). + // Do not call preserveBankAccountStateOnNextShow() from custom layouts — + // that latch is for the drop-in bottom sheet open path only. + if (activity?.isChangingConfigurations != true) { + sdk.resetBankAccountState() + } + // On configuration change, leave SDK in-memory state as-is (no preserve latch). + } +} + BankAccountSheet( state = sdk.bankAccountState.value, callbacks = sdk.bankAccountCallbacks, - fieldConfig = BankAccountFieldConfig.Default, - button = { + fieldConfig = fieldConfig, + button = { isFormValid -> CheckoutButton( sdk = sdk, formFields = listOf( @@ -137,19 +211,20 @@ BankAccountSheet( FormFieldType.ACCOUNT_NUMBER(required = true), ), paymentMethodType = PaymentMethodType.BANK_ACCOUNT, + isFormValid = isFormValid, ) }, ) ``` -### Parameters +Prefer `BankAccountSheet(sdk, …)` when you can — it syncs `fieldConfig` and clears on leave for you. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `state` | `BankAccountSheetState` | -- | Current form state from `sdk.bankAccountState` | | `callbacks` | `BankAccountSheetCallbacks` | -- | Change handlers from `sdk.bankAccountCallbacks` | -| `button` | `@Composable () -> Unit` | -- | Your submit button (typically `CheckoutButton`) | -| `fieldConfig` | `BankAccountFieldConfig` | `Default` | Which optional fields to show | +| `button` | `@Composable (isFormValid: Boolean) -> Unit` | -- | Your submit button; pass `isFormValid` into `CheckoutButton` | +| `fieldConfig` | `BankAccountFieldConfig` | `Default` | Which optional fields to show (must match `setBankAccountFieldConfig`) | | `customFieldsConfig` | `CustomFieldsConfig` | `Default` | Colors, border radius, etc. | | `borderRadius` | `Dp` | `8.dp` | Surface corner radius | @@ -166,6 +241,28 @@ BankAccountSheet( | `formBorderColor` | `Color` | `Unspecified` | Color for field borders | | `iconColor` | `Color` | `Unspecified` | Color for trailing icons | | `borderRadius` | `Dp` | `8.dp` | Corner radius for fields | +| `fieldShape` | `Shape` | `RoundedCornerShape(8.dp)` | Field corner shape; synced from `borderRadius` on SDK resolution paths | + +### Theme resolution + +`BankAccountSheet` and `SPLTextField` merge your `customFieldsConfig` with the global theme from `Spreedly.setGlobalTheme()` at render time. For each color, an explicit value on `customFieldsConfig` wins; `Color.Unspecified` falls back to the global theme. + +`borderRadius` is the authoritative control for field corners on all ACH fields (name `AppTextField` and routing/account `SPLTextField`). SDK resolution paths derive `fieldShape` from `borderRadius`, so a mismatched explicit `fieldShape` is overridden — set `borderRadius` instead. + +For headless layouts that mix `AppTextField` with `SPLTextField`, call `resolveEffectiveCustomFieldsConfig(config)` inside a `@Composable` to style name fields the same way the SDK resolves SPL fields: + +```kotlin +import com.spreedly.ui.theme.resolveEffectiveCustomFieldsConfig + +val resolvedConfig = resolveEffectiveCustomFieldsConfig(customFieldsConfig) + +AppTextField( + backgroundColor = resolvedConfig.fieldBackgroundColor, + focusedBorderColor = resolvedConfig.primaryColor, + shape = resolvedConfig.fieldShape, + // ... +) +``` --- @@ -173,12 +270,20 @@ BankAccountSheet( Build your own form using SPL text fields for routing/account numbers and standard text fields for names. +Call `sdk.setBankAccountFieldConfig(fieldConfig)` whenever your headless form’s field visibility differs from `BankAccountFieldConfig.Default`, and keep `allowBlankName` consistent between UI gating and `createBankAccount`. + +> **PCI:** Any screen that renders cardholder or bank-account fields must block screenshots and screen recording. Call `SecureScreen()` (or apply `Modifier.secureScreen()` to the container) in your headless form — the drop-in `SpreedlyBankAccountBottomSheet` and `BankAccountSheet` already do this for you. + ```kotlin import com.spreedly.hostedfields.ui.SPLTextField import com.spreedly.sdk.models.FormFieldType +import com.spreedly.security.SecureScreen val sdk = remember { Spreedly() } -var fullName by rememberSaveable { mutableStateOf("") } +var fullName by remember { mutableStateOf("") } + +// Prevent screenshots / screen recording of the bank-account fields (FLAG_SECURE). +SecureScreen() // Name field (not sensitive -- use a standard text field) OutlinedTextField( @@ -255,8 +360,8 @@ LaunchedEffect(Unit) { ```kotlin val sdk = remember { Spreedly() } -var firstName by rememberSaveable { mutableStateOf("") } -var lastName by rememberSaveable { mutableStateOf("") } +var firstName by remember { mutableStateOf("") } +var lastName by remember { mutableStateOf("") } OutlinedTextField( value = firstName, @@ -329,7 +434,73 @@ val config = BankAccountFieldConfig( ## Handling Results -Results flow through `sdk.paymentResultFlow` (same as credit card payments): +Results are delivered by different channels depending on the integration path. + +**One ACH session per `Spreedly` instance.** Do not run two ACH UIs (drop-in + custom, or two drop-ins) against the same SDK at once. A second `createBankAccount()` / submit while another operation is in flight, in the post-claim cleanup window, or while a terminal awaits acknowledgement returns **`PaymentProcessingResult.Rejected(ALREADY_PROCESSING)`** before any network dispatch — parameters from the rejected call are ignored. Prefer one ACH UI, or separate `Spreedly` instances for concurrent flows. + +| Path | Result delivery | Submitting / duplicate-submit | +|---|---|---| +| Drop-in `SpreedlyBankAccountBottomSheet` | Terminals on `onPaymentResult` (surface-scoped). `Completed`/`Failed` also on `paymentResultFlow`. `Canceled` is surface-only (not on the shared flow). | SDK-owned submitting latch; duplicate taps return `Rejected(ALREADY_PROCESSING)` | +| Custom `BankAccountSheet(sdk)` + `CheckoutButton` | Same as headless for tokenize: `paymentResultFlow` for `Completed`/`Failed` | Same latch and rejection semantics when using SDK `CheckoutButton` | +| Headless / direct `createBankAccount()` | `paymentResultFlow` only (`Completed`/`Failed`) | Second concurrent call returns `Rejected(ALREADY_PROCESSING)`; handle in your submit handler | + +- **Drop-in** — every terminal (`Completed`, `Failed`, `Canceled`) is delivered to `onPaymentResult`, scoped to that sheet’s surface id. Shared-flow collectors still see `Completed`/`Failed`. **Cancellation is surface-only.** +- **Headless / direct** — `Completed` and `Failed` on `paymentResultFlow`; no user-cancel concept. Unexpected SDK failures return immediate `PaymentProcessingResult.Failed(UNEXPECTED_ERROR)` while the sanitized `PaymentResult.Failed` is delivered asynchronously. ACH network/API failures never expose `originalError` or `rawErrorResponse` on public results. + +**Programmatic hide vs user-cancel**: setting `show = false` from the parent hides the sheet and clears SDK state, but does **not** call `onPaymentResult(Canceled)`. Only a swipe/back dismiss triggered by the user produces a `Canceled` result. If your UI needs to distinguish "user abandoned" from "parent closed the sheet", track that distinction in your own state rather than relying on `Canceled` alone. + +**Single ACH surface per `Spreedly` instance**: the form state (`bankAccountState`) is a single global bucket per SDK instance. While terminal routing is surface-scoped, the form data is shared. Do not render two ACH UIs against the same `Spreedly` at the same time; use separate instances if you need concurrent flows. + +**Swipe/back dismiss when another session owns the latch**: `cancelBankAccountAttempt` is fail-closed — if a headless or anonymous attempt owns the in-flight session when the user swipes to dismiss the drop-in sheet, the cancel is a no-op (no `Canceled` terminal). The drop-in sheet still hides, but `onPaymentResult(Canceled)` is **not** delivered. Sensitive form fields are still cleared when the sheet leaves composition (leave-clear), independent of the cancel result. This is an unsupported configuration; it occurs only when you violate the "single ACH surface per `Spreedly`" constraint above. Use separate `Spreedly` instances for concurrent flows. + +**Callback threading**: drop-in `onPaymentResult` runs on the **main thread** after sensitive bank-account state is cleared. Keep callbacks fast — avoid blocking work or starting another payment while the sheet is dismissing. + +Prefer the drop-in `onPaymentResult` callback for the sheet; use `paymentResultFlow` for the headless/custom path. If you observe both, expect `Completed`/`Failed` on each — deduplicate by your own request state. + +Client-side validation failures emit a generic `Failed` on `paymentResultFlow` and keep the form open for correction (not a dismiss terminal). + +### Cancellation, in-flight tokenization, and retry + +Once client validation passes, the SDK **always runs the tokenization HTTP request to completion**, even if the merchant coroutine is cancelled (sheet dismiss, navigation away, or `Job.cancel()`). User cancel or leave during an in-flight request enters **CanceledDraining**: `isAchSubmitting` stays true and `tryBeginAttempt` returns `Rejected(ALREADY_PROCESSING)` until the abandoned request returns (result dropped) or the attempt is abandoned before the network call starts. + +A surface-owned `Canceled` terminal may be delivered and acknowledged **while the latch is still held** through drain. Do not treat ack of `Canceled` as permission to submit again until `isAchSubmitting` is false. + +Once a surface retains `Completed` or `Failed`, cancellation **cannot** replace that terminal with `Canceled`. Drop-in swipe/back waits for the collector to deliver the definitive result (then dismisses). Global publish and surface retain happen in one Main transaction — an attempt is never both completed/failed on `paymentResultFlow` and canceled on the drop-in surface. + +| Phase | Coroutine cancelled | Latch | Merchant retry | +|-------|---------------------|-------|----------------| +| Before network dispatch (validation) | `abandonBankAccountAttempt` | Released immediately | Allowed | +| During / after network dispatch | NonCancellable completion + CanceledDraining | Held until abandoned request returns | **Rejected** until drain completes (even after `Canceled` ack) | + +**Client cancellation is not server revocation.** Aborting the coroutine or closing the UI cancels the Ktor request on a best-effort basis only. Spreedly may still create a payment method if the request was already accepted (orphan token). The SDK does **not** send idempotency keys today. + +**Safe retry guidance for merchants:** + +1. Wait until `isAchSubmitting` is false (and no unacknowledged surface terminal) before calling `createBankAccount()` again. +2. If the user dismissed during an in-flight submit, treat the operation as **pending** until the latch clears or your backend confirms no payment method was created. +3. For backend deduplication, include your own correlation id in `metadata` (for example `metadata = mapOf("client_attempt_id" to yourUuid)`). + +Do not call `createBankAccount()` again while `isAchSubmitting` is true or a surface terminal awaits acknowledgement. + +### Drop-in callback + +```kotlin +SpreedlyBankAccountBottomSheet( + sdk = sdk, + show = showSheet, + onDismiss = { showSheet = false }, + onPaymentResult = { result -> + when (result) { + is PaymentResult.Completed -> { /* result.token */ } + is PaymentResult.Failed -> { /* result.message */ } + is PaymentResult.Canceled -> { /* user dismissed the sheet */ } + PaymentResult.Initial -> {} + } + }, +) +``` + +### Headless flow ```kotlin lifecycleScope.launch { @@ -346,8 +517,9 @@ lifecycleScope.launch { PaymentResult.Failed.ErrorType.UNKNOWN_ERROR -> { /* validation or other */ } } } - is PaymentResult.Canceled -> { /* user dismissed */ } - PaymentResult.Initial -> { /* waiting */ } + is PaymentResult.Canceled, + PaymentResult.Initial, + -> {} } } } @@ -361,17 +533,25 @@ For the full error handling strategy, see [error-handling.md](error-handling.md) ### Resetting State -Call `sdk.resetBankAccountState()` to clear all bank account fields. The pre-built bottom sheet does this automatically when it opens. +Call `sdk.resetBankAccountState()` to clear all bank account fields. + +`BankAccountSheet(sdk, …)` and the pre-built bottom sheet clear state when the form leaves composition (swipe/back dismiss, cancel-driven close, or navigation away) and again when the bottom sheet opens, unless preserve-on-next-show is set. After success or API/network failure, `createBankAccount()` resets form state **before** publishing the payment result; the drop-in sheet dismisses only on a terminal retained for **its** surface id (`Completed`, `Failed` with `API_ERROR` / `NETWORK_ERROR` / `UNKNOWN_ERROR`, or `Canceled`), delivered through `onPaymentResult`. An untyped result on the shared flow never dismisses the drop-in. The state/callbacks overload does **not** auto-clear on leave — call `resetBankAccountState()` yourself, or prefer the `sdk` overload. + +`createBankAccount()` also calls `resetBankAccountState()` after a successful tokenize and after API/network failures; client-side validation failures leave the form filled so the user can correct input. Merchants observing success or failure callbacks should expect ACH UI state to already be empty after tokenize success and network/API errors. ### Preserving State on Configuration Change -If you manage the sheet visibility yourself, preserve state across orientation changes: +`BankAccountSheet(sdk, …)` leaves in-memory values in place across configuration change when a host Activity reports `isChangingConfigurations` (fail-closed clear if Activity cannot be resolved). The drop-in bottom sheet additionally sets `preserveBankAccountStateOnNextShow()` so its open-path reset is skipped after recreation. Standalone `BankAccountSheet(sdk)` and custom state/callbacks layouts should **not** call that latch — a later drop-in open must still reset. Process death is not a configuration change: in-memory SDK state is not restored. If you manage visibility yourself with the state/callbacks overload (or a custom wrapper): ```kotlin -sdk.preserveBankAccountStateOnNextShow() +// Only when the Activity is actually recreating (rotation), not on every ON_STOP +if (activity?.isChangingConfigurations != true) { + sdk.resetBankAccountState() +} +// Else leave SDK in-memory state as-is (do not call preserveBankAccountStateOnNextShow) ``` -Call this before the configuration change (e.g., in `ON_STOP`). The next time the sheet opens, it will skip the automatic reset. +Card payment sheet still resets only on open (intentional difference). --- diff --git a/docs/guides/custom-payment-forms.md b/docs/guides/custom-payment-forms.md index dd49f21..c8b2d0a 100644 --- a/docs/guides/custom-payment-forms.md +++ b/docs/guides/custom-payment-forms.md @@ -301,6 +301,8 @@ SPLTextField( ) ``` +Call `sdk.resetBankAccountState()` when the custom ACH form leaves composition (navigation back, dismiss), unless the Activity is changing configurations — then leave in-memory SDK state as-is. Do **not** call `preserveBankAccountStateOnNextShow()` from custom layouts; that latch is only for the drop-in bottom sheet open path. Prefer `BankAccountSheet(sdk, …)` from [ach-bank-account.md](ach-bank-account.md) when you want leave-clear built in. + ### Bank Account Submission Use `sdk.createBankAccount()` instead of `sdk.createCreditCard()`: diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index b1e005c..3bffea0 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -29,6 +29,29 @@ data class Failed( ) ``` +### ACH bank account failures + +`createBankAccount()` failures delivered on `paymentResultFlow` and drop-in `onPaymentResult` use an ACH-specific mapper: + +- `originalError` is always `null` +- `rawErrorResponse` is always `null` +- Only allowlisted validation field names are included +- Error messages are sanitized and length-bounded + +Use `errorType`, `apiError`, `statusCode`, `message`, and `validationErrors` for merchant logging — not raw backend bodies. See [ACH Bank Account](ach-bank-account.md#handling-results). + +Immediate `createBankAccount()` return values: + +| Result | Meaning | +|--------|---------| +| `Processing` | Validation passed; API call started | +| `ValidationFailed` | Client-side validation failed; form stays open | +| `Rejected(ALREADY_PROCESSING)` | Another ACH operation owns this `Spreedly` instance; no network dispatch | +| `Failed(UNEXPECTED_ERROR)` | Unexpected SDK error; sanitized `PaymentResult.Failed` follows async | + +ACH tokenization after validation runs to completion even when the merchant coroutine is cancelled; do not retry until a terminal is delivered. See [ACH Bank Account — cancellation and retry](ach-bank-account.md#cancellation-in-flight-tokenization-and-retry). + + ### SpreedlyApiError Types The SDK categorizes API errors into specific types: diff --git a/docs/guides/express-checkout.md b/docs/guides/express-checkout.md index a680fa0..0c9c5b7 100644 --- a/docs/guides/express-checkout.md +++ b/docs/guides/express-checkout.md @@ -349,7 +349,7 @@ The bottom sheet auto-dismisses on `Completed`, `Canceled`, and API/network `Fai | `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 | +| `fieldShape` | `Shape` | `RoundedCornerShape(8.dp)` | Shape for input fields. Independent of `borderRadius` on this API — set both explicitly if they must match. ACH `CustomFieldsConfig` resolution paths sync `fieldShape` from `borderRadius` instead; see [ACH Bank Account](ach-bank-account.md#theme-resolution). | | `nameFieldDisplayMode` | `NameFieldDisplayMode` | `SINGLE_FIELD` | How cardholder name is displayed | | `allowBlankName` | `Boolean` | `false` | Allow empty cardholder name | | `allowBlankDate` | `Boolean` | `false` | Allow empty expiration date | diff --git a/docs/guides/migration/from-legacy.md b/docs/guides/migration/from-legacy.md index e7e5490..f2349b5 100644 --- a/docs/guides/migration/from-legacy.md +++ b/docs/guides/migration/from-legacy.md @@ -671,7 +671,7 @@ Complete checklist of SDK APIs available to merchants. Use this to confirm you'v | `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) | +| `config: CustomFieldsConfig` | Per-field visual overrides (colors, borders). On SDK resolution paths, `borderRadius` drives field corners — `fieldShape` is synced from `borderRadius` (set radius, not a mismatched shape). Use `resolveEffectiveCustomFieldsConfig()` for custom ACH layouts mixing `AppTextField` with `SPLTextField`. See [ACH Bank Account](../ach-bank-account.md#theme-resolution). | #### `HostedFieldState` properties (on `onFieldStateChange`) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a27a86..e10f6b4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ coreKtx = "1.17.0" # AndroidX Core KTX extensions appcompat = "1.7.1" # AndroidX AppCompat activity = "1.11.0" # AndroidX Activity browser = "1.9.0" # AndroidX Browser (Chrome Custom Tabs + Auth Tab) +webkit = "1.12.1" # AndroidX WebKit cardview = "1.0.0" # AndroidX CardView constraintlayout = "2.2.1" # AndroidX ConstraintLayout lifecycle = "2.9.4" # AndroidX Lifecycle (runtime, viewmodel, compose) @@ -77,7 +78,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.1.0" # Spreedly SDK version +spreedlySdk = "1.2.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 @@ -95,6 +96,7 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity" } androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" } +androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" } androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } @@ -200,14 +202,7 @@ datadog-logs = { group = "com.datadoghq", name = "dd-sdk-android-logs", version. # ---------------------------------------------------------------------------- # 3DS Authentication (Optional) # ---------------------------------------------------------------------------- -# Note: Forter SDK requires adding their Maven repository to settings.gradle.kts or project build.gradle: -# maven { -# url = uri("https://mobile-sdks.forter.com/android") -# credentials { -# username = "forter-android-sdk" -# password = "" -# } -# } +# Note: Forter SDK requires their Maven repository in settings.gradle.kts — see docs/guides/3ds-global.md forter-sdk = { group = "com.forter.mobile", name = "forter3ds", version.ref = "forter3ds" } stripe-android = { group = "com.stripe", name = "stripe-android", version.ref = "stripe" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2ea8af8..01917e8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,12 +32,8 @@ dependencyResolutionManagement { maven { url = uri("https://mobile-sdks.forter.com/android") credentials { - username = providers.gradleProperty("forter.usr").orNull - ?: System.getenv("FORTER_USERNAME") - ?: "forter-android-sdk" - password = providers.gradleProperty("forter.key").orNull - ?: System.getenv("FORTER_PASSWORD") - ?: "HvYumAfjVQYQFyoGsmNAefGdR84Esqig" + username = providers.gradleProperty("forter.usr").orNull ?: System.getenv("FORTER_USERNAME") + password = providers.gradleProperty("forter.key").orNull ?: System.getenv("FORTER_PASSWORD") } } From 35c3b583714bd9f46399fe76e0df751bdfcd8aca Mon Sep 17 00:00:00 2001 From: aaryan-collab Date: Thu, 23 Jul 2026 21:39:13 +0530 Subject: [PATCH 2/3] fix: restore Forter Maven credential defaults for CI --- settings.gradle.kts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/settings.gradle.kts b/settings.gradle.kts index 01917e8..2ea8af8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,8 +32,12 @@ dependencyResolutionManagement { maven { url = uri("https://mobile-sdks.forter.com/android") credentials { - username = providers.gradleProperty("forter.usr").orNull ?: System.getenv("FORTER_USERNAME") - password = providers.gradleProperty("forter.key").orNull ?: System.getenv("FORTER_PASSWORD") + username = providers.gradleProperty("forter.usr").orNull + ?: System.getenv("FORTER_USERNAME") + ?: "forter-android-sdk" + password = providers.gradleProperty("forter.key").orNull + ?: System.getenv("FORTER_PASSWORD") + ?: "HvYumAfjVQYQFyoGsmNAefGdR84Esqig" } } From 1de6f48806d1924b85ddff3bec83f341319af4b7 Mon Sep 17 00:00:00 2001 From: aaryan-collab Date: Thu, 23 Jul 2026 21:49:23 +0530 Subject: [PATCH 3/3] fix: restore includeAndroidResources for Robolectric Compose tests --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 154cca1..77a171e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -130,6 +130,7 @@ android { testOptions { unitTests { + isIncludeAndroidResources = true isReturnDefaultValues = true } }