diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsAction.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsAction.kt new file mode 100644 index 00000000000..40d20d00d33 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsAction.kt @@ -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) +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionController.kt index d04a1003e79..ff2b9a89c63 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionController.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionController.kt @@ -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 { @@ -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() { diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionElement.kt index 5de7584dd1d..a83122f6eb1 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionElement.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionElement.kt @@ -22,6 +22,7 @@ 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, @@ -29,6 +30,7 @@ class CardDetailsSectionElement( cbcEligibility = cbcEligibility, cardBrandFilter = cardBrandFilter, cardFundingFilter = cardFundingFilter, + cardDetailsAction = cardDetailsAction, automaticallyLaunchedCardScanFormDataHelper = automaticallyLaunchedCardScanFormDataHelper, ) ) : FormElement { diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionElementUI.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionElementUI.kt index 4b92ec08666..874243ea730 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionElementUI.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSectionElementUI.kt @@ -74,10 +74,12 @@ fun CardDetailsSectionElementUI( heading() } ) - ScanCardButtonUI( - enabled = enabled, - cardScanGoogleLauncher = cardScanLauncher - ) + controller.cardDetailsAction?.Content(enabled) ?: run { + ScanCardButtonUI( + enabled = enabled, + cardScanGoogleLauncher = cardScanLauncher + ) + } } SectionElementUI( modifier = Modifier.padding(top = 8.dp), diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardDetailsSectionElementUITest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardDetailsSectionElementUITest.kt index 46e16aae63e..cfb962fa777 100644 --- a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardDetailsSectionElementUITest.kt +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardDetailsSectionElementUITest.kt @@ -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 @@ -124,6 +125,43 @@ 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, ) @@ -131,6 +169,7 @@ internal class CardDetailsSectionElementUITest { private fun getController( context: Context, automaticallyLaunchedCardScanFormDataHelper: AutomaticallyLaunchedCardScanFormDataHelper?, + cardDetailsAction: CardDetailsAction? = null, ): CardDetailsSectionController { val cardAccountRangeRepositoryFactory = DefaultCardAccountRangeRepositoryFactory(context) @@ -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() @@ -178,6 +219,7 @@ internal class CardDetailsSectionElementUITest { } val controller = getController( context = context, + cardDetailsAction = cardDetailsAction, automaticallyLaunchedCardScanFormDataHelper = automaticallyLaunchedCardScanFormDataHelper ) diff --git a/paymentsheet/detekt-baseline.xml b/paymentsheet/detekt-baseline.xml index 0ebe4cf0c0a..59eb44513fd 100644 --- a/paymentsheet/detekt-baseline.xml +++ b/paymentsheet/detekt-baseline.xml @@ -16,6 +16,7 @@ FunctionNaming:PaymentSheetTopBar.kt$@Preview @Composable internal fun TestModeBadge_Preview() FunctionOnlyReturningConstant:FlowControllerModule.kt$FlowControllerModule$@Provides @Singleton @Named(ALLOWS_MANUAL_CONFIRMATION) fun provideAllowsManualConfirmation() FunctionOnlyReturningConstant:PaymentSheetLauncherModule.kt$PaymentSheetLauncherModule.Companion$@Provides @Singleton @Named(ALLOWS_MANUAL_CONFIRMATION) fun provideAllowsManualConfirmation() + LargeClass:CardDefinitionTest.kt$CardDefinitionTest LargeClass:CustomerAdapterTest.kt$CustomerAdapterTest LargeClass:CustomerSheetViewModel.kt$CustomerSheetViewModel : ViewModel LargeClass:CustomerSheetViewModelTest.kt$CustomerSheetViewModelTest @@ -57,7 +58,6 @@ LongMethod:InputAddressViewModelTest.kt$InputAddressViewModelTest$@OptIn(AddressElementSameAsBillingPreview::class) @Test fun `'Shipping same as billing' should work as expected with both billing & shipping`() LongMethod:InputAddressViewModelTest.kt$InputAddressViewModelTest$@OptIn(AddressElementSameAsBillingPreview::class) @Test fun `'Shipping same as billing' should work as expected with same billing & shipping & empty values`() LongMethod:InputAddressViewModelTest.kt$InputAddressViewModelTest$@OptIn(AddressElementSameAsBillingPreview::class) @Test fun `'Shipping same as billing' should work as expected with same billing & shipping`() - LongMethod:LinkInlineSignupConfirmationDefinitionTest.kt$LinkInlineSignupConfirmationDefinitionTest$@Test fun `'action' should return 'Launch' after successful sign-in & attach`() LongMethod:LinkInlineSignupConfirmationDefinitionTest.kt$LinkInlineSignupConfirmationDefinitionTest$private fun testSuccessfulSignupWithNewCard( saveOption: LinkInlineSignupConfirmationOption.PaymentMethodSaveOption, expectedSetupForFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage?, expectedShouldSave: Boolean, ) LongMethod:LinkInlineSignupConfirmationDefinitionTest.kt$LinkInlineSignupConfirmationDefinitionTest$private fun testSuccessfulSignupWithSavedLinkCard( saveOption: LinkInlineSignupConfirmationOption.PaymentMethodSaveOption, expectedSetupForFutureUsage: ConfirmPaymentIntentParams.SetupFutureUsage, verifyBillingDetails: Boolean = false, includePaymentMethod: Boolean = false, ) LongMethod:PaymentDetails.kt$@Preview(showBackground = true) @Composable private fun PaymentDetailsListItemPreview() diff --git a/paymentsheet/res/drawable/stripe_ic_nfc_tap.xml b/paymentsheet/res/drawable/stripe_ic_nfc_tap.xml new file mode 100644 index 00000000000..01a4d2b640c --- /dev/null +++ b/paymentsheet/res/drawable/stripe_ic_nfc_tap.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/paymentsheet/res/values/strings.xml b/paymentsheet/res/values/strings.xml index 7da503a6ce0..69ba217430a 100644 --- a/paymentsheet/res/values/strings.xml +++ b/paymentsheet/res/values/strings.xml @@ -332,4 +332,6 @@ to be saved and used in future checkout sessions. --> Update card This card has expired. Update your card info or choose a different payment method. + + Tap to add card diff --git a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddButtonUI.kt b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddButtonUI.kt new file mode 100644 index 00000000000..1dd7c110188 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddButtonUI.kt @@ -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, + ) + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddCardDetailsAction.kt b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddCardDetailsAction.kt new file mode 100644 index 00000000000..27238f3beb4 --- /dev/null +++ b/paymentsheet/src/main/java/com/stripe/android/common/taptoadd/TapToAddCardDetailsAction.kt @@ -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() + } + } +} diff --git a/paymentsheet/src/main/java/com/stripe/android/lpmfoundations/paymentmethod/definitions/CardDefinition.kt b/paymentsheet/src/main/java/com/stripe/android/lpmfoundations/paymentmethod/definitions/CardDefinition.kt index 64e9314f430..9e89ab205de 100644 --- a/paymentsheet/src/main/java/com/stripe/android/lpmfoundations/paymentmethod/definitions/CardDefinition.kt +++ b/paymentsheet/src/main/java/com/stripe/android/lpmfoundations/paymentmethod/definitions/CardDefinition.kt @@ -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 @@ -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, ) ) diff --git a/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/TapToAddButtonUIScreenshotTest.kt b/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/TapToAddButtonUIScreenshotTest.kt new file mode 100644 index 00000000000..71102a92e72 --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/TapToAddButtonUIScreenshotTest.kt @@ -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) {} + } + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/TapToAddCardDetailsActionTest.kt b/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/TapToAddCardDetailsActionTest.kt new file mode 100644 index 00000000000..f4e49069bf7 --- /dev/null +++ b/paymentsheet/src/test/java/com/stripe/android/common/taptoadd/TapToAddCardDetailsActionTest.kt @@ -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() + } + } +} diff --git a/paymentsheet/src/test/java/com/stripe/android/lpmfoundations/paymentmethod/definitions/CardDefinitionTest.kt b/paymentsheet/src/test/java/com/stripe/android/lpmfoundations/paymentmethod/definitions/CardDefinitionTest.kt index df2cca55a66..44144be2932 100644 --- a/paymentsheet/src/test/java/com/stripe/android/lpmfoundations/paymentmethod/definitions/CardDefinitionTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/lpmfoundations/paymentmethod/definitions/CardDefinitionTest.kt @@ -2,6 +2,7 @@ package com.stripe.android.lpmfoundations.paymentmethod.definitions import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat +import com.stripe.android.common.taptoadd.FakeTapToAddHelper import com.stripe.android.core.model.CountryUtils import com.stripe.android.core.strings.resolvableString import com.stripe.android.isInstanceOf @@ -26,6 +27,7 @@ import com.stripe.android.paymentsheet.state.LinkState import com.stripe.android.ui.core.elements.AutomaticallyLaunchedCardScanFormDataHelper import com.stripe.android.ui.core.elements.CardBillingAddressElement import com.stripe.android.ui.core.elements.CardDetailsSectionController +import com.stripe.android.ui.core.elements.CardDetailsSectionElement import com.stripe.android.ui.core.elements.MandateTextElement import com.stripe.android.ui.core.elements.SaveForFutureUseElement import com.stripe.android.ui.core.elements.SetAsDefaultPaymentMethodElement @@ -668,6 +670,71 @@ class CardDefinitionTest { .isEqualTo(PaymentsUiCoreR.string.stripe_card_with_tap_or_enter_manually.resolvableString) } + @Test + fun `createFormElements has null cardDetailsAction when tap to add is not supported`() { + val metadata = PaymentMethodMetadataFactory.create( + isTapToAddSupported = false, + billingDetailsCollectionConfiguration = PaymentSheet.BillingDetailsCollectionConfiguration( + address = PaymentSheet.BillingDetailsCollectionConfiguration.AddressCollectionMode.Never + ) + ) + val tapToAddHelper = FakeTapToAddHelper.noOp() + + val formElements = CardDefinition.formElements( + metadata = metadata, + tapToAddHelper = tapToAddHelper, + ) + + assertThat(formElements).hasSize(1) + assertThat(formElements[0]).isInstanceOf() + + val cardDetailsSectionElement = formElements[0] as CardDetailsSectionElement + val controller = cardDetailsSectionElement.controller + + assertThat(controller.cardDetailsAction).isNull() + } + + @Test + fun `createFormElements has null cardDetailsAction when tapToAddHelper is null`() { + val metadata = PaymentMethodMetadataFactory.create( + isTapToAddSupported = false, + billingDetailsCollectionConfiguration = PaymentSheet.BillingDetailsCollectionConfiguration( + address = PaymentSheet.BillingDetailsCollectionConfiguration.AddressCollectionMode.Never + ) + ) + + val formElements = CardDefinition.formElements( + metadata = metadata, + tapToAddHelper = null, + ) + + assertThat(formElements).hasSize(1) + assertThat(formElements[0]).isInstanceOf() + + val cardDetailsSectionElement = formElements[0] as CardDetailsSectionElement + val controller = cardDetailsSectionElement.controller + + assertThat(controller.cardDetailsAction).isNull() + } + + @Test + fun `createFormElements returns empty when tap to add is supported`() { + val metadata = PaymentMethodMetadataFactory.create( + isTapToAddSupported = true, + billingDetailsCollectionConfiguration = PaymentSheet.BillingDetailsCollectionConfiguration( + address = PaymentSheet.BillingDetailsCollectionConfiguration.AddressCollectionMode.Never + ) + ) + + val formElements = CardDefinition.formElements( + metadata = metadata, + tapToAddHelper = FakeTapToAddHelper.noOp(), + ) + + // When tap-to-add is supported, CardWithTapUiDefinitionFactory is used which returns empty form + assertThat(formElements).isEmpty() + } + private fun createLinkConfiguration(): LinkConfiguration { return TestFactory.LINK_CONFIGURATION.copy( stripeIntent = PaymentIntentFixtures.PI_REQUIRES_PAYMENT_METHOD diff --git a/paymentsheet/src/test/snapshots/images/com.stripe.android.common.taptoadd_TapToAddButtonUIScreenshotTest_default[].png b/paymentsheet/src/test/snapshots/images/com.stripe.android.common.taptoadd_TapToAddButtonUIScreenshotTest_default[].png new file mode 100644 index 00000000000..21544848161 Binary files /dev/null and b/paymentsheet/src/test/snapshots/images/com.stripe.android.common.taptoadd_TapToAddButtonUIScreenshotTest_default[].png differ