diff --git a/paymentsheet-example/src/main/AndroidManifest.xml b/paymentsheet-example/src/main/AndroidManifest.xml index f3f12bde90a..7ee32f1113a 100644 --- a/paymentsheet-example/src/main/AndroidManifest.xml +++ b/paymentsheet-example/src/main/AndroidManifest.xml @@ -61,6 +61,19 @@ /> + + + + + + + + + + diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/MainActivity.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/MainActivity.kt index 69c1809d206..551d107a500 100644 --- a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/MainActivity.kt +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/MainActivity.kt @@ -35,13 +35,13 @@ import com.stripe.android.paymentsheet.example.databinding.ActivityMainBinding import com.stripe.android.paymentsheet.example.samples.ui.SECTION_ALPHA import com.stripe.android.paymentsheet.example.samples.ui.addresselement.AddressElementExampleActivity import com.stripe.android.paymentsheet.example.samples.ui.customersheet.CustomerSheetExampleActivity -import com.stripe.android.paymentsheet.example.samples.ui.customersheet.playground.CustomerSheetPlaygroundActivity import com.stripe.android.paymentsheet.example.samples.ui.paymentsheet.complete_flow.CompleteFlowActivity import com.stripe.android.paymentsheet.example.samples.ui.paymentsheet.custom_flow.CustomFlowActivity import com.stripe.android.paymentsheet.example.samples.ui.paymentsheet.server_side_confirm.complete_flow.ServerSideConfirmationCompleteFlowActivity import com.stripe.android.paymentsheet.example.samples.ui.paymentsheet.server_side_confirm.custom_flow.ServerSideConfirmationCustomFlowActivity import com.stripe.android.paymentsheet.example.samples.ui.shared.PaymentSheetExampleTheme import com.stripe.android.paymentsheet.example.playground.PaymentSheetPlaygroundActivity as NewPaymentSheetPlaygroundActivity +import com.stripe.android.paymentsheet.example.playground.customersheet.CustomerSheetPlaygroundActivity as NewCustomerSheetPlaygroundActivity private const val SurfaceOverlayOpacity = 0.12f @@ -101,7 +101,7 @@ class MainActivity : AppCompatActivity() { MenuItem( titleResId = R.string.customersheet_playground_title, subtitleResId = R.string.playground_subtitle, - klass = CustomerSheetPlaygroundActivity::class.java, + klass = NewCustomerSheetPlaygroundActivity::class.java, section = MenuItem.Section.Internal, ), ) diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PaymentSheetPlaygroundActivity.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PaymentSheetPlaygroundActivity.kt index 3223b31bf21..a90c9a58fa3 100644 --- a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PaymentSheetPlaygroundActivity.kt +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PaymentSheetPlaygroundActivity.kt @@ -161,7 +161,9 @@ internal class PaymentSheetPlaygroundActivity : AppCompatActivity() { context.startActivity( QrCodeActivity.create( context = context, - settingsJson = playgroundSettings.snapshot().asJsonString(), + settingsUri = PaymentSheetPlaygroundUrlHelper.createUri( + playgroundSettings.snapshot().asJsonString() + ), ) ) }, diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/activity/QrCodeActivity.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/activity/QrCodeActivity.kt index 0de6c2b487a..8823d3c03c8 100644 --- a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/activity/QrCodeActivity.kt +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/activity/QrCodeActivity.kt @@ -20,15 +20,14 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import com.google.zxing.BarcodeFormat import com.google.zxing.qrcode.QRCodeWriter -import com.stripe.android.paymentsheet.example.playground.PaymentSheetPlaygroundUrlHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch internal class QrCodeActivity : AppCompatActivity() { companion object { - fun create(context: Context, settingsJson: String): Intent { + fun create(context: Context, settingsUri: Uri): Intent { return Intent(context, QrCodeActivity::class.java).apply { - putExtra("settingsJson", settingsJson) + putExtra("settingsUri", settingsUri.toString()) } } } @@ -36,20 +35,19 @@ internal class QrCodeActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val settingsJson = intent.getStringExtra("settingsJson") - if (settingsJson == null) { + val settingsUri = Uri.parse(intent.getStringExtra("settingsUri")) + + if (settingsUri == null) { finish() return } - val uri = PaymentSheetPlaygroundUrlHelper.createUri(settingsJson) - setContent { var bitmap: Bitmap? by remember { mutableStateOf(null) } - LaunchedEffect(uri) { + LaunchedEffect(settingsUri) { launch(Dispatchers.IO) { - bitmap = getQrCodeBitmap(uri) + bitmap = getQrCodeBitmap(settingsUri) } } diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/CustomerSheetPlaygroundActivity.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/CustomerSheetPlaygroundActivity.kt new file mode 100644 index 00000000000..2eefb19ff8e --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/CustomerSheetPlaygroundActivity.kt @@ -0,0 +1,240 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.stripe.android.customersheet.CustomerSheetResultCallback +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.customersheet.rememberCustomerSheet +import com.stripe.android.paymentsheet.example.playground.activity.AppearanceBottomSheetDialogFragment +import com.stripe.android.paymentsheet.example.playground.activity.AppearanceStore +import com.stripe.android.paymentsheet.example.playground.activity.QrCodeActivity +import com.stripe.android.paymentsheet.example.playground.customersheet.settings.CustomerSheetPlaygroundSettings +import com.stripe.android.paymentsheet.example.playground.customersheet.settings.SettingsUi +import com.stripe.android.paymentsheet.example.samples.ui.shared.PaymentMethodSelector + +internal class CustomerSheetPlaygroundActivity : AppCompatActivity() { + companion object { + fun createTestIntent(settingsJson: String): Intent { + return Intent( + Intent.ACTION_VIEW, + CustomerSheetPlaygroundUrlHelper.createUri(settingsJson) + ) + } + } + + val viewModel: CustomerSheetPlaygroundViewModel by viewModels { + CustomerSheetPlaygroundViewModel.Factory( + applicationSupplier = { application }, + uriSupplier = { intent.data }, + ) + } + + @SuppressLint("UnusedContentLambdaTargetStateParameter") + @OptIn(ExperimentalCustomerSheetApi::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val playgroundSettings by viewModel.playgroundSettingsFlow.collectAsState() + val localPlaygroundSettings = playgroundSettings ?: return@setContent + + val playgroundState by viewModel.playgroundState.collectAsState() + val context = LocalContext.current + + LaunchedEffect(viewModel.status) { + viewModel.status.collect { status -> + Toast.makeText(context, status.message, Toast.LENGTH_LONG).show() + } + } + + PlaygroundTheme( + content = { + SettingsUi(playgroundSettings = localPlaygroundSettings) + + AppearanceButton() + + QrCodeButton(playgroundSettings = localPlaygroundSettings) + }, + bottomBarContent = { + ReloadButton() + + AnimatedContent( + label = PLAYGROUND_BOTTOM_BAR_LABEL, + targetState = playgroundState != null, + ) { + Column { + PlaygroundStateUi( + playgroundState = playgroundState, + callback = viewModel::onCustomerSheetCallback + ) + } + } + }, + ) + } + } + + @Composable + private fun AppearanceButton() { + Button( + onClick = { + val bottomSheet = AppearanceBottomSheetDialogFragment.newInstance() + bottomSheet.show(supportFragmentManager, bottomSheet.tag) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Change Appearance") + } + } + + @Composable + private fun QrCodeButton(playgroundSettings: CustomerSheetPlaygroundSettings) { + val context = LocalContext.current + Button( + onClick = { + context.startActivity( + QrCodeActivity.create( + context = context, + settingsUri = CustomerSheetPlaygroundUrlHelper.createUri( + playgroundSettings.snapshot().asJsonString() + ), + ) + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("QR code for current settings") + } + } + + @Composable + private fun ReloadButton() { + Button( + onClick = viewModel::reset, + modifier = Modifier + .fillMaxWidth() + .testTag(RELOAD_TEST_TAG), + ) { + Text("Reload") + } + } + + @OptIn(ExperimentalCustomerSheetApi::class) + @Composable + private fun PlaygroundStateUi( + playgroundState: CustomerSheetPlaygroundState?, + callback: CustomerSheetResultCallback, + ) { + if (playgroundState == null) { + return + } + + val customerSheet = rememberCustomerSheet( + configuration = playgroundState.customerSheetConfiguration(), + customerAdapter = playgroundState.adapter, + callback = callback, + ) + + LaunchedEffect(customerSheet) { + viewModel.fetchOption(customerSheet) + } + + val loaded = playgroundState.optionState as? CustomerSheetPlaygroundState.PaymentOptionState.Loaded + val option = loaded?.paymentOption + + PaymentMethodSelector( + isEnabled = playgroundState.optionState is CustomerSheetPlaygroundState.PaymentOptionState.Loaded, + paymentMethodLabel = option?.label ?: "Select", + paymentMethodPainter = option?.iconPainter, + onClick = customerSheet::present + ) + } +} + +@Composable +private fun PlaygroundTheme( + content: @Composable ColumnScope.() -> Unit, + bottomBarContent: @Composable ColumnScope.() -> Unit, +) { + val colors = if (isSystemInDarkTheme() || AppearanceStore.forceDarkMode) { + darkColors() + } else { + lightColors() + } + MaterialTheme( + typography = MaterialTheme.typography.copy( + body1 = MaterialTheme.typography.body1.copy(fontSize = 14.sp) + ), + colors = colors, + ) { + Surface( + color = MaterialTheme.colors.background, + ) { + Scaffold( + bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.surface) + .animateContentSize() + ) { + Divider() + Column( + content = bottomBarContent, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 8.dp) + .fillMaxWidth() + ) + } + }, + ) { paddingValues -> + Box(modifier = Modifier.padding(paddingValues)) { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .fillMaxSize() + .padding(16.dp), + content = content, + ) + } + } + } + } +} + +const val RELOAD_TEST_TAG = "RELOAD" +private const val PLAYGROUND_BOTTOM_BAR_LABEL = "CustomerSheetPlaygroundBottomBar" diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/CustomerSheetPlaygroundState.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/CustomerSheetPlaygroundState.kt new file mode 100644 index 00000000000..4206029d87b --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/CustomerSheetPlaygroundState.kt @@ -0,0 +1,27 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet + +import androidx.compose.runtime.Stable +import com.stripe.android.customersheet.CustomerAdapter +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.paymentsheet.example.playground.customersheet.settings.CustomerSheetPlaygroundSettings +import com.stripe.android.paymentsheet.model.PaymentOption + +@Stable +@OptIn(ExperimentalCustomerSheetApi::class) +internal data class CustomerSheetPlaygroundState( + private val snapshot: CustomerSheetPlaygroundSettings.Snapshot, + val adapter: CustomerAdapter, + val optionState: PaymentOptionState, +) { + sealed interface PaymentOptionState { + data object Unloaded : PaymentOptionState + + data class Loaded(val paymentOption: PaymentOption?) : PaymentOptionState + } + + @OptIn(ExperimentalCustomerSheetApi::class) + fun customerSheetConfiguration(): CustomerSheet.Configuration { + return snapshot.customerSheetConfiguration(this) + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/CustomerSheetPlaygroundUrlHelper.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/CustomerSheetPlaygroundUrlHelper.kt new file mode 100644 index 00000000000..8a955960c8d --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/CustomerSheetPlaygroundUrlHelper.kt @@ -0,0 +1,23 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet + +import android.net.Uri +import com.stripe.android.paymentsheet.example.playground.customersheet.settings.CustomerSheetPlaygroundSettings +import okio.ByteString.Companion.decodeBase64 +import okio.ByteString.Companion.toByteString + +internal object CustomerSheetPlaygroundUrlHelper { + fun createUri(settingsJson: String): Uri { + val base64Settings = settingsJson.encodeToByteArray().toByteString().base64Url() + .trimEnd('=') + return Uri.parse( + "stripepaymentsheetexample://customersheetplayground?settings=" + + base64Settings + ) + } + + fun settingsFromUri(uri: Uri?): CustomerSheetPlaygroundSettings? { + val settingsJson = uri?.getQueryParameter("settings") + ?.decodeBase64()?.utf8() ?: return null + return CustomerSheetPlaygroundSettings.createFromJsonString(settingsJson) + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/CustomerSheetPlaygroundViewModel.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/CustomerSheetPlaygroundViewModel.kt new file mode 100644 index 00000000000..b3ad045fbf7 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/CustomerSheetPlaygroundViewModel.kt @@ -0,0 +1,242 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.github.kittinunf.fuel.Fuel +import com.github.kittinunf.fuel.core.extensions.jsonBody +import com.github.kittinunf.fuel.core.requests.suspendable +import com.github.kittinunf.result.Result +import com.stripe.android.PaymentConfiguration +import com.stripe.android.customersheet.CustomerAdapter +import com.stripe.android.customersheet.CustomerEphemeralKey +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.CustomerSheetResult +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.paymentsheet.example.Settings +import com.stripe.android.paymentsheet.example.playground.customersheet.model.CreateSetupIntentRequest +import com.stripe.android.paymentsheet.example.playground.customersheet.model.CreateSetupIntentResponse +import com.stripe.android.paymentsheet.example.playground.customersheet.model.CustomerEphemeralKeyRequest +import com.stripe.android.paymentsheet.example.playground.customersheet.model.CustomerEphemeralKeyResponse +import com.stripe.android.paymentsheet.example.playground.customersheet.settings.CountrySettingsDefinition +import com.stripe.android.paymentsheet.example.playground.customersheet.settings.CustomerSheetPlaygroundSettings +import com.stripe.android.paymentsheet.example.playground.customersheet.settings.PaymentMethodMode +import com.stripe.android.paymentsheet.example.playground.customersheet.settings.PaymentMethodModeDefinition +import com.stripe.android.paymentsheet.example.samples.networking.awaitModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +internal class CustomerSheetPlaygroundViewModel( + application: Application, + launchUri: Uri?, +) : AndroidViewModel(application) { + private val settings by lazy { + Settings(application) + } + + val playgroundSettingsFlow = MutableStateFlow(null) + val playgroundState = MutableStateFlow(null) + + val status = MutableSharedFlow() + + init { + viewModelScope.launch(Dispatchers.IO) { + playgroundSettingsFlow.value = + CustomerSheetPlaygroundUrlHelper.settingsFromUri(launchUri) + ?: CustomerSheetPlaygroundSettings.createFromSharedPreferences(application) + } + } + + @OptIn(ExperimentalCustomerSheetApi::class) + fun reset() { + val playgroundSettings = playgroundSettingsFlow.value ?: return + + val setupIntentClientSecretProvider = if ( + playgroundSettings[PaymentMethodModeDefinition].value == PaymentMethodMode.SetupIntent + ) { + ::createSetupIntentClientSecret + } else { + null + } + + playgroundState.value = CustomerSheetPlaygroundState( + snapshot = playgroundSettings.snapshot(), + adapter = CustomerAdapter.create( + context = getApplication(), + customerEphemeralKeyProvider = ::fetchEphemeralKey, + setupIntentClientSecretProvider = setupIntentClientSecretProvider + + ), + optionState = CustomerSheetPlaygroundState.PaymentOptionState.Unloaded + ) + } + + @OptIn(ExperimentalCustomerSheetApi::class) + fun fetchOption(customerSheet: CustomerSheet) { + viewModelScope.launch(Dispatchers.IO) { + when (val result = customerSheet.retrievePaymentOptionSelection()) { + is CustomerSheetResult.Selected -> { + playgroundState.update { existingState -> + existingState?.let { state -> + return@update state.copy( + optionState = CustomerSheetPlaygroundState.PaymentOptionState.Loaded( + paymentOption = result.selection?.paymentOption + ) + ) + } + } + } + is CustomerSheetResult.Failed -> { + status.emit( + StatusMessage( + message = "Failed to retrieve payment options:\n${result.exception.message}" + ) + ) + } + is CustomerSheetResult.Canceled -> Unit + } + } + } + + @OptIn(ExperimentalCustomerSheetApi::class) + fun onCustomerSheetCallback(result: CustomerSheetResult) { + val statusMessage = when (result) { + is CustomerSheetResult.Selected -> { + playgroundState.update { existingState -> + existingState?.let { state -> + if (state.optionState is CustomerSheetPlaygroundState.PaymentOptionState.Loaded) { + return@update state.copy( + optionState = CustomerSheetPlaygroundState.PaymentOptionState.Loaded( + paymentOption = result.selection?.paymentOption + ) + ) + } + } + + existingState + } + + null + } + is CustomerSheetResult.Failed -> "An error occurred: ${result.exception.message}" + is CustomerSheetResult.Canceled -> "Canceled" + } + + statusMessage?.let { message -> + viewModelScope.launch { + status.emit(StatusMessage(message)) + } + } + } + + @OptIn(ExperimentalCustomerSheetApi::class) + private suspend fun fetchEphemeralKey(): CustomerAdapter.Result { + val playgroundSettings = playgroundSettingsFlow.value + ?: return CustomerAdapter.Result.failure( + cause = Exception("Unexpected error: Playground settings were not found!"), + displayMessage = null, + ) + + // Snapshot before making the network request to not rely on UI staying in sync. + val playgroundSettingsSnapshot = playgroundSettings.snapshot() + + playgroundSettingsSnapshot.saveToSharedPreferences(getApplication()) + + val requestBody = playgroundSettingsSnapshot.customerEphemeralKeyRequest() + + val apiResponse = Fuel.post(settings.playgroundBackendUrl + "customer_ephemeral_key") + .jsonBody(Json.encodeToString(CustomerEphemeralKeyRequest.serializer(), requestBody)) + .suspendable() + .awaitModel(CustomerEphemeralKeyResponse.serializer()) + + return when (apiResponse) { + is Result.Failure -> { + val exception = apiResponse.getException() + + CustomerAdapter.Result.failure( + cause = exception, + displayMessage = "Failed to fetch ephemeral key:\n${exception.message}" + ) + } + is Result.Success -> { + val response = apiResponse.value + + // Init PaymentConfiguration with the publishable key returned from the backend, + // which will be used on all Stripe API calls + PaymentConfiguration.init( + getApplication(), + response.publishableKey + ) + + try { + CustomerAdapter.Result.success( + CustomerEphemeralKey.create( + customerId = response.customerId, + ephemeralKey = response.customerEphemeralKeySecret + ?: throw IllegalStateException( + "No 'customerEphemeralKeySecret' was found in backend response!" + ) + ) + ) + } catch (exception: IllegalStateException) { + CustomerAdapter.Result.failure( + cause = exception, + displayMessage = "Failed to fetch ephemeral key:\n${exception.message}", + ) + } + } + } + } + + @OptIn(ExperimentalCustomerSheetApi::class) + private suspend fun createSetupIntentClientSecret(customerId: String): CustomerAdapter.Result { + val playgroundSettings = playgroundSettingsFlow.value + ?: return CustomerAdapter.Result.failure( + cause = Exception("Unexpected error: Playground settings were not found!"), + displayMessage = null, + ) + + val request = CreateSetupIntentRequest( + customerId = customerId, + merchantCountryCode = playgroundSettings[CountrySettingsDefinition].value.value, + ) + + val apiResponse = Fuel.post(settings.playgroundBackendUrl + "create_setup_intent") + .jsonBody(Json.encodeToString(CreateSetupIntentRequest.serializer(), request)) + .suspendable() + .awaitModel(CreateSetupIntentResponse.serializer()) + + return when (apiResponse) { + is Result.Failure -> { + val exception = apiResponse.getException() + + CustomerAdapter.Result.failure( + cause = exception, + displayMessage = "Failed to fetch setup intent secret:\n${exception.message}" + ) + } + is Result.Success -> CustomerAdapter.Result.success(apiResponse.value.clientSecret) + } + } + + internal class Factory( + private val applicationSupplier: () -> Application, + private val uriSupplier: () -> Uri?, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return CustomerSheetPlaygroundViewModel(applicationSupplier(), uriSupplier()) as T + } + } +} + +data class StatusMessage( + val message: String, +) diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/model/PlaygroundCheckoutModel.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/model/PlaygroundCheckoutModel.kt new file mode 100644 index 00000000000..ffdd38c9210 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/model/PlaygroundCheckoutModel.kt @@ -0,0 +1,70 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class CustomerEphemeralKeyRequest private constructor( + @SerialName("customer_type") + val customerType: String?, + @SerialName("customer_key_type") + val customerKeyType: CustomerKeyType?, + @SerialName("merchant_country_code") + val merchantCountryCode: String?, +) { + @Serializable + enum class CustomerKeyType { + @SerialName("customer_session") + CustomerSession, + + @SerialName("legacy") + Legacy; + } + + class Builder { + private var customerType: String? = null + private var merchantCountryCode: String? = null + + fun customerType(customerType: String) = apply { + this.customerType = customerType + } + + fun merchantCountryCode(merchantCountryCode: String?) = apply { + this.merchantCountryCode = merchantCountryCode + } + + fun build(): CustomerEphemeralKeyRequest { + return CustomerEphemeralKeyRequest( + customerType = customerType, + customerKeyType = CustomerKeyType.Legacy, + merchantCountryCode = merchantCountryCode, + ) + } + } +} + +@Serializable +data class CustomerEphemeralKeyResponse( + @SerialName("publishableKey") + val publishableKey: String, + @SerialName("customerId") + val customerId: String, + @SerialName("customerEphemeralKeySecret") + val customerEphemeralKeySecret: String? = null, + @SerialName("customerSessionClientSecret") + val customerSessionClientSecret: String? = null, +) + +@Serializable +data class CreateSetupIntentRequest( + @SerialName("customer_id") + val customerId: String, + @SerialName("merchant_country_code") + val merchantCountryCode: String, +) + +@Serializable +data class CreateSetupIntentResponse( + @SerialName("client_secret") + val clientSecret: String, +) diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/AllowsRemovalOfLastSavedPaymentMethodSettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/AllowsRemovalOfLastSavedPaymentMethodSettingsDefinition.kt new file mode 100644 index 00000000000..be7f08073a6 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/AllowsRemovalOfLastSavedPaymentMethodSettingsDefinition.kt @@ -0,0 +1,22 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.ExperimentalAllowsRemovalOfLastSavedPaymentMethodApi +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.paymentsheet.example.playground.customersheet.CustomerSheetPlaygroundState + +internal object AllowsRemovalOfLastSavedPaymentMethodSettingsDefinition : BooleanSettingsDefinition( + key = "allowsRemovalOfLastSavedPaymentMethod", + displayName = "Allows removal of last saved payment method", + defaultValue = true, +) { + @OptIn(ExperimentalAllowsRemovalOfLastSavedPaymentMethodApi::class, ExperimentalCustomerSheetApi::class) + override fun configure( + value: Boolean, + configurationBuilder: CustomerSheet.Configuration.Builder, + playgroundState: CustomerSheetPlaygroundState, + configurationData: CustomerSheetPlaygroundSettingDefinition.CustomerSheetConfigurationData + ) { + configurationBuilder.allowsRemovalOfLastSavedPaymentMethod(value) + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/AppearanceSettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/AppearanceSettingsDefinition.kt new file mode 100644 index 00000000000..859118351e7 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/AppearanceSettingsDefinition.kt @@ -0,0 +1,20 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.paymentsheet.example.playground.activity.AppearanceStore +import com.stripe.android.paymentsheet.example.playground.customersheet.CustomerSheetPlaygroundState + +internal object AppearanceSettingsDefinition : CustomerSheetPlaygroundSettingDefinition { + override val defaultValue: Unit = Unit + + @OptIn(ExperimentalCustomerSheetApi::class) + override fun configure( + value: Unit, + configurationBuilder: CustomerSheet.Configuration.Builder, + playgroundState: CustomerSheetPlaygroundState, + configurationData: CustomerSheetPlaygroundSettingDefinition.CustomerSheetConfigurationData + ) { + configurationBuilder.appearance(AppearanceStore.state) + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/AttachDefaultBillingDetailsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/AttachDefaultBillingDetailsDefinition.kt new file mode 100644 index 00000000000..92eb0ece80c --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/AttachDefaultBillingDetailsDefinition.kt @@ -0,0 +1,21 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.paymentsheet.example.playground.customersheet.CustomerSheetPlaygroundState + +internal object AttachDefaultBillingDetailsDefinition : BooleanSettingsDefinition( + key = "attachDefaults", + displayName = "Attach Billing Details to Payment Method", + defaultValue = true, +) { + @OptIn(ExperimentalCustomerSheetApi::class) + override fun configure( + value: Boolean, + configurationBuilder: CustomerSheet.Configuration.Builder, + playgroundState: CustomerSheetPlaygroundState, + configurationData: CustomerSheetPlaygroundSettingDefinition.CustomerSheetConfigurationData, + ) { + configurationData.updateBillingDetails { copy(attachDefaultsToPaymentMethod = value) } + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/BooleanSettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/BooleanSettingsDefinition.kt new file mode 100644 index 00000000000..a69ab6af97e --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/BooleanSettingsDefinition.kt @@ -0,0 +1,24 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +internal abstract class BooleanSettingsDefinition( + override val key: String, + override val displayName: String, + override val defaultValue: Boolean, +) : CustomerSheetPlaygroundSettingDefinition, + CustomerSheetPlaygroundSettingDefinition.Saveable, + CustomerSheetPlaygroundSettingDefinition.Displayable { + override val options: List> by lazy { + listOf( + option("On", true), + option("Off", false), + ) + } + + override fun convertToString(value: Boolean): String { + return value.toString() + } + + override fun convertToValue(value: String): Boolean { + return value == "true" + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectAddressSettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectAddressSettingsDefinition.kt new file mode 100644 index 00000000000..e6fe19e47b0 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectAddressSettingsDefinition.kt @@ -0,0 +1,46 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.paymentsheet.example.playground.customersheet.CustomerSheetPlaygroundState +import com.stripe.android.paymentsheet.PaymentSheet.BillingDetailsCollectionConfiguration.AddressCollectionMode as CollectionMode + +internal object CollectAddressSettingsDefinition : + CustomerSheetPlaygroundSettingDefinition, + CustomerSheetPlaygroundSettingDefinition.Saveable, + CustomerSheetPlaygroundSettingDefinition.Displayable { + + override val defaultValue: CollectionMode = CollectionMode.Automatic + override val key: String = "collectAddress" + override val displayName: String = "Collect Address" + override val options: List> by lazy { + listOf( + option("Auto", CollectionMode.Automatic), + option("Never", CollectionMode.Never), + option("Full", CollectionMode.Full), + ) + } + + @OptIn(ExperimentalCustomerSheetApi::class) + override fun configure( + value: CollectionMode, + configurationBuilder: CustomerSheet.Configuration.Builder, + playgroundState: CustomerSheetPlaygroundState, + configurationData: CustomerSheetPlaygroundSettingDefinition.CustomerSheetConfigurationData, + ) { + configurationData.updateBillingDetails { copy(address = value) } + } + + override fun convertToString(value: CollectionMode): String = when (value) { + CollectionMode.Automatic -> "auto" + CollectionMode.Never -> "never" + CollectionMode.Full -> "full" + } + + override fun convertToValue(value: String): CollectionMode = when (value) { + "auto" -> CollectionMode.Automatic + "never" -> CollectionMode.Never + "full" -> CollectionMode.Full + else -> CollectionMode.Automatic + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectEmailSettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectEmailSettingsDefinition.kt new file mode 100644 index 00000000000..4f1772a0ce4 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectEmailSettingsDefinition.kt @@ -0,0 +1,21 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.paymentsheet.PaymentSheet.BillingDetailsCollectionConfiguration.CollectionMode +import com.stripe.android.paymentsheet.example.playground.customersheet.CustomerSheetPlaygroundState + +internal object CollectEmailSettingsDefinition : CollectionModeSettingsDefinition( + key = "collectEmail", + displayName = "Collect Email", +) { + @OptIn(ExperimentalCustomerSheetApi::class) + override fun configure( + value: CollectionMode, + configurationBuilder: CustomerSheet.Configuration.Builder, + playgroundState: CustomerSheetPlaygroundState, + configurationData: CustomerSheetPlaygroundSettingDefinition.CustomerSheetConfigurationData, + ) { + configurationData.updateBillingDetails { copy(email = value) } + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectNameSettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectNameSettingsDefinition.kt new file mode 100644 index 00000000000..bec2ee0588c --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectNameSettingsDefinition.kt @@ -0,0 +1,21 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.paymentsheet.PaymentSheet.BillingDetailsCollectionConfiguration.CollectionMode +import com.stripe.android.paymentsheet.example.playground.customersheet.CustomerSheetPlaygroundState + +internal object CollectNameSettingsDefinition : CollectionModeSettingsDefinition( + key = "collectName", + displayName = "Collect Name", +) { + @OptIn(ExperimentalCustomerSheetApi::class) + override fun configure( + value: CollectionMode, + configurationBuilder: CustomerSheet.Configuration.Builder, + playgroundState: CustomerSheetPlaygroundState, + configurationData: CustomerSheetPlaygroundSettingDefinition.CustomerSheetConfigurationData, + ) { + configurationData.updateBillingDetails { copy(name = value) } + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectPhoneSettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectPhoneSettingsDefinition.kt new file mode 100644 index 00000000000..4f43c3b276b --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectPhoneSettingsDefinition.kt @@ -0,0 +1,21 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.paymentsheet.PaymentSheet.BillingDetailsCollectionConfiguration.CollectionMode +import com.stripe.android.paymentsheet.example.playground.customersheet.CustomerSheetPlaygroundState + +internal object CollectPhoneSettingsDefinition : CollectionModeSettingsDefinition( + key = "collectPhone", + displayName = "Collect Phone", +) { + @OptIn(ExperimentalCustomerSheetApi::class) + override fun configure( + value: CollectionMode, + configurationBuilder: CustomerSheet.Configuration.Builder, + playgroundState: CustomerSheetPlaygroundState, + configurationData: CustomerSheetPlaygroundSettingDefinition.CustomerSheetConfigurationData, + ) { + configurationData.updateBillingDetails { copy(phone = value) } + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectionModeSettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectionModeSettingsDefinition.kt new file mode 100644 index 00000000000..0ce08e4f035 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CollectionModeSettingsDefinition.kt @@ -0,0 +1,32 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.paymentsheet.PaymentSheet.BillingDetailsCollectionConfiguration.CollectionMode + +internal open class CollectionModeSettingsDefinition( + override val key: String, + override val displayName: String +) : CustomerSheetPlaygroundSettingDefinition.Saveable, + CustomerSheetPlaygroundSettingDefinition.Displayable { + + override val defaultValue: CollectionMode = CollectionMode.Automatic + override val options: List> by lazy { + listOf( + option("Auto", CollectionMode.Automatic), + option("Never", CollectionMode.Never), + option("Always", CollectionMode.Always), + ) + } + + override fun convertToString(value: CollectionMode): String = when (value) { + CollectionMode.Automatic -> "auto" + CollectionMode.Never -> "never" + CollectionMode.Always -> "always" + } + + override fun convertToValue(value: String): CollectionMode = when (value) { + "auto" -> CollectionMode.Automatic + "never" -> CollectionMode.Never + "always" -> CollectionMode.Always + else -> CollectionMode.Automatic + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CountrySettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CountrySettingsDefinition.kt new file mode 100644 index 00000000000..84ea4cf8511 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CountrySettingsDefinition.kt @@ -0,0 +1,41 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.core.model.CountryUtils +import com.stripe.android.paymentsheet.example.playground.customersheet.model.CustomerEphemeralKeyRequest +import java.util.Locale + +internal object CountrySettingsDefinition : + CustomerSheetPlaygroundSettingDefinition, + CustomerSheetPlaygroundSettingDefinition.Saveable by EnumSaveable( + key = "country", + values = Country.entries.toTypedArray(), + defaultValue = Country.US, + ), + CustomerSheetPlaygroundSettingDefinition.Displayable { + private val supportedCountries = Country.entries.map { it.value }.toSet() + + override val displayName: String = "Merchant Country" + + override val options: List> + get() = CountryUtils.getOrderedCountries(Locale.getDefault()).filter { country -> + country.code.value in supportedCountries + }.map { country -> + option(country.name, convertToValue(country.code.value)) + }.toList() + + override fun configure(value: Country, requestBuilder: CustomerEphemeralKeyRequest.Builder) { + requestBuilder.merchantCountryCode(value.value) + } + + override fun valueUpdated(value: Country, playgroundSettings: CustomerSheetPlaygroundSettings) { + // When the country changes via the UI, reset the customer. + if (playgroundSettings[CustomerSettingsDefinition].value is CustomerType.Existing) { + playgroundSettings[CustomerSettingsDefinition] = CustomerType.New + } + } +} + +enum class Country(override val value: String) : ValueEnum { + US("US"), + FR("FR"), +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CustomerSettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CustomerSettingsDefinition.kt new file mode 100644 index 00000000000..691f4aff8bd --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CustomerSettingsDefinition.kt @@ -0,0 +1,55 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.paymentsheet.example.playground.customersheet.model.CustomerEphemeralKeyRequest + +internal object CustomerSettingsDefinition : + CustomerSheetPlaygroundSettingDefinition, + CustomerSheetPlaygroundSettingDefinition.Saveable, + CustomerSheetPlaygroundSettingDefinition.Displayable { + override val displayName: String = "Customer" + override val options: List> = + listOf( + CustomerSettingsDefinition.option("New", CustomerType.New), + CustomerSettingsDefinition.option("Returning", CustomerType.Returning), + ) + + override fun configure( + value: CustomerType, + requestBuilder: CustomerEphemeralKeyRequest.Builder, + ) { + requestBuilder.customerType(value.value) + } + + override val key: String = "customer" + override val defaultValue: CustomerType = CustomerType.New + + override fun convertToValue(value: String): CustomerType { + val hardcodedCustomerTypes = mapOf( + CustomerType.New.value to CustomerType.New, + CustomerType.Returning.value to CustomerType.Returning, + ) + return if (value.startsWith("cus_")) { + CustomerType.Existing(value) + } else if (hardcodedCustomerTypes.containsKey(value)) { + hardcodedCustomerTypes[value]!! + } else { + defaultValue + } + } + + override fun convertToString(value: CustomerType): String { + return value.value + } +} + +sealed class CustomerType(val value: String) { + data object New : CustomerType("new") + + data object Returning : CustomerType("returning") + + class Existing(val customerId: String) : CustomerType(customerId) { + override fun toString(): String { + return customerId + } + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CustomerSheetPlaygroundSettingDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CustomerSheetPlaygroundSettingDefinition.kt new file mode 100644 index 00000000000..6a203cc2675 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CustomerSheetPlaygroundSettingDefinition.kt @@ -0,0 +1,78 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.paymentsheet.PaymentSheet +import com.stripe.android.paymentsheet.example.playground.customersheet.CustomerSheetPlaygroundState +import com.stripe.android.paymentsheet.example.playground.customersheet.model.CustomerEphemeralKeyRequest + +internal interface CustomerSheetPlaygroundSettingDefinition { + val defaultValue: T + + @OptIn(ExperimentalCustomerSheetApi::class) + fun configure( + value: T, + configurationBuilder: CustomerSheet.Configuration.Builder, + playgroundState: CustomerSheetPlaygroundState, + configurationData: CustomerSheetConfigurationData, + ) { + } + + fun configure( + value: T, + requestBuilder: CustomerEphemeralKeyRequest.Builder, + ) { + } + + fun valueUpdated(value: T, playgroundSettings: CustomerSheetPlaygroundSettings) {} + + fun saveable(): Saveable? { + @Suppress("UNCHECKED_CAST") + return this as? Saveable? + } + + fun displayable(): Displayable? { + return this as? Displayable? + } + + @OptIn(ExperimentalCustomerSheetApi::class) + data class CustomerSheetConfigurationData( + private val configurationBuilder: CustomerSheet.Configuration.Builder, + var billingDetailsCollectionConfiguration: PaymentSheet.BillingDetailsCollectionConfiguration = + PaymentSheet.BillingDetailsCollectionConfiguration() + ) { + // Billing details is a nested configuration, but we have individual settings for it in the + // UI, this helper keeps all of the configurations, rather than just the most recent. + fun updateBillingDetails( + block: PaymentSheet.BillingDetailsCollectionConfiguration.() -> + PaymentSheet.BillingDetailsCollectionConfiguration + ) { + billingDetailsCollectionConfiguration.apply { + billingDetailsCollectionConfiguration = block() + } + configurationBuilder.billingDetailsCollectionConfiguration( + billingDetailsCollectionConfiguration + ) + } + } + + interface Saveable { + val key: String + val defaultValue: T + fun convertToString(value: T): String + fun convertToValue(value: String): T + val saveToSharedPreferences: Boolean + get() = true + } + + interface Displayable : CustomerSheetPlaygroundSettingDefinition { + val displayName: String + val options: List> + + fun option(name: String, value: T): Option { + return Option(name, value) + } + + data class Option(val name: String, val value: T) + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CustomerSheetPlaygroundSettings.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CustomerSheetPlaygroundSettings.kt new file mode 100644 index 00000000000..3af7ed6deb7 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/CustomerSheetPlaygroundSettings.kt @@ -0,0 +1,211 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import android.content.Context +import androidx.compose.runtime.Stable +import androidx.core.content.edit +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.paymentsheet.example.playground.customersheet.CustomerSheetPlaygroundState +import com.stripe.android.paymentsheet.example.playground.customersheet.model.CustomerEphemeralKeyRequest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +internal class CustomerSheetPlaygroundSettings private constructor( + private val settings: MutableMap, MutableStateFlow> +) { + operator fun get(settingsDefinition: CustomerSheetPlaygroundSettingDefinition): StateFlow { + @Suppress("UNCHECKED_CAST") + return settings[settingsDefinition]?.asStateFlow() as StateFlow + } + + operator fun set(settingsDefinition: CustomerSheetPlaygroundSettingDefinition, value: T) { + if (settings.containsKey(settingsDefinition)) { + settings[settingsDefinition]?.value = value + } else { + settings[settingsDefinition] = MutableStateFlow(value) + } + settingsDefinition.valueUpdated(value, this) + } + + fun snapshot(): Snapshot { + return Snapshot(this) + } + + @Stable + class Snapshot private constructor( + private val settings: Map, Any?> + ) { + constructor(playgroundSettings: CustomerSheetPlaygroundSettings) : this( + playgroundSettings.settings.map { it.key to it.value.value }.toMap() + ) + + operator fun get(settingsDefinition: CustomerSheetPlaygroundSettingDefinition): T { + @Suppress("UNCHECKED_CAST") + return settings[settingsDefinition] as T + } + + fun playgroundSettings(): CustomerSheetPlaygroundSettings { + val mutableSettings = settings.map { + it.key to MutableStateFlow(it.value) + }.toMap().toMutableMap() + return CustomerSheetPlaygroundSettings(mutableSettings) + } + + @OptIn(ExperimentalCustomerSheetApi::class) + fun customerSheetConfiguration( + playgroundState: CustomerSheetPlaygroundState + ): CustomerSheet.Configuration { + val builder = CustomerSheet.Configuration.builder("Example, Inc.") + + val configurationData = + CustomerSheetPlaygroundSettingDefinition.CustomerSheetConfigurationData(builder) + + settings.onEach { (settingDefinition, value) -> + settingDefinition.configure(value, builder, playgroundState, configurationData) + } + return builder.build() + } + + @OptIn(ExperimentalCustomerSheetApi::class) + private fun CustomerSheetPlaygroundSettingDefinition.configure( + value: Any?, + configurationBuilder: CustomerSheet.Configuration.Builder, + playgroundState: CustomerSheetPlaygroundState, + configurationData: CustomerSheetPlaygroundSettingDefinition.CustomerSheetConfigurationData, + ) { + @Suppress("UNCHECKED_CAST") + configure( + value = value as T, + configurationBuilder = configurationBuilder, + playgroundState = playgroundState, + configurationData = configurationData, + ) + } + + fun customerEphemeralKeyRequest(): CustomerEphemeralKeyRequest { + val builder = CustomerEphemeralKeyRequest.Builder() + settings.onEach { (settingDefinition, value) -> + settingDefinition.configure(builder, value) + } + return builder.build() + } + + private fun CustomerSheetPlaygroundSettingDefinition.configure( + requestBuilder: CustomerEphemeralKeyRequest.Builder, + value: Any?, + ) { + @Suppress("UNCHECKED_CAST") + configure(value as T, requestBuilder) + } + + private fun asJsonString(filter: (CustomerSheetPlaygroundSettingDefinition<*>) -> Boolean): String { + val settingsMap = settings.filterKeys(filter).map { + val saveable = it.key.saveable() + if (saveable != null) { + saveable.key to JsonPrimitive(saveable.convertToString(it.value)) + } else { + null + } + }.filterNotNull().toMap() + return Json.encodeToString(JsonObject(settingsMap)) + } + + fun saveToSharedPreferences(context: Context) { + val sharedPreferences = context.getSharedPreferences( + SHARED_PREFERENCES_NAME, + Context.MODE_PRIVATE + ) + + sharedPreferences.edit { + putString( + SHARED_PREFERENCES_KEY, + asJsonString(filter = { it.saveable()?.saveToSharedPreferences == true }) + ) + } + } + + fun asJsonString(): String { + return asJsonString { true } + } + + private fun CustomerSheetPlaygroundSettingDefinition.Saveable.convertToString( + value: Any?, + ): String { + @Suppress("UNCHECKED_CAST") + return convertToString(value as T) + } + } + + companion object { + private const val SHARED_PREFERENCES_NAME = "CustomerSheetPlaygroundSettings" + private const val SHARED_PREFERENCES_KEY = "json" + + fun createFromDefaults(): CustomerSheetPlaygroundSettings { + val settings = allSettingDefinitions.associateWith { settingDefinition -> + MutableStateFlow(settingDefinition.defaultValue) + }.toMutableMap() + return CustomerSheetPlaygroundSettings(settings) + } + + fun createFromJsonString(jsonString: String): CustomerSheetPlaygroundSettings { + val settings: MutableMap, MutableStateFlow> = + mutableMapOf() + val jsonObject = Json.decodeFromString(JsonObject.serializer(), jsonString) + + for (settingDefinition in allSettingDefinitions) { + val saveable = settingDefinition.saveable() + if (saveable != null) { + val jsonPrimitive = jsonObject[saveable.key] as? JsonPrimitive? + if (jsonPrimitive?.isString == true) { + settings[settingDefinition] = + MutableStateFlow(saveable.convertToValue(jsonPrimitive.content)) + } else { + settings[settingDefinition] = MutableStateFlow(settingDefinition.defaultValue) + } + } else { + settings[settingDefinition] = MutableStateFlow(settingDefinition.defaultValue) + } + } + + return CustomerSheetPlaygroundSettings(settings) + } + + fun createFromSharedPreferences(context: Context): CustomerSheetPlaygroundSettings { + val sharedPreferences = context.getSharedPreferences( + SHARED_PREFERENCES_NAME, + Context.MODE_PRIVATE + ) + + val jsonString = sharedPreferences.getString(SHARED_PREFERENCES_KEY, null) + ?: return createFromDefaults() + + return createFromJsonString(jsonString) + } + + val uiSettingDefinitions: List> = listOf( + CustomerSettingsDefinition, + CountrySettingsDefinition, + PaymentMethodModeDefinition, + GooglePaySettingsDefinition, + AttachDefaultBillingDetailsDefinition, + PreferredNetworkSettingsDefinition, + AllowsRemovalOfLastSavedPaymentMethodSettingsDefinition, + CollectNameSettingsDefinition, + CollectEmailSettingsDefinition, + CollectPhoneSettingsDefinition, + CollectAddressSettingsDefinition, + ) + + private val nonUiSettingDefinitions: List> = listOf( + AppearanceSettingsDefinition, + ) + + private val allSettingDefinitions: List> = + uiSettingDefinitions + nonUiSettingDefinitions + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/EnumSaveable.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/EnumSaveable.kt new file mode 100644 index 00000000000..03264d3f569 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/EnumSaveable.kt @@ -0,0 +1,19 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +internal class EnumSaveable( + override val key: String, + override val defaultValue: T, + private val values: Array, +) : CustomerSheetPlaygroundSettingDefinition.Saveable { + override fun convertToValue(value: String): T { + return values.firstOrNull { it.value == value } ?: defaultValue + } + + override fun convertToString(value: T): String { + return value.value + } +} + +internal interface ValueEnum { + val value: String +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/GooglePaySettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/GooglePaySettingsDefinition.kt new file mode 100644 index 00000000000..dcbf806cda1 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/GooglePaySettingsDefinition.kt @@ -0,0 +1,21 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.paymentsheet.example.playground.customersheet.CustomerSheetPlaygroundState + +internal object GooglePaySettingsDefinition : BooleanSettingsDefinition( + key = "googlePay", + displayName = "Google Pay", + defaultValue = true, +) { + @OptIn(ExperimentalCustomerSheetApi::class) + override fun configure( + value: Boolean, + configurationBuilder: CustomerSheet.Configuration.Builder, + playgroundState: CustomerSheetPlaygroundState, + configurationData: CustomerSheetPlaygroundSettingDefinition.CustomerSheetConfigurationData, + ) { + configurationBuilder.googlePayEnabled(value) + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/PaymentMethodModeDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/PaymentMethodModeDefinition.kt new file mode 100644 index 00000000000..e1099e0e0b9 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/PaymentMethodModeDefinition.kt @@ -0,0 +1,24 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +internal object PaymentMethodModeDefinition : + CustomerSheetPlaygroundSettingDefinition, + CustomerSheetPlaygroundSettingDefinition.Saveable by EnumSaveable( + key = "paymentMethodMode", + values = PaymentMethodMode.entries.toTypedArray(), + defaultValue = PaymentMethodMode.SetupIntent, + ), + CustomerSheetPlaygroundSettingDefinition.Displayable { + override val displayName: String = "Payment Method Mode" + + override val options by lazy { + listOf( + option("Setup Intent", PaymentMethodMode.SetupIntent), + option("Create And Attach", PaymentMethodMode.CreateAndAttach), + ) + } +} + +enum class PaymentMethodMode(override val value: String) : ValueEnum { + SetupIntent("setup_intent"), + CreateAndAttach("create_and_attach") +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/PreferredNetworkSettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/PreferredNetworkSettingsDefinition.kt new file mode 100644 index 00000000000..dc70c3eaadb --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/PreferredNetworkSettingsDefinition.kt @@ -0,0 +1,25 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +import com.stripe.android.customersheet.CustomerSheet +import com.stripe.android.customersheet.ExperimentalCustomerSheetApi +import com.stripe.android.model.CardBrand +import com.stripe.android.paymentsheet.example.playground.customersheet.CustomerSheetPlaygroundState + +internal object PreferredNetworkSettingsDefinition : BooleanSettingsDefinition( + key = "cartesBancairesAsMerchantPreferredNetwork", + displayName = "Cartes Bancaires as preferred network", + defaultValue = false, +) { + + @OptIn(ExperimentalCustomerSheetApi::class) + override fun configure( + value: Boolean, + configurationBuilder: CustomerSheet.Configuration.Builder, + playgroundState: CustomerSheetPlaygroundState, + configurationData: CustomerSheetPlaygroundSettingDefinition.CustomerSheetConfigurationData + ) { + if (value) { + configurationBuilder.preferredNetworks(listOf(CardBrand.CartesBancaires)) + } + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/SettingsUI.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/SettingsUI.kt new file mode 100644 index 00000000000..eee25b5d90e --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/customersheet/settings/SettingsUI.kt @@ -0,0 +1,197 @@ +package com.stripe.android.paymentsheet.example.playground.customersheet.settings + +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.selection.selectable +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ExposedDropdownMenuBox +import androidx.compose.material.ExposedDropdownMenuDefaults +import androidx.compose.material.RadioButton +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.StateFlow + +@Composable +internal fun SettingsUi(playgroundSettings: CustomerSheetPlaygroundSettings) { + Column { + for (settingDefinition in CustomerSheetPlaygroundSettings.uiSettingDefinitions) { + Row(modifier = Modifier.padding(bottom = 16.dp)) { + Setting(settingDefinition, playgroundSettings) + } + } + } +} + +@Composable +private fun Setting( + settingDefinition: CustomerSheetPlaygroundSettingDefinition.Displayable, + playgroundSettings: CustomerSheetPlaygroundSettings, +) { + Setting( + name = settingDefinition.displayName, + options = settingDefinition.options, + valueFlow = playgroundSettings[settingDefinition], + ) { newValue -> + playgroundSettings[settingDefinition] = newValue + } +} + +@Composable +private fun Setting( + name: String, + options: List>, + valueFlow: StateFlow, + onOptionChanged: (T) -> Unit, +) { + val value by valueFlow.collectAsState() + if (options.isEmpty() && value is String) { + @Suppress("UNCHECKED_CAST") + TextSetting( + name = name, + value = value as String, + onOptionChanged = onOptionChanged as (String) -> Unit, + ) + } else if (options.size < MAX_RADIO_BUTTON_OPTIONS) { + RadioButtonSetting( + name = name, + options = options, + value = value, + onOptionChanged = onOptionChanged, + ) + } else { + DropdownSetting( + name = name, + options = options, + value = value, + onOptionChanged = onOptionChanged, + ) + } +} + +@Composable +private fun TextSetting( + name: String, + value: String, + onOptionChanged: (String) -> Unit, +) { + TextField( + placeholder = { Text(text = name) }, + label = { Text(text = name) }, + value = value, + onValueChange = { newValue: String -> + onOptionChanged(newValue) + }, + modifier = Modifier.fillMaxWidth(), + ) +} + +@Composable +private fun RadioButtonSetting( + name: String, + options: List>, + value: T, + onOptionChanged: (T) -> Unit, +) { + Column { + Row { + Text( + text = name, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 5.dp) + ) + } + + val selectedOption = remember(value) { options.firstOrNull { it.value == value } } + + Row { + options.forEach { option -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .selectable( + selected = (option == selectedOption), + onClick = { + onOptionChanged(option.value) + } + ) + .padding(end = 5.dp) + ) { + RadioButton( + selected = (option == selectedOption), + onClick = null, + ) + Text( + text = option.name, + ) + } + } + } + + Row { + if (selectedOption == null) { + Text( + text = value.toString(), + ) + } + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun DropdownSetting( + name: String, + options: List>, + value: T, + onOptionChanged: (T) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val selectedOption = remember(value) { options.firstOrNull { it.value == value } } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + TextField( + readOnly = true, + value = selectedOption?.name.orEmpty(), + onValueChange = {}, + label = { Text(name) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + modifier = Modifier.fillMaxWidth() + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + for (option in options) { + DropdownMenuItem( + onClick = { + onOptionChanged(option.value) + expanded = false + } + ) { + Text( + text = option.name, + ) + } + } + } + } +} + +private const val MAX_RADIO_BUTTON_OPTIONS = 4