Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
ca23e1f
Compiling demo of PayPal smart button on Compose. Doesn't run success…
saperi22 Feb 10, 2026
8c859e7
Reorder buttons in demo app
saperi22 Feb 10, 2026
f59f019
refactor file name
saperi22 Feb 10, 2026
f8d8265
fix button size
saperi22 Feb 10, 2026
6cd26f3
fix button size in demo
saperi22 Feb 10, 2026
76a263b
Skip modular and store pendingRequest state within composable.
saperi22 Feb 10, 2026
2fa41a7
hoist style to parent composable
saperi22 Feb 13, 2026
c8356ff
update demo app
saperi22 Feb 13, 2026
c17424d
remove unused classes
saperi22 Feb 23, 2026
92391ae
linter'
saperi22 Feb 23, 2026
c986b62
remember PayPalLauncher and PayPalClient beyond recomposition
saperi22 Feb 23, 2026
58a4091
Address PR comments
saperi22 Feb 23, 2026
6becf70
surface failure scenarios to host app
saperi22 Feb 24, 2026
3c20dd1
persist pending request to data store
saperi22 Feb 24, 2026
934b312
linter
saperi22 Feb 24, 2026
1545cc0
Cleanup.
saperi22 Feb 26, 2026
e41b1fe
Use scope properly
saperi22 Feb 26, 2026
c5fdd9a
linter fixes
saperi22 Mar 3, 2026
dd12bda
Address PR comments.
saperi22 Mar 4, 2026
dca8f06
make function private
saperi22 Mar 4, 2026
bccc828
add tests
saperi22 Mar 5, 2026
2ffc34c
Add analytics events for paypal compose button flow
saperi22 Mar 5, 2026
a18f95c
fix CI
saperi22 Mar 6, 2026
698a82c
fix test.
saperi22 Mar 6, 2026
b3dbb06
remove viewmodel to simplify flow
saperi22 Mar 6, 2026
0faa265
linter
saperi22 Mar 7, 2026
f81d931
add kdoc on composable
saperi22 Mar 9, 2026
36df6ee
Address PR comments
saperi22 Mar 9, 2026
87009fc
Venmo buttons compose UI
saperi22 Mar 4, 2026
caad8d8
Update analytics
saperi22 Mar 9, 2026
e8cdb91
Venmo compose button
saperi22 Mar 10, 2026
136eb7a
update demo app
saperi22 Mar 10, 2026
0f4aefd
rename options
saperi22 Mar 10, 2026
f9c3d45
Merge branch 'ui-components-compose-support' into venmo-compose-smart…
saperi22 Mar 11, 2026
782d957
update checkout values
saperi22 Mar 11, 2026
f56dd4d
consolidate extension functions
saperi22 Mar 11, 2026
3c4c38b
refactoring
saperi22 Mar 11, 2026
c754d45
refactor
saperi22 Mar 11, 2026
46cd64d
add kdoc
saperi22 Mar 11, 2026
9c87aca
Update repository to take in a moduleName parameter and update tests
saperi22 Mar 11, 2026
92e7e86
rename file
saperi22 Mar 11, 2026
6b36203
Add exception message
saperi22 Mar 11, 2026
69e5ebf
update venmo button content description
saperi22 Mar 12, 2026
f7cacfd
update to 3.5.1 of browser switch
saperi22 Mar 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
package com.braintreepayments.demo

import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.core.net.toUri
import androidx.navigation.fragment.NavHostFragment
import com.braintreepayments.api.core.PaymentMethodNonce
import com.braintreepayments.api.paypal.PayPalResult
import com.braintreepayments.api.paypal.PayPalTokenizeCallback
import com.braintreepayments.api.uicomponents.PayPalButtonColor
import com.braintreepayments.api.uicomponents.VenmoButtonColor
import com.braintreepayments.api.uicomponents.compose.PayPalSmartButton
import com.braintreepayments.api.uicomponents.compose.VenmoSmartButton
import com.braintreepayments.api.venmo.VenmoPaymentMethodUsage
import com.braintreepayments.api.venmo.VenmoRequest
import com.braintreepayments.api.venmo.VenmoResult
import com.braintreepayments.api.venmo.VenmoTokenizeCallback

class ComposeButtonsFragment : BaseFragment() {

Expand All @@ -22,53 +40,75 @@ class ComposeButtonsFragment : BaseFragment() {
savedInstanceState: Bundle?
): View {
super.onCreateView(inflater, container, savedInstanceState)

// PayPal flow setup
val payPalRequest = PayPalRequestFactory.createPayPalCheckoutRequest(
requireContext(),
"10.0",
null,
null,
null,
false,
null,
false,
false,
false
)

val paypalRequest = paypalRequest(requireContext())
return ComposeView(requireContext()).apply {
val paypalTokenizeCallback = PayPalTokenizeCallback { payPalResult ->
when (payPalResult) {
is PayPalResult.Success -> {
handlePayPalResult(payPalResult.nonce)
}

is PayPalResult.Cancel -> {
handleError(Exception("User did not complete payment flow"))
}

is PayPalResult.Failure -> {
handleError(payPalResult.error)
}
}
}
setContent {
var paypalStyle: PayPalButtonColor by remember { mutableStateOf(PayPalButtonColor.Blue) }
var venmoStyle: VenmoButtonColor by remember { mutableStateOf(VenmoButtonColor.Blue) }
Column {
SingleChoiceSegmentedButton(onClick = { selectedIndex ->
paypalStyle = when (selectedIndex) {
0 -> PayPalButtonColor.Blue
1 -> PayPalButtonColor.Black
2 -> PayPalButtonColor.White
else -> PayPalButtonColor.Blue
}
})
PayPalSmartButton(
style = PayPalButtonColor.Blue,
payPalRequest = payPalRequest,
style = paypalStyle,
payPalRequest = paypalRequest,
authorization = authStringArg,
appLinkReturnUrl =
"https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments".toUri(),
deepLinkFallbackUrlScheme = "com.braintreepayments.demo.braintree",
paypalTokenizeCallback = paypalTokenizeCallback
)

SingleChoiceSegmentedButton(onClick = { selectedIndex ->
venmoStyle = when (selectedIndex) {
0 -> VenmoButtonColor.Blue
1 -> VenmoButtonColor.Black
2 -> VenmoButtonColor.White
else -> VenmoButtonColor.Blue
}
})
VenmoSmartButton(
style = venmoStyle,
venmoRequest = venmoRequest,
authorization = authStringArg,
appLinkReturnUrl =
"https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments".toUri(),
deepLinkFallbackUrlScheme = "com.braintreepayments.demo.braintree",
venmoTokenizeCallback = venmoTokenizeCallback
)
}
}
}
}

@Composable
fun SingleChoiceSegmentedButton(modifier: Modifier = Modifier, onClick: (Int) -> Unit = {}) {
var selectedIndex by remember { mutableIntStateOf(0) }
val options = listOf("Blue", "Black", "White")

SingleChoiceSegmentedButtonRow {
options.forEachIndexed { index, label ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = options.size
),
onClick = {
selectedIndex = index
onClick(selectedIndex)
},
selected = index == selectedIndex,
label = { Text(label) }
)
}
}
}

private fun handlePayPalResult(paymentMethodNonce: PaymentMethodNonce?) {
if (paymentMethodNonce != null) {
super.onPaymentMethodNonceCreated(paymentMethodNonce)
Expand All @@ -80,4 +120,72 @@ class ComposeButtonsFragment : BaseFragment() {
NavHostFragment.findNavController(this).navigate(action)
}
}

private fun handleVenmoResult(paymentMethodNonce: PaymentMethodNonce?) {
if (paymentMethodNonce != null) {
super.onPaymentMethodNonceCreated(paymentMethodNonce)

val action =
ComposeButtonsFragmentDirections.actionComposePaymentButtonsFragmentToDisplayNonceFragment(
paymentMethodNonce
)
NavHostFragment.findNavController(this).navigate(action)
}
}

private fun paypalRequest(context: Context) = PayPalRequestFactory.createPayPalCheckoutRequest(
context,
"10.0",
null,
null,
null,
false,
null,
false,
false,
false
)

private val paypalTokenizeCallback = PayPalTokenizeCallback { payPalResult ->
when (payPalResult) {
is PayPalResult.Success -> {
handlePayPalResult(payPalResult.nonce)
}

is PayPalResult.Cancel -> {
handleError(Exception("User did not complete PayPal payment flow"))
}

is PayPalResult.Failure -> {
handleError(payPalResult.error)
}
}
}

private val venmoRequest = VenmoRequest(VenmoPaymentMethodUsage.SINGLE_USE).apply {
profileId = null
shouldVault = shouldVault
collectCustomerBillingAddress = true
collectCustomerShippingAddress = true
totalAmount = "0.20"
subTotalAmount = "0.18"
taxAmount = "0.01"
shippingAmount = "0.01"
}

private val venmoTokenizeCallback = VenmoTokenizeCallback { venmoResult ->
when (venmoResult) {
is VenmoResult.Success -> {
handleVenmoResult(venmoResult.nonce)
}

is VenmoResult.Cancel -> {
handleError(Exception("User did not complete Venmo payment flow"))
}

is VenmoResult.Failure -> {
handleError(venmoResult.error)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.activity.result.ActivityResultCaller
import androidx.appcompat.widget.AppCompatButton
import androidx.core.content.ContextCompat
import com.braintreepayments.api.core.AnalyticsClient
import com.braintreepayments.api.core.AnalyticsEventParams
import com.braintreepayments.api.uicomponents.VenmoButtonColor.Companion.fromId
import com.braintreepayments.api.venmo.VenmoClient
import com.braintreepayments.api.venmo.VenmoLauncher
Expand Down Expand Up @@ -101,7 +102,10 @@ class VenmoButton @JvmOverloads constructor(
)

val analyticsClient = AnalyticsClient.lazyInstance.value
analyticsClient.sendEvent(UIComponentsAnalytics.VENMO_BUTTON_PRESENTED)
analyticsClient.sendEvent(
UIComponentsAnalytics.VENMO_BUTTON_PRESENTED,
AnalyticsEventParams(uiType = UIComponentsAnalytics.UI_TYPE_XML_VIEW)
)
}

/**
Expand Down Expand Up @@ -145,7 +149,10 @@ class VenmoButton @JvmOverloads constructor(
private fun completeVenmoFlow(venmoPaymentAuthRequest: VenmoPaymentAuthRequest.ReadyToLaunch) {
getActivity()?.let { activity ->
val analyticsClient = AnalyticsClient.lazyInstance.value
analyticsClient.sendEvent(UIComponentsAnalytics.VENMO_BUTTON_SELECTED)
analyticsClient.sendEvent(
UIComponentsAnalytics.VENMO_BUTTON_SELECTED,
AnalyticsEventParams(uiType = UIComponentsAnalytics.UI_TYPE_XML_VIEW)
)
val venmoPendingRequest = venmoLauncher.launch(
activity = activity,
paymentAuthRequest = venmoPaymentAuthRequest
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.braintreepayments.api.uicomponents.compose

import android.app.Activity
import android.content.Context
import android.content.ContextWrapper

internal fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package com.braintreepayments.api.uicomponents.compose

import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
Expand Down Expand Up @@ -49,7 +47,7 @@ fun PayPalSmartButton(
authorization: String,
appLinkReturnUrl: Uri,
deepLinkFallbackUrlScheme: String,
pendingRequestRepository: PayPalPendingRequestRepository = PayPalPendingRequestRepository(LocalContext.current),
pendingRequestRepository: PendingRequestRepository = PendingRequestRepository(LocalContext.current, "paypal"),
paypalTokenizeCallback: PayPalTokenizeCallback
) {
val context = LocalContext.current
Expand Down Expand Up @@ -113,10 +111,10 @@ fun PayPalSmartButton(
if (flowLaunched) {
flowLaunched = false
lifecycle.coroutineScope.launch {
val pendingRequest = pendingRequestRepository.getPendingRequest()
val pendingRequestStr = pendingRequestRepository.getPendingRequest()

activity?.intent?.let { intent ->
handleReturnToApp(payPalLauncher, payPalClient, pendingRequest, intent, paypalTokenizeCallback)
handleReturnToApp(payPalLauncher, payPalClient, pendingRequestStr, intent, paypalTokenizeCallback)
enabled = true
pendingRequestRepository.clearPendingRequest()
activity.intent.data = null
Expand All @@ -130,7 +128,7 @@ fun PayPalSmartButton(

internal suspend fun completePayPalFlow(
payPalLauncher: PayPalLauncher,
pendingRequestRepository: PayPalPendingRequestRepository,
pendingRequestRepository: PendingRequestRepository,
activity: Activity,
paymentAuthRequest: PayPalPaymentAuthRequest.ReadyToLaunch,
paypalTokenizeCallback: PayPalTokenizeCallback
Expand All @@ -153,16 +151,16 @@ internal suspend fun completePayPalFlow(
private fun handleReturnToApp(
payPalLauncher: PayPalLauncher,
payPalClient: PayPalClient,
pendingRequest: String,
pendingRequestString: String,
intent: Intent,
callback: PayPalTokenizeCallback
) {
if (pendingRequest.isEmpty()) {
callback.onPayPalResult(PayPalResult.Failure(Exception("Unable to recover pending request.")))
if (pendingRequestString.isEmpty()) {
callback.onPayPalResult(PayPalResult.Failure(PendingRequestException()))
return
}
val paymentAuthResult = payPalLauncher.handleReturnToApp(
pendingRequest = PayPalPendingRequest.Started(pendingRequest),
pendingRequest = PayPalPendingRequest.Started(pendingRequestString),
intent = intent,
)

Expand Down Expand Up @@ -196,9 +194,3 @@ private fun logButtonSelected(analyticsClient: AnalyticsClient) {
AnalyticsEventParams(uiType = UI_TYPE_COMPOSE)
)
}

private fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.braintreepayments.api.uicomponents.compose

import androidx.annotation.RestrictTo
import com.braintreepayments.api.core.BraintreeException

/**
* Error class thrown when there's an issue fetching the pending request to complete the flow.
*/
class PendingRequestException @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor(
message: String? = "Unable to recover pending request. Cannot complete flow."
) : BraintreeException(message)
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
* Repository responsible for storing and retrieving the pending request using [DataStore].
*/
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
class PayPalPendingRequestRepository(
class PendingRequestRepository(
context: Context,
moduleName: String,
private val dataStore: DataStore<Preferences> = context.dataStore,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {

private val pendingRequestKey = stringPreferencesKey("pending_request_key")
private val pendingRequestKey = stringPreferencesKey("${moduleName}_pending_request_key")

suspend fun storePendingRequest(pendingRequest: String) {
withContext(dispatcher) {
Expand Down
Loading
Loading