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
52e949b
feat: add pay later wordmark assets
agedd Oct 13, 2026
c55e205
feat: add basic pay later button
agedd Oct 14, 2026
be78b2f
fix: remove analytic constants and render PayPalResult as non-optional
agedd Oct 14, 2026
8a14f23
refactor: set `offerPayLater` to true
agedd Oct 14, 2026
fd3c553
feat: clean-up
agedd Oct 14, 2026
d082a42
feat: add basic pay later button
agedd Oct 14, 2026
7a4833d
fix: remove analytic constants and render PayPalResult as non-optional
agedd Oct 14, 2026
ad159e8
refactor: set `offerPayLater` to true
agedd Oct 14, 2026
b241352
feat: clean-up
agedd Oct 14, 2026
2a7f2d4
Merge branch 'add-basic-pay-later-button' of https://github.com/brain…
agedd Oct 19, 2026
0aa4be7
refactor: share button style mapping between paypal and pay later colors
agedd Oct 21, 2026
5da3d65
refactor(demo): handle pay later return in onResume with separate slots
agedd Oct 21, 2026
9212222
style: fix trailing space and missing final newline
agedd Oct 21, 2026
f5334ab
feat: add composable paylaterbuttonview
agedd Oct 21, 2026
d04f184
style: add new line
agedd Oct 21, 2026
624417d
refactor: extract shared drawing logic into PaymentButtonRenderer
agedd Oct 22, 2026
1f985bf
Merge branch 'add-basic-pay-later-button' into add-jetpack-compose-to…
agedd Oct 22, 2026
d7dee58
refactor: update pay later button composable
agedd Oct 22, 2026
ecbf8b4
feat: add compose pay later button
agedd Oct 22, 2026
bdc481d
feat: add pay later composable to demo app
agedd Oct 22, 2026
666f0a5
fix: disable payment buttons while a flow is in progress
agedd Oct 22, 2026
bf0acdf
fix: re-enable payment buttons when a flow fails or finishes
agedd Oct 23, 2026
41a7e41
Merge branch 'add-basic-pay-later-button' into add-jetpack-compose-to…
agedd Oct 23, 2026
f81a771
refactor: address lint warning
agedd Oct 23, 2026
56b96b7
refactor: address pr feedback
agedd Oct 26, 2026
7343fd4
Merge branch 'add-basic-pay-later-button' into add-jetpack-compose-to…
agedd Oct 26, 2026
47b54f8
refactor: fix lint warnings
agedd Oct 27, 2026
156b932
Merge branch 'add-basic-pay-later-button' into add-jetpack-compose-to…
agedd Oct 27, 2026
30e71e1
feat: add composable paylaterbuttonview
agedd Oct 21, 2026
0b65e54
style: add new line
agedd Oct 21, 2026
cf192a7
refactor: update pay later button composable
agedd Oct 22, 2026
e6e08ba
feat: add compose pay later button
agedd Oct 22, 2026
58e7690
feat: add pay later composable to demo app
agedd Oct 22, 2026
ea14496
refactor: address lint warning
agedd Oct 23, 2026
081c942
Merge branch 'add-jetpack-compose-to-pay-later' of https://github.com…
agedd Oct 28, 2026
1ebb916
fix: persist compose pay later flow state across activity recreation
agedd Oct 28, 2026
6f26250
fix: re-enable compose pay later button when the flow fails to launch
agedd Oct 28, 2026
0bd129a
fix: adding missing }
agedd Oct 28, 2026
5d59b16
fix: deliver compose pay later missing-registry failure from a Launch…
agedd Oct 28, 2026
d87ccf9
refactor: extract shared paypal flow for compose paypal and pay later…
agedd Oct 29, 2026
c1411f5
test: add unit tests for compose PayPal flow helpers
agedd Nov 2, 2026
03f101b
fix: lint issues
agedd Nov 2, 2026
066431f
test: add compose pay later button test
agedd Nov 3, 2026
d7e33c5
test: add test to make sure the flag is only set for pay later request
agedd Nov 3, 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
Expand Up @@ -46,6 +46,8 @@ 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.PayLaterButtonColor
import com.braintreepayments.api.uicomponents.compose.PayLaterButton
import com.braintreepayments.api.uicomponents.VenmoButtonColor
import com.braintreepayments.api.uicomponents.cardfields.CardFieldsResult
import com.braintreepayments.api.uicomponents.compose.CardFields
Expand Down Expand Up @@ -73,6 +75,8 @@ class ComposeUIComponentsFragment : BaseFragment() {
): View {
super.onCreateView(inflater, container, savedInstanceState)
val payPalRequest = payPalRequest(requireContext())
// Pay Later gets its own request: PayLaterButton sets shouldOfferPayLater = true on the request it is given.
val payLaterRequest = payPalRequest(requireContext())
return ComposeView(requireContext()).apply {
setContent {
var venmoStyleId by rememberSaveable { mutableIntStateOf(VenmoButtonColor.Blue.key) }
Expand All @@ -89,6 +93,13 @@ class ComposeUIComponentsFragment : BaseFragment() {
else -> PayPalButtonColor.Blue
}

var payLaterStyleId by rememberSaveable { mutableIntStateOf(PayLaterButtonColor.Blue.key) }
val payLaterStyle: PayLaterButtonColor = when (payLaterStyleId) {
1 -> PayLaterButtonColor.Black
2 -> PayLaterButtonColor.White
else -> PayLaterButtonColor.Blue
}

val cardFieldsController = rememberCardFieldsController(
authorization = authStringArg,
// optional customer data
Expand Down Expand Up @@ -153,6 +164,33 @@ class ComposeUIComponentsFragment : BaseFragment() {
}
}

// Pay Later section
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
) {
SectionLabel(stringResource(R.string.paypal_pay_later))
SingleChoiceSegmentedButton(
modifier = Modifier.fillMaxWidth(),
selectedIndex = payLaterStyleId,
onClick = { index -> payLaterStyleId = index }
)
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
PayLaterButton(
style = payLaterStyle,
payPalRequest = payLaterRequest,
authorization = authStringArg,
appLinkReturnUrl = APP_LINK_RETURN_URL.toUri(),
deepLinkFallbackUrlScheme = DEEP_LINK_FALLBACK_SCHEME,
paypalTokenizeCallback = payLaterTokenizeCallback
)
}
}

// Card Fields section
SectionLabel(
text = stringResource(R.string.card_fields),
Expand Down Expand Up @@ -252,10 +290,11 @@ class ComposeUIComponentsFragment : BaseFragment() {
)
}

private fun handleNonce(nonce: PaymentMethodNonce) {
private fun handleNonce(nonce: PaymentMethodNonce, isPayLaterSelected: Boolean = false) {
onPaymentMethodNonceCreated(nonce)
val action = ComposeUIComponentsFragmentDirections
.actionComposeUiComponentsFragmentToDisplayNonceFragment(nonce)
.setIsPayLaterSelected(isPayLaterSelected)
NavHostFragment.findNavController(this).navigate(action)
}

Expand All @@ -273,9 +312,12 @@ class ComposeUIComponentsFragment : BaseFragment() {
null
)

private val paypalTokenizeCallback = PayPalTokenizeCallback { payPalResult ->
private val paypalTokenizeCallback = createPayPalCallback(isPayLaterSelected = false)
private val payLaterTokenizeCallback = createPayPalCallback(isPayLaterSelected = true)

private fun createPayPalCallback(isPayLaterSelected: Boolean) = PayPalTokenizeCallback { payPalResult ->
when (payPalResult) {
is PayPalResult.Success -> handleNonce(payPalResult.nonce)
is PayPalResult.Success -> handleNonce(payPalResult.nonce, isPayLaterSelected)
is PayPalResult.Cancel ->
handleError(Exception("User did not complete PayPal payment flow"))
is PayPalResult.Failure -> handleError(payPalResult.error)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,23 +241,22 @@ class UIComponentsFragment : BaseFragment() {
super.onResume()

// PayPal or PayPal Pay Later flow after returning to app
val callback = 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)
}
}

getPayPalPendingRequest()?.let { pending ->
payPalButton.handleReturnToApp(pending, requireActivity().intent, callback)
payPalButton.handleReturnToApp(
pending,
requireActivity().intent,
createPayPalCallback(isPayLaterSelected = false)
)
clearPayPalPendingRequest()
requireActivity().intent.data = null
}

getPayLaterPendingRequest()?.let { pending ->
payLaterButton.handleReturnToApp(pending, requireActivity().intent, callback)
payLaterButton.handleReturnToApp(
pending,
requireActivity().intent,
createPayPalCallback(isPayLaterSelected = true)
)
clearPayLaterPendingRequest()
requireActivity().intent.data = null
}
Expand Down Expand Up @@ -289,11 +288,21 @@ class UIComponentsFragment : BaseFragment() {
NavHostFragment.findNavController(this).navigate(action)
}

private fun handlePayPalResult(paymentMethodNonce: PaymentMethodNonce?) {
private fun createPayPalCallback(isPayLaterSelected: Boolean) = PayPalTokenizeCallback { payPalResult ->
when (payPalResult) {
is PayPalResult.Success -> handlePayPalResult(payPalResult.nonce, isPayLaterSelected)
is PayPalResult.Cancel ->
handleError(Exception("User did not complete payment flow"))
is PayPalResult.Failure -> handleError(payPalResult.error)
}
}

private fun handlePayPalResult(paymentMethodNonce: PaymentMethodNonce?, isPayLaterSelected: Boolean) {
if (paymentMethodNonce != null) {
onPaymentMethodNonceCreated(paymentMethodNonce)
val action = UIComponentsFragmentDirections
.actionUiComponentsFragmentToDisplayNonceFragment(paymentMethodNonce)
.setIsPayLaterSelected(isPayLaterSelected)
NavHostFragment.findNavController(this).navigate(action)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package com.braintreepayments.api.uicomponents.compose

import android.net.Uri
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import com.braintreepayments.api.uicomponents.PayPalButtonColor
import androidx.compose.ui.test.hasClickAction
import androidx.test.platform.app.InstrumentationRegistry
import com.braintreepayments.api.paypal.PayPalCheckoutRequest
import com.braintreepayments.api.testutils.Fixtures
import com.braintreepayments.api.uicomponents.PayLaterButtonColor
import com.braintreepayments.api.uicomponents.R
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test

class PayLaterButtonTest {

@get:Rule
val composeTestRule = createComposeRule()

private val context = InstrumentationRegistry.getInstrumentation().targetContext
private fun str(resId: Int) = context.getString(resId)

private fun payLaterRequest() = PayPalCheckoutRequest("10.00", true)

private fun setPayLaterButton(
request: PayPalCheckoutRequest = payLaterRequest(),
style: PayLaterButtonColor = PayLaterButtonColor.Blue
) {
composeTestRule.setContent {
PayLaterButton(
style = style,
payPalRequest = request,
authorization = Fixtures.TOKENIZATION_KEY,
appLinkReturnUrl = Uri.parse("https://example.com/return"),
deepLinkFallbackUrlScheme = "com.braintreepayments.demo",
paypalTokenizeCallback = {}
)
}
}

private fun payLaterButton() =
composeTestRule.onNodeWithContentDescription(str(R.string.pay_later_button_content_description))

// region Rendering

@Test
fun payLaterButton_isEnabledByDefault() {
setPayLaterButton()

payLaterButton().assertIsEnabled()
}

@Test
fun payLaterButton_rendersForEachColor() {
listOf(PayLaterButtonColor.Blue, PayLaterButtonColor.Black, PayLaterButtonColor.White).forEach { color ->
setPayLaterButton(style = color)
payLaterButton().assertIsEnabled()
// setContent can only be called once per rule, so stop after the first for this test
return
}
}

// endregion

// region Request

@Test
fun payLaterButton_doesNotSetShouldOfferPayLater_untilClicked() {
val request = payLaterRequest()
setPayLaterButton(request)

assertFalse(request.shouldOfferPayLater)
}

@Test
fun clickingPayLaterButton_setsShouldOfferPayLater() {
val request = payLaterRequest()
setPayLaterButton(request)

payLaterButton().performClick()

assertTrue(request.shouldOfferPayLater)
}

// endregion

// region Enabled state

@Test
fun clickingPayLaterButton_disablesTheButton() {
setPayLaterButton()

payLaterButton().performClick()

payLaterButton().assertIsNotEnabled()
}

// endregion

@Test
fun clickingPayPalButton_doesNotSetShouldOfferPayLater() {
val request = PayPalCheckoutRequest("10.00", true)
composeTestRule.setContent {
PayPalButton(
style = PayPalButtonColor.Blue,
payPalRequest = request,
authorization = Fixtures.TOKENIZATION_KEY,
appLinkReturnUrl = Uri.parse("https://example.com/return"),
deepLinkFallbackUrlScheme = "com.braintreepayments.demo",
paypalTokenizeCallback = {}
)
}

composeTestRule.onNode(hasClickAction()).performClick()

assertFalse(request.shouldOfferPayLater)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.braintreepayments.api.uicomponents.compose

import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.braintreepayments.api.paypal.PayPalCheckoutRequest
import com.braintreepayments.api.paypal.PayPalTokenizeCallback
import com.braintreepayments.api.uicomponents.PayLaterButtonColor

/**
* A composable that displays a PayPal Pay Later branded button and handles the PayPal Pay Later flow.
*
* PayPal Pay Later is only available with PayPal Checkout. The button sets
* [PayPalCheckoutRequest.shouldOfferPayLater] to `true` on the provided request when it is clicked.
*
* @param style: A [PayLaterButtonColor] that determines the color of the button.
* @param payPalRequest: A [PayPalCheckoutRequest] that provides the parameters to tokenize a PayPal account.
* @param authorization: An authorization string to use for tokenization.
* @param appLinkReturnUrl: A [Uri] that sends back control to the host app after PayPal flow completes.
* @param deepLinkFallbackUrlScheme: Fallback scheme in case [appLinkReturnUrl] doesn't work.
*
* The value must be a bare URL scheme, not a full URL. It must:
* - Contain only alphanumeric characters, hyphens (`-`), and periods (`.`)
* - Not include `://`
* - Not include a path, query string, or fragment (no `/`, `?`, or `#`)
* - Match the scheme declared in your `AndroidManifest.xml`
*
* Valid examples: `"com.merchant.app.payments"`, `"merchantapp"`, `"merchant-app.payments"`.
*
* Invalid examples: `"com.merchant.app.payments://"` (contains `://`),
* `"com.merchant.app.payments/callback"` (contains a path),
* `"com.merchant_app"` (underscore not allowed).
* @param paypalTokenizeCallback: A [PayPalTokenizeCallback] that handles the result of the tokenization.
*/
@Composable
fun PayLaterButton(
style: PayLaterButtonColor,
payPalRequest: PayPalCheckoutRequest,
authorization: String,
appLinkReturnUrl: Uri,
deepLinkFallbackUrlScheme: String,
pendingRequestRepository: PendingRequestRepository = PendingRequestRepository(LocalContext.current, "paylater"),
paypalTokenizeCallback: PayPalTokenizeCallback
) {
PayPalButtonFlow(
payPalRequest = payPalRequest,
authorization = authorization,
appLinkReturnUrl = appLinkReturnUrl,
deepLinkFallbackUrlScheme = deepLinkFallbackUrlScheme,
pendingRequestRepository = pendingRequestRepository,
paypalTokenizeCallback = paypalTokenizeCallback,
onButtonSelected = { payPalRequest.shouldOfferPayLater = true }
Comment thread
mzlangreder marked this conversation as resolved.
Resolved
) { enabled, onClick ->
PayLaterButtonView(style = style, enabled = enabled, onClick = onClick)
}
}
Loading
Loading