Skip to content

Commit 1008832

Browse files
authored
Venmo compose button (#1541)
* Compiling demo of PayPal smart button on Compose. Doesn't run successfully. The flow is broken. * Reorder buttons in demo app * refactor file name * fix button size * fix button size in demo * Skip modular and store pendingRequest state within composable. Works end to end and shows a successful response. * hoist style to parent composable * update demo app * remove unused classes * linter' * remember PayPalLauncher and PayPalClient beyond recomposition * Address PR comments * surface failure scenarios to host app * persist pending request to data store * linter * Cleanup. Use anticipated dependency version. * Use scope properly * linter fixes * Address PR comments. Restrict view model and repository to library group. Make sure that handle return is called only after flow is launched. * make function private * add tests * Add analytics events for paypal compose button flow * fix CI * fix test. * remove viewmodel to simplify flow * linter * add kdoc on composable * Address PR comments * Venmo buttons compose UI * Update analytics * Venmo compose button * update demo app * rename options * update checkout values * consolidate extension functions * refactoring * refactor * add kdoc * Update repository to take in a moduleName parameter and update tests * rename file * Add exception message * update venmo button content description * update to 3.5.1 of browser switch
1 parent daf8428 commit 1008832

File tree

11 files changed

+563
-61
lines changed

11 files changed

+563
-61
lines changed
Lines changed: 140 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,36 @@
11
package com.braintreepayments.demo
22

3+
import android.content.Context
34
import android.os.Bundle
45
import android.view.LayoutInflater
56
import android.view.View
67
import android.view.ViewGroup
78
import androidx.compose.foundation.layout.Column
9+
import androidx.compose.material3.SegmentedButton
10+
import androidx.compose.material3.SegmentedButtonDefaults
11+
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
12+
import androidx.compose.material3.Text
13+
import androidx.compose.runtime.Composable
14+
import androidx.compose.runtime.getValue
15+
import androidx.compose.runtime.mutableIntStateOf
16+
import androidx.compose.runtime.mutableStateOf
17+
import androidx.compose.runtime.remember
18+
import androidx.compose.runtime.setValue
19+
import androidx.compose.ui.Modifier
820
import androidx.compose.ui.platform.ComposeView
921
import androidx.core.net.toUri
1022
import androidx.navigation.fragment.NavHostFragment
1123
import com.braintreepayments.api.core.PaymentMethodNonce
1224
import com.braintreepayments.api.paypal.PayPalResult
1325
import com.braintreepayments.api.paypal.PayPalTokenizeCallback
1426
import com.braintreepayments.api.uicomponents.PayPalButtonColor
27+
import com.braintreepayments.api.uicomponents.VenmoButtonColor
1528
import com.braintreepayments.api.uicomponents.compose.PayPalSmartButton
29+
import com.braintreepayments.api.uicomponents.compose.VenmoSmartButton
30+
import com.braintreepayments.api.venmo.VenmoPaymentMethodUsage
31+
import com.braintreepayments.api.venmo.VenmoRequest
32+
import com.braintreepayments.api.venmo.VenmoResult
33+
import com.braintreepayments.api.venmo.VenmoTokenizeCallback
1634

1735
class ComposeButtonsFragment : BaseFragment() {
1836

@@ -22,53 +40,75 @@ class ComposeButtonsFragment : BaseFragment() {
2240
savedInstanceState: Bundle?
2341
): View {
2442
super.onCreateView(inflater, container, savedInstanceState)
25-
26-
// PayPal flow setup
27-
val payPalRequest = PayPalRequestFactory.createPayPalCheckoutRequest(
28-
requireContext(),
29-
"10.0",
30-
null,
31-
null,
32-
null,
33-
false,
34-
null,
35-
false,
36-
false,
37-
false
38-
)
39-
43+
val paypalRequest = paypalRequest(requireContext())
4044
return ComposeView(requireContext()).apply {
41-
val paypalTokenizeCallback = PayPalTokenizeCallback { payPalResult ->
42-
when (payPalResult) {
43-
is PayPalResult.Success -> {
44-
handlePayPalResult(payPalResult.nonce)
45-
}
46-
47-
is PayPalResult.Cancel -> {
48-
handleError(Exception("User did not complete payment flow"))
49-
}
50-
51-
is PayPalResult.Failure -> {
52-
handleError(payPalResult.error)
53-
}
54-
}
55-
}
5645
setContent {
46+
var paypalStyle: PayPalButtonColor by remember { mutableStateOf(PayPalButtonColor.Blue) }
47+
var venmoStyle: VenmoButtonColor by remember { mutableStateOf(VenmoButtonColor.Blue) }
5748
Column {
49+
SingleChoiceSegmentedButton(onClick = { selectedIndex ->
50+
paypalStyle = when (selectedIndex) {
51+
0 -> PayPalButtonColor.Blue
52+
1 -> PayPalButtonColor.Black
53+
2 -> PayPalButtonColor.White
54+
else -> PayPalButtonColor.Blue
55+
}
56+
})
5857
PayPalSmartButton(
59-
style = PayPalButtonColor.Blue,
60-
payPalRequest = payPalRequest,
58+
style = paypalStyle,
59+
payPalRequest = paypalRequest,
6160
authorization = authStringArg,
6261
appLinkReturnUrl =
6362
"https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments".toUri(),
6463
deepLinkFallbackUrlScheme = "com.braintreepayments.demo.braintree",
6564
paypalTokenizeCallback = paypalTokenizeCallback
6665
)
66+
67+
SingleChoiceSegmentedButton(onClick = { selectedIndex ->
68+
venmoStyle = when (selectedIndex) {
69+
0 -> VenmoButtonColor.Blue
70+
1 -> VenmoButtonColor.Black
71+
2 -> VenmoButtonColor.White
72+
else -> VenmoButtonColor.Blue
73+
}
74+
})
75+
VenmoSmartButton(
76+
style = venmoStyle,
77+
venmoRequest = venmoRequest,
78+
authorization = authStringArg,
79+
appLinkReturnUrl =
80+
"https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments".toUri(),
81+
deepLinkFallbackUrlScheme = "com.braintreepayments.demo.braintree",
82+
venmoTokenizeCallback = venmoTokenizeCallback
83+
)
6784
}
6885
}
6986
}
7087
}
7188

89+
@Composable
90+
fun SingleChoiceSegmentedButton(modifier: Modifier = Modifier, onClick: (Int) -> Unit = {}) {
91+
var selectedIndex by remember { mutableIntStateOf(0) }
92+
val options = listOf("Blue", "Black", "White")
93+
94+
SingleChoiceSegmentedButtonRow {
95+
options.forEachIndexed { index, label ->
96+
SegmentedButton(
97+
shape = SegmentedButtonDefaults.itemShape(
98+
index = index,
99+
count = options.size
100+
),
101+
onClick = {
102+
selectedIndex = index
103+
onClick(selectedIndex)
104+
},
105+
selected = index == selectedIndex,
106+
label = { Text(label) }
107+
)
108+
}
109+
}
110+
}
111+
72112
private fun handlePayPalResult(paymentMethodNonce: PaymentMethodNonce?) {
73113
if (paymentMethodNonce != null) {
74114
super.onPaymentMethodNonceCreated(paymentMethodNonce)
@@ -80,4 +120,72 @@ class ComposeButtonsFragment : BaseFragment() {
80120
NavHostFragment.findNavController(this).navigate(action)
81121
}
82122
}
123+
124+
private fun handleVenmoResult(paymentMethodNonce: PaymentMethodNonce?) {
125+
if (paymentMethodNonce != null) {
126+
super.onPaymentMethodNonceCreated(paymentMethodNonce)
127+
128+
val action =
129+
ComposeButtonsFragmentDirections.actionComposePaymentButtonsFragmentToDisplayNonceFragment(
130+
paymentMethodNonce
131+
)
132+
NavHostFragment.findNavController(this).navigate(action)
133+
}
134+
}
135+
136+
private fun paypalRequest(context: Context) = PayPalRequestFactory.createPayPalCheckoutRequest(
137+
context,
138+
"10.0",
139+
null,
140+
null,
141+
null,
142+
false,
143+
null,
144+
false,
145+
false,
146+
false
147+
)
148+
149+
private val paypalTokenizeCallback = PayPalTokenizeCallback { payPalResult ->
150+
when (payPalResult) {
151+
is PayPalResult.Success -> {
152+
handlePayPalResult(payPalResult.nonce)
153+
}
154+
155+
is PayPalResult.Cancel -> {
156+
handleError(Exception("User did not complete PayPal payment flow"))
157+
}
158+
159+
is PayPalResult.Failure -> {
160+
handleError(payPalResult.error)
161+
}
162+
}
163+
}
164+
165+
private val venmoRequest = VenmoRequest(VenmoPaymentMethodUsage.SINGLE_USE).apply {
166+
profileId = null
167+
shouldVault = shouldVault
168+
collectCustomerBillingAddress = true
169+
collectCustomerShippingAddress = true
170+
totalAmount = "0.20"
171+
subTotalAmount = "0.18"
172+
taxAmount = "0.01"
173+
shippingAmount = "0.01"
174+
}
175+
176+
private val venmoTokenizeCallback = VenmoTokenizeCallback { venmoResult ->
177+
when (venmoResult) {
178+
is VenmoResult.Success -> {
179+
handleVenmoResult(venmoResult.nonce)
180+
}
181+
182+
is VenmoResult.Cancel -> {
183+
handleError(Exception("User did not complete Venmo payment flow"))
184+
}
185+
186+
is VenmoResult.Failure -> {
187+
handleError(venmoResult.error)
188+
}
189+
}
190+
}
83191
}

UIComponents/src/main/java/com/braintreepayments/api/uicomponents/VenmoButton.kt

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import androidx.activity.result.ActivityResultCaller
1212
import androidx.appcompat.widget.AppCompatButton
1313
import androidx.core.content.ContextCompat
1414
import com.braintreepayments.api.core.AnalyticsClient
15+
import com.braintreepayments.api.core.AnalyticsEventParams
1516
import com.braintreepayments.api.uicomponents.VenmoButtonColor.Companion.fromId
1617
import com.braintreepayments.api.venmo.VenmoClient
1718
import com.braintreepayments.api.venmo.VenmoLauncher
@@ -101,7 +102,10 @@ class VenmoButton @JvmOverloads constructor(
101102
)
102103

103104
val analyticsClient = AnalyticsClient.lazyInstance.value
104-
analyticsClient.sendEvent(UIComponentsAnalytics.VENMO_BUTTON_PRESENTED)
105+
analyticsClient.sendEvent(
106+
UIComponentsAnalytics.VENMO_BUTTON_PRESENTED,
107+
AnalyticsEventParams(uiType = UIComponentsAnalytics.UI_TYPE_XML_VIEW)
108+
)
105109
}
106110

107111
/**
@@ -145,7 +149,10 @@ class VenmoButton @JvmOverloads constructor(
145149
private fun completeVenmoFlow(venmoPaymentAuthRequest: VenmoPaymentAuthRequest.ReadyToLaunch) {
146150
getActivity()?.let { activity ->
147151
val analyticsClient = AnalyticsClient.lazyInstance.value
148-
analyticsClient.sendEvent(UIComponentsAnalytics.VENMO_BUTTON_SELECTED)
152+
analyticsClient.sendEvent(
153+
UIComponentsAnalytics.VENMO_BUTTON_SELECTED,
154+
AnalyticsEventParams(uiType = UIComponentsAnalytics.UI_TYPE_XML_VIEW)
155+
)
149156
val venmoPendingRequest = venmoLauncher.launch(
150157
activity = activity,
151158
paymentAuthRequest = venmoPaymentAuthRequest
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.braintreepayments.api.uicomponents.compose
2+
3+
import android.app.Activity
4+
import android.content.Context
5+
import android.content.ContextWrapper
6+
7+
internal fun Context.findActivity(): Activity? = when (this) {
8+
is Activity -> this
9+
is ContextWrapper -> baseContext.findActivity()
10+
else -> null
11+
}

UIComponents/src/main/java/com/braintreepayments/api/uicomponents/compose/PayPalSmartButton.kt

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package com.braintreepayments.api.uicomponents.compose
22

33
import android.app.Activity
4-
import android.content.Context
5-
import android.content.ContextWrapper
64
import android.content.Intent
75
import android.net.Uri
86
import androidx.activity.ComponentActivity
@@ -49,7 +47,7 @@ fun PayPalSmartButton(
4947
authorization: String,
5048
appLinkReturnUrl: Uri,
5149
deepLinkFallbackUrlScheme: String,
52-
pendingRequestRepository: PayPalPendingRequestRepository = PayPalPendingRequestRepository(LocalContext.current),
50+
pendingRequestRepository: PendingRequestRepository = PendingRequestRepository(LocalContext.current, "paypal"),
5351
paypalTokenizeCallback: PayPalTokenizeCallback
5452
) {
5553
val context = LocalContext.current
@@ -113,10 +111,10 @@ fun PayPalSmartButton(
113111
if (flowLaunched) {
114112
flowLaunched = false
115113
lifecycle.coroutineScope.launch {
116-
val pendingRequest = pendingRequestRepository.getPendingRequest()
114+
val pendingRequestStr = pendingRequestRepository.getPendingRequest()
117115

118116
activity?.intent?.let { intent ->
119-
handleReturnToApp(payPalLauncher, payPalClient, pendingRequest, intent, paypalTokenizeCallback)
117+
handleReturnToApp(payPalLauncher, payPalClient, pendingRequestStr, intent, paypalTokenizeCallback)
120118
enabled = true
121119
pendingRequestRepository.clearPendingRequest()
122120
activity.intent.data = null
@@ -130,7 +128,7 @@ fun PayPalSmartButton(
130128

131129
internal suspend fun completePayPalFlow(
132130
payPalLauncher: PayPalLauncher,
133-
pendingRequestRepository: PayPalPendingRequestRepository,
131+
pendingRequestRepository: PendingRequestRepository,
134132
activity: Activity,
135133
paymentAuthRequest: PayPalPaymentAuthRequest.ReadyToLaunch,
136134
paypalTokenizeCallback: PayPalTokenizeCallback
@@ -153,16 +151,16 @@ internal suspend fun completePayPalFlow(
153151
private fun handleReturnToApp(
154152
payPalLauncher: PayPalLauncher,
155153
payPalClient: PayPalClient,
156-
pendingRequest: String,
154+
pendingRequestString: String,
157155
intent: Intent,
158156
callback: PayPalTokenizeCallback
159157
) {
160-
if (pendingRequest.isEmpty()) {
161-
callback.onPayPalResult(PayPalResult.Failure(Exception("Unable to recover pending request.")))
158+
if (pendingRequestString.isEmpty()) {
159+
callback.onPayPalResult(PayPalResult.Failure(PendingRequestException()))
162160
return
163161
}
164162
val paymentAuthResult = payPalLauncher.handleReturnToApp(
165-
pendingRequest = PayPalPendingRequest.Started(pendingRequest),
163+
pendingRequest = PayPalPendingRequest.Started(pendingRequestString),
166164
intent = intent,
167165
)
168166

@@ -196,9 +194,3 @@ private fun logButtonSelected(analyticsClient: AnalyticsClient) {
196194
AnalyticsEventParams(uiType = UI_TYPE_COMPOSE)
197195
)
198196
}
199-
200-
private fun Context.findActivity(): Activity? = when (this) {
201-
is Activity -> this
202-
is ContextWrapper -> baseContext.findActivity()
203-
else -> null
204-
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.braintreepayments.api.uicomponents.compose
2+
3+
import androidx.annotation.RestrictTo
4+
import com.braintreepayments.api.core.BraintreeException
5+
6+
/**
7+
* Error class thrown when there's an issue fetching the pending request to complete the flow.
8+
*/
9+
class PendingRequestException @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) constructor(
10+
message: String? = "Unable to recover pending request. Cannot complete flow."
11+
) : BraintreeException(message)

UIComponents/src/main/java/com/braintreepayments/api/uicomponents/compose/PayPalPendingRequestRepository.kt renamed to UIComponents/src/main/java/com/braintreepayments/api/uicomponents/compose/PendingRequestRepository.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
2020
* Repository responsible for storing and retrieving the pending request using [DataStore].
2121
*/
2222
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
23-
class PayPalPendingRequestRepository(
23+
class PendingRequestRepository(
2424
context: Context,
25+
moduleName: String,
2526
private val dataStore: DataStore<Preferences> = context.dataStore,
2627
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
2728
) {
2829

29-
private val pendingRequestKey = stringPreferencesKey("pending_request_key")
30+
private val pendingRequestKey = stringPreferencesKey("${moduleName}_pending_request_key")
3031

3132
suspend fun storePendingRequest(pendingRequest: String) {
3233
withContext(dispatcher) {

0 commit comments

Comments
 (0)