Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -0,0 +1,10 @@
package com.stripe.android.ui.core.elements

import androidx.annotation.RestrictTo
import androidx.compose.runtime.Composable

@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
interface CardDetailsAction {
@Composable
fun Content(enabled: Boolean)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class CardDetailsSectionController(
cbcEligibility: CardBrandChoiceEligibility = CardBrandChoiceEligibility.Ineligible,
cardBrandFilter: CardBrandFilter = DefaultCardBrandFilter,
cardFundingFilter: CardFundingFilter = DefaultCardFundingFilter,
val cardDetailsAction: CardDetailsAction? = null,
private val automaticallyLaunchedCardScanFormDataHelper: AutomaticallyLaunchedCardScanFormDataHelper?,
) : SectionFieldValidationController {

Expand All @@ -29,11 +30,12 @@ class CardDetailsSectionController(
collectName,
cbcEligibility,
cardBrandFilter,
cardFundingFilter
cardFundingFilter,
)

fun shouldAutomaticallyLaunchCardScan(): Boolean {
return automaticallyLaunchedCardScanFormDataHelper?.shouldLaunchCardScanAutomatically == true
return cardDetailsAction == null &&
automaticallyLaunchedCardScanFormDataHelper?.shouldLaunchCardScanAutomatically == true
}

fun setHasAutomaticallyLaunchedCardScan() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ class CardDetailsSectionElement(
private val cardBrandFilter: CardBrandFilter = DefaultCardBrandFilter,
private val cardFundingFilter: CardFundingFilter,
override val identifier: IdentifierSpec,
private val cardDetailsAction: CardDetailsAction? = null,
override val controller: CardDetailsSectionController = CardDetailsSectionController(
cardAccountRangeRepositoryFactory = cardAccountRangeRepositoryFactory,
initialValues = initialValues,
collectName = collectName,
cbcEligibility = cbcEligibility,
cardBrandFilter = cardBrandFilter,
cardFundingFilter = cardFundingFilter,
cardDetailsAction = cardDetailsAction,
automaticallyLaunchedCardScanFormDataHelper = automaticallyLaunchedCardScanFormDataHelper,
)
) : FormElement {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@ fun CardDetailsSectionElementUI(
heading()
}
)
ScanCardButtonUI(
enabled = enabled,
cardScanGoogleLauncher = cardScanLauncher
)
controller.cardDetailsAction?.Content(enabled) ?: run {
ScanCardButtonUI(
enabled = enabled,
cardScanGoogleLauncher = cardScanLauncher
)
Copy link
Collaborator Author

@samer-stripe samer-stripe Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a large refactor here we can do for Scan card but it requires the foundations from this PR and a follow up PR. It's a relatively expensive engineering refactor but we should create a task to do it to make ScanCard a CardDetailsAction.

}
}
SectionElementUI(
modifier = Modifier.padding(top = 8.dp),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
Expand Down Expand Up @@ -124,13 +125,51 @@ internal class CardDetailsSectionElementUITest {
}
}

@Test
fun `CardDetailsSectionElement shows custom action and not card scan when cardDetailsAction is provided`() {
runScenario(
cardDetailsAction = FakeCardDetailsAction(contentText = "Tap to add card")
) {
composeTestRule.onNodeWithText("Tap to add card").assertExists()
composeTestRule.onNodeWithText("Scan card").assertDoesNotExist()
}
}

@Test
fun `CardDetailsSectionElement does not auto open card scan if custom action provided`() {
runScenario(
automaticallyLaunchedCardScanFormDataHelper = AutomaticallyLaunchedCardScanFormDataHelper(
openCardScanAutomaticallyConfig = true,
hasAutomaticallyLaunchedCardScanInitialValue = false,
savedStateHandle = SavedStateHandle()
),
cardDetailsAction = FakeCardDetailsAction(contentText = "Tap to add card")
) {
assertThat(controller.shouldAutomaticallyLaunchCardScan()).isFalse()
verify(controller, times(0))
.onCardScanResult(any())
verify(controller, times(0))
.setHasAutomaticallyLaunchedCardScan()
}
}

private class FakeCardDetailsAction(
private val contentText: String,
) : CardDetailsAction {
@Composable
override fun Content(enabled: Boolean) {
androidx.compose.material.Text(contentText)
}
}

private class Scenario(
val controller: CardDetailsSectionController,
)

private fun getController(
context: Context,
automaticallyLaunchedCardScanFormDataHelper: AutomaticallyLaunchedCardScanFormDataHelper?,
cardDetailsAction: CardDetailsAction? = null,
): CardDetailsSectionController {
val cardAccountRangeRepositoryFactory = DefaultCardAccountRangeRepositoryFactory(context)

Expand All @@ -140,13 +179,15 @@ internal class CardDetailsSectionElementUITest {
collectName = false,
cbcEligibility = CardBrandChoiceEligibility.Ineligible,
cardBrandFilter = DefaultCardBrandFilter,
automaticallyLaunchedCardScanFormDataHelper = automaticallyLaunchedCardScanFormDataHelper
automaticallyLaunchedCardScanFormDataHelper = automaticallyLaunchedCardScanFormDataHelper,
cardDetailsAction = cardDetailsAction,
)
return output
}

private fun runScenario(
automaticallyLaunchedCardScanFormDataHelper: AutomaticallyLaunchedCardScanFormDataHelper?,
automaticallyLaunchedCardScanFormDataHelper: AutomaticallyLaunchedCardScanFormDataHelper? = null,
cardDetailsAction: CardDetailsAction? = null,
block: suspend Scenario.() -> Unit
) = runTest {
val mockResult = mock<PaymentCardRecognitionResult>()
Expand Down Expand Up @@ -178,6 +219,7 @@ internal class CardDetailsSectionElementUITest {
}
val controller = getController(
context = context,
cardDetailsAction = cardDetailsAction,
automaticallyLaunchedCardScanFormDataHelper = automaticallyLaunchedCardScanFormDataHelper
)

Expand Down
2 changes: 1 addition & 1 deletion paymentsheet/detekt-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<ID>FunctionNaming:PaymentSheetTopBar.kt$@Preview @Composable internal fun TestModeBadge_Preview()</ID>
<ID>FunctionOnlyReturningConstant:FlowControllerModule.kt$FlowControllerModule$@Provides @Singleton @Named(ALLOWS_MANUAL_CONFIRMATION) fun provideAllowsManualConfirmation()</ID>
<ID>FunctionOnlyReturningConstant:PaymentSheetLauncherModule.kt$PaymentSheetLauncherModule.Companion$@Provides @Singleton @Named(ALLOWS_MANUAL_CONFIRMATION) fun provideAllowsManualConfirmation()</ID>
<ID>LargeClass:CardDefinitionTest.kt$CardDefinitionTest</ID>
<ID>LargeClass:CustomerAdapterTest.kt$CustomerAdapterTest</ID>
<ID>LargeClass:CustomerSheetViewModel.kt$CustomerSheetViewModel : ViewModel</ID>
<ID>LargeClass:CustomerSheetViewModelTest.kt$CustomerSheetViewModelTest</ID>
Expand Down Expand Up @@ -57,7 +58,6 @@
<ID>LongMethod:InputAddressViewModelTest.kt$InputAddressViewModelTest$@OptIn(AddressElementSameAsBillingPreview::class) @Test fun `'Shipping same as billing' should work as expected with both billing &amp; shipping`()</ID>
<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>
<ID>LongMethod:InputAddressViewModelTest.kt$InputAddressViewModelTest$@OptIn(AddressElementSameAsBillingPreview::class) @Test fun `'Shipping same as billing' should work as expected with same billing &amp; shipping`()</ID>
<ID>LongMethod:LinkInlineSignupConfirmationDefinitionTest.kt$LinkInlineSignupConfirmationDefinitionTest$@Test fun `'action' should return 'Launch' after successful sign-in &amp; attach`()</ID>
<ID>LongMethod:LinkInlineSignupConfirmationDefinitionTest.kt$LinkInlineSignupConfirmationDefinitionTest$private fun testSuccessfulSignupWithNewCard( saveOption: LinkInlineSignupConfirmationOption.PaymentMethodSaveOption, expectedSetupForFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage?, expectedShouldSave: Boolean, )</ID>
<ID>LongMethod:LinkInlineSignupConfirmationDefinitionTest.kt$LinkInlineSignupConfirmationDefinitionTest$private fun testSuccessfulSignupWithSavedLinkCard( saveOption: LinkInlineSignupConfirmationOption.PaymentMethodSaveOption, expectedSetupForFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage, verifyBillingDetails: Boolean = false, includePaymentMethod: Boolean = false, )</ID>
<ID>LongMethod:PaymentDetails.kt$@Preview(showBackground = true) @Composable private fun PaymentDetailsListItemPreview()</ID>
Expand Down
28 changes: 28 additions & 0 deletions paymentsheet/res/drawable/stripe_ic_nfc_tap.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="18dp"
android:viewportWidth="18"
android:viewportHeight="18">
<path
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"
android:strokeWidth="1.02857"
android:strokeColor="#007AFF"/>
<path
android:pathData="M5.76,7.195L5.795,7.246C6.472,8.221 6.457,9.516 5.76,10.473"
android:strokeWidth="1.17669"
android:fillColor="#00000000"
android:strokeColor="#007AFF"
android:strokeLineCap="square"/>
<path
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"
android:strokeWidth="1.17669"
android:fillColor="#00000000"
android:strokeColor="#007AFF"
android:strokeLineCap="square"/>
<path
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"
android:strokeWidth="1.17669"
android:fillColor="#00000000"
android:strokeColor="#007AFF"
android:strokeLineCap="square"/>
</vector>
2 changes: 2 additions & 0 deletions paymentsheet/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -332,4 +332,6 @@ to be saved and used in future checkout sessions. -->
<string name="stripe_wallet_update_card">Update card</string>
<!-- A text notice shown when the user selects an expired card. -->
<string name="stripe_wallet_update_expired_card_error">This card has expired. Update your card info or choose a different payment method.</string>
<!-- Button label for a button that launches a flow for tapping a card on the device -->
<string name="stripe_tap_to_add_card_button_label">Tap to add card</string>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.stripe.android.common.taptoadd

import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.stripe.android.paymentsheet.R

@Composable
internal fun TapToButtonUI(
enabled: Boolean,
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
enabled = enabled,
onClick = onClick,
),
) {
Image(
painter = painterResource(R.drawable.stripe_ic_nfc_tap),
contentDescription = stringResource(R.string.stripe_tap_to_add_card_button_label),
colorFilter = ColorFilter.tint(MaterialTheme.colors.primary),
modifier = Modifier.width(18.dp).height(18.dp),
)
Text(
text = stringResource(R.string.stripe_tap_to_add_card_button_label),
modifier = Modifier.padding(start = 4.dp),
color = MaterialTheme.colors.primary,
style = MaterialTheme.typography.h6,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.stripe.android.common.taptoadd

import androidx.compose.runtime.Composable
import com.stripe.android.ui.core.elements.CardDetailsAction

internal class TapToAddCardDetailsAction(
private val tapToAddHelper: TapToAddHelper,
) : CardDetailsAction {
@Composable
override fun Content(enabled: Boolean) {
TapToButtonUI(enabled) {
tapToAddHelper.startPaymentMethodCollection()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.stripe.android.common.taptoadd.TapToAddCardDetailsAction
import com.stripe.android.core.strings.resolvableString
import com.stripe.android.link.ui.inline.InlineSignupViewState
import com.stripe.android.link.ui.inline.LinkSignupMode
Expand Down Expand Up @@ -105,6 +106,11 @@ private object CardUiDefinitionFactory : UiDefinitionFactory.Custom {
cbcEligibility = arguments.cbcEligibility,
cardBrandFilter = arguments.cardBrandFilter,
cardFundingFilter = arguments.cardFundingFilter,
cardDetailsAction = arguments.tapToAddHelper?.takeIf {
metadata.isTapToAddSupported
}?.let {
TapToAddCardDetailsAction(tapToAddHelper = it)
},
automaticallyLaunchedCardScanFormDataHelper = arguments.automaticallyLaunchedCardScanFormDataHelper,
)
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.stripe.android.common.taptoadd

import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.stripe.android.screenshottesting.PaparazziRule
import org.junit.Rule
import org.junit.Test

internal class TapToAddButtonUIScreenshotTest {
@get:Rule
val paparazziRule = PaparazziRule(
boxModifier = Modifier.padding(10.dp),
)

@Test
fun default() {
paparazziRule.snapshot {
TapToButtonUI(enabled = true) {}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.stripe.android.common.taptoadd

import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
internal class TapToAddCardDetailsActionTest {
@get:Rule
val composeTestRule = createComposeRule()

@Test
fun `clicking button calls startPaymentMethodCollection`() = runTest {
FakeTapToAddHelper.test {
val action = TapToAddCardDetailsAction(tapToAddHelper = helper)

composeTestRule.setContent {
action.Content(enabled = true)
}

composeTestRule.onNodeWithText("Tap to add card").performClick()

assertThat(collectCalls.awaitItem()).isEqualTo(Unit)
}
}

@Test
fun `clicking disabled button does not call startPaymentMethodCollection`() = runTest {
FakeTapToAddHelper.test {
val action = TapToAddCardDetailsAction(tapToAddHelper = helper)

composeTestRule.setContent {
action.Content(enabled = false)
}

composeTestRule.onNodeWithText("Tap to add card").performClick()

// No collect calls should have been made
collectCalls.expectNoEvents()
}
}
}
Loading
Loading