Skip to content

Commit 2fa7da7

Browse files
authored
Add Tap to Add button in card form (#12299)
1 parent 206541c commit 2fa7da7

File tree

15 files changed

+304
-9
lines changed

15 files changed

+304
-9
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.stripe.android.ui.core.elements
2+
3+
import androidx.annotation.RestrictTo
4+
import androidx.compose.runtime.Composable
5+
6+
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
7+
interface CardDetailsAction {
8+
@Composable
9+
fun Content(enabled: Boolean)
10+
}

payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionController.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class CardDetailsSectionController(
1919
cbcEligibility: CardBrandChoiceEligibility = CardBrandChoiceEligibility.Ineligible,
2020
cardBrandFilter: CardBrandFilter = DefaultCardBrandFilter,
2121
cardFundingFilter: CardFundingFilter = DefaultCardFundingFilter,
22+
val cardDetailsAction: CardDetailsAction? = null,
2223
private val automaticallyLaunchedCardScanFormDataHelper: AutomaticallyLaunchedCardScanFormDataHelper?,
2324
) : SectionFieldValidationController {
2425

@@ -29,11 +30,12 @@ class CardDetailsSectionController(
2930
collectName,
3031
cbcEligibility,
3132
cardBrandFilter,
32-
cardFundingFilter
33+
cardFundingFilter,
3334
)
3435

3536
fun shouldAutomaticallyLaunchCardScan(): Boolean {
36-
return automaticallyLaunchedCardScanFormDataHelper?.shouldLaunchCardScanAutomatically == true
37+
return cardDetailsAction == null &&
38+
automaticallyLaunchedCardScanFormDataHelper?.shouldLaunchCardScanAutomatically == true
3739
}
3840

3941
fun setHasAutomaticallyLaunchedCardScan() {

payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionElement.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ class CardDetailsSectionElement(
2222
private val cardBrandFilter: CardBrandFilter = DefaultCardBrandFilter,
2323
private val cardFundingFilter: CardFundingFilter,
2424
override val identifier: IdentifierSpec,
25+
private val cardDetailsAction: CardDetailsAction? = null,
2526
override val controller: CardDetailsSectionController = CardDetailsSectionController(
2627
cardAccountRangeRepositoryFactory = cardAccountRangeRepositoryFactory,
2728
initialValues = initialValues,
2829
collectName = collectName,
2930
cbcEligibility = cbcEligibility,
3031
cardBrandFilter = cardBrandFilter,
3132
cardFundingFilter = cardFundingFilter,
33+
cardDetailsAction = cardDetailsAction,
3234
automaticallyLaunchedCardScanFormDataHelper = automaticallyLaunchedCardScanFormDataHelper,
3335
)
3436
) : FormElement {

payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionElementUI.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,12 @@ fun CardDetailsSectionElementUI(
7474
heading()
7575
}
7676
)
77-
ScanCardButtonUI(
78-
enabled = enabled,
79-
cardScanGoogleLauncher = cardScanLauncher
80-
)
77+
controller.cardDetailsAction?.Content(enabled) ?: run {
78+
ScanCardButtonUI(
79+
enabled = enabled,
80+
cardScanGoogleLauncher = cardScanLauncher
81+
)
82+
}
8183
}
8284
SectionElementUI(
8385
modifier = Modifier.padding(top = 8.dp),

payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardDetailsSectionElementUITest.kt

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.activity.compose.LocalActivityResultRegistryOwner
77
import androidx.activity.result.ActivityResultRegistry
88
import androidx.activity.result.ActivityResultRegistryOwner
99
import androidx.activity.result.contract.ActivityResultContract
10+
import androidx.compose.runtime.Composable
1011
import androidx.compose.runtime.CompositionLocalProvider
1112
import androidx.compose.ui.test.junit4.createComposeRule
1213
import androidx.compose.ui.test.onNodeWithText
@@ -124,13 +125,51 @@ internal class CardDetailsSectionElementUITest {
124125
}
125126
}
126127

128+
@Test
129+
fun `CardDetailsSectionElement shows custom action and not card scan when cardDetailsAction is provided`() {
130+
runScenario(
131+
cardDetailsAction = FakeCardDetailsAction(contentText = "Tap to add card")
132+
) {
133+
composeTestRule.onNodeWithText("Tap to add card").assertExists()
134+
composeTestRule.onNodeWithText("Scan card").assertDoesNotExist()
135+
}
136+
}
137+
138+
@Test
139+
fun `CardDetailsSectionElement does not auto open card scan if custom action provided`() {
140+
runScenario(
141+
automaticallyLaunchedCardScanFormDataHelper = AutomaticallyLaunchedCardScanFormDataHelper(
142+
openCardScanAutomaticallyConfig = true,
143+
hasAutomaticallyLaunchedCardScanInitialValue = false,
144+
savedStateHandle = SavedStateHandle()
145+
),
146+
cardDetailsAction = FakeCardDetailsAction(contentText = "Tap to add card")
147+
) {
148+
assertThat(controller.shouldAutomaticallyLaunchCardScan()).isFalse()
149+
verify(controller, times(0))
150+
.onCardScanResult(any())
151+
verify(controller, times(0))
152+
.setHasAutomaticallyLaunchedCardScan()
153+
}
154+
}
155+
156+
private class FakeCardDetailsAction(
157+
private val contentText: String,
158+
) : CardDetailsAction {
159+
@Composable
160+
override fun Content(enabled: Boolean) {
161+
androidx.compose.material.Text(contentText)
162+
}
163+
}
164+
127165
private class Scenario(
128166
val controller: CardDetailsSectionController,
129167
)
130168

131169
private fun getController(
132170
context: Context,
133171
automaticallyLaunchedCardScanFormDataHelper: AutomaticallyLaunchedCardScanFormDataHelper?,
172+
cardDetailsAction: CardDetailsAction? = null,
134173
): CardDetailsSectionController {
135174
val cardAccountRangeRepositoryFactory = DefaultCardAccountRangeRepositoryFactory(context)
136175

@@ -140,13 +179,15 @@ internal class CardDetailsSectionElementUITest {
140179
collectName = false,
141180
cbcEligibility = CardBrandChoiceEligibility.Ineligible,
142181
cardBrandFilter = DefaultCardBrandFilter,
143-
automaticallyLaunchedCardScanFormDataHelper = automaticallyLaunchedCardScanFormDataHelper
182+
automaticallyLaunchedCardScanFormDataHelper = automaticallyLaunchedCardScanFormDataHelper,
183+
cardDetailsAction = cardDetailsAction,
144184
)
145185
return output
146186
}
147187

148188
private fun runScenario(
149-
automaticallyLaunchedCardScanFormDataHelper: AutomaticallyLaunchedCardScanFormDataHelper?,
189+
automaticallyLaunchedCardScanFormDataHelper: AutomaticallyLaunchedCardScanFormDataHelper? = null,
190+
cardDetailsAction: CardDetailsAction? = null,
150191
block: suspend Scenario.() -> Unit
151192
) = runTest {
152193
val mockResult = mock<PaymentCardRecognitionResult>()
@@ -178,6 +219,7 @@ internal class CardDetailsSectionElementUITest {
178219
}
179220
val controller = getController(
180221
context = context,
222+
cardDetailsAction = cardDetailsAction,
181223
automaticallyLaunchedCardScanFormDataHelper = automaticallyLaunchedCardScanFormDataHelper
182224
)
183225

paymentsheet/detekt-baseline.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
<ID>FunctionNaming:PaymentSheetTopBar.kt$@Preview @Composable internal fun TestModeBadge_Preview()</ID>
1717
<ID>FunctionOnlyReturningConstant:FlowControllerModule.kt$FlowControllerModule$@Provides @Singleton @Named(ALLOWS_MANUAL_CONFIRMATION) fun provideAllowsManualConfirmation()</ID>
1818
<ID>FunctionOnlyReturningConstant:PaymentSheetLauncherModule.kt$PaymentSheetLauncherModule.Companion$@Provides @Singleton @Named(ALLOWS_MANUAL_CONFIRMATION) fun provideAllowsManualConfirmation()</ID>
19+
<ID>LargeClass:CardDefinitionTest.kt$CardDefinitionTest</ID>
1920
<ID>LargeClass:CustomerAdapterTest.kt$CustomerAdapterTest</ID>
2021
<ID>LargeClass:CustomerSheetViewModel.kt$CustomerSheetViewModel : ViewModel</ID>
2122
<ID>LargeClass:CustomerSheetViewModelTest.kt$CustomerSheetViewModelTest</ID>
@@ -57,7 +58,6 @@
5758
<ID>LongMethod:InputAddressViewModelTest.kt$InputAddressViewModelTest$@OptIn(AddressElementSameAsBillingPreview::class) @Test fun `'Shipping same as billing' should work as expected with both billing &amp; shipping`()</ID>
5859
<ID>LongMethod:InputAddressViewModelTest.kt$InputAddressViewModelTest$@OptIn(AddressElementSameAsBillingPreview::class) @Test fun `'Shipping same as billing' should work as expected with same billing &amp; shipping &amp; empty values`()</ID>
5960
<ID>LongMethod:InputAddressViewModelTest.kt$InputAddressViewModelTest$@OptIn(AddressElementSameAsBillingPreview::class) @Test fun `'Shipping same as billing' should work as expected with same billing &amp; shipping`()</ID>
60-
<ID>LongMethod:LinkInlineSignupConfirmationDefinitionTest.kt$LinkInlineSignupConfirmationDefinitionTest$@Test fun `'action' should return 'Launch' after successful sign-in &amp; attach`()</ID>
6161
<ID>LongMethod:LinkInlineSignupConfirmationDefinitionTest.kt$LinkInlineSignupConfirmationDefinitionTest$private fun testSuccessfulSignupWithNewCard( saveOption: LinkInlineSignupConfirmationOption.PaymentMethodSaveOption, expectedSetupForFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage?, expectedShouldSave: Boolean, )</ID>
6262
<ID>LongMethod:LinkInlineSignupConfirmationDefinitionTest.kt$LinkInlineSignupConfirmationDefinitionTest$private fun testSuccessfulSignupWithSavedLinkCard( saveOption: LinkInlineSignupConfirmationOption.PaymentMethodSaveOption, expectedSetupForFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage, verifyBillingDetails: Boolean = false, includePaymentMethod: Boolean = false, )</ID>
6363
<ID>LongMethod:PaymentDetails.kt$@Preview(showBackground = true) @Composable private fun PaymentDetailsListItemPreview()</ID>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
2+
android:width="18dp"
3+
android:height="18dp"
4+
android:viewportWidth="18"
5+
android:viewportHeight="18">
6+
<path
7+
android:pathData="M8.559,8.559m-8.045,0a8.045,8.045 0,1 1,16.09 0a8.045,8.045 0,1 1,-16.09 0"
8+
android:strokeWidth="1.02857"
9+
android:strokeColor="#007AFF"/>
10+
<path
11+
android:pathData="M5.76,7.195L5.795,7.246C6.472,8.221 6.457,9.516 5.76,10.473"
12+
android:strokeWidth="1.17669"
13+
android:fillColor="#00000000"
14+
android:strokeColor="#007AFF"
15+
android:strokeLineCap="square"/>
16+
<path
17+
android:pathData="M8.184,6.181L8.544,6.872C8.645,7.065 8.724,7.268 8.78,7.479L8.832,7.675C8.871,7.821 8.898,7.969 8.914,8.119L8.957,8.517C8.983,8.766 8.978,9.017 8.941,9.264L8.867,9.757C8.824,10.043 8.739,10.321 8.615,10.582L8.202,11.453"
18+
android:strokeWidth="1.17669"
19+
android:fillColor="#00000000"
20+
android:strokeColor="#007AFF"
21+
android:strokeLineCap="square"/>
22+
<path
23+
android:pathData="M10.572,5.07L11.128,6.185C11.216,6.361 11.286,6.545 11.337,6.735L11.47,7.227C11.513,7.388 11.542,7.552 11.558,7.717L11.636,8.553C11.656,8.769 11.653,8.986 11.625,9.201L11.535,9.909L11.438,10.419C11.402,10.611 11.347,10.798 11.274,10.979L11.013,11.622L10.572,12.453"
24+
android:strokeWidth="1.17669"
25+
android:fillColor="#00000000"
26+
android:strokeColor="#007AFF"
27+
android:strokeLineCap="square"/>
28+
</vector>

paymentsheet/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,4 +332,6 @@ to be saved and used in future checkout sessions. -->
332332
<string name="stripe_wallet_update_card">Update card</string>
333333
<!-- A text notice shown when the user selects an expired card. -->
334334
<string name="stripe_wallet_update_expired_card_error">This card has expired. Update your card info or choose a different payment method.</string>
335+
<!-- Button label for a button that launches a flow for tapping a card on the device -->
336+
<string name="stripe_tap_to_add_card_button_label">Tap to add card</string>
335337
</resources>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.stripe.android.common.taptoadd
2+
3+
import androidx.compose.foundation.Image
4+
import androidx.compose.foundation.clickable
5+
import androidx.compose.foundation.interaction.MutableInteractionSource
6+
import androidx.compose.foundation.layout.Row
7+
import androidx.compose.foundation.layout.height
8+
import androidx.compose.foundation.layout.padding
9+
import androidx.compose.foundation.layout.width
10+
import androidx.compose.material.MaterialTheme
11+
import androidx.compose.material.Text
12+
import androidx.compose.runtime.Composable
13+
import androidx.compose.runtime.remember
14+
import androidx.compose.ui.Alignment
15+
import androidx.compose.ui.Modifier
16+
import androidx.compose.ui.graphics.ColorFilter
17+
import androidx.compose.ui.res.painterResource
18+
import androidx.compose.ui.res.stringResource
19+
import androidx.compose.ui.unit.dp
20+
import com.stripe.android.paymentsheet.R
21+
22+
@Composable
23+
internal fun TapToButtonUI(
24+
enabled: Boolean,
25+
onClick: () -> Unit,
26+
) {
27+
Row(
28+
verticalAlignment = Alignment.CenterVertically,
29+
modifier = Modifier.clickable(
30+
interactionSource = remember { MutableInteractionSource() },
31+
indication = null,
32+
enabled = enabled,
33+
onClick = onClick,
34+
),
35+
) {
36+
Image(
37+
painter = painterResource(R.drawable.stripe_ic_nfc_tap),
38+
contentDescription = stringResource(R.string.stripe_tap_to_add_card_button_label),
39+
colorFilter = ColorFilter.tint(MaterialTheme.colors.primary),
40+
modifier = Modifier.width(18.dp).height(18.dp),
41+
)
42+
Text(
43+
text = stringResource(R.string.stripe_tap_to_add_card_button_label),
44+
modifier = Modifier.padding(start = 4.dp),
45+
color = MaterialTheme.colors.primary,
46+
style = MaterialTheme.typography.h6,
47+
)
48+
}
49+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.stripe.android.common.taptoadd
2+
3+
import androidx.compose.runtime.Composable
4+
import com.stripe.android.ui.core.elements.CardDetailsAction
5+
6+
internal class TapToAddCardDetailsAction(
7+
private val tapToAddHelper: TapToAddHelper,
8+
) : CardDetailsAction {
9+
@Composable
10+
override fun Content(enabled: Boolean) {
11+
TapToButtonUI(enabled) {
12+
tapToAddHelper.startPaymentMethodCollection()
13+
}
14+
}
15+
}

0 commit comments

Comments
 (0)