From c79c564a0f1875eaea374cf670b5359848d4e73b Mon Sep 17 00:00:00 2001 From: Samer Alabi Date: Thu, 23 May 2024 17:24:27 -0400 Subject: [PATCH 1/2] Add `CustomerSheet` playground UI --- .../example/playground/CustomerSheetState.kt | 19 ++++++ .../PaymentSheetPlaygroundActivity.kt | 39 ++++++++++- .../PaymentSheetPlaygroundViewModel.kt | 65 +++++++++++++++++++ .../settings/CountrySettingsDefinition.kt | 4 +- .../example/playground/settings/SettingsUI.kt | 4 ++ 5 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/CustomerSheetState.kt diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/CustomerSheetState.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/CustomerSheetState.kt new file mode 100644 index 00000000000..824a3421e8a --- /dev/null +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/CustomerSheetState.kt @@ -0,0 +1,19 @@ +package com.stripe.android.paymentsheet.example.playground + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import com.stripe.android.paymentsheet.model.PaymentOption + +internal data class CustomerSheetState( + val selectedPaymentOption: PaymentOption? = null, + val shouldFetchPaymentOption: Boolean = true +) + +internal fun CustomerSheetState?.paymentMethodLabel(): String { + return this?.selectedPaymentOption?.label ?: "Select" +} + +@Composable +internal fun CustomerSheetState?.paymentMethodPainter(): Painter? { + return this?.selectedPaymentOption?.iconPainter +} 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 b24f25a76f3..a47914b3824 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 @@ -35,6 +35,8 @@ 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.ExperimentalCustomerSheetApi +import com.stripe.android.customersheet.rememberCustomerSheet import com.stripe.android.model.PaymentMethod import com.stripe.android.paymentsheet.ExternalPaymentMethodConfirmHandler import com.stripe.android.paymentsheet.PaymentSheet @@ -242,7 +244,14 @@ internal class PaymentSheetPlaygroundActivity : AppCompatActivity(), ExternalPay } } is PlaygroundState.Customer -> { - // TODO(samer-stripe): Implement Customer Sheet UI + val customerSheetState by viewModel.customerSheetState.collectAsState() + + customerSheetState?.let { state -> + CustomerSheetUi( + customerSheetState = state, + playgroundState = playgroundState + ) + } } } } @@ -306,6 +315,34 @@ internal class PaymentSheetPlaygroundActivity : AppCompatActivity(), ExternalPay ) } + @OptIn(ExperimentalCustomerSheetApi::class) + @Composable + fun CustomerSheetUi( + playgroundState: PlaygroundState.Customer, + customerSheetState: CustomerSheetState, + ) { + val customerSheet = rememberCustomerSheet( + configuration = playgroundState.customerSheetConfiguration(), + customerAdapter = playgroundState.adapter, + callback = viewModel::onCustomerSheetCallback + ) + + LaunchedEffect(customerSheet) { + viewModel.fetchOption(customerSheet) + } + + if (customerSheetState.shouldFetchPaymentOption) { + return + } + + PaymentMethodSelector( + isEnabled = true, + paymentMethodLabel = customerSheetState.paymentMethodLabel(), + paymentMethodPainter = customerSheetState.paymentMethodPainter(), + onClick = customerSheet::present + ) + } + @Composable private fun ShippingAddressButton( addressLauncher: AddressLauncher, diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PaymentSheetPlaygroundViewModel.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PaymentSheetPlaygroundViewModel.kt index 74ac71ef507..fbcb0c94bb1 100644 --- a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PaymentSheetPlaygroundViewModel.kt +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PaymentSheetPlaygroundViewModel.kt @@ -13,6 +13,8 @@ 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.model.PaymentMethod import com.stripe.android.paymentsheet.CreateIntentResult @@ -59,6 +61,7 @@ internal class PaymentSheetPlaygroundViewModel( val status = MutableStateFlow(null) val state = MutableStateFlow(null) val flowControllerState = MutableStateFlow(null) + val customerSheetState = MutableStateFlow(null) init { viewModelScope.launch(Dispatchers.IO) { @@ -73,6 +76,7 @@ internal class PaymentSheetPlaygroundViewModel( ) { state.value = null flowControllerState.value = null + customerSheetState.value = null if (playgroundSettings.configurationData.value.integrationType.isPaymentFlow()) { prepareCheckout(playgroundSettings) @@ -162,6 +166,7 @@ internal class PaymentSheetPlaygroundViewModel( } ) ) + customerSheetState.value = CustomerSheetState() } } @@ -299,6 +304,66 @@ internal class PaymentSheetPlaygroundViewModel( status.value = StatusMessage(statusMessage) } + @OptIn(ExperimentalCustomerSheetApi::class) + fun fetchOption(customerSheet: CustomerSheet) { + viewModelScope.launch(Dispatchers.IO) { + when (val result = customerSheet.retrievePaymentOptionSelection()) { + is CustomerSheetResult.Selected -> { + customerSheetState.update { existingState -> + existingState?.copy( + selectedPaymentOption = result.selection?.paymentOption, + shouldFetchPaymentOption = false, + ) + } + } + is CustomerSheetResult.Failed -> { + customerSheetState.update { existingState -> + existingState?.copy( + shouldFetchPaymentOption = false, + ) + } + + status.emit( + StatusMessage( + message = "Failed to retrieve payment options:\n${result.exception.message}" + ) + ) + } + is CustomerSheetResult.Canceled -> { + customerSheetState.update { existingState -> + existingState?.copy( + shouldFetchPaymentOption = false, + ) + } + } + } + } + } + + @OptIn(ExperimentalCustomerSheetApi::class) + fun onCustomerSheetCallback(result: CustomerSheetResult) { + val statusMessage = when (result) { + is CustomerSheetResult.Selected -> { + customerSheetState.update { existingState -> + existingState?.copy( + selectedPaymentOption = result.selection?.paymentOption, + shouldFetchPaymentOption = false + ) + } + + null + } + is CustomerSheetResult.Failed -> "An error occurred: ${result.exception.message}" + is CustomerSheetResult.Canceled -> "Canceled" + } + + statusMessage?.let { message -> + viewModelScope.launch { + status.emit(StatusMessage(message)) + } + } + } + private fun createIntent(clientSecret: String): CreateIntentResult { // Note: This is not how you'd do this in a real application. Instead, your app would // call your backend and create (and optionally confirm) a payment or setup intent. diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/settings/CountrySettingsDefinition.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/settings/CountrySettingsDefinition.kt index c93dab44f30..43cd89be76f 100644 --- a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/settings/CountrySettingsDefinition.kt +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/settings/CountrySettingsDefinition.kt @@ -15,8 +15,8 @@ internal object CountrySettingsDefinition : PlaygroundSettingDefinition.Displayable { private val supportedPaymentFlowCountries = Country.entries.map { it.value }.toSet() private val supportedCustomerFlowCountries = setOf( - Country.US, - Country.FR, + Country.US.value, + Country.FR.value, ) override val displayName: String = "Merchant" diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/settings/SettingsUI.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/settings/SettingsUI.kt index 9f738d87cdb..f1ec56c63f4 100644 --- a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/settings/SettingsUI.kt +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/settings/SettingsUI.kt @@ -117,6 +117,10 @@ private fun IntegrationTypeConfigurableSetting( name = "Flow Controller", value = PlaygroundConfigurationData.IntegrationType.FlowController ), + PlaygroundSettingDefinition.Displayable.Option( + name = "Customer Sheet", + value = PlaygroundConfigurationData.IntegrationType.CustomerSheet + ), ), value = configurationData.integrationType ) { integrationType -> From 86ecb9d356b52d48e03d1e2a95153ec3d58ff707 Mon Sep 17 00:00:00 2001 From: Samer Alabi Date: Thu, 23 May 2024 19:00:51 -0400 Subject: [PATCH 2/2] Move `rememberCustomerSheet` to `onCreate` & properly fetch option. --- .../PaymentSheetPlaygroundActivity.kt | 89 +++++++++++++------ .../PaymentSheetPlaygroundViewModel.kt | 37 -------- .../example/playground/PlaygroundState.kt | 4 + 3 files changed, 65 insertions(+), 65 deletions(-) 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 a47914b3824..cbe796b3559 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 @@ -35,6 +35,8 @@ 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.CustomerSheet +import com.stripe.android.customersheet.CustomerSheetResult import com.stripe.android.customersheet.ExperimentalCustomerSheetApi import com.stripe.android.customersheet.rememberCustomerSheet import com.stripe.android.model.PaymentMethod @@ -55,9 +57,12 @@ import com.stripe.android.paymentsheet.example.playground.settings.SettingsUi import com.stripe.android.paymentsheet.example.samples.ui.shared.BuyButton import com.stripe.android.paymentsheet.example.samples.ui.shared.CHECKOUT_TEST_TAG import com.stripe.android.paymentsheet.example.samples.ui.shared.PaymentMethodSelector +import com.stripe.android.paymentsheet.model.PaymentOption import com.stripe.android.paymentsheet.rememberPaymentSheet import com.stripe.android.paymentsheet.rememberPaymentSheetFlowController +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext internal class PaymentSheetPlaygroundActivity : AppCompatActivity(), ExternalPaymentMethodConfirmHandler { companion object { @@ -76,6 +81,7 @@ internal class PaymentSheetPlaygroundActivity : AppCompatActivity(), ExternalPay ) } + @OptIn(ExperimentalCustomerSheetApi::class) @Suppress("LongMethod") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -101,6 +107,14 @@ internal class PaymentSheetPlaygroundActivity : AppCompatActivity(), ExternalPay val playgroundState by viewModel.state.collectAsState() + val customerSheet = playgroundState?.asCustomerState()?.let { customerPlaygroundState -> + rememberCustomerSheet( + configuration = customerPlaygroundState.customerSheetConfiguration(), + customerAdapter = customerPlaygroundState.adapter, + callback = viewModel::onCustomerSheetCallback + ) + } + PlaygroundTheme( content = { playgroundState?.asPaymentState()?.stripeIntentId?.let { stripeIntentId -> @@ -130,6 +144,7 @@ internal class PaymentSheetPlaygroundActivity : AppCompatActivity(), ExternalPay playgroundState = playgroundState, paymentSheet = paymentSheet, flowController = flowController, + customerSheet = customerSheet, addressLauncher = addressLauncher, ) } @@ -208,11 +223,13 @@ internal class PaymentSheetPlaygroundActivity : AppCompatActivity(), ExternalPay } } + @OptIn(ExperimentalCustomerSheetApi::class) @Composable private fun PlaygroundStateUi( playgroundState: PlaygroundState?, paymentSheet: PaymentSheet, flowController: PaymentSheet.FlowController, + customerSheet: CustomerSheet?, addressLauncher: AddressLauncher ) { if (playgroundState == null) { @@ -243,15 +260,8 @@ internal class PaymentSheetPlaygroundActivity : AppCompatActivity(), ExternalPay else -> Unit } } - is PlaygroundState.Customer -> { - val customerSheetState by viewModel.customerSheetState.collectAsState() - - customerSheetState?.let { state -> - CustomerSheetUi( - customerSheetState = state, - playgroundState = playgroundState - ) - } + is PlaygroundState.Customer -> customerSheet?.run { + CustomerSheetUi(customerSheet = this) } } } @@ -318,29 +328,41 @@ internal class PaymentSheetPlaygroundActivity : AppCompatActivity(), ExternalPay @OptIn(ExperimentalCustomerSheetApi::class) @Composable fun CustomerSheetUi( - playgroundState: PlaygroundState.Customer, - customerSheetState: CustomerSheetState, + customerSheet: CustomerSheet, ) { - val customerSheet = rememberCustomerSheet( - configuration = playgroundState.customerSheetConfiguration(), - customerAdapter = playgroundState.adapter, - callback = viewModel::onCustomerSheetCallback - ) + val customerSheetState by viewModel.customerSheetState.collectAsState() + + customerSheetState?.let { state -> + LaunchedEffect(state) { + if (state.shouldFetchPaymentOption) { + fetchOption(customerSheet).onSuccess { option -> + viewModel.customerSheetState.emit( + CustomerSheetState( + selectedPaymentOption = option, + shouldFetchPaymentOption = false + ) + ) + }.onFailure { exception -> + viewModel.status.emit( + StatusMessage( + message = "Failed to retrieve payment options:\n${exception.message}" + ) + ) + } + } + } - LaunchedEffect(customerSheet) { - viewModel.fetchOption(customerSheet) - } + if (state.shouldFetchPaymentOption) { + return + } - if (customerSheetState.shouldFetchPaymentOption) { - return + PaymentMethodSelector( + isEnabled = true, + paymentMethodLabel = customerSheetState.paymentMethodLabel(), + paymentMethodPainter = customerSheetState.paymentMethodPainter(), + onClick = customerSheet::present + ) } - - PaymentMethodSelector( - isEnabled = true, - paymentMethodLabel = customerSheetState.paymentMethodLabel(), - paymentMethodPainter = customerSheetState.paymentMethodPainter(), - onClick = customerSheet::present - ) } @Composable @@ -419,6 +441,17 @@ internal class PaymentSheetPlaygroundActivity : AppCompatActivity(), ExternalPay } } + @OptIn(ExperimentalCustomerSheetApi::class) + private suspend fun fetchOption( + customerSheet: CustomerSheet + ): Result = withContext(Dispatchers.IO) { + when (val result = customerSheet.retrievePaymentOptionSelection()) { + is CustomerSheetResult.Selected -> Result.success(result.selection?.paymentOption) + is CustomerSheetResult.Failed -> Result.failure(result.exception) + is CustomerSheetResult.Canceled -> Result.success(null) + } + } + override fun confirmExternalPaymentMethod( externalPaymentMethodType: String, billingDetails: PaymentMethod.BillingDetails diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PaymentSheetPlaygroundViewModel.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PaymentSheetPlaygroundViewModel.kt index fbcb0c94bb1..f4922d6ae43 100644 --- a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PaymentSheetPlaygroundViewModel.kt +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PaymentSheetPlaygroundViewModel.kt @@ -13,7 +13,6 @@ 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.model.PaymentMethod @@ -304,42 +303,6 @@ internal class PaymentSheetPlaygroundViewModel( status.value = StatusMessage(statusMessage) } - @OptIn(ExperimentalCustomerSheetApi::class) - fun fetchOption(customerSheet: CustomerSheet) { - viewModelScope.launch(Dispatchers.IO) { - when (val result = customerSheet.retrievePaymentOptionSelection()) { - is CustomerSheetResult.Selected -> { - customerSheetState.update { existingState -> - existingState?.copy( - selectedPaymentOption = result.selection?.paymentOption, - shouldFetchPaymentOption = false, - ) - } - } - is CustomerSheetResult.Failed -> { - customerSheetState.update { existingState -> - existingState?.copy( - shouldFetchPaymentOption = false, - ) - } - - status.emit( - StatusMessage( - message = "Failed to retrieve payment options:\n${result.exception.message}" - ) - ) - } - is CustomerSheetResult.Canceled -> { - customerSheetState.update { existingState -> - existingState?.copy( - shouldFetchPaymentOption = false, - ) - } - } - } - } - } - @OptIn(ExperimentalCustomerSheetApi::class) fun onCustomerSheetCallback(result: CustomerSheetResult) { val statusMessage = when (result) { diff --git a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PlaygroundState.kt b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PlaygroundState.kt index 80274f324b0..9a06ad7b868 100644 --- a/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PlaygroundState.kt +++ b/paymentsheet-example/src/main/java/com/stripe/android/paymentsheet/example/playground/PlaygroundState.kt @@ -64,6 +64,10 @@ internal sealed interface PlaygroundState { return this as? Payment } + fun asCustomerState(): Customer? { + return this as? Customer + } + companion object { fun CheckoutResponse.asPlaygroundState( snapshot: PlaygroundSettings.Snapshot,