From 3d373fc3e46053b9efde08f29b50d0720ef10e71 Mon Sep 17 00:00:00 2001 From: "Bruno R. Nunes" <77990083+brnunes-stripe@users.noreply.github.com> Date: Mon, 15 Aug 2022 14:34:34 -0400 Subject: [PATCH] Support saved bank accounts in Link (#5405) --- .../example/{network.kt => Network.kt} | 0 link/api/link.api | 19 +- link/res/drawable/ic_link_bank.xml | 9 + link/res/values/strings.xml | 6 +- .../link/ui/wallet/WalletScreenTest.kt | 242 ++++++++---- .../link/model/ConsumerPaymentDetailsKtx.kt | 19 + .../link/model/SupportedPaymentMethodTypes.kt | 23 ++ .../link/repositories/LinkApiRepository.kt | 2 +- .../java/com/stripe/android/link/ui/Common.kt | 43 -- .../com/stripe/android/link/ui/ErrorText.kt | 94 +++++ .../com/stripe/android/link/ui/LinkAppBar.kt | 5 +- .../stripe/android/link/ui/LinkButtonView.kt | 10 +- .../link/ui/cardedit/CardEditScreen.kt | 5 +- .../ui/paymentmethod/PaymentMethodBody.kt | 5 +- .../paymentmethod/PaymentMethodViewModel.kt | 4 +- .../paymentmethod/SupportedPaymentMethod.kt | 33 +- .../android/link/ui/signup/SignUpScreen.kt | 5 +- .../ui/verification/VerificationScreen.kt | 22 +- .../android/link/ui/wallet/PaymentDetails.kt | 212 ++++++++++ .../android/link/ui/wallet/WalletModals.kt | 94 +++-- .../android/link/ui/wallet/WalletScreen.kt | 372 ++++++++---------- .../android/link/ui/wallet/WalletViewModel.kt | 50 ++- .../link/model/PaymentDetailsFixtures.kt | 23 ++ .../android/link/model/StripeIntentKtxTest.kt | 66 ++++ .../repositories/LinkApiRepositoryTest.kt | 4 +- .../link/ui/wallet/WalletViewModelTest.kt | 67 +++- .../android/model/ConsumerPaymentDetails.kt | 4 +- 27 files changed, 1023 insertions(+), 415 deletions(-) rename identity-example/src/main/java/com/stripe/android/identity/example/{network.kt => Network.kt} (100%) create mode 100644 link/res/drawable/ic_link_bank.xml create mode 100644 link/src/main/java/com/stripe/android/link/model/ConsumerPaymentDetailsKtx.kt create mode 100644 link/src/main/java/com/stripe/android/link/model/SupportedPaymentMethodTypes.kt create mode 100644 link/src/main/java/com/stripe/android/link/ui/ErrorText.kt create mode 100644 link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt create mode 100644 link/src/test/java/com/stripe/android/link/model/StripeIntentKtxTest.kt diff --git a/identity-example/src/main/java/com/stripe/android/identity/example/network.kt b/identity-example/src/main/java/com/stripe/android/identity/example/Network.kt similarity index 100% rename from identity-example/src/main/java/com/stripe/android/identity/example/network.kt rename to identity-example/src/main/java/com/stripe/android/identity/example/Network.kt diff --git a/link/api/link.api b/link/api/link.api index c85a33d1f96..f928cb30059 100644 --- a/link/api/link.api +++ b/link/api/link.api @@ -483,6 +483,14 @@ public final class com/stripe/android/link/ui/paymentmethod/PaymentMethodViewMod public static fun injectSubComponentBuilderProvider (Lcom/stripe/android/link/ui/paymentmethod/PaymentMethodViewModel$Factory;Ljavax/inject/Provider;)V } +public final class com/stripe/android/link/ui/paymentmethod/SupportedPaymentMethod$BankAccount$Creator : android/os/Parcelable$Creator { + public fun ()V + public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/link/ui/paymentmethod/SupportedPaymentMethod$BankAccount; + public synthetic fun createFromParcel (Landroid/os/Parcel;)Ljava/lang/Object; + public final fun newArray (I)[Lcom/stripe/android/link/ui/paymentmethod/SupportedPaymentMethod$BankAccount; + public synthetic fun newArray (I)[Ljava/lang/Object; +} + public final class com/stripe/android/link/ui/paymentmethod/SupportedPaymentMethod$Card$Creator : android/os/Parcelable$Creator { public fun ()V public final fun createFromParcel (Landroid/os/Parcel;)Lcom/stripe/android/link/ui/paymentmethod/SupportedPaymentMethod$Card; @@ -547,26 +555,29 @@ public final class com/stripe/android/link/ui/verification/VerificationViewModel public static fun injectViewModel (Lcom/stripe/android/link/ui/verification/VerificationViewModel$Factory;Lcom/stripe/android/link/ui/verification/VerificationViewModel;)V } +public final class com/stripe/android/link/ui/wallet/ComposableSingletons$PaymentDetailsKt { + public static final field INSTANCE Lcom/stripe/android/link/ui/wallet/ComposableSingletons$PaymentDetailsKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$link_release ()Lkotlin/jvm/functions/Function2; +} + public final class com/stripe/android/link/ui/wallet/ComposableSingletons$WalletModalsKt { public static final field INSTANCE Lcom/stripe/android/link/ui/wallet/ComposableSingletons$WalletModalsKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; public static field lambda-2 Lkotlin/jvm/functions/Function3; - public static field lambda-3 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$link_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda-2$link_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda-3$link_release ()Lkotlin/jvm/functions/Function2; } public final class com/stripe/android/link/ui/wallet/ComposableSingletons$WalletScreenKt { public static final field INSTANCE Lcom/stripe/android/link/ui/wallet/ComposableSingletons$WalletScreenKt; public static field lambda-1 Lkotlin/jvm/functions/Function2; public static field lambda-2 Lkotlin/jvm/functions/Function2; - public static field lambda-3 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$link_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-2$link_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-3$link_release ()Lkotlin/jvm/functions/Function2; } public final class com/stripe/android/link/ui/wallet/WalletViewModel_Factory : dagger/internal/Factory { diff --git a/link/res/drawable/ic_link_bank.xml b/link/res/drawable/ic_link_bank.xml new file mode 100644 index 00000000000..71593285b99 --- /dev/null +++ b/link/res/drawable/ic_link_bank.xml @@ -0,0 +1,9 @@ + + + diff --git a/link/res/values/strings.xml b/link/res/values/strings.xml index c6f8f93ef77..4161d3c0c92 100644 --- a/link/res/values/strings.xml +++ b/link/res/values/strings.xml @@ -25,7 +25,11 @@ Default Update card Remove card - Are you sure you want to remove this card? + Remove linked account + Unavailable for this purchase + Are you sure you want to remove this card? + Are you sure you want to remove this account? + By continuing, you agree to authorize payments pursuant to <a href=\"https://stripe.com/legal/ach-payments/authorization\">these terms</a>. Add a payment method Pay another way diff --git a/link/src/androidTest/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt b/link/src/androidTest/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt index 491068e0bf5..ebb8df08443 100644 --- a/link/src/androidTest/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt +++ b/link/src/androidTest/java/com/stripe/android/link/ui/wallet/WalletScreenTest.kt @@ -17,11 +17,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.filter import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -34,6 +36,7 @@ import com.stripe.android.link.theme.DefaultLinkTheme import com.stripe.android.link.ui.BottomSheetContent import com.stripe.android.link.ui.ErrorMessage import com.stripe.android.link.ui.PrimaryButtonState +import com.stripe.android.link.ui.paymentmethod.SupportedPaymentMethod import com.stripe.android.model.CardBrand import com.stripe.android.model.ConsumerPaymentDetails import kotlinx.coroutines.flow.MutableStateFlow @@ -51,74 +54,142 @@ internal class WalletScreenTest { private val primaryButtonLabel = "Pay $10.99" private val paymentDetails = listOf( ConsumerPaymentDetails.Card( - "id1", - true, - 2022, - 12, - CardBrand.Visa, - "4242" + id = "id1", + isDefault = true, + expiryYear = 2022, + expiryMonth = 12, + brand = CardBrand.Visa, + last4 = "4242" ), ConsumerPaymentDetails.Card( - "id2", - false, - 2023, - 11, - CardBrand.MasterCard, - "4444" + id = "id2", + isDefault = false, + expiryYear = 2023, + expiryMonth = 11, + brand = CardBrand.MasterCard, + last4 = "4444" ), ConsumerPaymentDetails.Card( - "id3", - false, - 2023, - 11, - CardBrand.AmericanExpress, - "0005" + id = "id3", + isDefault = false, + expiryYear = 2023, + expiryMonth = 11, + brand = CardBrand.AmericanExpress, + last4 = "0005" + ), + ConsumerPaymentDetails.BankAccount( + id = "id4", + isDefault = false, + bankIconCode = "icon", + bankName = "Stripe Bank", + last4 = "6789" + ), + ConsumerPaymentDetails.BankAccount( + id = "id5", + isDefault = false, + bankIconCode = "icon2", + bankName = "Stripe Credit Union", + last4 = "1234" ) ) private val paymentDetailsFlow = MutableStateFlow(paymentDetails) @Test - fun default_payment_method_is_initially_selected() { - var paymentMethod: ConsumerPaymentDetails.PaymentDetails? = null + fun selected_payment_method_is_shown_when_collapsed() { + val initiallySelectedItem = paymentDetails[4] + setContent(selectedItem = initiallySelectedItem) + + composeTestRule.onNodeWithText("Payment").onParent().onChildren() + .filter(hasText(initiallySelectedItem.label, substring = true)) + .assertCountEquals(1) + } + + @Test + fun when_no_payment_option_is_selected_then_list_is_expanded() { + setContent(selectedItem = null) + assertExpanded() + } + + @Test + fun when_no_payment_option_is_selected_then_primary_button_is_disabled() { + var payButtonClickCount = 0 + setContent( + selectedItem = null, onPayButtonClick = { - paymentMethod = it + payButtonClickCount++ } ) + onPrimaryButton().assertIsNotEnabled() onPrimaryButton().performClick() - assertThat(paymentMethod).isEqualTo(paymentDetails.first()) + assertThat(payButtonClickCount).isEqualTo(0) } @Test - fun selected_payment_method_is_shown_when_collapsed() { - setContent() + fun when_card_is_not_supported_then_cards_cannot_be_selected() { + var selectedItem: ConsumerPaymentDetails.PaymentDetails? = null + setContent( + supportedTypes = setOf(ConsumerPaymentDetails.BankAccount.type), + selectedItem = paymentDetails.first(), + onItemSelected = { + selectedItem = it + } + ) - val secondPaymentMethod = paymentDetails[1] + assertExpanded() + assertThat(selectedItem).isNull() + onPrimaryButton().assertIsNotEnabled() - toggleListExpanded() - onPaymentDetailsItem(secondPaymentMethod).performClick() + onPaymentDetailsItem(paymentDetails[1]).assertIsNotEnabled().performClick() - composeTestRule.onNodeWithText("Payment").onParent().onChildren() - .filter(hasText(secondPaymentMethod.last4, substring = true)).assertCountEquals(1) + assertThat(selectedItem).isNull() + onPrimaryButton().assertIsNotEnabled() + + onPaymentDetailsItem(paymentDetails[2]).assertIsNotEnabled().performClick() + + assertThat(selectedItem).isNull() + onPrimaryButton().assertIsNotEnabled() } @Test - fun selected_payment_method_is_used_for_payment() { - var paymentMethod: ConsumerPaymentDetails.PaymentDetails? = null + fun when_bank_account_is_not_supported_then_bank_accounts_cannot_be_selected() { + var selectedItem: ConsumerPaymentDetails.PaymentDetails? = null setContent( - onPayButtonClick = { - paymentMethod = it + supportedTypes = setOf(ConsumerPaymentDetails.Card.type), + selectedItem = paymentDetails[3], + onItemSelected = { + selectedItem = it } ) - val secondPaymentMethod = paymentDetails[1] + assertExpanded() + assertThat(selectedItem).isNull() + onPrimaryButton().assertIsNotEnabled() - toggleListExpanded() - onPaymentDetailsItem(secondPaymentMethod).performClick() - onPrimaryButton().performClick() + onPaymentDetailsItem(paymentDetails[4]).assertIsNotEnabled().performClick() + + assertThat(selectedItem).isNull() + onPrimaryButton().assertIsNotEnabled() + } + + @Test + fun when_payment_method_is_not_supported_then_error_message_is_shown() { + var selectedItem: ConsumerPaymentDetails.PaymentDetails? = null + setContent( + supportedTypes = emptySet(), + selectedItem = paymentDetails.first(), + onItemSelected = { + selectedItem = it + } + ) - assertThat(paymentMethod).isEqualTo(secondPaymentMethod) + assertExpanded() + assertThat(selectedItem).isNull() + onPrimaryButton().assertIsNotEnabled() + + composeTestRule.onAllNodesWithText("Unavailable for this purchase") + .assertCountEquals(5) } @Test @@ -169,17 +240,49 @@ internal class WalletScreenTest { } @Test - fun delete_item_shows_dialog_confirmation() { + fun card_options_menu_shows_correct_options() { + setContent() + toggleListExpanded() + onOptionsForPaymentMethod(paymentDetails.first()).performClick() + onRemoveCardButton().assertExists() + onUpdateCardButton().assertExists() + onCancelButton().assertExists() + onRemoveAccountButton().assertDoesNotExist() + } + + @Test + fun bank_account_options_menu_shows_correct_options() { + setContent() + toggleListExpanded() + onOptionsForPaymentMethod(paymentDetails[3]).performClick() + onRemoveAccountButton().assertExists() + onCancelButton().assertExists() + onRemoveCardButton().assertDoesNotExist() + onUpdateCardButton().assertDoesNotExist() + } + + @Test + fun delete_card_shows_dialog_confirmation() { setContent() toggleListExpanded() onOptionsForPaymentMethod(paymentDetails.first()).performClick() onRemoveCardButton().assertExists() onRemoveCardButton().performClick() - onRemoveConfirmationDialog().assertExists() + onRemoveCardConfirmationDialog().assertExists() + } + + @Test + fun delete_bank_account_shows_dialog_confirmation() { + setContent() + toggleListExpanded() + onOptionsForPaymentMethod(paymentDetails[3]).performClick() + onRemoveAccountButton().assertExists() + onRemoveAccountButton().performClick() + onRemoveBankAccountConfirmationDialog().assertExists() } @Test - fun update_item_triggers_callback() { + fun update_card_triggers_callback() { var paymentMethod: ConsumerPaymentDetails.PaymentDetails? = null setContent( onEditPaymentMethod = { @@ -241,7 +344,7 @@ internal class WalletScreenTest { onCancelButton().performClick() onOptionsForPaymentMethod(paymentDetails.first()).performClick() onRemoveCardButton().performClick() - onRemoveConfirmationDialog().assertExists() + onRemoveCardConfirmationDialog().assertExists() } @Test @@ -251,39 +354,23 @@ internal class WalletScreenTest { composeTestRule.onNodeWithText(errorMessage).assertExists() } - @Test - fun when_selected_item_is_removed_then_default_is_selected() { - setContent() - - val secondPaymentMethod = paymentDetails[1] - - toggleListExpanded() - onPaymentDetailsItem(secondPaymentMethod).performClick() - - composeTestRule.onNodeWithText("Payment").onParent().onChildren() - .filter(hasText(secondPaymentMethod.last4, substring = true)).assertCountEquals(1) - - val defaultPaymentDetails = paymentDetails.first() - paymentDetailsFlow.tryEmit(listOf(paymentDetails[2], defaultPaymentDetails)) - - composeTestRule.onNodeWithText("Payment").onParent().onChildren() - .filter(hasText(defaultPaymentDetails.last4, substring = true)).assertCountEquals(1) - } - private fun setContent( + supportedTypes: Set = SupportedPaymentMethod.allTypes, + selectedItem: ConsumerPaymentDetails.PaymentDetails? = paymentDetails.first(), primaryButtonState: PrimaryButtonState = PrimaryButtonState.Enabled, errorMessage: ErrorMessage? = null, + onItemSelected: (ConsumerPaymentDetails.PaymentDetails) -> Unit = {}, onAddNewPaymentMethodClick: () -> Unit = {}, onEditPaymentMethod: (ConsumerPaymentDetails.PaymentDetails) -> Unit = {}, onDeletePaymentMethod: (ConsumerPaymentDetails.PaymentDetails) -> Unit = {}, - onPayButtonClick: (ConsumerPaymentDetails.PaymentDetails) -> Unit = {}, + onPayButtonClick: () -> Unit = {}, onPayAnotherWayClick: () -> Unit = {}, showBottomSheetContent: ((BottomSheetContent?) -> Unit)? = null ) = composeTestRule.setContent { var bottomSheetContent by remember { mutableStateOf(null) } val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden) val coroutineScope = rememberCoroutineScope() - val paymentDetails by paymentDetailsFlow.collectAsState() + val paymentDetailsList by paymentDetailsFlow.collectAsState() if (bottomSheetContent != null) { DisposableEffect(bottomSheetContent) { @@ -305,11 +392,13 @@ internal class WalletScreenTest { ) { DefaultLinkTheme { WalletBody( - paymentDetails = paymentDetails, - initiallySelectedId = null, + paymentDetailsList = paymentDetailsList, + supportedTypes = supportedTypes, + selectedItem = selectedItem, primaryButtonLabel = primaryButtonLabel, primaryButtonState = primaryButtonState, errorMessage = errorMessage, + onItemSelected = onItemSelected, onAddNewPaymentMethodClick = onAddNewPaymentMethodClick, onEditPaymentMethod = onEditPaymentMethod, onDeletePaymentMethod = onDeletePaymentMethod, @@ -329,21 +418,34 @@ internal class WalletScreenTest { private fun toggleListExpanded() = composeTestRule.onNodeWithTag("ChevronIcon", useUnmergedTree = true).performClick() - private fun onPaymentDetailsItem(paymentDetails: ConsumerPaymentDetails.Card) = - composeTestRule.onNodeWithText(paymentDetails.last4, substring = true) + private fun onPaymentDetailsItem(paymentDetails: ConsumerPaymentDetails.PaymentDetails) = + composeTestRule.onNodeWithText(paymentDetails.label, substring = true) - private fun onOptionsForPaymentMethod(paymentDetails: ConsumerPaymentDetails.Card) = + private fun onOptionsForPaymentMethod(paymentDetails: ConsumerPaymentDetails.PaymentDetails) = onPaymentDetailsItem(paymentDetails).onChildren().filterToOne(hasContentDescription("Edit")) + // Assert list is expanded or collapsed based on the header text + private fun assertCollapsed() = composeTestRule.onNodeWithText("Payment").assertExists() + private fun assertExpanded() = composeTestRule.onNodeWithText("Payment methods").assertExists() + private fun onPrimaryButton() = composeTestRule.onNodeWithText(primaryButtonLabel) private fun onRemoveCardButton() = composeTestRule.onNodeWithText("Remove card") - private fun onUpdateCardButton() = composeTestRule.onNodeWithText("Update card") + private fun onRemoveAccountButton() = composeTestRule.onNodeWithText("Remove linked account") - private fun onRemoveConfirmationDialog() = + private fun onRemoveCardConfirmationDialog() = composeTestRule.onNodeWithText("Are you sure you want to remove this card?") + private fun onRemoveBankAccountConfirmationDialog() = + composeTestRule.onNodeWithText("Are you sure you want to remove this account?") + private fun onCancelButton() = composeTestRule.onNodeWithText("Cancel") private fun onRemoveButton() = composeTestRule.onNodeWithText("Remove") + + private val ConsumerPaymentDetails.PaymentDetails.label + get() = when (this) { + is ConsumerPaymentDetails.Card -> last4 + is ConsumerPaymentDetails.BankAccount -> last4 + } } diff --git a/link/src/main/java/com/stripe/android/link/model/ConsumerPaymentDetailsKtx.kt b/link/src/main/java/com/stripe/android/link/model/ConsumerPaymentDetailsKtx.kt new file mode 100644 index 00000000000..d8688afcffa --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/model/ConsumerPaymentDetailsKtx.kt @@ -0,0 +1,19 @@ +package com.stripe.android.link.model + +import com.stripe.android.link.R +import com.stripe.android.model.ConsumerPaymentDetails + +internal val ConsumerPaymentDetails.BankAccount.icon + get() = R.drawable.ic_link_bank + +internal val ConsumerPaymentDetails.PaymentDetails.removeLabel + get() = when (this) { + is ConsumerPaymentDetails.Card -> R.string.wallet_remove_card + is ConsumerPaymentDetails.BankAccount -> R.string.wallet_remove_linked_account + } + +internal val ConsumerPaymentDetails.PaymentDetails.removeConfirmation + get() = when (this) { + is ConsumerPaymentDetails.Card -> R.string.wallet_remove_card_confirmation + is ConsumerPaymentDetails.BankAccount -> R.string.wallet_remove_account_confirmation + } diff --git a/link/src/main/java/com/stripe/android/link/model/SupportedPaymentMethodTypes.kt b/link/src/main/java/com/stripe/android/link/model/SupportedPaymentMethodTypes.kt new file mode 100644 index 00000000000..b4df6af530c --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/model/SupportedPaymentMethodTypes.kt @@ -0,0 +1,23 @@ +package com.stripe.android.link.model + +import com.stripe.android.link.ui.paymentmethod.SupportedPaymentMethod +import com.stripe.android.model.ConsumerPaymentDetails +import com.stripe.android.model.StripeIntent + +/** + * Provides the supported payment method types for the given Link account. + * + * In test mode, accounts with email in the format {any_prefix}+multiple_funding_sources@{any_domain} + * enable all payment method types supported by the SDK. + * + * The supported payment methods are read from [StripeIntent.linkFundingSources], and fallback to + * card only if the list is empty or none of them is valid. + */ +internal fun StripeIntent.supportedPaymentMethodTypes(linkAccount: LinkAccount) = + if (!isLiveMode && linkAccount.email.contains("+multiple_funding_sources@")) { + SupportedPaymentMethod.allTypes + } else { + linkFundingSources.filter { SupportedPaymentMethod.allTypes.contains(it) } + .takeIf { it.isNotEmpty() }?.toSet() + ?: setOf(ConsumerPaymentDetails.Card.type) + } diff --git a/link/src/main/java/com/stripe/android/link/repositories/LinkApiRepository.kt b/link/src/main/java/com/stripe/android/link/repositories/LinkApiRepository.kt index 7878b65e1ce..cae07db2c0f 100644 --- a/link/src/main/java/com/stripe/android/link/repositories/LinkApiRepository.kt +++ b/link/src/main/java/com/stripe/android/link/repositories/LinkApiRepository.kt @@ -189,7 +189,7 @@ internal class LinkApiRepository @Inject constructor( runCatching { stripeRepository.listPaymentDetails( consumerSessionClientSecret, - setOf("card"), + SupportedPaymentMethod.allTypes, consumerPublishableKey?.let { ApiRequest.Options(it) } ?: ApiRequest.Options( diff --git a/link/src/main/java/com/stripe/android/link/ui/Common.kt b/link/src/main/java/com/stripe/android/link/ui/Common.kt index d077a1a7120..aebc0a948cf 100644 --- a/link/src/main/java/com/stripe/android/link/ui/Common.kt +++ b/link/src/main/java/com/stripe/android/link/ui/Common.kt @@ -1,25 +1,16 @@ package com.stripe.android.link.ui -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.stripe.android.link.R -import com.stripe.android.link.theme.linkColors /** * A Composable that is shown in the ModalBottomSheetLayout. @@ -44,37 +35,3 @@ internal fun ScrollableTopLevelColumn( } } } - -@Preview -@Composable -private fun ErrorTextPreview() { - ErrorText(text = "Test error message") -} - -@Composable -internal fun ErrorText( - text: String -) { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - color = MaterialTheme.linkColors.errorComponentBackground, - shape = MaterialTheme.shapes.small - ) - ) { - Icon( - painter = painterResource(id = R.drawable.ic_link_error), - contentDescription = null, - modifier = Modifier - .padding(12.dp), - tint = MaterialTheme.linkColors.errorText - ) - Text( - text = text, - modifier = Modifier.padding(top = 12.dp, end = 12.dp, bottom = 12.dp), - style = MaterialTheme.typography.body2, - color = MaterialTheme.linkColors.errorText - ) - } -} diff --git a/link/src/main/java/com/stripe/android/link/ui/ErrorText.kt b/link/src/main/java/com/stripe/android/link/ui/ErrorText.kt new file mode 100644 index 00000000000..053531ff257 --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/ui/ErrorText.kt @@ -0,0 +1,94 @@ +package com.stripe.android.link.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.stripe.android.link.R +import com.stripe.android.link.theme.linkColors + +internal sealed class ErrorTextStyle { + abstract val shape: Shape + abstract val iconModifier: Modifier + abstract val textModifier: Modifier + abstract val textStyle: TextStyle + + internal object Small : ErrorTextStyle() { + override val shape = RoundedCornerShape(4.dp) + override val iconModifier = Modifier + .padding(4.dp) + .size(12.dp) + override val textModifier = Modifier + .padding(top = 2.dp, end = 4.dp, bottom = 2.dp) + override val textStyle = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + lineHeight = 16.sp + ) + } + + internal object Medium : ErrorTextStyle() { + override val shape = RoundedCornerShape(8.dp) + override val iconModifier = Modifier + .padding(horizontal = 10.dp, vertical = 12.dp) + .size(20.dp) + override val textModifier = Modifier + .padding(top = 12.dp, end = 12.dp, bottom = 12.dp) + override val textStyle = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp + ) + } +} + +@Preview +@Composable +private fun ErrorTextPreview() { + ErrorText(text = "Test error message") +} + +@Composable +internal fun ErrorText( + text: String, + modifier: Modifier = Modifier, + style: ErrorTextStyle = ErrorTextStyle.Medium +) { + Row( + modifier = modifier.background( + color = MaterialTheme.linkColors.errorComponentBackground, + shape = style.shape + ), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(id = R.drawable.ic_link_error), + contentDescription = null, + modifier = style.iconModifier, + tint = MaterialTheme.linkColors.errorText + ) + Text( + text = text, + modifier = style.textModifier, + style = style.textStyle, + color = MaterialTheme.linkColors.errorText + ) + } +} diff --git a/link/src/main/java/com/stripe/android/link/ui/LinkAppBar.kt b/link/src/main/java/com/stripe/android/link/ui/LinkAppBar.kt index 83305742512..0c92651eae6 100644 --- a/link/src/main/java/com/stripe/android/link/ui/LinkAppBar.kt +++ b/link/src/main/java/com/stripe/android/link/ui/LinkAppBar.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.stripe.android.link.R @@ -95,7 +96,9 @@ internal fun LinkAppBar( ) { Text( text = email.orEmpty(), - color = MaterialTheme.linkColors.disabledText + color = MaterialTheme.linkColors.disabledText, + overflow = TextOverflow.Ellipsis, + maxLines = 1 ) } } diff --git a/link/src/main/java/com/stripe/android/link/ui/LinkButtonView.kt b/link/src/main/java/com/stripe/android/link/ui/LinkButtonView.kt index 8374cd8c2d5..7db39a58bcd 100644 --- a/link/src/main/java/com/stripe/android/link/ui/LinkButtonView.kt +++ b/link/src/main/java/com/stripe/android/link/ui/LinkButtonView.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -93,9 +94,8 @@ private fun LinkButton( modifier = Modifier .height(22.dp) .padding( - start = 5.dp, - top = 3.dp, - bottom = 3.dp + horizontal = 5.dp, + vertical = 3.dp ), tint = MaterialTheme.linkColors.buttonLabel .copy(alpha = LocalContentAlpha.current) @@ -114,7 +114,9 @@ private fun LinkButton( modifier = Modifier .padding(6.dp), color = MaterialTheme.linkColors.buttonLabel, - fontSize = 14.sp + fontSize = 14.sp, + overflow = TextOverflow.Ellipsis, + maxLines = 1 ) } } diff --git a/link/src/main/java/com/stripe/android/link/ui/cardedit/CardEditScreen.kt b/link/src/main/java/com/stripe/android/link/ui/cardedit/CardEditScreen.kt index 1ebc54eeb0b..f10ac62eced 100644 --- a/link/src/main/java/com/stripe/android/link/ui/cardedit/CardEditScreen.kt +++ b/link/src/main/java/com/stripe/android/link/ui/cardedit/CardEditScreen.kt @@ -170,7 +170,10 @@ internal fun CardEditBody( } } errorMessage?.let { - ErrorText(text = it.getMessage(LocalContext.current.resources)) + ErrorText( + text = it.getMessage(LocalContext.current.resources), + modifier = Modifier.fillMaxWidth() + ) } PrimaryButton( label = stringResource(R.string.wallet_update_card), diff --git a/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodBody.kt b/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodBody.kt index ab23252aadc..44da62c8af1 100644 --- a/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodBody.kt +++ b/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodBody.kt @@ -129,7 +129,10 @@ internal fun PaymentMethodBody( } Spacer(modifier = Modifier.height(8.dp)) errorMessage?.let { - ErrorText(text = it.getMessage(LocalContext.current.resources)) + ErrorText( + text = it.getMessage(LocalContext.current.resources), + modifier = Modifier.fillMaxWidth() + ) } PrimaryButton( label = primaryButtonLabel, diff --git a/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodViewModel.kt b/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodViewModel.kt index e64532da865..0bfbe69aa10 100644 --- a/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodViewModel.kt +++ b/link/src/main/java/com/stripe/android/link/ui/paymentmethod/PaymentMethodViewModel.kt @@ -94,8 +94,8 @@ internal class PaymentMethodViewModel @Inject constructor( val paymentMethodCreateParams = FieldValuesToParamsMapConverter.transformToPaymentMethodCreateParams( formValues, - paymentMethod.type.code, - paymentMethod.requiresMandate + paymentMethod.type, + false ) viewModelScope.launch { diff --git a/link/src/main/java/com/stripe/android/link/ui/paymentmethod/SupportedPaymentMethod.kt b/link/src/main/java/com/stripe/android/link/ui/paymentmethod/SupportedPaymentMethod.kt index 2605da0b055..4d0a2ea89bd 100644 --- a/link/src/main/java/com/stripe/android/link/ui/paymentmethod/SupportedPaymentMethod.kt +++ b/link/src/main/java/com/stripe/android/link/ui/paymentmethod/SupportedPaymentMethod.kt @@ -1,27 +1,25 @@ package com.stripe.android.link.ui.paymentmethod import android.os.Parcelable +import com.stripe.android.model.ConsumerPaymentDetails import com.stripe.android.model.ConsumerPaymentDetailsCreateParams -import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodCreateParams import com.stripe.android.ui.core.elements.FormItemSpec import com.stripe.android.ui.core.forms.LinkCardForm import kotlinx.parcelize.Parcelize /** - * Class representing the Payment Methods that are supported by Link. + * Represents the Payment Methods that are supported by Link. * - * @param type The Payment Method type + * @param type The Payment Method type. Matches the [ConsumerPaymentDetails] types. * @param formSpec Specification of how the payment method data collection UI should look. */ internal sealed class SupportedPaymentMethod( - val type: PaymentMethod.Type, + val type: String, val formSpec: List ) : Parcelable { - internal val requiresMandate = type.requiresMandate - /** - * Builds the [ConsumerPaymentDetailsCreateParams] used to create this payment method. + * Build the [ConsumerPaymentDetailsCreateParams] that will to create this payment method. */ abstract fun createParams( paymentMethodCreateParams: PaymentMethodCreateParams, @@ -29,14 +27,14 @@ internal sealed class SupportedPaymentMethod( ): ConsumerPaymentDetailsCreateParams /** - * Creates a map containing additional parameters that must be sent during payment confirmation. + * A map containing additional parameters that must be sent during payment confirmation. */ open fun extraConfirmationParams(paymentMethodCreateParams: PaymentMethodCreateParams): Map? = null @Parcelize object Card : SupportedPaymentMethod( - PaymentMethod.Type.Card, + ConsumerPaymentDetails.Card.type, LinkCardForm.items ) { override fun createParams( @@ -55,4 +53,21 @@ internal sealed class SupportedPaymentMethod( mapOf("card" to mapOf("cvc" to card["cvc"])) } } + + @Parcelize + object BankAccount : SupportedPaymentMethod( + ConsumerPaymentDetails.BankAccount.type, + emptyList() + ) { + override fun createParams( + paymentMethodCreateParams: PaymentMethodCreateParams, + email: String + ): ConsumerPaymentDetailsCreateParams { + TODO("Not yet implemented") + } + } + + internal companion object { + val allTypes = setOf(Card.type, BankAccount.type) + } } diff --git a/link/src/main/java/com/stripe/android/link/ui/signup/SignUpScreen.kt b/link/src/main/java/com/stripe/android/link/ui/signup/SignUpScreen.kt index 3615fdcdb8d..b589e672970 100644 --- a/link/src/main/java/com/stripe/android/link/ui/signup/SignUpScreen.kt +++ b/link/src/main/java/com/stripe/android/link/ui/signup/SignUpScreen.kt @@ -145,7 +145,10 @@ internal fun SignUpBody( ) } errorMessage?.let { - ErrorText(text = it.getMessage(LocalContext.current.resources)) + ErrorText( + text = it.getMessage(LocalContext.current.resources), + modifier = Modifier.fillMaxWidth() + ) } PrimaryButton( label = stringResource(R.string.sign_up), diff --git a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt index f637ce83b53..d86a87618e4 100644 --- a/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt +++ b/link/src/main/java/com/stripe/android/link/ui/verification/VerificationScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -171,15 +172,16 @@ internal fun VerificationBody( } if (showChangeEmailMessage) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 14.dp), + modifier = Modifier.padding(vertical = 14.dp), horizontalArrangement = Arrangement.Center ) { Text( text = stringResource(id = R.string.verification_not_email, email), - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onSecondary + modifier = Modifier.weight(weight = 1f, fill = false), + color = MaterialTheme.colors.onSecondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.body2 ) Text( text = stringResource(id = R.string.verification_change_email), @@ -189,13 +191,17 @@ internal fun VerificationBody( enabled = !isProcessing, onClick = onChangeEmailClick ), - style = MaterialTheme.typography.body2, - color = MaterialTheme.linkColors.actionLabel + color = MaterialTheme.linkColors.actionLabel, + maxLines = 1, + style = MaterialTheme.typography.body2 ) } } errorMessage?.let { - ErrorText(text = it.getMessage(LocalContext.current.resources)) + ErrorText( + text = it.getMessage(LocalContext.current.resources), + modifier = Modifier.fillMaxWidth() + ) } Box( modifier = Modifier diff --git a/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt b/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt new file mode 100644 index 00000000000..a06076b6c27 --- /dev/null +++ b/link/src/main/java/com/stripe/android/link/ui/wallet/PaymentDetails.kt @@ -0,0 +1,212 @@ +package com.stripe.android.link.ui.wallet + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.RadioButton +import androidx.compose.material.RadioButtonDefaults +import androidx.compose.material.TabRowDefaults +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.stripe.android.link.R +import com.stripe.android.link.model.icon +import com.stripe.android.link.theme.linkColors +import com.stripe.android.link.ui.ErrorText +import com.stripe.android.link.ui.ErrorTextStyle +import com.stripe.android.model.ConsumerPaymentDetails + +@Composable +internal fun PaymentDetailsListItem( + paymentDetails: ConsumerPaymentDetails.PaymentDetails, + enabled: Boolean, + isSupported: Boolean, + isSelected: Boolean, + onClick: () -> Unit, + onMenuButtonClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 56.dp) + .clickable(enabled = enabled && isSupported, onClick = onClick), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = null, + modifier = Modifier.padding(start = 20.dp, end = 6.dp), + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.linkColors.actionLabelLight, + unselectedColor = MaterialTheme.linkColors.disabledText + ) + ) + Column( + modifier = Modifier + .padding(vertical = 8.dp) + .weight(1f) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + PaymentDetails(paymentDetails = paymentDetails, enabled = isSupported) + Spacer(modifier = Modifier.weight(1f)) + if (paymentDetails.isDefault) { + Box( + modifier = Modifier + .height(20.dp) + .background( + color = MaterialTheme.colors.secondary, + shape = MaterialTheme.shapes.small + ), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(id = R.string.wallet_default), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + color = MaterialTheme.linkColors.disabledText, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + } + } + } + if (!isSupported) { + ErrorText( + text = stringResource(id = R.string.wallet_unavailable), + style = ErrorTextStyle.Small, + modifier = Modifier.padding(start = 8.dp, top = 8.dp, end = 8.dp) + ) + } + } + IconButton( + onClick = onMenuButtonClick, + modifier = Modifier.padding(end = 6.dp), + enabled = enabled + ) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = stringResource(R.string.edit), + tint = MaterialTheme.linkColors.actionLabelLight + ) + } + } + TabRowDefaults.Divider( + modifier = Modifier.padding(horizontal = 20.dp), + color = MaterialTheme.linkColors.componentDivider, + thickness = 1.dp + ) +} + +@Composable +internal fun PaymentDetails( + paymentDetails: ConsumerPaymentDetails.PaymentDetails, + enabled: Boolean +) { + when (paymentDetails) { + is ConsumerPaymentDetails.Card -> { + CardInfo(card = paymentDetails, enabled = enabled) + } + is ConsumerPaymentDetails.BankAccount -> { + BankAccountInfo(bankAccount = paymentDetails, enabled = enabled) + } + } +} + +@Composable +internal fun CardInfo( + card: ConsumerPaymentDetails.Card, + enabled: Boolean +) { + CompositionLocalProvider(LocalContentAlpha provides if (enabled) 1f else 0.6f) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = card.brand.icon), + contentDescription = card.brand.displayName, + modifier = Modifier + .width(38.dp) + .padding(horizontal = 6.dp), + alpha = LocalContentAlpha.current + ) + Text( + text = "•••• ", + color = MaterialTheme.colors.onPrimary + .copy(alpha = LocalContentAlpha.current) + ) + Text( + text = card.last4, + color = MaterialTheme.colors.onPrimary + .copy(alpha = LocalContentAlpha.current), + style = MaterialTheme.typography.h6 + ) + } + } +} + +@Composable +internal fun BankAccountInfo( + bankAccount: ConsumerPaymentDetails.BankAccount, + enabled: Boolean +) { + CompositionLocalProvider(LocalContentAlpha provides if (enabled) 1f else 0.6f) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(bankAccount.icon), + contentDescription = null, + modifier = Modifier + .width(38.dp) + .padding(horizontal = 6.dp), + tint = MaterialTheme.linkColors.actionLabelLight + .copy(alpha = LocalContentAlpha.current) + ) + Column(horizontalAlignment = Alignment.Start) { + Text( + text = bankAccount.bankName, + color = MaterialTheme.colors.onPrimary + .copy(alpha = LocalContentAlpha.current), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + style = MaterialTheme.typography.h6 + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "•••• ", + color = MaterialTheme.colors.onSecondary + .copy(alpha = LocalContentAlpha.current), + style = MaterialTheme.typography.body2 + ) + Text( + text = bankAccount.last4, + color = MaterialTheme.colors.onSecondary + .copy(alpha = LocalContentAlpha.current), + style = MaterialTheme.typography.body2 + ) + } + } + } + } +} diff --git a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletModals.kt b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletModals.kt index 4d17f72d69c..b5d83b7dcbb 100644 --- a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletModals.kt +++ b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletModals.kt @@ -1,5 +1,6 @@ package com.stripe.android.link.ui.wallet +import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -15,13 +16,42 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.stripe.android.link.R +import com.stripe.android.link.model.removeConfirmation +import com.stripe.android.link.model.removeLabel import com.stripe.android.link.theme.HorizontalPadding import com.stripe.android.link.theme.linkColors +import com.stripe.android.model.ConsumerPaymentDetails + +private val BottomSheetFirstItemModifier = Modifier.padding( + start = HorizontalPadding, + top = 24.dp, + end = HorizontalPadding, + bottom = 10.dp +) + +private val BottomSheetMiddleItemModifier = Modifier.padding( + horizontal = HorizontalPadding, + vertical = 10.dp +) + +private val BottomSheetLastItemModifier = Modifier.padding( + start = HorizontalPadding, + top = 10.dp, + end = HorizontalPadding, + bottom = 24.dp +) @Preview @Composable internal fun WalletBottomSheetContent() { WalletBottomSheetContent( + paymentDetails = ConsumerPaymentDetails.BankAccount( + id = "id", + isDefault = true, + bankIconCode = null, + bankName = "Bank Name", + last4 = "last4" + ), onCancelClick = {}, onEditClick = {}, onRemoveClick = {} @@ -30,29 +60,41 @@ internal fun WalletBottomSheetContent() { @Composable internal fun WalletBottomSheetContent( - onCancelClick: () -> Unit, + paymentDetails: ConsumerPaymentDetails.PaymentDetails, onEditClick: () -> Unit, - onRemoveClick: () -> Unit + onRemoveClick: () -> Unit, + onCancelClick: () -> Unit +) { + BottomSheetContent( + removeLabel = paymentDetails.removeLabel, + onRemoveClick = onRemoveClick, + onCancelClick = onCancelClick, + onEditClick = onEditClick.takeIf { paymentDetails is ConsumerPaymentDetails.Card } + ) +} + +@Composable +private fun BottomSheetContent( + @StringRes removeLabel: Int, + onRemoveClick: () -> Unit, + onCancelClick: () -> Unit, + onEditClick: (() -> Unit)? ) { Column( modifier = Modifier .fillMaxWidth() ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onEditClick) - ) { - Text( - text = stringResource(R.string.wallet_update_card), + onEditClick?.let { + Row( modifier = Modifier - .padding( - start = HorizontalPadding, - top = 24.dp, - end = HorizontalPadding, - bottom = 10.dp - ) - ) + .fillMaxWidth() + .clickable(onClick = it) + ) { + Text( + text = stringResource(R.string.wallet_update_card), + modifier = BottomSheetFirstItemModifier + ) + } } Row( @@ -61,12 +103,9 @@ internal fun WalletBottomSheetContent( .clickable(onClick = onRemoveClick) ) { Text( - text = stringResource(R.string.wallet_remove_card), - modifier = Modifier - .padding( - horizontal = HorizontalPadding, - vertical = 10.dp - ) + text = stringResource(removeLabel), + modifier = onEditClick?.let { BottomSheetMiddleItemModifier } + ?: BottomSheetFirstItemModifier ) } @@ -77,13 +116,7 @@ internal fun WalletBottomSheetContent( ) { Text( text = stringResource(R.string.cancel), - modifier = Modifier - .padding( - start = HorizontalPadding, - top = 10.dp, - end = HorizontalPadding, - bottom = 24.dp - ) + modifier = BottomSheetLastItemModifier ) } } @@ -91,6 +124,7 @@ internal fun WalletBottomSheetContent( @Composable internal fun ConfirmRemoveDialog( + paymentDetails: ConsumerPaymentDetails.PaymentDetails, showDialog: Boolean, onDialogDismissed: (Boolean) -> Unit ) { @@ -124,7 +158,7 @@ internal fun ConfirmRemoveDialog( } }, text = { - Text(stringResource(R.string.wallet_remove_confirmation)) + Text(stringResource(paymentDetails.removeConfirmation)) } ) } diff --git a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletScreen.kt b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletScreen.kt index e35983ba8df..f9041c59663 100644 --- a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletScreen.kt +++ b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletScreen.kt @@ -13,15 +13,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon -import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme -import androidx.compose.material.RadioButton -import androidx.compose.material.RadioButtonDefaults import androidx.compose.material.Surface -import androidx.compose.material.TabRowDefaults.Divider import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.MoreVert import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -39,10 +33,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.stripe.android.link.R import com.stripe.android.link.model.LinkAccount @@ -56,10 +49,13 @@ import com.stripe.android.link.ui.PrimaryButton import com.stripe.android.link.ui.PrimaryButtonState import com.stripe.android.link.ui.ScrollableTopLevelColumn import com.stripe.android.link.ui.SecondaryButton +import com.stripe.android.link.ui.paymentmethod.SupportedPaymentMethod import com.stripe.android.link.ui.primaryButtonLabel import com.stripe.android.model.CardBrand import com.stripe.android.model.ConsumerPaymentDetails +import com.stripe.android.ui.core.elements.Html import com.stripe.android.ui.core.injection.NonFallbackInjector +import com.stripe.android.ui.core.paymentsColors @Preview @Composable @@ -67,7 +63,7 @@ private fun WalletBodyPreview() { DefaultLinkTheme { Surface { WalletBody( - paymentDetails = listOf( + paymentDetailsList = listOf( ConsumerPaymentDetails.Card( "id1", true, @@ -85,10 +81,12 @@ private fun WalletBodyPreview() { "4444" ) ), - initiallySelectedId = null, + supportedTypes = SupportedPaymentMethod.allTypes, + selectedItem = null, primaryButtonLabel = "Pay $10.99", primaryButtonState = PrimaryButtonState.Enabled, errorMessage = null, + onItemSelected = {}, onAddNewPaymentMethodClick = {}, onEditPaymentMethod = {}, onDeletePaymentMethod = {}, @@ -113,139 +111,163 @@ internal fun WalletBody( ) ) - val paymentDetails by viewModel.paymentDetails.collectAsState() + val paymentDetailsList by viewModel.paymentDetailsList.collectAsState() val primaryButtonState by viewModel.primaryButtonState.collectAsState() - + val selectedItem by viewModel.selectedItem.collectAsState() val errorMessage by viewModel.errorMessage.collectAsState() - WalletBody( - paymentDetails = paymentDetails, - initiallySelectedId = null, - primaryButtonLabel = primaryButtonLabel(viewModel.args, LocalContext.current.resources), - primaryButtonState = primaryButtonState, - errorMessage = errorMessage, - onAddNewPaymentMethodClick = viewModel::addNewPaymentMethod, - onEditPaymentMethod = viewModel::editPaymentMethod, - onDeletePaymentMethod = viewModel::deletePaymentMethod, - onPrimaryButtonClick = viewModel::onSelectedPaymentDetails, - onPayAnotherWayClick = viewModel::payAnotherWay, - showBottomSheetContent = showBottomSheetContent - ) + if (paymentDetailsList.isEmpty()) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else { + WalletBody( + paymentDetailsList = paymentDetailsList, + supportedTypes = viewModel.supportedTypes, + selectedItem = selectedItem, + primaryButtonLabel = primaryButtonLabel(viewModel.args, LocalContext.current.resources), + primaryButtonState = primaryButtonState, + errorMessage = errorMessage, + onItemSelected = viewModel::onItemSelected, + onAddNewPaymentMethodClick = viewModel::addNewPaymentMethod, + onEditPaymentMethod = viewModel::editPaymentMethod, + onDeletePaymentMethod = viewModel::deletePaymentMethod, + onPrimaryButtonClick = viewModel::onConfirmPayment, + onPayAnotherWayClick = viewModel::payAnotherWay, + showBottomSheetContent = showBottomSheetContent + ) + } } @Composable internal fun WalletBody( - paymentDetails: List, - initiallySelectedId: String?, + paymentDetailsList: List, + supportedTypes: Set, + selectedItem: ConsumerPaymentDetails.PaymentDetails?, primaryButtonLabel: String, primaryButtonState: PrimaryButtonState, errorMessage: ErrorMessage?, + onItemSelected: (ConsumerPaymentDetails.PaymentDetails) -> Unit, onAddNewPaymentMethodClick: () -> Unit, onEditPaymentMethod: (ConsumerPaymentDetails.PaymentDetails) -> Unit, onDeletePaymentMethod: (ConsumerPaymentDetails.PaymentDetails) -> Unit, - onPrimaryButtonClick: (ConsumerPaymentDetails.PaymentDetails) -> Unit, + onPrimaryButtonClick: () -> Unit, onPayAnotherWayClick: () -> Unit, showBottomSheetContent: (BottomSheetContent?) -> Unit ) { - var isWalletExpanded by rememberSaveable { mutableStateOf(false) } - var cardBeingRemoved by remember { mutableStateOf(null) } + val selectedItemIsValid = selectedItem?.let { supportedTypes.contains(it.type) } ?: false + var isWalletExpanded by rememberSaveable { mutableStateOf(!selectedItemIsValid) } + var itemBeingRemoved by remember { + mutableStateOf(null) + } var openDialog by remember { mutableStateOf(false) } - cardBeingRemoved?.let { - // Launch dialog when the value of [cardBeingRemoved] changes. + itemBeingRemoved?.let { + // Launch confirmation dialog at the first recomposition after marking item for deletion LaunchedEffect(it) { openDialog = true } - ConfirmRemoveDialog(openDialog) { confirmed -> + ConfirmRemoveDialog( + paymentDetails = it, + showDialog = openDialog + ) { confirmed -> if (confirmed) { onDeletePaymentMethod(it) } openDialog = false - cardBeingRemoved = null + itemBeingRemoved = null } } - if (paymentDetails.isEmpty()) { - Box( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } else { - ScrollableTopLevelColumn { - Spacer(modifier = Modifier.height(12.dp)) + ScrollableTopLevelColumn { + Spacer(modifier = Modifier.height(12.dp)) - var selectedItemId by rememberSaveable { - mutableStateOf(initiallySelectedId ?: getDefaultSelectedCard(paymentDetails)) - } - - // Update selected item if it's not on the list anymore - if (paymentDetails.firstOrNull { it.id == selectedItemId } == null) { - selectedItemId = getDefaultSelectedCard(paymentDetails) - } - - if (isWalletExpanded) { - ExpandedPaymentDetails( - paymentDetails = paymentDetails, - selectedItemId = selectedItemId, - enabled = !primaryButtonState.isBlocking, - onIndexSelected = { - selectedItemId = paymentDetails[it].id - isWalletExpanded = false - }, - onMenuButtonClick = { - showBottomSheetContent { - WalletBottomSheetContent( - onCancelClick = { - showBottomSheetContent(null) - }, - onEditClick = { - showBottomSheetContent(null) - onEditPaymentMethod(it) - }, - onRemoveClick = { - showBottomSheetContent(null) - cardBeingRemoved = it - } - ) - } - }, - onAddNewPaymentMethodClick = onAddNewPaymentMethodClick, - onCollapse = { - isWalletExpanded = false - } - ) - } else { - CollapsedPaymentDetails( - selectedPaymentMethod = paymentDetails.first { it.id == selectedItemId }, - enabled = !primaryButtonState.isBlocking, - onClick = { - isWalletExpanded = true + if (isWalletExpanded || !selectedItemIsValid) { + isWalletExpanded = true + ExpandedPaymentDetails( + paymentDetailsList = paymentDetailsList, + supportedTypes = supportedTypes, + selectedItem = selectedItem?.takeIf { selectedItemIsValid }, + enabled = !primaryButtonState.isBlocking, + onItemSelected = { + onItemSelected(it) + isWalletExpanded = false + }, + onMenuButtonClick = { + showBottomSheetContent { + WalletBottomSheetContent( + paymentDetails = it, + onRemoveClick = { + showBottomSheetContent(null) + itemBeingRemoved = it + }, + onCancelClick = { + showBottomSheetContent(null) + }, + onEditClick = { + showBottomSheetContent(null) + onEditPaymentMethod(it) + } + ) } - ) - } - Spacer(modifier = Modifier.height(20.dp)) - errorMessage?.let { - ErrorText(text = it.getMessage(LocalContext.current.resources)) - } - PrimaryButton( - label = primaryButtonLabel, - state = primaryButtonState, - icon = R.drawable.stripe_ic_lock - ) { - onPrimaryButtonClick(paymentDetails.first { it.id == selectedItemId }) - } - SecondaryButton( + }, + onAddNewPaymentMethodClick = onAddNewPaymentMethodClick, + onCollapse = { + isWalletExpanded = false + } + ) + } else { + CollapsedPaymentDetails( + selectedPaymentMethod = selectedItem!!, enabled = !primaryButtonState.isBlocking, - label = stringResource(id = R.string.wallet_pay_another_way), - onClick = onPayAnotherWayClick + onClick = { + isWalletExpanded = true + } ) } + if (selectedItem is ConsumerPaymentDetails.BankAccount) { + Html( + html = stringResource(R.string.wallet_bank_account_terms), + imageGetter = emptyMap(), + color = MaterialTheme.paymentsColors.placeholderText, + style = MaterialTheme.typography.caption, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp), + urlSpanStyle = SpanStyle( + color = MaterialTheme.colors.primary + ) + ) + } + Spacer(modifier = Modifier.height(20.dp)) + errorMessage?.let { + ErrorText( + text = it.getMessage(LocalContext.current.resources), + modifier = Modifier.fillMaxWidth() + ) + } + PrimaryButton( + label = primaryButtonLabel, + state = if (selectedItemIsValid) { + primaryButtonState + } else { + PrimaryButtonState.Disabled + }, + icon = R.drawable.stripe_ic_lock, + onButtonClick = onPrimaryButtonClick + ) + SecondaryButton( + enabled = !primaryButtonState.isBlocking, + label = stringResource(id = R.string.wallet_pay_another_way), + onClick = onPayAnotherWayClick + ) } } @@ -276,12 +298,13 @@ internal fun CollapsedPaymentDetails( ) { Text( text = stringResource(id = R.string.wallet_collapsed_payment), - modifier = Modifier.padding(horizontal = HorizontalPadding), + modifier = Modifier.padding( + start = HorizontalPadding, + end = 8.dp + ), color = MaterialTheme.linkColors.disabledText ) - if (selectedPaymentMethod is ConsumerPaymentDetails.Card) { - CardDetails(card = selectedPaymentMethod) - } + PaymentDetails(paymentDetails = selectedPaymentMethod, enabled = true) Spacer(modifier = Modifier.weight(1f)) Icon( painter = painterResource(id = R.drawable.ic_link_chevron), @@ -298,11 +321,12 @@ internal fun CollapsedPaymentDetails( @Composable private fun ExpandedPaymentDetails( - paymentDetails: List, - selectedItemId: String, + paymentDetailsList: List, + supportedTypes: Set, + selectedItem: ConsumerPaymentDetails.PaymentDetails?, enabled: Boolean, - onIndexSelected: (Int) -> Unit, - onMenuButtonClick: (ConsumerPaymentDetails.Card) -> Unit, + onItemSelected: (ConsumerPaymentDetails.PaymentDetails) -> Unit, + onMenuButtonClick: (ConsumerPaymentDetails.PaymentDetails) -> Unit, onAddNewPaymentMethodClick: () -> Unit, onCollapse: () -> Unit ) { @@ -346,24 +370,21 @@ private fun ExpandedPaymentDetails( ) } - // TODO(brnunes-stripe): Use LazyColumn. - paymentDetails.forEachIndexed { index, item -> - when (item) { - is ConsumerPaymentDetails.Card -> { - CardPaymentMethodItem( - cardDetails = item, - enabled = enabled, - isSelected = selectedItemId == item.id, - onClick = { - onIndexSelected(index) - }, - onMenuButtonClick = { - onMenuButtonClick(item) - } - ) + // TODO(brnunes-stripe): Use LazyColumn, will need to write custom shape for the border + // https://juliensalvi.medium.com/custom-shape-with-jetpack-compose-1cb48a991d42 + paymentDetailsList.forEachIndexed { index, item -> + PaymentDetailsListItem( + paymentDetails = item, + enabled = enabled, + isSupported = supportedTypes.contains(item.type), + isSelected = selectedItem?.id == item.id, + onClick = { + onItemSelected(item) + }, + onMenuButtonClick = { + onMenuButtonClick(item) } - else -> {} - } + ) } Row( @@ -388,88 +409,3 @@ private fun ExpandedPaymentDetails( } } } - -@Composable -private fun CardPaymentMethodItem( - cardDetails: ConsumerPaymentDetails.Card, - enabled: Boolean, - isSelected: Boolean, - onClick: () -> Unit, - onMenuButtonClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(56.dp) - .clickable(enabled = enabled, onClick = onClick), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = isSelected, - onClick = null, - modifier = Modifier.padding(start = 20.dp, end = 6.dp), - colors = RadioButtonDefaults.colors( - selectedColor = MaterialTheme.linkColors.actionLabelLight, - unselectedColor = MaterialTheme.linkColors.disabledText - ) - ) - CardDetails(card = cardDetails) - Spacer(modifier = Modifier.weight(1f)) - if (cardDetails.isDefault) { - Box( - modifier = Modifier - .height(20.dp) - .background( - color = MaterialTheme.colors.secondary, - shape = MaterialTheme.shapes.small - ), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(id = R.string.wallet_default), - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), - color = MaterialTheme.linkColors.disabledText, - fontSize = 12.sp, - fontWeight = FontWeight.Medium - ) - } - } - IconButton( - onClick = onMenuButtonClick, - modifier = Modifier.padding(end = 6.dp), - enabled = enabled - ) { - Icon( - imageVector = Icons.Filled.MoreVert, - contentDescription = stringResource(R.string.edit), - tint = MaterialTheme.linkColors.actionLabelLight - ) - } - } - Divider(color = MaterialTheme.linkColors.componentDivider, thickness = 1.dp) -} - -@Composable -internal fun CardDetails( - card: ConsumerPaymentDetails.Card -) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - painter = painterResource(id = card.brand.icon), - contentDescription = card.brand.displayName, - modifier = Modifier.padding(horizontal = 6.dp), - tint = Color.Unspecified - ) - Text( - text = "•••• ", - color = MaterialTheme.colors.onPrimary - ) - Text( - text = card.last4, - color = MaterialTheme.colors.onPrimary - ) - } -} - -private fun getDefaultSelectedCard(paymentDetails: List) = - paymentDetails.firstOrNull { it.isDefault }?.id ?: paymentDetails.first().id diff --git a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt index 71ac7815dda..7ad1b56c045 100644 --- a/link/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt +++ b/link/src/main/java/com/stripe/android/link/ui/wallet/WalletViewModel.kt @@ -13,6 +13,7 @@ import com.stripe.android.link.confirmation.ConfirmationManager import com.stripe.android.link.injection.SignedInViewModelSubcomponent import com.stripe.android.link.model.LinkAccount import com.stripe.android.link.model.Navigator +import com.stripe.android.link.model.supportedPaymentMethodTypes import com.stripe.android.link.ui.ErrorMessage import com.stripe.android.link.ui.PrimaryButtonState import com.stripe.android.link.ui.cardedit.CardEditViewModel @@ -37,9 +38,17 @@ internal class WalletViewModel @Inject constructor( ) : ViewModel() { private val stripeIntent = args.stripeIntent - private val _paymentDetails = + private val _paymentDetailsList = MutableStateFlow>(emptyList()) - val paymentDetails: StateFlow> = _paymentDetails + val paymentDetailsList: StateFlow> = + _paymentDetailsList + + val supportedTypes = args.stripeIntent.supportedPaymentMethodTypes( + requireNotNull(linkAccountManager.linkAccount.value) + ) + + private val _selectedItem = MutableStateFlow(null) + val selectedItem: StateFlow = _selectedItem private val _primaryButtonState = MutableStateFlow(PrimaryButtonState.Disabled) val primaryButtonState: StateFlow = _primaryButtonState @@ -62,7 +71,9 @@ internal class WalletViewModel @Inject constructor( } } - fun onSelectedPaymentDetails(selectedPaymentDetails: ConsumerPaymentDetails.PaymentDetails) { + fun onConfirmPayment() { + val selectedPaymentDetails = selectedItem.value ?: return + clearError() setState(PrimaryButtonState.Processing) @@ -133,26 +144,30 @@ internal class WalletViewModel @Inject constructor( } } + fun onItemSelected(item: ConsumerPaymentDetails.PaymentDetails) { + _selectedItem.value = item + } + private fun loadPaymentDetails(initialSetup: Boolean = false) { setState(PrimaryButtonState.Processing) viewModelScope.launch { linkAccountManager.listPaymentDetails().fold( onSuccess = { response -> setState(PrimaryButtonState.Enabled) - val hasSavedCards = - response.paymentDetails.filterIsInstance() - .takeIf { it.isNotEmpty() }?.let { - _paymentDetails.value = it - true - } ?: false + _paymentDetailsList.value = response.paymentDetails + + _selectedItem.value = _selectedItem.value?.let { previouslySelectedItem -> + // If currently selected item is still available, keep it selected + response.paymentDetails.firstOrNull { it.id == previouslySelectedItem.id } + } ?: getDefaultItemSelection(response.paymentDetails) if (initialSetup && args.prefilledCardParams != null) { - // User is returning and had previously added a new payment method + // User has already pre-filled the payment details navigator.navigateTo( LinkScreen.PaymentMethod(true), - clearBackStack = !hasSavedCards + clearBackStack = response.paymentDetails.isEmpty() ) - } else if (!hasSavedCards) { + } else if (response.paymentDetails.isEmpty()) { addNewPaymentMethod(clearBackStack = true) } }, @@ -186,6 +201,17 @@ internal class WalletViewModel @Inject constructor( navigator.backNavigationEnabled = !state.isBlocking } + /** + * The item that should be selected by default from the [paymentDetailsList]. + * + * @return the default item, if supported. Otherwise the first supported item on the list. + */ + private fun getDefaultItemSelection( + paymentDetailsList: List + ) = paymentDetailsList.filter { supportedTypes.contains(it.type) }.let { filteredItems -> + filteredItems.firstOrNull { it.isDefault } ?: filteredItems.firstOrNull() + } + internal class Factory( private val linkAccount: LinkAccount, private val injector: NonFallbackInjector diff --git a/link/src/test/java/com/stripe/android/link/model/PaymentDetailsFixtures.kt b/link/src/test/java/com/stripe/android/link/model/PaymentDetailsFixtures.kt index ecbcdb10919..1eb5861ceb1 100644 --- a/link/src/test/java/com/stripe/android/link/model/PaymentDetailsFixtures.kt +++ b/link/src/test/java/com/stripe/android/link/model/PaymentDetailsFixtures.kt @@ -65,6 +65,29 @@ internal object PaymentDetailsFixtures { }, "is_default": false, "type": "CARD" + }, + { + "id": "wAAACGA", + "bank_account_details": { + "bank_icon_code": null, + "bank_name": "STRIPE TEST BANK", + "last4": "6789" + }, + "billing_address": { + "administrative_area": null, + "country_code": null, + "dependent_locality": null, + "line_1": null, + "line_2": null, + "locality": null, + "name": null, + "postal_code": null, + "sorting_code": null + }, + "billing_email_address": "", + "card_details": null, + "is_default": false, + "type": "BANK_ACCOUNT" } ] } diff --git a/link/src/test/java/com/stripe/android/link/model/StripeIntentKtxTest.kt b/link/src/test/java/com/stripe/android/link/model/StripeIntentKtxTest.kt new file mode 100644 index 00000000000..6e3a12396f2 --- /dev/null +++ b/link/src/test/java/com/stripe/android/link/model/StripeIntentKtxTest.kt @@ -0,0 +1,66 @@ +package com.stripe.android.link.model + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class StripeIntentKtxTest { + + @Test + fun `When test mode and test account then all funding sources are enabled`() { + val supportedTypes = stripeIntent(liveMode = false) + .supportedPaymentMethodTypes(linkAccount("test+multiple_funding_sources@test.abc")) + assertThat(supportedTypes).containsExactly("card", "bank_account") + } + + @Test + fun `When live mode and test account then uses intent funding sources`() { + val supportedTypes = stripeIntent(listOf("card")) + .supportedPaymentMethodTypes(linkAccount("test+multiple_funding_sources@test.abc")) + assertThat(supportedTypes).containsExactly("card") + } + + @Test + fun `When test mode and not test account then uses intent funding sources`() { + val supportedTypes = stripeIntent(listOf("card")) + .supportedPaymentMethodTypes(linkAccount("test@test.abc")) + assertThat(supportedTypes).containsExactly("card") + } + + @Test + fun `When funding sources is empty then default to card`() { + val supportedTypes = stripeIntent() + .supportedPaymentMethodTypes(linkAccount("test+multiple_funding_sources@test.abc")) + assertThat(supportedTypes).containsExactly("card") + } + + @Test + fun `When funding sources contains invalid items then invalid items are ignored`() { + val supportedTypes = stripeIntent(listOf("invalid", "invalid2", "bank_account")) + .supportedPaymentMethodTypes(linkAccount("test+multiple_funding_sources@test.abc")) + assertThat(supportedTypes).containsExactly("bank_account") + } + + @Test + fun `When funding sources contains only invalid items then default to card`() { + val supportedTypes = stripeIntent(listOf("invalid", "invalid2", "bank_acc0unt")) + .supportedPaymentMethodTypes(linkAccount("test+multiple_funding_sources@test.abc")) + assertThat(supportedTypes).containsExactly("card") + } + + private fun stripeIntent( + fundingSources: List = emptyList(), + liveMode: Boolean = true + ) = StripeIntentFixtures.PI_SUCCEEDED.copy( + linkFundingSources = fundingSources, + isLiveMode = liveMode + ) + + private fun linkAccount(accountEmail: String) = mock().apply { + whenever(email).thenReturn(accountEmail) + } +} diff --git a/link/src/test/java/com/stripe/android/link/repositories/LinkApiRepositoryTest.kt b/link/src/test/java/com/stripe/android/link/repositories/LinkApiRepositoryTest.kt index f9b7ef97b5c..c03eb856fdf 100644 --- a/link/src/test/java/com/stripe/android/link/repositories/LinkApiRepositoryTest.kt +++ b/link/src/test/java/com/stripe/android/link/repositories/LinkApiRepositoryTest.kt @@ -285,7 +285,7 @@ class LinkApiRepositoryTest { verify(stripeRepository).listPaymentDetails( eq(secret), - argThat { contains("card") && size == 1 }, + eq(SupportedPaymentMethod.allTypes), eq(ApiRequest.Options(consumerKey)) ) } @@ -297,7 +297,7 @@ class LinkApiRepositoryTest { verify(stripeRepository).listPaymentDetails( eq(secret), - argThat { contains("card") && size == 1 }, + eq(SupportedPaymentMethod.allTypes), eq(ApiRequest.Options(PUBLISHABLE_KEY, STRIPE_ACCOUNT_ID)) ) } diff --git a/link/src/test/java/com/stripe/android/link/ui/wallet/WalletViewModelTest.kt b/link/src/test/java/com/stripe/android/link/ui/wallet/WalletViewModelTest.kt index d414564945a..846b5ebb0f0 100644 --- a/link/src/test/java/com/stripe/android/link/ui/wallet/WalletViewModelTest.kt +++ b/link/src/test/java/com/stripe/android/link/ui/wallet/WalletViewModelTest.kt @@ -66,6 +66,7 @@ class WalletViewModelTest { whenever(args.stripeIntent).thenReturn(StripeIntentFixtures.PI_SUCCEEDED) val mockLinkAccount = mock().apply { whenever(clientSecret).thenReturn(CLIENT_SECRET) + whenever(email).thenReturn("email@stripe.com") } linkAccountManager = mock().apply { whenever(linkAccount).thenReturn(MutableStateFlow(mockLinkAccount)) @@ -96,7 +97,7 @@ class WalletViewModelTest { val viewModel = createViewModel() - assertThat(viewModel.paymentDetails.value).containsExactly(card1, card2) + assertThat(viewModel.paymentDetailsList.value).containsExactly(card1, card2) } @Test @@ -132,8 +133,10 @@ class WalletViewModelTest { @Test fun `onSelectedPaymentDetails starts payment confirmation`() { val paymentDetails = PaymentDetailsFixtures.CONSUMER_PAYMENT_DETAILS.paymentDetails.first() + val viewModel = createViewModel() - createViewModel().onSelectedPaymentDetails(paymentDetails) + viewModel.onItemSelected(paymentDetails) + viewModel.onConfirmPayment() val paramsCaptor = argumentCaptor() verify(confirmationManager).confirmStripeIntent(paramsCaptor.capture(), any()) @@ -149,12 +152,66 @@ class WalletViewModelTest { ) } + @Test + fun `onItemSelected updates selected item`() { + val paymentDetails = PaymentDetailsFixtures.CONSUMER_PAYMENT_DETAILS.paymentDetails.first() + val viewModel = createViewModel() + + viewModel.onItemSelected(paymentDetails) + + assertThat(viewModel.selectedItem.value).isEqualTo(paymentDetails) + } + + @Test + fun `when selected item is removed then default item is selected`() = runTest { + val deletedPaymentDetails = + PaymentDetailsFixtures.CONSUMER_PAYMENT_DETAILS.paymentDetails[1] + val viewModel = createViewModel() + viewModel.onItemSelected(deletedPaymentDetails) + + assertThat(viewModel.selectedItem.value).isEqualTo(deletedPaymentDetails) + + whenever(linkAccountManager.deletePaymentDetails(anyOrNull())) + .thenReturn(Result.success(Unit)) + whenever(linkAccountManager.listPaymentDetails()) + .thenReturn( + Result.success( + PaymentDetailsFixtures.CONSUMER_PAYMENT_DETAILS.copy( + paymentDetails = PaymentDetailsFixtures.CONSUMER_PAYMENT_DETAILS.paymentDetails + .filter { it != deletedPaymentDetails } + ) + ) + ) + + viewModel.deletePaymentMethod(deletedPaymentDetails) + + assertThat(viewModel.selectedItem.value) + .isEqualTo(PaymentDetailsFixtures.CONSUMER_PAYMENT_DETAILS.paymentDetails.first()) + } + + @Test + fun `when default item is not supported then first supported item is selected`() = runTest { + whenever(args.stripeIntent).thenReturn( + StripeIntentFixtures.PI_SUCCEEDED.copy( + linkFundingSources = listOf(ConsumerPaymentDetails.BankAccount.type) + ) + ) + whenever(linkAccountManager.listPaymentDetails()) + .thenReturn(Result.success(PaymentDetailsFixtures.CONSUMER_PAYMENT_DETAILS)) + + val viewModel = createViewModel() + + val bankAccount = PaymentDetailsFixtures.CONSUMER_PAYMENT_DETAILS.paymentDetails[2] + assertThat(viewModel.selectedItem.value).isEqualTo(bankAccount) + } + @Test fun `when payment confirmation fails then an error message is shown`() { val errorThrown = "Error message" val viewModel = createViewModel() - viewModel.onSelectedPaymentDetails(PaymentDetailsFixtures.CONSUMER_PAYMENT_DETAILS.paymentDetails.first()) + viewModel.onItemSelected(PaymentDetailsFixtures.CONSUMER_PAYMENT_DETAILS.paymentDetails.first()) + viewModel.onConfirmPayment() val callbackCaptor = argumentCaptor() verify(confirmationManager).confirmStripeIntent(any(), callbackCaptor.capture()) @@ -175,7 +232,7 @@ class WalletViewModelTest { clearInvocations(linkAccountManager) // Initially has two elements - assertThat(viewModel.paymentDetails.value) + assertThat(viewModel.paymentDetailsList.value) .containsExactlyElementsIn(paymentDetails.paymentDetails) whenever(linkAccountManager.deletePaymentDetails(anyOrNull())) @@ -216,7 +273,7 @@ class WalletViewModelTest { val paymentDetails = PaymentDetailsFixtures.CONSUMER_PAYMENT_DETAILS.paymentDetails.first() val viewModel = createViewModel() - viewModel.onSelectedPaymentDetails(paymentDetails) + viewModel.onConfirmPayment() assertThat(viewModel.primaryButtonState.value).isEqualTo(PrimaryButtonState.Completed) diff --git a/payments-core/src/main/java/com/stripe/android/model/ConsumerPaymentDetails.kt b/payments-core/src/main/java/com/stripe/android/model/ConsumerPaymentDetails.kt index 1302ddb4e61..b2b76ebb3ce 100644 --- a/payments-core/src/main/java/com/stripe/android/model/ConsumerPaymentDetails.kt +++ b/payments-core/src/main/java/com/stripe/android/model/ConsumerPaymentDetails.kt @@ -25,7 +25,7 @@ data class ConsumerPaymentDetails internal constructor( val expiryMonth: Int, val brand: CardBrand, val last4: String - ) : PaymentDetails(id, isDefault, type) { + ) : PaymentDetails(id, isDefault, Companion.type) { companion object { const val type = "card" @@ -59,7 +59,7 @@ data class ConsumerPaymentDetails internal constructor( val bankIconCode: String?, val bankName: String, val last4: String - ) : PaymentDetails(id, isDefault, type) { + ) : PaymentDetails(id, isDefault, Companion.type) { companion object { const val type = "bank_account" }