From c7714919636c0ced3dc02bd781cba636b6a1d83d Mon Sep 17 00:00:00 2001 From: Jay Newstrom Date: Mon, 29 Jun 2026 09:10:29 -0600 Subject: [PATCH 1/2] Add CheckoutController and UI element classes as initial scaffolding. --- .../com/stripe/android/checkout/CheckoutController.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt index da0fe3ade0e..96ce4b8ba37 100644 --- a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt @@ -26,8 +26,14 @@ class CheckoutController @Inject internal constructor( resultCallback: ResultCallback, @ViewModelScope private val viewModelScope: CoroutineScope, ) { - private val _checkoutSession = MutableStateFlow(null) - val checkoutSession: StateFlow = _checkoutSession.asStateFlow() + val checkoutSession: StateFlow = + MutableStateFlow(null).asStateFlow() + + val isLoading: StateFlow = + MutableStateFlow(false).asStateFlow() + + val paymentOption: StateFlow = + MutableStateFlow(null).asStateFlow() suspend fun configure( checkoutSessionClientSecret: String, From e3d3c8f1647d9e9e976cd219f24b2c6cf7663144 Mon Sep 17 00:00:00 2001 From: Jay Newstrom Date: Mon, 29 Jun 2026 09:31:44 -0600 Subject: [PATCH 2/2] Introduce checkout controller playground. --- paymentsheet-example/detekt-baseline.xml | 1 + .../src/main/AndroidManifest.xml | 3 + .../paymentsheet/example/MainActivity.kt | 7 + .../CheckoutControllerExampleActivity.kt | 237 ++++++++++++++++++ ...ckoutControllerExampleBackendRepository.kt | 60 +++++ .../CheckoutControllerExampleViewModel.kt | 114 +++++++++ .../src/main/res/values/strings.xml | 2 + .../android/checkout/CheckoutController.kt | 19 +- 8 files changed, 434 insertions(+), 9 deletions(-) create mode 100644 paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/checkout/CheckoutControllerExampleActivity.kt create mode 100644 paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/checkout/CheckoutControllerExampleBackendRepository.kt create mode 100644 paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/checkout/CheckoutControllerExampleViewModel.kt diff --git a/paymentsheet-example/detekt-baseline.xml b/paymentsheet-example/detekt-baseline.xml index 4426d838496..8ef337bb578 100644 --- a/paymentsheet-example/detekt-baseline.xml +++ b/paymentsheet-example/detekt-baseline.xml @@ -18,6 +18,7 @@ MagicNumber:AppearanceStore.kt:AppearanceStore.State.Embedded$0xFF0074D4 MagicNumber:AppearanceStore.kt:AppearanceStore.State.Embedded$0xFF007AFF MagicNumber:CartProduct.kt:100 + MagicNumber:CheckoutControllerExampleBackendRepository.kt:CheckoutControllerExampleBackendRepository$5000 MagicNumber:DrawablePainter.kt:DrawablePainter$255 MagicNumber:Payment.kt:0.5f MagicNumber:SharedPaymentTokenPlaygroundActivity.kt:SharedPaymentTokenPlaygroundActivity$9999L diff --git a/paymentsheet-example/src/main/AndroidManifest.xml b/paymentsheet-example/src/main/AndroidManifest.xml index 61c648b2909..06544b1953b 100644 --- a/paymentsheet-example/src/main/AndroidManifest.xml +++ b/paymentsheet-example/src/main/AndroidManifest.xml @@ -60,6 +60,9 @@ + { + LoadingContent() + } + is CheckoutControllerExampleViewModel.Status.Error -> { + ErrorContent(currentStatus.message) + } + is CheckoutControllerExampleViewModel.Status.Configured -> { + val session = currentStatus.checkoutSession + if (session != null) { + LineItemsSection(session) + TotalSummarySection(session) + paymentElement.PaymentOptionsContent() + } + } + } + }, + bottomBarContent = { + val configured = status as? CheckoutControllerExampleViewModel.Status.Configured +// PaymentOptionRow(configured?.paymentOption) + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { paymentElement.presentPaymentOptions() }, + enabled = configured != null, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Select Payment Method") + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { presenter.confirm() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Confirm") + } + }, + ) + } + } +} + +@Composable +private fun LoadingContent() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ErrorContent(message: String) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Error", + style = MaterialTheme.typography.h6, + color = Color.Red, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text(text = message) + } +} + +@Composable +private fun PaymentOptionRow(paymentOption: PaymentElement.PaymentOptionDisplayData?) { + if (paymentOption != null) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Image( + painter = paymentOption.iconPainter, + contentDescription = null, + modifier = Modifier.size(32.dp), + ) + Text( + text = paymentOption.label, + style = MaterialTheme.typography.body1, + ) + } + } else { + Text( + text = "No payment method selected", + style = MaterialTheme.typography.body2, + color = Color.Gray, + ) + } +} + +@Composable +private fun LineItemsSection(session: CheckoutSession) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(text = "Line Items", style = MaterialTheme.typography.h6) + Spacer(modifier = Modifier.height(8.dp)) + + for (item in session.lineItems) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${item.name} x${item.quantity}", + style = MaterialTheme.typography.body2, + ) + Text( + text = formatAmount(item.total, session.currency), + style = MaterialTheme.typography.body2, + ) + } + } + } +} + +@Composable +private fun TotalSummarySection(session: CheckoutSession) { + val summary = session.totalSummary ?: return + + Column(modifier = Modifier.fillMaxWidth()) { + Divider(modifier = Modifier.padding(vertical = 12.dp)) + + SummaryRow(label = "Subtotal", amount = formatAmount(summary.subtotal, session.currency)) + + for (discount in summary.discountAmounts) { + SummaryRow( + label = discount.displayName, + amount = "-${formatAmount(discount.amount, session.currency)}", + ) + } + + summary.shippingRate?.let { shipping -> + val amountText = if (shipping.amount == 0L) "Free" else formatAmount(shipping.amount, session.currency) + SummaryRow(label = "Shipping", amount = amountText) + } + + for (tax in summary.taxAmounts) { + val label = if (tax.inclusive) "${tax.displayName} (included)" else tax.displayName + SummaryRow(label = label, amount = formatAmount(tax.amount, session.currency)) + } + + Divider(modifier = Modifier.padding(vertical = 8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = "Total", style = MaterialTheme.typography.subtitle1) + Text( + text = formatAmount(summary.totalDueToday, session.currency), + style = MaterialTheme.typography.subtitle1, + ) + } + } +} + +@Composable +private fun SummaryRow(label: String, amount: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = label, style = MaterialTheme.typography.body2) + Text(text = amount, style = MaterialTheme.typography.body2) + } +} + +private fun formatAmount(amount: Long, currency: String): String { + return CurrencyFormatter.format(amount, currency) +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/checkout/CheckoutControllerExampleBackendRepository.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/checkout/CheckoutControllerExampleBackendRepository.kt new file mode 100644 index 00000000000..f5c40b61745 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/checkout/CheckoutControllerExampleBackendRepository.kt @@ -0,0 +1,60 @@ +package com.stripe.android.paymentsheet.example.playground.checkout + +import android.content.Context +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.paymentsheet.example.Settings +import com.stripe.android.paymentsheet.example.playground.model.CheckoutRequest +import com.stripe.android.paymentsheet.example.playground.model.CheckoutResponse +import com.stripe.android.paymentsheet.example.samples.networking.awaitModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +internal class CheckoutControllerExampleBackendRepository( + private val applicationContext: Context, +) { + private val json = Json { ignoreUnknownKeys = true } + private val backendUrl = Settings(applicationContext).playgroundBackendUrl + + suspend fun fetchCheckoutSessionClientSecret(): kotlin.Result { + val requestBody = CheckoutRequest.Builder() + .initialization("checkout_session") + .useCheckoutSession(true) + .mode("payment") + .currency("usd") + .amount(5000) + .customer("new") + .build() + + val apiResponse = withContext(Dispatchers.IO) { + Fuel.post(backendUrl + "checkout") + .jsonBody(json.encodeToString(CheckoutRequest.serializer(), requestBody)) + .suspendable() + .awaitModel(CheckoutResponse.serializer(), json) + } + + return when (apiResponse) { + is Result.Failure -> { + kotlin.Result.failure(apiResponse.getException()) + } + is Result.Success -> { + val response = apiResponse.value + + withContext(Dispatchers.IO) { + PaymentConfiguration.init(applicationContext, response.publishableKey) + } + + val clientSecret = response.checkoutSessionClientSecret + ?: return kotlin.Result.failure( + IllegalStateException("No checkout session client secret in response") + ) + + kotlin.Result.success(clientSecret) + } + } + } +} diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/checkout/CheckoutControllerExampleViewModel.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/checkout/CheckoutControllerExampleViewModel.kt new file mode 100644 index 00000000000..b84dd07cbe4 --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/checkout/CheckoutControllerExampleViewModel.kt @@ -0,0 +1,114 @@ +@file:OptIn(CheckoutSessionPreview::class) + +package com.stripe.android.paymentsheet.example.playground.checkout + +import android.app.Application +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.stripe.android.checkout.CheckoutController +import com.stripe.android.checkout.CheckoutSession +import com.stripe.android.paymentelement.CheckoutSessionPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +internal class CheckoutControllerExampleViewModel( + private val repository: CheckoutControllerExampleBackendRepository, + savedStateHandle: SavedStateHandle, + application: Application, +) : ViewModel() { + + private val _status = MutableStateFlow(Status.Loading) + val status: StateFlow = _status.asStateFlow() + + private val _sessionComplete = MutableSharedFlow(extraBufferCapacity = 1) + val sessionComplete: SharedFlow = _sessionComplete.asSharedFlow() + + val controller = CheckoutController.Builder( + application = application, + savedStateHandle = savedStateHandle, + ).resultCallback { result -> + Log.d(TAG, "Result: $result") + }.build() + + init { + viewModelScope.launch { + fetchAndConfigure() + } + viewModelScope.launch { + controller.checkoutSession.collect { session -> + updateConfiguredState { it.copy(checkoutSession = session) } + if (session?.status == CheckoutSession.Status.Complete) { + _sessionComplete.tryEmit(Unit) + } + } + } + } + + private fun updateConfiguredState(update: (Status.Configured) -> Status.Configured) { + val current = _status.value + if (current is Status.Configured) { + _status.value = update(current) + } + } + + private suspend fun fetchAndConfigure() { + repository.fetchCheckoutSessionClientSecret().fold( + onSuccess = { clientSecret -> + controller.configure(clientSecret).fold( + onSuccess = { + _status.value = Status.Configured( + checkoutSession = controller.checkoutSession.value, + ) + }, + onFailure = { error -> + Log.e(TAG, "Failed to configure", error) + _status.value = Status.Error(error.message ?: "Configure failed") + }, + ) + }, + onFailure = { error -> + Log.e(TAG, "Failed to fetch checkout session", error) + _status.value = Status.Error(error.message ?: "Unknown error") + }, + ) + } + + override fun onCleared() { + super.onCleared() + controller.destroy() + } + + sealed interface Status { + data object Loading : Status + data class Configured( + val checkoutSession: CheckoutSession?, + ) : Status + data class Error(val message: String) : Status + } + + companion object { + private const val TAG = "CheckoutControllerExample" + + val factory = viewModelFactory { + initializer { + val application = this[APPLICATION_KEY] as Application + CheckoutControllerExampleViewModel( + repository = CheckoutControllerExampleBackendRepository(application), + savedStateHandle = createSavedStateHandle(), + application = application, + ) + } + } + } +} diff --git a/paymentsheet-example/src/main/res/values/strings.xml b/paymentsheet-example/src/main/res/values/strings.xml index fe30581bba8..f6beac299e6 100644 --- a/paymentsheet-example/src/main/res/values/strings.xml +++ b/paymentsheet-example/src/main/res/values/strings.xml @@ -18,6 +18,8 @@ This is currently under construction Playground Testing playground for Stripe engineers + CheckoutController Example + Simple CheckoutController integration Onramp Coordinator Test the OnrampCoordinator functionality Settings diff --git a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt index 96ce4b8ba37..2b837be2578 100644 --- a/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt +++ b/paymentsheet/src/main/java/com/stripe/android/checkout/CheckoutController.kt @@ -26,14 +26,8 @@ class CheckoutController @Inject internal constructor( resultCallback: ResultCallback, @ViewModelScope private val viewModelScope: CoroutineScope, ) { - val checkoutSession: StateFlow = - MutableStateFlow(null).asStateFlow() - - val isLoading: StateFlow = - MutableStateFlow(false).asStateFlow() - - val paymentOption: StateFlow = - MutableStateFlow(null).asStateFlow() + private val _checkoutSession = MutableStateFlow(null) + val checkoutSession: StateFlow = _checkoutSession.asStateFlow() suspend fun configure( checkoutSessionClientSecret: String, @@ -99,8 +93,15 @@ class CheckoutController @Inject internal constructor( class Builder( private val application: Application, private val savedStateHandle: SavedStateHandle, - private val resultCallback: ResultCallback, ) { + private var resultCallback: ResultCallback = ResultCallback {} + + fun resultCallback( + resultCallback: ResultCallback + ): Builder = apply { + this.resultCallback = resultCallback + } + fun build(): CheckoutController { val component = DaggerCheckoutControllerComponent.factory().create( application = application,