diff --git a/CHANGELOG.md b/CHANGELOG.md index 4013d08fa03..a7842b0469c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This release patches a crash with payment launcher when there is a configuration ### Payments * [FIXED] [4776](https://github.com/stripe/stripe-android/pull/4776) fix issue with PaymentLauncher configuration change +* [CHANGED] [4358](https://github.com/stripe/stripe-android/pull/4358) Updated the card element on PaymentSheet to use Compose. ## 19.3.1 - 2022-03-22 This release patches an issue with 3ds2 confirmation @@ -16,6 +17,7 @@ This release enables a new configuration object to be defined for StripeCardScan ### PaymentSheet * [FIXED] [4646](https://github.com/stripe/stripe-android/pull/4646) Update 3ds2 to latest version 6.1.4, see PR for specific issues addressed. +* [FIXED] [4669](https://github.com/stripe/stripe-android/pull/4669) Restrict the list of SEPA debit supported countries. ### CardScan * [ADDED] [4689](https://github.com/stripe/stripe-android/pull/4689) The `CardImageVerificationSheet` initializer can now take an additional `Configuration` object. 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 3c42cdf1edd..34020d88267 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 @@ -146,7 +146,8 @@ private fun EmailCollectionSection( listOf(emailElement.sectionFieldErrorController()) ) ), - emptyList() + emptyList(), + emailElement.identifier ) if (signUpState == SignUpState.VerifyingEmail) { CircularProgressIndicator( diff --git a/payments-core/api/payments-core.api b/payments-core/api/payments-core.api index 4f851eb61c8..5c26489ef56 100644 --- a/payments-core/api/payments-core.api +++ b/payments-core/api/payments-core.api @@ -878,6 +878,32 @@ public final class com/stripe/android/StripeKtxKt { public static synthetic fun retrieveSource$default (Lcom/stripe/android/Stripe;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } +public abstract interface class com/stripe/android/cards/CardAccountRangeRepository$Factory { + public abstract fun create ()Lcom/stripe/android/cards/CardAccountRangeRepository; +} + +public abstract interface class com/stripe/android/cards/CardAccountRangeService$AccountRangeResultListener { + public abstract fun onAccountRangeResult (Lcom/stripe/android/model/AccountRange;)V +} + +public final class com/stripe/android/cards/CardNumber$Companion { +} + +public final class com/stripe/android/cards/CardNumber$Unvalidated : com/stripe/android/cards/CardNumber { + public static final field $stable I + public fun (Ljava/lang/String;)V + public final fun copy (Ljava/lang/String;)Lcom/stripe/android/cards/CardNumber$Unvalidated; + public static synthetic fun copy$default (Lcom/stripe/android/cards/CardNumber$Unvalidated;Ljava/lang/String;ILjava/lang/Object;)Lcom/stripe/android/cards/CardNumber$Unvalidated; + public fun equals (Ljava/lang/Object;)Z + public final fun getBin ()Lcom/stripe/android/cards/Bin; + public final fun getLength ()I + public final fun getNormalized ()Ljava/lang/String; + public fun hashCode ()I + public final fun isMaxLength ()Z + public final fun isValidLuhn ()Z + public fun toString ()Ljava/lang/String; +} + public final class com/stripe/android/exception/AuthenticationException : com/stripe/android/core/exception/StripeException { public static final field $stable I } @@ -5794,8 +5820,11 @@ public final class com/stripe/android/view/CardNumberEditText : com/stripe/andro public fun (Landroid/content/Context;Landroid/util/AttributeSet;)V public fun (Landroid/content/Context;Landroid/util/AttributeSet;I)V public synthetic fun (Landroid/content/Context;Landroid/util/AttributeSet;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAccountRangeService ()Lcom/stripe/android/cards/CardAccountRangeService; public final fun getCardBrand ()Lcom/stripe/android/model/CardBrand; + public final fun getWorkContext ()Lkotlin/coroutines/CoroutineContext; public final fun isCardNumberValid ()Z + public final fun setWorkContext (Lkotlin/coroutines/CoroutineContext;)V } public abstract interface class com/stripe/android/view/CardValidCallback { diff --git a/payments-core/src/main/java/com/stripe/android/CardUtils.kt b/payments-core/src/main/java/com/stripe/android/CardUtils.kt index 75027f85b69..d7cd84cbe56 100644 --- a/payments-core/src/main/java/com/stripe/android/CardUtils.kt +++ b/payments-core/src/main/java/com/stripe/android/CardUtils.kt @@ -1,5 +1,6 @@ package com.stripe.android +import androidx.annotation.RestrictTo import com.stripe.android.cards.CardNumber import com.stripe.android.model.CardBrand @@ -13,7 +14,10 @@ object CardUtils { * @return the [CardBrand] that matches the card number based on prefixes, * or [CardBrand.Unknown] if it can't be determined */ - @Deprecated("CardInputWidget and CardMultilineWidget handle card brand lookup. This method should not be relied on for determining CardBrand.") + @Deprecated( + "CardInputWidget and CardMultilineWidget handle card brand lookup. " + + "This method should not be relied on for determining CardBrand." + ) @JvmStatic fun getPossibleCardBrand(cardNumber: String?): CardBrand { return if (cardNumber.isNullOrBlank()) { @@ -29,7 +33,9 @@ object CardUtils { * @param cardNumber a String that may or may not represent a valid Luhn number * @return `true` if and only if the input value is a valid Luhn number */ - internal fun isValidLuhnNumber(cardNumber: String?): Boolean { + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + @SuppressWarnings("ReturnCount") + fun isValidLuhnNumber(cardNumber: String?): Boolean { if (cardNumber == null) { return false } diff --git a/payments-core/src/main/java/com/stripe/android/cards/Bin.kt b/payments-core/src/main/java/com/stripe/android/cards/Bin.kt index 5f4d8bd86cb..11f3f14b956 100644 --- a/payments-core/src/main/java/com/stripe/android/cards/Bin.kt +++ b/payments-core/src/main/java/com/stripe/android/cards/Bin.kt @@ -1,15 +1,17 @@ package com.stripe.android.cards +import androidx.annotation.RestrictTo import com.stripe.android.core.model.StripeModel import kotlinx.parcelize.Parcelize +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @Parcelize -internal data class Bin internal constructor( +data class Bin internal constructor( internal val value: String ) : StripeModel { override fun toString() = value - companion object { + internal companion object { fun create(cardNumber: String): Bin? { return cardNumber .take(BIN_LENGTH) diff --git a/payments-core/src/main/java/com/stripe/android/cards/CardAccountRangeRepository.kt b/payments-core/src/main/java/com/stripe/android/cards/CardAccountRangeRepository.kt index 636553c1c81..03dd6bf3218 100644 --- a/payments-core/src/main/java/com/stripe/android/cards/CardAccountRangeRepository.kt +++ b/payments-core/src/main/java/com/stripe/android/cards/CardAccountRangeRepository.kt @@ -1,9 +1,11 @@ package com.stripe.android.cards +import androidx.annotation.RestrictTo import com.stripe.android.model.AccountRange import kotlinx.coroutines.flow.Flow -internal interface CardAccountRangeRepository { +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +interface CardAccountRangeRepository { suspend fun getAccountRange( cardNumber: CardNumber.Unvalidated ): AccountRange? diff --git a/payments-core/src/main/java/com/stripe/android/cards/CardAccountRangeService.kt b/payments-core/src/main/java/com/stripe/android/cards/CardAccountRangeService.kt new file mode 100644 index 00000000000..091829e0e1e --- /dev/null +++ b/payments-core/src/main/java/com/stripe/android/cards/CardAccountRangeService.kt @@ -0,0 +1,103 @@ +package com.stripe.android.cards + +import androidx.annotation.RestrictTo +import androidx.annotation.VisibleForTesting +import com.stripe.android.model.AccountRange +import com.stripe.android.model.CardBrand +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.CoroutineContext + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class CardAccountRangeService constructor( + private val cardAccountRangeRepository: CardAccountRangeRepository, + private val workContext: CoroutineContext, + val staticCardAccountRanges: StaticCardAccountRanges, + private val accountRangeResultListener: AccountRangeResultListener +) { + + val isLoading: Flow = cardAccountRangeRepository.loading + + var accountRange: AccountRange? = null + private set + + @VisibleForTesting + var accountRangeRepositoryJob: Job? = null + + fun onCardNumberChanged(cardNumber: CardNumber.Unvalidated) { + val staticAccountRange = staticCardAccountRanges.filter(cardNumber) + .let { accountRanges -> + if (accountRanges.size == 1) { + accountRanges.first() + } else { + null + } + } + if (staticAccountRange == null || shouldQueryRepository(staticAccountRange)) { + // query for AccountRange data + queryAccountRangeRepository(cardNumber) + } else { + // use static AccountRange data + updateAccountRangeResult(staticAccountRange) + } + } + + @JvmSynthetic + fun queryAccountRangeRepository(cardNumber: CardNumber.Unvalidated) { + if (shouldQueryAccountRange(cardNumber)) { + // cancel in-flight job + cancelAccountRangeRepositoryJob() + + // invalidate accountRange before fetching + accountRange = null + + accountRangeRepositoryJob = CoroutineScope(workContext).launch { + val bin = cardNumber.bin + val accountRange = if (bin != null) { + cardAccountRangeRepository.getAccountRange(cardNumber) + } else { + null + } + + withContext(Dispatchers.Main) { + updateAccountRangeResult(accountRange) + } + } + } + } + + fun cancelAccountRangeRepositoryJob() { + accountRangeRepositoryJob?.cancel() + accountRangeRepositoryJob = null + } + + @JvmSynthetic + fun updateAccountRangeResult( + newAccountRange: AccountRange? + ) { + accountRange = newAccountRange + accountRangeResultListener.onAccountRangeResult(accountRange) + } + + private fun shouldQueryRepository( + accountRange: AccountRange + ) = when (accountRange.brand) { + CardBrand.Unknown, + CardBrand.UnionPay -> true + else -> false + } + + private fun shouldQueryAccountRange(cardNumber: CardNumber.Unvalidated): Boolean { + return accountRange == null || + cardNumber.bin == null || + accountRange?.binRange?.matches(cardNumber) == false + } + + interface AccountRangeResultListener { + fun onAccountRangeResult(newAccountRange: AccountRange?) + } +} diff --git a/payments-core/src/main/java/com/stripe/android/cards/CardNumber.kt b/payments-core/src/main/java/com/stripe/android/cards/CardNumber.kt index 600d9e985f8..320c8de4ef4 100644 --- a/payments-core/src/main/java/com/stripe/android/cards/CardNumber.kt +++ b/payments-core/src/main/java/com/stripe/android/cards/CardNumber.kt @@ -1,14 +1,16 @@ package com.stripe.android.cards +import androidx.annotation.RestrictTo import com.stripe.android.CardUtils import com.stripe.android.model.CardBrand -internal sealed class CardNumber { +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +sealed class CardNumber { /** * A representation of a partial or full card number that hasn't been validated. */ - internal data class Unvalidated internal constructor( + data class Unvalidated constructor( private val denormalized: String ) : CardNumber() { val normalized = denormalized.filterNot { REJECT_CHARS.contains(it) } @@ -21,7 +23,7 @@ internal sealed class CardNumber { val isValidLuhn = CardUtils.isValidLuhnNumber(normalized) - fun validate(panLength: Int): Validated? { + internal fun validate(panLength: Int): Validated? { return if (panLength >= MIN_PAN_LENGTH && normalized.length == panLength && isValidLuhn @@ -41,7 +43,7 @@ internal sealed class CardNumber { * `"424242"` with pan length `16` will return `"4242 42"`; * `"4242424242424242"` with pan length `14` will return `"4242 424242 4242"` */ - fun getFormatted( + internal fun getFormatted( panLength: Int = DEFAULT_PAN_LENGTH ) = formatNumber(panLength) @@ -96,17 +98,17 @@ internal sealed class CardNumber { /** * A representation of a client-side validated card number. */ - internal data class Validated internal constructor( + internal data class Validated constructor( internal val value: String ) : CardNumber() - internal companion object { + companion object { internal fun getSpacePositions(panLength: Int) = SPACE_POSITIONS[panLength] ?: DEFAULT_SPACE_POSITIONS internal const val MIN_PAN_LENGTH = 14 internal const val MAX_PAN_LENGTH = 19 - internal const val DEFAULT_PAN_LENGTH = 16 + const val DEFAULT_PAN_LENGTH = 16 private val DEFAULT_SPACE_POSITIONS = setOf(4, 9, 14) private val SPACE_POSITIONS = mapOf( diff --git a/payments-core/src/main/java/com/stripe/android/cards/DefaultCardAccountRangeRepositoryFactory.kt b/payments-core/src/main/java/com/stripe/android/cards/DefaultCardAccountRangeRepositoryFactory.kt index 96ce6fff9eb..cbe1f28b241 100644 --- a/payments-core/src/main/java/com/stripe/android/cards/DefaultCardAccountRangeRepositoryFactory.kt +++ b/payments-core/src/main/java/com/stripe/android/cards/DefaultCardAccountRangeRepositoryFactory.kt @@ -1,6 +1,7 @@ package com.stripe.android.cards import android.content.Context +import androidx.annotation.RestrictTo import com.stripe.android.PaymentConfiguration import com.stripe.android.core.networking.AnalyticsRequestExecutor import com.stripe.android.core.networking.ApiRequest @@ -17,7 +18,8 @@ import kotlinx.coroutines.flow.flowOf * * Will throw an exception if [PaymentConfiguration] has not been instantiated. */ -internal class DefaultCardAccountRangeRepositoryFactory( +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class DefaultCardAccountRangeRepositoryFactory( context: Context, private val analyticsRequestExecutor: AnalyticsRequestExecutor ) : CardAccountRangeRepository.Factory { diff --git a/payments-core/src/main/java/com/stripe/android/cards/DefaultStaticCardAccountRanges.kt b/payments-core/src/main/java/com/stripe/android/cards/DefaultStaticCardAccountRanges.kt index c7601b52b7b..e8be54c210c 100644 --- a/payments-core/src/main/java/com/stripe/android/cards/DefaultStaticCardAccountRanges.kt +++ b/payments-core/src/main/java/com/stripe/android/cards/DefaultStaticCardAccountRanges.kt @@ -1,9 +1,11 @@ package com.stripe.android.cards +import androidx.annotation.RestrictTo import com.stripe.android.model.AccountRange import com.stripe.android.model.BinRange -internal class DefaultStaticCardAccountRanges : StaticCardAccountRanges { +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class DefaultStaticCardAccountRanges : StaticCardAccountRanges { override fun first( cardNumber: CardNumber.Unvalidated ) = filter(cardNumber).firstOrNull() @@ -99,9 +101,14 @@ internal class DefaultStaticCardAccountRanges : StaticCardAccountRanges { ) } - private val UNIONPAY_ACCOUNTS = setOf( + private val UNIONPAY16_ACCOUNTS = setOf( BinRange( low = "6200000000000000", + high = "6216828049999999" + ), + + BinRange( + low = "6216828060000000", high = "6299999999999999" ), @@ -117,6 +124,19 @@ internal class DefaultStaticCardAccountRanges : StaticCardAccountRanges { ) } + private val UNIONPAY19_ACCOUNTS = setOf( + BinRange( + low = "6216828050000000000", + high = "6216828059999999999" + ) + ).map { + AccountRange( + binRange = it, + panLength = 19, + brandInfo = AccountRange.BrandInfo.UnionPay + ) + } + private val DINERSCLUB16_ACCOUNT_RANGES = setOf( BinRange( low = "3000000000000000", @@ -159,7 +179,8 @@ internal class DefaultStaticCardAccountRanges : StaticCardAccountRanges { .plus(AMEX_ACCOUNTS) .plus(DISCOVER_ACCOUNTS) .plus(JCB_ACCOUNTS) - .plus(UNIONPAY_ACCOUNTS) + .plus(UNIONPAY16_ACCOUNTS) + .plus(UNIONPAY19_ACCOUNTS) .plus(DINERSCLUB16_ACCOUNT_RANGES) .plus(DINERSCLUB14_ACCOUNT_RANGES) } diff --git a/payments-core/src/main/java/com/stripe/android/cards/StaticCardAccountRangeSource.kt b/payments-core/src/main/java/com/stripe/android/cards/StaticCardAccountRangeSource.kt index 17d83c0f607..d2165152ef2 100644 --- a/payments-core/src/main/java/com/stripe/android/cards/StaticCardAccountRangeSource.kt +++ b/payments-core/src/main/java/com/stripe/android/cards/StaticCardAccountRangeSource.kt @@ -1,5 +1,6 @@ package com.stripe.android.cards +import androidx.annotation.RestrictTo import com.stripe.android.model.AccountRange import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @@ -7,7 +8,8 @@ import kotlinx.coroutines.flow.flowOf /** * A [CardAccountRangeSource] that uses a local, static source of BIN ranges. */ -internal class StaticCardAccountRangeSource( +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class StaticCardAccountRangeSource( private val accountRanges: StaticCardAccountRanges = DefaultStaticCardAccountRanges() ) : CardAccountRangeSource { override val loading: Flow = flowOf(false) diff --git a/payments-core/src/main/java/com/stripe/android/cards/StaticCardAccountRanges.kt b/payments-core/src/main/java/com/stripe/android/cards/StaticCardAccountRanges.kt index 5f7a9b6ab13..aba62100667 100644 --- a/payments-core/src/main/java/com/stripe/android/cards/StaticCardAccountRanges.kt +++ b/payments-core/src/main/java/com/stripe/android/cards/StaticCardAccountRanges.kt @@ -1,8 +1,10 @@ package com.stripe.android.cards +import androidx.annotation.RestrictTo import com.stripe.android.model.AccountRange -internal interface StaticCardAccountRanges { +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +interface StaticCardAccountRanges { /** * Return the first [AccountRange] that contains the given [cardNumber], or `null`. */ diff --git a/payments-core/src/main/java/com/stripe/android/model/AccountRange.kt b/payments-core/src/main/java/com/stripe/android/model/AccountRange.kt index 5c95ba3f4cf..ad6a7c40997 100644 --- a/payments-core/src/main/java/com/stripe/android/model/AccountRange.kt +++ b/payments-core/src/main/java/com/stripe/android/model/AccountRange.kt @@ -1,10 +1,12 @@ package com.stripe.android.model +import androidx.annotation.RestrictTo import com.stripe.android.core.model.StripeModel import kotlinx.parcelize.Parcelize +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @Parcelize -internal data class AccountRange internal constructor( +data class AccountRange internal constructor( val binRange: BinRange, val panLength: Int, val brandInfo: BrandInfo, diff --git a/payments-core/src/main/java/com/stripe/android/model/BinRange.kt b/payments-core/src/main/java/com/stripe/android/model/BinRange.kt index 0798472ea04..e05218d6e60 100644 --- a/payments-core/src/main/java/com/stripe/android/model/BinRange.kt +++ b/payments-core/src/main/java/com/stripe/android/model/BinRange.kt @@ -1,11 +1,13 @@ package com.stripe.android.model +import androidx.annotation.RestrictTo import com.stripe.android.cards.CardNumber import com.stripe.android.core.model.StripeModel import kotlinx.parcelize.Parcelize +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @Parcelize -internal data class BinRange( +data class BinRange( val low: String, val high: String ) : StripeModel { @@ -13,7 +15,7 @@ internal data class BinRange( * Number matching strategy: Truncate the longer of the two numbers (theirs and our * bounds) to match the length of the shorter one, then do numerical compare. */ - internal fun matches(cardNumber: CardNumber.Unvalidated): Boolean { + fun matches(cardNumber: CardNumber.Unvalidated): Boolean { val number = cardNumber.normalized val numberBigDecimal = number.toBigDecimalOrNull() ?: return false diff --git a/payments-core/src/main/java/com/stripe/android/model/CardBrand.kt b/payments-core/src/main/java/com/stripe/android/model/CardBrand.kt index 23f1addc949..7316c725cba 100644 --- a/payments-core/src/main/java/com/stripe/android/model/CardBrand.kt +++ b/payments-core/src/main/java/com/stripe/android/model/CardBrand.kt @@ -1,6 +1,7 @@ package com.stripe.android.model import androidx.annotation.DrawableRes +import androidx.annotation.RestrictTo import com.stripe.android.R import com.stripe.android.cards.CardNumber import java.util.regex.Pattern @@ -117,7 +118,10 @@ enum class CardBrand( "mastercard", "Mastercard", R.drawable.stripe_ic_mastercard, - pattern = Pattern.compile("^(2221|2222|2223|2224|2225|2226|2227|2228|2229|222|223|224|225|226|227|228|229|23|24|25|26|270|271|2720|50|51|52|53|54|55|56|57|58|59|67)[0-9]*$"), + pattern = Pattern.compile( + "^(2221|2222|2223|2224|2225|2226|2227|2228|2229|222|223|224|225|226|" + + "227|228|229|23|24|25|26|270|271|2720|50|51|52|53|54|55|56|57|58|59|67)[0-9]*$" + ), partialPatterns = mapOf( 1 to Pattern.compile("^2|5|6$"), 2 to Pattern.compile("^(22|23|24|25|26|27|50|51|52|53|54|55|56|57|58|59|67)$") @@ -174,7 +178,8 @@ enum class CardBrand( * * Note: currently only [CardBrand.DinersClub] has variants */ - internal fun getMaxLengthForCardNumber(cardNumber: String): Int { + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + fun getMaxLengthForCardNumber(cardNumber: String): Int { val normalizedCardNumber = CardNumber.Unvalidated(cardNumber).normalized return variantMaxLength.entries.firstOrNull { (pattern, _) -> pattern.matcher(normalizedCardNumber).matches() @@ -191,7 +196,8 @@ enum class CardBrand( * @return the [CardBrand] that matches the [cardNumber]'s prefix, if one is found; * otherwise, [CardBrand.Unknown] */ - internal fun fromCardNumber(cardNumber: String?): CardBrand { + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + fun fromCardNumber(cardNumber: String?): CardBrand { if (cardNumber.isNullOrBlank()) { return Unknown } @@ -205,7 +211,8 @@ enum class CardBrand( ).first() } - internal fun getCardBrands(cardNumber: String?): List { + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + fun getCardBrands(cardNumber: String?): List { if (cardNumber.isNullOrBlank()) { return listOf(Unknown) } diff --git a/payments-core/src/main/java/com/stripe/android/view/CardNumberEditText.kt b/payments-core/src/main/java/com/stripe/android/view/CardNumberEditText.kt index 69ff2c279b9..edab10b0172 100644 --- a/payments-core/src/main/java/com/stripe/android/view/CardNumberEditText.kt +++ b/payments-core/src/main/java/com/stripe/android/view/CardNumberEditText.kt @@ -10,6 +10,7 @@ import androidx.annotation.VisibleForTesting import com.stripe.android.PaymentConfiguration import com.stripe.android.R import com.stripe.android.cards.CardAccountRangeRepository +import com.stripe.android.cards.CardAccountRangeService import com.stripe.android.cards.CardNumber import com.stripe.android.cards.DefaultCardAccountRangeRepositoryFactory import com.stripe.android.cards.DefaultStaticCardAccountRanges @@ -23,7 +24,6 @@ import com.stripe.android.networking.PaymentAnalyticsRequestFactory import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.coroutines.CoroutineContext @@ -31,13 +31,15 @@ import kotlin.coroutines.CoroutineContext /** * A [StripeEditText] that handles spacing out the digits of a credit card. */ +@SuppressWarnings("LongParameterList") class CardNumberEditText internal constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = androidx.appcompat.R.attr.editTextStyle, // TODO(mshafrir-stripe): make immutable after `CardWidgetViewModel` is integrated in `CardWidget` subclasses - internal var workContext: CoroutineContext, + @VisibleForTesting + var workContext: CoroutineContext, private val cardAccountRangeRepository: CardAccountRangeRepository, private val staticCardAccountRanges: StaticCardAccountRanges = DefaultStaticCardAccountRanges(), @@ -103,15 +105,9 @@ class CardNumberEditText internal constructor( @JvmSynthetic internal var completionCallback: () -> Unit = {} - private var accountRange: AccountRange? = null - set(value) { - field = value - updateLengthFilter() - } - internal val panLength: Int - get() = accountRange?.panLength - ?: staticCardAccountRanges.first(unvalidatedCardNumber)?.panLength + get() = accountRangeService.accountRange?.panLength + ?: accountRangeService.staticCardAccountRanges.first(unvalidatedCardNumber)?.panLength ?: CardNumber.DEFAULT_PAN_LENGTH private val formattedPanLength: Int @@ -133,7 +129,17 @@ class CardNumberEditText internal constructor( get() = validatedCardNumber != null @VisibleForTesting - internal var accountRangeRepositoryJob: Job? = null + val accountRangeService = CardAccountRangeService( + cardAccountRangeRepository, + workContext, + staticCardAccountRanges, + object : CardAccountRangeService.AccountRangeResultListener { + override fun onAccountRangeResult(newAccountRange: AccountRange?) { + updateLengthFilter() + cardBrand = newAccountRange?.brand ?: CardBrand.Unknown + } + } + ) @JvmSynthetic internal var isLoadingCallback: (Boolean) -> Unit = {} @@ -181,7 +187,7 @@ class CardNumberEditText internal constructor( loadingJob?.cancel() loadingJob = null - cancelAccountRangeRepositoryJob() + accountRangeService.cancelAccountRangeRepositoryJob() super.onDetachedFromWindow() } @@ -233,49 +239,6 @@ class CardNumberEditText internal constructor( } } - @JvmSynthetic - internal fun queryAccountRangeRepository(cardNumber: CardNumber.Unvalidated) { - if (shouldQueryAccountRange(cardNumber)) { - // cancel in-flight job - cancelAccountRangeRepositoryJob() - - // invalidate accountRange before fetching - accountRange = null - - accountRangeRepositoryJob = CoroutineScope(workContext).launch { - val bin = cardNumber.bin - val accountRange = if (bin != null) { - cardAccountRangeRepository.getAccountRange(cardNumber) - } else { - null - } - - withContext(Dispatchers.Main) { - onAccountRangeResult(accountRange) - } - } - } - } - - private fun cancelAccountRangeRepositoryJob() { - accountRangeRepositoryJob?.cancel() - accountRangeRepositoryJob = null - } - - @JvmSynthetic - internal fun onAccountRangeResult( - newAccountRange: AccountRange? - ) { - accountRange = newAccountRange - cardBrand = newAccountRange?.brand ?: CardBrand.Unknown - } - - private fun shouldQueryAccountRange(cardNumber: CardNumber.Unvalidated): Boolean { - return accountRange == null || - cardNumber.bin == null || - accountRange?.binRange?.matches(cardNumber) == false - } - @JvmSynthetic internal fun onCardMetadataLoadedTooSlow() { analyticsRequestExecutor.executeAsync( @@ -304,21 +267,7 @@ class CardNumberEditText internal constructor( override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { val cardNumber = CardNumber.Unvalidated(s?.toString().orEmpty()) - val staticAccountRange = staticCardAccountRanges.filter(cardNumber) - .let { accountRanges -> - if (accountRanges.size == 1) { - accountRanges.first() - } else { - null - } - } - if (staticAccountRange == null || shouldQueryRepository(staticAccountRange)) { - // query for AccountRange data - queryAccountRangeRepository(cardNumber) - } else { - // use static AccountRange data - onAccountRangeResult(staticAccountRange) - } + accountRangeService.onCardNumberChanged(cardNumber) isPastedPan = isPastedPan(start, before, count, cardNumber) @@ -358,7 +307,7 @@ class CardNumberEditText internal constructor( isCardNumberValid = isValid shouldShowError = !isValid - if (accountRange == null && unvalidatedCardNumber.isValidLuhn) { + if (accountRangeService.accountRange == null && unvalidatedCardNumber.isValidLuhn) { // a complete PAN was inputted before the card service returned results onCardMetadataLoadedTooSlow() } @@ -396,7 +345,7 @@ class CardNumberEditText internal constructor( wasCardNumberValid: Boolean ) = !wasCardNumberValid && ( unvalidatedCardNumber.isMaxLength || - (isValid && accountRange != null) + (isValid && accountRangeService.accountRange != null) ) /** @@ -413,13 +362,5 @@ class CardNumberEditText internal constructor( return currentCount > previousCount && startPosition == 0 && cardNumber.normalized.length >= CardNumber.MIN_PAN_LENGTH } - - private fun shouldQueryRepository( - accountRange: AccountRange - ) = when (accountRange.brand) { - CardBrand.Unknown, - CardBrand.UnionPay -> true - else -> false - } } } diff --git a/payments-core/src/test/java/com/stripe/android/CardNumberFixtures.kt b/payments-core/src/test/java/com/stripe/android/CardNumberFixtures.kt index 06d9e3aec41..65842f3fa6c 100644 --- a/payments-core/src/test/java/com/stripe/android/CardNumberFixtures.kt +++ b/payments-core/src/test/java/com/stripe/android/CardNumberFixtures.kt @@ -49,4 +49,9 @@ internal object CardNumberFixtures { const val UNIONPAY_WITH_SPACES = "6200 0000 0000 0005" val UNIONPAY_BIN = UNIONPAY_NO_SPACES.take(6) val UNIONPAY = CardNumber.Unvalidated(UNIONPAY_NO_SPACES) + + const val UNIONPAY_19_NO_SPACES = "6200500000000000004" + const val UNIONPAY_19_WITH_SPACES = "6200 5000 0000 0000 004" + val UNIONPAY_19_BIN = UNIONPAY_19_NO_SPACES.take(6) + val UNIONPAY_19 = CardNumber.Unvalidated(UNIONPAY_19_NO_SPACES) } diff --git a/payments-core/src/test/java/com/stripe/android/cards/DefaultStaticCardAccountRangesTest.kt b/payments-core/src/test/java/com/stripe/android/cards/DefaultStaticCardAccountRangesTest.kt index a58787f28b2..5d667a6e860 100644 --- a/payments-core/src/test/java/com/stripe/android/cards/DefaultStaticCardAccountRangesTest.kt +++ b/payments-core/src/test/java/com/stripe/android/cards/DefaultStaticCardAccountRangesTest.kt @@ -13,7 +13,7 @@ class DefaultStaticCardAccountRangesTest { DefaultStaticCardAccountRanges().filter( CardNumber.Unvalidated("6") ) - ).hasSize(4) + ).hasSize(6) } @Test diff --git a/payments-core/src/test/java/com/stripe/android/utils/TestUtils.kt b/payments-core/src/test/java/com/stripe/android/utils/TestUtils.kt index 116d716835b..fcce6c707d8 100644 --- a/payments-core/src/test/java/com/stripe/android/utils/TestUtils.kt +++ b/payments-core/src/test/java/com/stripe/android/utils/TestUtils.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import org.robolectric.shadows.ShadowLooper.idleMainLooper -internal object TestUtils { +object TestUtils { @JvmStatic fun idleLooper() = idleMainLooper() diff --git a/payments-core/src/test/java/com/stripe/android/view/CardNumberEditTextTest.kt b/payments-core/src/test/java/com/stripe/android/view/CardNumberEditTextTest.kt index f115df7dadd..8feba59a5a9 100644 --- a/payments-core/src/test/java/com/stripe/android/view/CardNumberEditTextTest.kt +++ b/payments-core/src/test/java/com/stripe/android/view/CardNumberEditTextTest.kt @@ -136,7 +136,7 @@ internal class CardNumberEditTextTest { @Test fun calculateCursorPosition_whenAmEx_increasesIndexWhenGoingPastTheSpaces() = runTest { - cardNumberEditText.onAccountRangeResult( + cardNumberEditText.accountRangeService.updateAccountRangeResult( AccountRangeFixtures.AMERICANEXPRESS ) @@ -151,7 +151,7 @@ internal class CardNumberEditTextTest { @Test fun calculateCursorPosition_whenDinersClub16_decreasesIndexWhenDeletingPastTheSpaces() = runTest { - cardNumberEditText.onAccountRangeResult( + cardNumberEditText.accountRangeService.updateAccountRangeResult( AccountRangeFixtures.DINERSCLUB16 ) @@ -169,7 +169,7 @@ internal class CardNumberEditTextTest { @Test fun calculateCursorPosition_whenDeletingNotOnGaps_doesNotDecreaseIndex() = runTest { - cardNumberEditText.onAccountRangeResult( + cardNumberEditText.accountRangeService.updateAccountRangeResult( AccountRangeFixtures.DINERSCLUB14 ) @@ -181,7 +181,7 @@ internal class CardNumberEditTextTest { @Test fun calculateCursorPosition_whenAmEx_decreasesIndexWhenDeletingPastTheSpaces() = runTest { - cardNumberEditText.onAccountRangeResult( + cardNumberEditText.accountRangeService.updateAccountRangeResult( AccountRangeFixtures.AMERICANEXPRESS ) @@ -196,7 +196,7 @@ internal class CardNumberEditTextTest { @Test fun calculateCursorPosition_whenSelectionInTheMiddle_increasesIndexOverASpace() = runTest { - cardNumberEditText.onAccountRangeResult( + cardNumberEditText.accountRangeService.updateAccountRangeResult( AccountRangeFixtures.VISA ) @@ -669,18 +669,18 @@ internal class CardNumberEditTextTest { @Test fun `queryAccountRangeRepository() should update cardBrand value`() { - cardNumberEditText.queryAccountRangeRepository(CardNumberFixtures.DINERS_CLUB_14) + cardNumberEditText.accountRangeService.queryAccountRangeRepository(CardNumberFixtures.DINERS_CLUB_14) idleLooper() assertEquals(CardBrand.DinersClub, lastBrandChangeCallbackInvocation) - cardNumberEditText.queryAccountRangeRepository(CardNumberFixtures.AMEX) + cardNumberEditText.accountRangeService.queryAccountRangeRepository(CardNumberFixtures.AMEX) idleLooper() assertEquals(CardBrand.AmericanExpress, lastBrandChangeCallbackInvocation) } @Test fun `queryAccountRangeRepository() with null bin should set cardBrand to Unknown`() { - cardNumberEditText.queryAccountRangeRepository(CardNumber.Unvalidated("")) + cardNumberEditText.accountRangeService.queryAccountRangeRepository(CardNumber.Unvalidated("")) assertEquals(CardBrand.Unknown, lastBrandChangeCallbackInvocation) } @@ -705,11 +705,11 @@ internal class CardNumberEditTextTest { } cardNumberEditText.setText(UNIONPAY_NO_SPACES) - assertThat(cardNumberEditText.accountRangeRepositoryJob) + assertThat(cardNumberEditText.accountRangeService.accountRangeRepositoryJob) .isNotNull() root.removeView(cardNumberEditText) - assertThat(cardNumberEditText.accountRangeRepositoryJob) + assertThat(cardNumberEditText.accountRangeService.accountRangeRepositoryJob) .isNull() } } diff --git a/payments-ui-core/api/payments-ui-core.api b/payments-ui-core/api/payments-ui-core.api index 915675fc776..8bde91f158c 100644 --- a/payments-ui-core/api/payments-ui-core.api +++ b/payments-ui-core/api/payments-ui-core.api @@ -213,6 +213,11 @@ public final class com/stripe/android/ui/core/forms/BancontactSpecKt { public static final fun getBancontactParamKey ()Ljava/util/Map; } +public final class com/stripe/android/ui/core/forms/CardSpecKt { + public static final fun getCardForm ()Lcom/stripe/android/ui/core/elements/LayoutSpec; + public static final fun getCardParamKey ()Ljava/util/Map; +} + public final class com/stripe/android/ui/core/forms/EpsSpecKt { public static final fun getEpsForm ()Lcom/stripe/android/ui/core/elements/LayoutSpec; public static final fun getEpsParamKey ()Ljava/util/Map; @@ -255,7 +260,7 @@ public final class com/stripe/android/ui/core/forms/SofortSpecKt { public final class com/stripe/android/ui/core/forms/TransformSpecToElements { public static final field $stable I - public fun (Lcom/stripe/android/ui/core/forms/resources/ResourceRepository;Ljava/util/Map;Lcom/stripe/android/ui/core/Amount;Ljava/lang/String;ZLjava/lang/String;)V + public fun (Lcom/stripe/android/ui/core/forms/resources/ResourceRepository;Ljava/util/Map;Lcom/stripe/android/ui/core/Amount;Ljava/lang/String;ZLjava/lang/String;Landroid/content/Context;)V public final fun transform (Ljava/util/List;)Ljava/util/List; } diff --git a/payments-ui-core/build.gradle b/payments-ui-core/build.gradle index d4fbf415042..c2239fb7f1b 100644 --- a/payments-ui-core/build.gradle +++ b/payments-ui-core/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation project(":stripe-core") implementation project(":payments-core") + implementation 'androidx.constraintlayout:constraintlayout-compose:1.0.0' implementation "androidx.core:core-ktx:$androidxCoreVersion" implementation "androidx.appcompat:appcompat:$androidxAppcompatVersion" implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion" diff --git a/paymentsheet/res/values/totranslate.xml b/payments-ui-core/res/values/totranslate.xml similarity index 58% rename from paymentsheet/res/values/totranslate.xml rename to payments-ui-core/res/values/totranslate.xml index 20debac65ad..ba0d349623a 100644 --- a/paymentsheet/res/values/totranslate.xml +++ b/payments-ui-core/res/values/totranslate.xml @@ -3,4 +3,6 @@ + + Card information diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/address/TransformAddressToElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/address/TransformAddressToElement.kt index 5b87573c6e1..4793fdd6d9e 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/address/TransformAddressToElement.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/address/TransformAddressToElement.kt @@ -201,7 +201,7 @@ private fun isCityOrPostal(identifierSpec: IdentifierSpec) = identifierSpec == IdentifierSpec.City private fun getKeyboard(fieldSchema: FieldSchema?) = if (fieldSchema?.isNumeric == true) { - KeyboardType.Number + KeyboardType.NumberPassword } else { KeyboardType.Text } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressController.kt index e6b77a3b7b7..cd8dcaee49f 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressController.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressController.kt @@ -2,7 +2,6 @@ package com.stripe.android.ui.core.elements import androidx.annotation.RestrictTo import androidx.annotation.StringRes -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest @@ -12,7 +11,6 @@ import kotlinx.coroutines.flow.flatMapLatest * This is in contrast to the [SectionController] which is a section in which the fields * in it do not change. */ -@ExperimentalCoroutinesApi @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) class AddressController( val fieldsFlowable: Flow> diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressElement.kt index c59d7d079c5..b4aba807add 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressElement.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressElement.kt @@ -3,15 +3,14 @@ package com.stripe.android.ui.core.elements import androidx.annotation.RestrictTo import androidx.annotation.VisibleForTesting import com.stripe.android.ui.core.address.AddressFieldElementRepository -import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -@ExperimentalCoroutinesApi -class AddressElement constructor( +open class AddressElement constructor( _identifier: IdentifierSpec, private val addressFieldRepository: AddressFieldElementRepository, private var rawValuesMap: Map = emptyMap(), @@ -62,6 +61,17 @@ class AddressElement constructor( } } + override fun getTextFieldIdentifiers(): Flow> = fields.flatMapLatest { + combine( + it + .map { + it.getTextFieldIdentifiers() + } + ) { + it.toList().flatten() + } + } + override fun setRawValue(rawValuesMap: Map) { this.rawValuesMap = rawValuesMap } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressElementUI.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressElementUI.kt index 7cc9abbad8e..60d6cb3c033 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressElementUI.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressElementUI.kt @@ -12,14 +12,23 @@ import com.stripe.android.ui.core.PaymentsTheme @Composable internal fun AddressElementUI( enabled: Boolean, - controller: AddressController + controller: AddressController, + hiddenIdentifiers: List?, + lastTextFieldIdentifier: IdentifierSpec? ) { val fields by controller.fieldsFlowable.collectAsState(null) fields?.let { fieldList -> Column { fieldList.forEachIndexed { index, field -> - SectionFieldElementUI(enabled, field) - if (index != fieldList.size - 1) { + SectionFieldElementUI( + enabled, + field, + hiddenIdentifiers = hiddenIdentifiers, + lastTextFieldIdentifier = lastTextFieldIdentifier + ) + if ((hiddenIdentifiers?.contains(field.identifier) == false) && + (index != fieldList.size - 1) + ) { Divider( color = PaymentsTheme.colors.colorComponentBorder, thickness = PaymentsTheme.shapes.borderStrokeWidth, diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressSpec.kt index 09095611270..1ba88a7e59e 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressSpec.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AddressSpec.kt @@ -6,6 +6,7 @@ import kotlinx.parcelize.Parcelize @Parcelize internal data class AddressSpec( override val identifier: IdentifierSpec, + val countryCodes: Set ) : SectionFieldSpec(identifier) { fun transform( initialValues: Map, @@ -14,6 +15,7 @@ internal data class AddressSpec( AddressElement( IdentifierSpec.Generic("billing"), addressRepository, - initialValues + initialValues, + countryCodes = countryCodes ) } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AuBankAccountNumberConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AuBankAccountNumberConfig.kt index 060fafcf2f1..a2b51d00982 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AuBankAccountNumberConfig.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AuBankAccountNumberConfig.kt @@ -6,6 +6,8 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.VisualTransformation import com.stripe.android.ui.core.R +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow /** * A text field configuration for an AU bank account number @@ -16,6 +18,9 @@ class AuBankAccountNumberConfig : TextFieldConfig { override val debugLabel = "au_bank_account_number" override val visualTransformation: VisualTransformation? = null + override val trailingIcon: StateFlow = MutableStateFlow(null) + override val loading: StateFlow = MutableStateFlow(false) + @StringRes override val label = R.string.becs_widget_account_number override val keyboard = KeyboardType.Number diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AuBankAccountNumberSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AuBankAccountNumberSpec.kt index c3c99bf159d..1e9743836bc 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AuBankAccountNumberSpec.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/AuBankAccountNumberSpec.kt @@ -7,6 +7,6 @@ internal object AuBankAccountNumberSpec : SectionFieldSpec(IdentifierSpec.Generi fun transform(): SectionFieldElement = SimpleTextElement( this.identifier, - TextFieldController(AuBankAccountNumberConfig()) + SimpleTextFieldController(AuBankAccountNumberConfig()) ) } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BillingSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BillingSpec.kt index e52bc670ff5..0743f481c02 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BillingSpec.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BillingSpec.kt @@ -15,3 +15,24 @@ internal val billingParams: MutableMap = mutableMapOf( "email" to null, "phone" to null, ) + +// This comes from: stripe-js-v3/blob/master/src/lib/shared/checkoutSupportedCountries.js +internal val supportedBillingCountries = setOf( + "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AT", "AU", "AW", "AX", + "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", + "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CD", "CF", "CG", "CH", "CI", + "CK", "CL", "CM", "CN", "CO", "CR", "CV", "CW", "CY", "CZ", "DE", "DJ", "DK", "DM", + "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FO", "FR", + "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", + "GS", "GT", "GU", "GW", "GY", "HK", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", + "IN", "IO", "IQ", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", + "KN", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", + "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MK", "ML", "MM", "MN", "MO", "MQ", + "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NG", "NI", + "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", + "PM", "PN", "PR", "PS", "PT", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", + "SC", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", + "SV", "SX", "SZ", "TA", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", + "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "US", "UY", "UZ", "VA", "VC", "VE", + "VG", "VN", "VU", "WF", "WS", "XK", "YE", "YT", "ZA", "ZM", "ZW", +) diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BsbConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BsbConfig.kt index 2901042f767..150f63cb81d 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BsbConfig.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BsbConfig.kt @@ -10,6 +10,8 @@ import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import com.stripe.android.ui.core.R import com.stripe.android.view.BecsDebitBanks +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow /** * A text field configuration for a BSB number, or Bank State Branch Number, @@ -20,6 +22,9 @@ class BsbConfig(private val Banks: List) : TextFieldConfig override val capitalization: KeyboardCapitalization = KeyboardCapitalization.None override val debugLabel = "bsb" + override val trailingIcon: StateFlow = MutableStateFlow(null) + override val loading: StateFlow = MutableStateFlow(false) + @StringRes override val label = R.string.becs_widget_bsb override val keyboard = KeyboardType.Number diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BsbSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BsbSpec.kt index 0514788afe4..a5b0c6d6fae 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BsbSpec.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/BsbSpec.kt @@ -8,7 +8,7 @@ internal object BsbSpec : SectionFieldSpec(IdentifierSpec.Generic("bsb_number")) fun transform(): SectionFieldElement = SimpleTextElement( this.identifier, - TextFieldController(BsbConfig(banks)) + SimpleTextFieldController(BsbConfig(banks)) ) } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardBillingAddressElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardBillingAddressElement.kt new file mode 100644 index 00000000000..68d8150d353 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardBillingAddressElement.kt @@ -0,0 +1,45 @@ +package com.stripe.android.ui.core.elements + +import androidx.annotation.RestrictTo +import com.stripe.android.ui.core.address.AddressFieldElementRepository +import com.stripe.android.ui.core.address.FieldType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * This is a special type of AddressElement that + * removes fields from the address based on the country. It + * is only intended to be used with the card payment method. + */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +class CardBillingAddressElement( + identifier: IdentifierSpec, + addressFieldRepository: AddressFieldElementRepository, + countryCodes: Set = emptySet(), + countryDropdownFieldController: DropdownFieldController = DropdownFieldController( + CountryConfig(countryCodes) + ), + rawValuesMap: Map = emptyMap(), +) : AddressElement( + identifier, + addressFieldRepository, + rawValuesMap, + countryCodes, + countryDropdownFieldController +) { + // Save for future use puts this in the controller rather than element + val hiddenIdentifiers: Flow> = + countryDropdownFieldController.rawFieldValue.map { countryCode -> + when (countryCode) { + "US", "GB", "CA" -> { + FieldType.values() + .filterNot { it == FieldType.PostalCode } + .map { it.identifierSpec } + } + else -> { + FieldType.values() + .map { it.identifierSpec } + } + } + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardBillingSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardBillingSpec.kt new file mode 100644 index 00000000000..7d0a51ceeed --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardBillingSpec.kt @@ -0,0 +1,18 @@ +package com.stripe.android.ui.core.elements + +import com.stripe.android.ui.core.address.AddressFieldElementRepository +import kotlinx.parcelize.Parcelize + +@Parcelize +internal data class CardBillingSpec( + override val identifier: IdentifierSpec = IdentifierSpec.Generic("card_billing"), + val countryCodes: Set +) : SectionFieldSpec(identifier) { + fun transform( + addressRepository: AddressFieldElementRepository + ) = CardBillingAddressElement( + IdentifierSpec.Generic("credit_billing"), + addressRepository, + countryCodes = countryCodes + ) +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsController.kt new file mode 100644 index 00000000000..e4db5cd2a80 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsController.kt @@ -0,0 +1,42 @@ +package com.stripe.android.ui.core.elements + +import android.content.Context +import kotlinx.coroutines.flow.combine +import java.util.UUID + +internal class CardDetailsController constructor(context: Context) : SectionFieldErrorController { + + val label: Int? = null + val numberElement = CardNumberElement( + IdentifierSpec.Generic("number"), + CardNumberController(CardNumberConfig(), context) + ) + + val cvcElement = CvcElement( + IdentifierSpec.Generic("cvc"), + CvcController(CvcConfig(), numberElement.controller.cardBrandFlow) + ) + + val expirationDateElement = SimpleTextElement( + IdentifierSpec.Generic("date"), + SimpleTextFieldController(DateConfig()) + ) + + private val rowFields = listOf(expirationDateElement, cvcElement) + val fields = listOf( + numberElement, + RowElement( + IdentifierSpec.Generic("row_" + UUID.randomUUID().leastSignificantBits), + rowFields, + RowController(rowFields) + ) + ) + + override val error = combine( + listOf(numberElement, expirationDateElement, cvcElement) + .map { it.controller } + .map { it.error } + ) { + it.filterNotNull().firstOrNull() + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsElement.kt new file mode 100644 index 00000000000..c506b10a986 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsElement.kt @@ -0,0 +1,59 @@ +package com.stripe.android.ui.core.elements + +import android.content.Context +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine + +/** + * This is the element that represent the collection of all the card details: + * card number, expiration date, and CVC. + */ +internal class CardDetailsElement( + identifier: IdentifierSpec, + context: Context, + val controller: CardDetailsController = CardDetailsController(context), +) : SectionMultiFieldElement(identifier) { + override fun sectionFieldErrorController(): SectionFieldErrorController = + controller + + override fun setRawValue(rawValuesMap: Map) { + // Nothing from formFragmentArguments to populate + } + + override fun getTextFieldIdentifiers(): Flow> = + MutableStateFlow( + listOf( + controller.numberElement.identifier, + controller.expirationDateElement.identifier, + controller.cvcElement.identifier + ) + ) + + override fun getFormFieldValueFlow() = combine( + controller.numberElement.controller.formFieldValue, + controller.cvcElement.controller.formFieldValue, + controller.expirationDateElement.controller.formFieldValue + ) { number, cvc, expirationDate -> + var month = -1 + var year = -1 + expirationDate.value?.let { date -> + val newString = convertTo4DigitDate(date) + if (newString.length == 4) { + month = requireNotNull(newString.take(2).toIntOrNull()) + year = requireNotNull(newString.takeLast(2).toIntOrNull()) + } + } + + listOf( + controller.numberElement.identifier to number, + controller.cvcElement.identifier to cvc, + IdentifierSpec.Generic("exp_month") to expirationDate.copy( + value = month.toString() + ), + IdentifierSpec.Generic("exp_year") to expirationDate.copy( + value = year.toString() + ) + ) + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsElementUI.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsElementUI.kt new file mode 100644 index 00000000000..1f97d8182f8 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsElementUI.kt @@ -0,0 +1,31 @@ +package com.stripe.android.ui.core.elements + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Divider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.stripe.android.ui.core.PaymentsTheme + +@Composable +internal fun CardDetailsElementUI( + enabled: Boolean, + controller: CardDetailsController, + hiddenIdentifiers: List?, + lastTextFieldIdentifier: IdentifierSpec? +) { + controller.fields.forEachIndexed { index, field -> + SectionFieldElementUI( + enabled, + field, + hiddenIdentifiers = hiddenIdentifiers, + lastTextFieldIdentifier = lastTextFieldIdentifier + ) + Divider( + color = PaymentsTheme.colors.colorComponentBorder, + thickness = PaymentsTheme.shapes.borderStrokeWidth, + modifier = Modifier.padding( + horizontal = PaymentsTheme.shapes.borderStrokeWidth + ) + ) + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSpec.kt new file mode 100644 index 00000000000..2babfe51302 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsSpec.kt @@ -0,0 +1,11 @@ +package com.stripe.android.ui.core.elements + +import android.content.Context +import kotlinx.parcelize.Parcelize + +@Parcelize +internal object CardDetailsSpec : SectionFieldSpec(IdentifierSpec.Generic("card_details")) { + fun transform(context: Context): SectionFieldElement = CardDetailsElement( + IdentifierSpec.Generic("credit_detail"), context + ) +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsTextFieldConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsTextFieldConfig.kt new file mode 100644 index 00000000000..8f6829d583e --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardDetailsTextFieldConfig.kt @@ -0,0 +1,22 @@ +package com.stripe.android.ui.core.elements + +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import com.stripe.android.model.CardBrand + +/** + * This is similar to the [TextFieldConfig], but in order to determine + * the state the card brand is required. + */ +internal interface CardDetailsTextFieldConfig { + val capitalization: KeyboardCapitalization + val debugLabel: String + val label: Int + val keyboard: KeyboardType + val visualTransformation: VisualTransformation + fun determineState(brand: CardBrand, number: String, numberAllowedDigits: Int): TextFieldState + fun filter(userTyped: String): String + fun convertToRaw(displayName: String): String + fun convertFromRaw(rawValue: String): String +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberConfig.kt new file mode 100644 index 00000000000..2c1caf8a5f8 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberConfig.kt @@ -0,0 +1,41 @@ +package com.stripe.android.ui.core.elements + +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import com.stripe.android.CardUtils +import com.stripe.android.model.CardBrand +import com.stripe.android.ui.core.R + +internal class CardNumberConfig : CardDetailsTextFieldConfig { + override val capitalization: KeyboardCapitalization = KeyboardCapitalization.None + override val debugLabel: String = "Card number" + override val label: Int = R.string.acc_label_card_number + override val keyboard: KeyboardType = KeyboardType.NumberPassword + override val visualTransformation: VisualTransformation = CardNumberVisualTransformation(' ') + + override fun determineState(brand: CardBrand, number: String, numberAllowedDigits: Int): TextFieldState { + val luhnValid = CardUtils.isValidLuhnNumber(number) + val isDigitLimit = brand.getMaxLengthForCardNumber(number) != -1 + + return if (number.isBlank()) { + TextFieldStateConstants.Error.Blank + } else if (brand == CardBrand.Unknown) { + TextFieldStateConstants.Error.Invalid(R.string.invalid_card_number) + } else if (isDigitLimit && number.length < numberAllowedDigits) { + TextFieldStateConstants.Error.Incomplete(R.string.invalid_card_number) + } else if (!luhnValid) { + TextFieldStateConstants.Error.Invalid(R.string.invalid_card_number) + } else if (isDigitLimit && number.length == numberAllowedDigits) { + TextFieldStateConstants.Valid.Full + } else { + TextFieldStateConstants.Error.Invalid(R.string.invalid_card_number) + } + } + + override fun filter(userTyped: String): String = userTyped.filter { it.isDigit() } + + override fun convertToRaw(displayName: String): String = displayName + + override fun convertFromRaw(rawValue: String): String = rawValue +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberController.kt new file mode 100644 index 00000000000..f756e81758f --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberController.kt @@ -0,0 +1,141 @@ +package com.stripe.android.ui.core.elements + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import com.stripe.android.cards.CardAccountRangeRepository +import com.stripe.android.cards.CardAccountRangeService +import com.stripe.android.cards.CardNumber +import com.stripe.android.cards.DefaultCardAccountRangeRepositoryFactory +import com.stripe.android.cards.DefaultStaticCardAccountRanges +import com.stripe.android.cards.StaticCardAccountRanges +import com.stripe.android.model.AccountRange +import com.stripe.android.model.CardBrand +import com.stripe.android.ui.core.forms.FormFieldEntry +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlin.coroutines.CoroutineContext + +internal class CardNumberController constructor( + private val cardTextFieldConfig: CardNumberConfig, + cardAccountRangeRepository: CardAccountRangeRepository, + workContext: CoroutineContext, + staticCardAccountRanges: StaticCardAccountRanges = DefaultStaticCardAccountRanges(), + override val showOptionalLabel: Boolean = false +) : TextFieldController, SectionFieldErrorController { + + @JvmOverloads + constructor( + cardTextFieldConfig: CardNumberConfig, + context: Context + ) : this( + cardTextFieldConfig, + DefaultCardAccountRangeRepositoryFactory(context).create(), + Dispatchers.IO + ) + + override val capitalization: KeyboardCapitalization = cardTextFieldConfig.capitalization + override val keyboardType: KeyboardType = cardTextFieldConfig.keyboard + override val visualTransformation = cardTextFieldConfig.visualTransformation + override val debugLabel = cardTextFieldConfig.debugLabel + + override val label: Flow = MutableStateFlow(cardTextFieldConfig.label) + + private val _fieldValue = MutableStateFlow("") + override val fieldValue: Flow = _fieldValue + + override val rawFieldValue: Flow = + _fieldValue.map { cardTextFieldConfig.convertToRaw(it) } + + override val contentDescription: Flow = _fieldValue + + internal val cardBrandFlow = _fieldValue.map { + accountRangeService.accountRange?.brand ?: CardBrand.getCardBrands(it).firstOrNull() ?: CardBrand.Unknown + } + + override val trailingIcon: Flow = _fieldValue.map { + val cardBrands = CardBrand.getCardBrands(it) + if (accountRangeService.accountRange != null) { + TextFieldIcon(accountRangeService.accountRange!!.brand.icon, isIcon = false) + } else if (cardBrands.size == 1) { + TextFieldIcon(cardBrands.first().icon, isIcon = false) + } else { + TextFieldIcon(CardBrand.Unknown.icon, isIcon = false) + } + } + + private val _fieldState = combine(cardBrandFlow, _fieldValue) { brand, fieldValue -> + cardTextFieldConfig.determineState( + brand, + fieldValue, + accountRangeService.accountRange?.panLength ?: brand.getMaxLengthForCardNumber(fieldValue) + ) + } + override val fieldState: Flow = _fieldState + + private val _hasFocus = MutableStateFlow(false) + + @VisibleForTesting + val accountRangeService = CardAccountRangeService( + cardAccountRangeRepository, + workContext, + staticCardAccountRanges, + object : CardAccountRangeService.AccountRangeResultListener { + override fun onAccountRangeResult(newAccountRange: AccountRange?) { + newAccountRange?.panLength?.let { panLength -> + (visualTransformation as CardNumberVisualTransformation).binBasedMaxPan = panLength + } + } + } + ) + + override val loading: Flow = accountRangeService.isLoading + + override val visibleError: Flow = + combine(_fieldState, _hasFocus) { fieldState, hasFocus -> + fieldState.shouldShowError(hasFocus) + } + + /** + * An error must be emitted if it is visible or not visible. + **/ + override val error: Flow = + combine(visibleError, _fieldState) { visibleError, fieldState -> + fieldState.getError()?.takeIf { visibleError } + } + + override val isComplete: Flow = _fieldState.map { it.isValid() } + + override val formFieldValue: Flow = + combine(isComplete, rawFieldValue) { complete, value -> + FormFieldEntry(value, complete) + } + + init { + onValueChange("") + } + + /** + * This is called when the value changed to is a display value. + */ + override fun onValueChange(displayFormatted: String) { + _fieldValue.value = cardTextFieldConfig.filter(displayFormatted) + val cardNumber = CardNumber.Unvalidated(displayFormatted) + accountRangeService.onCardNumberChanged(cardNumber) + } + + /** + * This is called when the value changed to is a raw backing value, not a display value. + */ + override fun onRawValueChange(rawValue: String) { + onValueChange(cardTextFieldConfig.convertFromRaw(rawValue)) + } + + override fun onFocusChange(newHasFocus: Boolean) { + _hasFocus.value = newHasFocus + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberElement.kt new file mode 100644 index 00000000000..8521a6b67be --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberElement.kt @@ -0,0 +1,10 @@ +package com.stripe.android.ui.core.elements + +internal data class CardNumberElement( + val _identifier: IdentifierSpec, + override val controller: CardNumberController, +) : SectionSingleFieldElement(_identifier) { + override fun setRawValue(rawValuesMap: Map) { + // Nothing from formFragmentArguments to populate + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberVisualTransformation.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberVisualTransformation.kt new file mode 100644 index 00000000000..b35cf8842d2 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberVisualTransformation.kt @@ -0,0 +1,132 @@ +package com.stripe.android.ui.core.elements + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import com.stripe.android.model.CardBrand + +internal class CardNumberVisualTransformation(private val separator: Char) : + VisualTransformation { + + internal var binBasedMaxPan: Int? = null + + override fun filter(text: AnnotatedString): TransformedText { + val cardBrand = CardBrand.fromCardNumber(text.text) + val panLength = binBasedMaxPan ?: cardBrand.getMaxLengthForCardNumber(text.text) + return if (panLength == 14 || panLength == 15) { + space4and11(text) + } else if (panLength == 16) { + space4and9and14(text) + } else if (panLength == 19) { + space4and9and14and19(text) + } else { + space4and9and14(text) + } + } + + private fun space4and11(text: AnnotatedString): TransformedText { + var out = "" + for (i in text.indices) { + out += text[i] + if (i == 3 || i == 9) out += separator + } + + /** + * The offset translator should ignore the hyphen characters, so conversion from + * original offset to transformed text works like + * - The 4th char of the original text is 5th char in the transformed text. + * - The 13th char of the original text is 15th char in the transformed text. + * Similarly, the reverse conversion works like + * - The 5th char of the transformed text is 4th char in the original text. + * - The 12th char of the transformed text is 10th char in the original text. + */ + val creditCardOffsetTranslator = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (offset <= 3) return offset + if (offset <= 9) return offset + 1 + return offset + 2 + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset <= 4) return offset + if (offset <= 11) return offset - 1 + return offset - 2 + } + } + + return TransformedText(AnnotatedString(out), creditCardOffsetTranslator) + } + + private fun space4and9and14(text: AnnotatedString): TransformedText { + var out = "" + for (i in text.indices) { + out += text[i] + if (i % 4 == 3 && i < 15) out += separator + } + + /** + * The offset translator should ignore the hyphen characters, so conversion from + * original offset to transformed text works like + * - The 4th char of the original text is 5th char in the transformed text. + * - The 13th char of the original text is 15th char in the transformed text. + * Similarly, the reverse conversion works like + * - The 5th char of the transformed text is 4th char in the original text. + * - The 12th char of the transformed text is 10th char in the original text. + */ + val creditCardOffsetTranslator = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (offset <= 3) return offset + if (offset <= 7) return offset + 1 + if (offset <= 11) return offset + 2 + return offset + 3 + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset <= 4) return offset + if (offset <= 9) return offset - 1 + if (offset <= 14) return offset - 2 + return offset - 3 + } + } + + return TransformedText(AnnotatedString(out), creditCardOffsetTranslator) + } + + private fun space4and9and14and19(text: AnnotatedString): TransformedText { + var out = "" + for (i in text.indices) { + out += text[i] + if (i % 4 == 3 && i < 19) out += separator + } + + /** + * The offset translator should ignore the hyphen characters, so conversion from + * original offset to transformed text works like + * - The 4th char of the original text is 5th char in the transformed text. + * - The 13th char of the original text is 15th char in the transformed text. + * Similarly, the reverse conversion works like + * - The 5th char of the transformed text is 4th char in the original text. + * - The 12th char of the transformed text is 10th char in the original text. + */ + val creditCardOffsetTranslator = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + if (offset <= 3) return offset + if (offset <= 7) return offset + 1 + if (offset <= 11) return offset + 2 + if (offset <= 15) return offset + 3 + return offset + 4 + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset <= 4) return offset + if (offset <= 9) return offset - 1 + if (offset <= 14) return offset - 2 + if (offset <= 19) return offset - 3 + return offset - 4 + } + } + + return TransformedText(AnnotatedString(out), creditCardOffsetTranslator) + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcConfig.kt new file mode 100644 index 00000000000..c5d60718cf0 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcConfig.kt @@ -0,0 +1,45 @@ +package com.stripe.android.ui.core.elements + +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import com.stripe.android.model.CardBrand +import com.stripe.android.ui.core.R + +internal class CvcConfig : CardDetailsTextFieldConfig { + override val capitalization: KeyboardCapitalization = KeyboardCapitalization.None + override val debugLabel: String = "cvc" + override val label: Int = R.string.cvc_number_hint + override val keyboard: KeyboardType = KeyboardType.NumberPassword + override val visualTransformation: VisualTransformation = VisualTransformation.None + + override fun determineState( + brand: CardBrand, + number: String, + numberAllowedDigits: Int + ): TextFieldState { + val isDigitLimit = brand.maxCvcLength != -1 + return if (number.isEmpty()) { + TextFieldStateConstants.Error.Blank + } else if (brand == CardBrand.Unknown) { + when (number.length) { + numberAllowedDigits -> TextFieldStateConstants.Valid.Full + else -> TextFieldStateConstants.Valid.Limitless + } + } else if (isDigitLimit && number.length < numberAllowedDigits) { + TextFieldStateConstants.Error.Incomplete(R.string.invalid_cvc) + } else if (isDigitLimit && number.length > numberAllowedDigits) { + TextFieldStateConstants.Error.Invalid(R.string.invalid_cvc) + } else if (isDigitLimit && number.length == numberAllowedDigits) { + TextFieldStateConstants.Valid.Full + } else { + TextFieldStateConstants.Error.Invalid(R.string.invalid_cvc) + } + } + + override fun filter(userTyped: String): String = userTyped.filter { it.isDigit() } + + override fun convertToRaw(displayName: String): String = displayName + + override fun convertFromRaw(rawValue: String): String = rawValue +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcController.kt new file mode 100644 index 00000000000..181750f35ff --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcController.kt @@ -0,0 +1,96 @@ +package com.stripe.android.ui.core.elements + +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import com.stripe.android.model.CardBrand +import com.stripe.android.ui.core.R +import com.stripe.android.ui.core.forms.FormFieldEntry +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map + +internal class CvcController constructor( + private val cvcTextFieldConfig: CvcConfig, + cardBrandFlow: Flow, + override val showOptionalLabel: Boolean = false +) : TextFieldController, SectionFieldErrorController { + override val capitalization: KeyboardCapitalization = cvcTextFieldConfig.capitalization + override val keyboardType: KeyboardType = cvcTextFieldConfig.keyboard + override val visualTransformation = cvcTextFieldConfig.visualTransformation + + private val _label = cardBrandFlow.map { cardBrand -> + if (cardBrand == CardBrand.AmericanExpress) { + R.string.cvc_amex_hint + } else { + R.string.cvc_number_hint + } + } + override val label: Flow = _label + + override val debugLabel = cvcTextFieldConfig.debugLabel + + private val _fieldValue = MutableStateFlow("") + override val fieldValue: Flow = _fieldValue + + override val rawFieldValue: Flow = + _fieldValue.map { cvcTextFieldConfig.convertToRaw(it) } + + // This makes the screen reader read out numbers digit by digit + override val contentDescription: Flow = _fieldValue.map { it.replace("\\d".toRegex(), "$0 ") } + + private val _fieldState = combine(cardBrandFlow, _fieldValue) { brand, fieldValue -> + cvcTextFieldConfig.determineState(brand, fieldValue, brand.maxCvcLength) + } + override val fieldState: Flow = _fieldState + + private val _hasFocus = MutableStateFlow(false) + + override val visibleError: Flow = + combine(_fieldState, _hasFocus) { fieldState, hasFocus -> + fieldState.shouldShowError(hasFocus) + } + + /** + * An error must be emitted if it is visible or not visible. + **/ + override val error: Flow = + combine(visibleError, _fieldState) { visibleError, fieldState -> + fieldState.getError()?.takeIf { visibleError } + } + + override val isComplete: Flow = _fieldState.map { it.isValid() } + + override val formFieldValue: Flow = + combine(isComplete, rawFieldValue) { complete, value -> + FormFieldEntry(value, complete) + } + + override val trailingIcon: Flow = cardBrandFlow.map { + TextFieldIcon(it.cvcIcon, isIcon = false) + } + + override val loading: Flow = MutableStateFlow(false) + + init { + onValueChange("") + } + + /** + * This is called when the value changed to is a display value. + */ + override fun onValueChange(displayFormatted: String) { + _fieldValue.value = cvcTextFieldConfig.filter(displayFormatted) + } + + /** + * This is called when the value changed to is a raw backing value, not a display value. + */ + override fun onRawValueChange(rawValue: String) { + onValueChange(cvcTextFieldConfig.convertFromRaw(rawValue)) + } + + override fun onFocusChange(newHasFocus: Boolean) { + _hasFocus.value = newHasFocus + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcElement.kt new file mode 100644 index 00000000000..a3d00f3cfea --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcElement.kt @@ -0,0 +1,10 @@ +package com.stripe.android.ui.core.elements + +internal data class CvcElement( + val _identifier: IdentifierSpec, + override val controller: CvcController, +) : SectionSingleFieldElement(_identifier) { + override fun setRawValue(rawValuesMap: Map) { + // Nothing from formFragmentArguments to populate + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DateConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DateConfig.kt new file mode 100644 index 00000000000..af6ed7d0435 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DateConfig.kt @@ -0,0 +1,79 @@ +package com.stripe.android.ui.core.elements + +import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import com.stripe.android.ui.core.R +import com.stripe.android.ui.core.elements.TextFieldStateConstants.Error +import com.stripe.android.ui.core.elements.TextFieldStateConstants.Valid +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.util.Calendar + +internal class DateConfig : TextFieldConfig { + override val capitalization: KeyboardCapitalization = KeyboardCapitalization.None + override val debugLabel = "date" + + @StringRes + override val label = R.string.stripe_paymentsheet_expiration_date_hint + override val keyboard = KeyboardType.NumberPassword + override val visualTransformation = ExpiryDateVisualTransformation() + override val trailingIcon: StateFlow = MutableStateFlow(null) + override val loading: StateFlow = MutableStateFlow(false) + + override fun filter(userTyped: String) = userTyped.filter { it.isDigit() } + + override fun convertToRaw(displayName: String) = displayName + + override fun convertFromRaw(rawValue: String) = rawValue + + override fun determineState(input: String): TextFieldState { + return if (input.isBlank()) { + Error.Blank + } else { + val newString = convertTo4DigitDate(input) + when { + newString.length < 4 -> { + Error.Incomplete(R.string.incomplete_expiry_date) + } + newString.length > 4 -> { + Error.Invalid(R.string.incomplete_expiry_date) + } + else -> { + return determineTextFieldState( + requireNotNull(newString.take(2).toIntOrNull()), + requireNotNull(newString.takeLast(2).toIntOrNull()), + // Calendar.getInstance().get(Calendar.MONTH) is 0-based, so add 1 + Calendar.getInstance().get(Calendar.MONTH) + 1, + Calendar.getInstance().get(Calendar.YEAR) + ) + } + } + } + } + + companion object { + @VisibleForTesting + fun determineTextFieldState( + month1Based: Int, + twoDigitYear: Int, + current1BasedMonth: Int, + currentYear: Int + ): TextFieldState { + val twoDigitCurrentYear = currentYear % 100 + + return if ((twoDigitYear - twoDigitCurrentYear) < 0) { + Error.Invalid(R.string.invalid_expiry_year) + } else if ((twoDigitYear - twoDigitCurrentYear) > 50) { + Error.Invalid(R.string.invalid_expiry_year) + } else if ((twoDigitYear - twoDigitCurrentYear) == 0 && current1BasedMonth > month1Based) { + Error.Invalid(R.string.invalid_expiry_month) + } else if (month1Based !in 1..12) { + Error.Incomplete(R.string.invalid_expiry_month) + } else { + Valid.Full + } + } + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DateUtils.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DateUtils.kt new file mode 100644 index 00000000000..ff4271205f4 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DateUtils.kt @@ -0,0 +1,7 @@ +package com.stripe.android.ui.core.elements + +internal fun convertTo4DigitDate(input: String) = + "0$input".takeIf { + (input.isNotBlank() && !(input[0] == '0' || input[0] == '1')) || + ((input.length > 1) && (input[0] == '1' && requireNotNull(input[1].digitToInt()) > 2)) + } ?: input diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DropdownFieldController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DropdownFieldController.kt index 2827e3707f4..07a7cc2d057 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DropdownFieldController.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DropdownFieldController.kt @@ -20,7 +20,7 @@ class DropdownFieldController( val displayItems: List = config.getDisplayItems() private val _selectedIndex = MutableStateFlow(0) val selectedIndex: Flow = _selectedIndex - override val label: Int = config.label + override val label: Flow = MutableStateFlow(config.label) override val fieldValue = selectedIndex.map { displayItems[it] } override val rawFieldValue = fieldValue.map { config.convertToRaw(it) } override val error: Flow = MutableStateFlow(null) diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DropdownFieldUI.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DropdownFieldUI.kt index 525654ec26e..a1b0c227697 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DropdownFieldUI.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/DropdownFieldUI.kt @@ -1,23 +1,26 @@ package com.stripe.android.ui.core.elements -import androidx.annotation.StringRes +import DropdownMenu import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSizeIn +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.material.DropdownMenu -import androidx.compose.material.DropdownMenuItem +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -26,17 +29,41 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.InputMode +import androidx.compose.ui.platform.LocalInputModeManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.stripe.android.ui.core.PaymentsTheme import com.stripe.android.ui.core.R +import com.stripe.android.ui.core.elements.menu.DropdownMenuItemDefaultMaxWidth +import com.stripe.android.ui.core.elements.menu.DropdownMenuItemDefaultMinHeight +import com.stripe.android.ui.core.elements.menu.DropdownMenuItemDefaultMinWidth +import kotlin.math.max +import kotlin.math.min +/** + * This composable will handle the display of dropdown items + * in a lazy column. + * + * Here are some relevant manual tests: + * - Short list of dropdown items + * - long list of dropdown items + * - Varying width of dropdown item + * - Display setting very large + * - Whole row is clickable, not just text + * - Scrolls to the selected item in the list + */ @Composable internal fun DropDown( - @StringRes label: Int, controller: DropdownFieldController, enabled: Boolean, ) { + val label by controller.label.collectAsState( + null + ) val selectedIndex by controller.selectedIndex.collectAsState(0) val items = controller.displayItems var expanded by remember { mutableStateOf(false) } @@ -50,6 +77,7 @@ internal fun DropDown( .value } + val inputModeManager = LocalInputModeManager.current Box( modifier = Modifier .wrapContentSize(Alignment.TopStart) @@ -58,6 +86,9 @@ internal fun DropDown( // Click handling happens on the box, so that it is a single accessible item Box( modifier = Modifier + .focusProperties { + canFocus = inputModeManager.inputMode != InputMode.Touch + } .clickable( enabled = enabled, onClickLabel = stringResource(R.string.change), @@ -72,7 +103,9 @@ internal fun DropDown( bottom = 8.dp ) ) { - FormLabel(stringResource(label), enabled) + label?.let { + FormLabel(stringResource(it), enabled) + } Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Bottom @@ -94,23 +127,89 @@ internal fun DropDown( DropdownMenu( expanded = expanded, + + // We will show up to two items before + initialFirstVisibleItemIndex = if (selectedIndex >= 1) { + min( + max(selectedIndex - 2, 0), + max(selectedIndex - 1, 0) + ) + } else { + selectedIndex + }, onDismissRequest = { expanded = false }, - modifier = Modifier.background(color = PaymentsTheme.colors.component) + modifier = Modifier + .background(color = PaymentsTheme.colors.component) + .width(DropdownMenuItemDefaultMaxWidth) + .requiredSizeIn(maxHeight = DropdownMenuItemDefaultMinHeight * 8.9f) ) { - items.forEachIndexed { index, displayValue -> + itemsIndexed(items) { index, displayValue -> DropdownMenuItem( + displayValue = displayValue, + isSelected = index == selectedIndex, + currentTextColor = currentTextColor, onClick = { - controller.onValueChange(index) expanded = false + controller.onValueChange(index) } - ) { - Text( - text = displayValue, - color = currentTextColor, - style = PaymentsTheme.typography.body1 - ) - } + ) + } + } + } +} + +@Composable +internal fun DropdownMenuItem( + displayValue: String, + isSelected: Boolean, + currentTextColor: Color, + onClick: () -> Unit = {} +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + modifier = Modifier + .fillMaxWidth() + .requiredSizeIn( + minWidth = DropdownMenuItemDefaultMinWidth, + minHeight = DropdownMenuItemDefaultMinHeight + ) + .clickable { + onClick() } + ) { + Text( + text = displayValue, + modifier = Modifier + // This padding makes up for the checkmark at the end. + .padding( + horizontal = if (isSelected) { + 13.dp + } else { + 0.dp + } + ) + .fillMaxWidth(.8f), + color = if (isSelected) { + PaymentsTheme.colors.material.primary + } else { + currentTextColor + }, + fontWeight = if (isSelected) { + FontWeight.Bold + } else { + FontWeight.Normal + } + ) + + if (isSelected) { + Icon( + Icons.Filled.Check, + contentDescription = null, + modifier = Modifier + .height(24.dp), + tint = PaymentsTheme.colors.material.primary + ) } } } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/EmailConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/EmailConfig.kt index 7378963d494..ac290b6737f 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/EmailConfig.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/EmailConfig.kt @@ -8,6 +8,8 @@ import androidx.compose.ui.text.input.VisualTransformation import com.stripe.android.ui.core.R import com.stripe.android.ui.core.elements.TextFieldStateConstants.Error import com.stripe.android.ui.core.elements.TextFieldStateConstants.Valid +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import java.util.regex.Pattern @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @@ -19,6 +21,8 @@ class EmailConfig : TextFieldConfig { override val label = R.string.email override val keyboard = KeyboardType.Email override val visualTransformation: VisualTransformation? = null + override val trailingIcon: MutableStateFlow = MutableStateFlow(null) + override val loading: StateFlow = MutableStateFlow(false) /** * This will allow all characters, but will show as invalid if it doesn't match diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/EmailSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/EmailSpec.kt index e542056187d..d79b64613f6 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/EmailSpec.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/EmailSpec.kt @@ -9,6 +9,6 @@ object EmailSpec : SectionFieldSpec(IdentifierSpec.Email) { fun transform(email: String?): SectionFieldElement = EmailElement( this.identifier, - TextFieldController(EmailConfig(), initialValue = email), + SimpleTextFieldController(EmailConfig(), initialValue = email), ) } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/ExpiryDateVisualTransformation.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/ExpiryDateVisualTransformation.kt new file mode 100644 index 00000000000..c7c10553a85 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/ExpiryDateVisualTransformation.kt @@ -0,0 +1,54 @@ +package com.stripe.android.ui.core.elements + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +internal class ExpiryDateVisualTransformation : VisualTransformation { + private val separator = " / " + + override fun filter(text: AnnotatedString): TransformedText { + + /** + * Depending on the first number is where the separator will be placed + * If the first number is 2-9 then the slash will come after the + * 2, if the first number is 11 or 12 it will be after the second digit, + * if the number is 01 it will be after the second digit. + */ + var separatorAfterIndex = 1 + if (text.isNotBlank() && !(text[0] == '0' || text[0] == '1')) { + separatorAfterIndex = 0 + } else if (text.length > 1 && + (text[0] == '1' && requireNotNull(text[1].digitToInt()) > 2) + ) { + separatorAfterIndex = 0 + } + + var out = "" + for (i in text.indices) { + out += text[i] + if (i == separatorAfterIndex) { + out += separator + } + } + + val offsetTranslator = object : OffsetMapping { + override fun originalToTransformed(offset: Int) = + if (offset <= separatorAfterIndex) { + offset + } else { + offset + separator.length + } + + override fun transformedToOriginal(offset: Int) = + if (offset <= separatorAfterIndex + 1) { + offset + } else { + offset - separator.length + } + } + + return TransformedText(AnnotatedString(out), offsetTranslator) + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/FormElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/FormElement.kt index b7a0064de66..28f53d7408c 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/FormElement.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/FormElement.kt @@ -3,6 +3,7 @@ package com.stripe.android.ui.core.elements import androidx.annotation.RestrictTo import com.stripe.android.ui.core.forms.FormFieldEntry import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow /** * This is used to define each section in the visual form layout. @@ -14,4 +15,6 @@ sealed class FormElement { abstract val controller: Controller? abstract fun getFormFieldValueFlow(): Flow>> + open fun getTextFieldIdentifiers(): Flow> = + MutableStateFlow(emptyList()) } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/IbanConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/IbanConfig.kt index 38d0f235891..b5d132908c5 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/IbanConfig.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/IbanConfig.kt @@ -9,6 +9,8 @@ import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import com.stripe.android.ui.core.R +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import java.math.BigInteger import java.util.Locale @@ -27,6 +29,14 @@ class IbanConfig : TextFieldConfig { override val label = R.string.iban override val keyboard = KeyboardType.Ascii + override val trailingIcon: MutableStateFlow = MutableStateFlow( + TextFieldIcon( + R.drawable.stripe_ic_bank_generic, + isIcon = true + ) + ) + override val loading: StateFlow = MutableStateFlow(false) + // Displays the IBAN in groups of 4 characters with spaces added between them override val visualTransformation: VisualTransformation = VisualTransformation { text -> val output = StringBuilder() diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/IbanSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/IbanSpec.kt index 84787d3f9b1..20fe17d9bd3 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/IbanSpec.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/IbanSpec.kt @@ -7,6 +7,6 @@ internal object IbanSpec : SectionFieldSpec(IdentifierSpec.Generic("iban")) { fun transform(): SectionFieldElement = IbanElement( this.identifier, - TextFieldController(IbanConfig()) + SimpleTextFieldController(IbanConfig()) ) } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/InputController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/InputController.kt index b2884e13f9b..4371fbe9913 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/InputController.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/InputController.kt @@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.Flow */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) sealed interface InputController : SectionFieldErrorController { - val label: Int + val label: Flow val fieldValue: Flow val rawFieldValue: Flow val isComplete: Flow diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/NameConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/NameConfig.kt index 64cf41d7012..74766c2e40b 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/NameConfig.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/NameConfig.kt @@ -8,6 +8,8 @@ import androidx.compose.ui.text.input.VisualTransformation import com.stripe.android.ui.core.R import com.stripe.android.ui.core.elements.TextFieldStateConstants.Error import com.stripe.android.ui.core.elements.TextFieldStateConstants.Valid +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) class NameConfig : TextFieldConfig { @@ -17,6 +19,8 @@ class NameConfig : TextFieldConfig { override val debugLabel = "name" override val keyboard = KeyboardType.Text override val visualTransformation: VisualTransformation? = null + override val trailingIcon: MutableStateFlow = MutableStateFlow(null) + override val loading: StateFlow = MutableStateFlow(false) override fun determineState(input: String): TextFieldState { return when { diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/RowElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/RowElement.kt index 2249b16dc77..1ae5b131dfb 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/RowElement.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/RowElement.kt @@ -23,4 +23,7 @@ class RowElement constructor( it.setRawValue(rawValuesMap) } } + + override fun getTextFieldIdentifiers(): Flow> = + fields.map { it.getTextFieldIdentifiers() }.last() } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/RowElementUI.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/RowElementUI.kt index 3ef8f30d0a2..b88595e084d 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/RowElementUI.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/RowElementUI.kt @@ -1,61 +1,77 @@ package com.stripe.android.ui.core.elements import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight 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.Divider import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension import com.stripe.android.ui.core.PaymentsTheme @Composable internal fun RowElementUI( enabled: Boolean, - controller: RowController + controller: RowController, + hiddenIdentifiers: List, + lastTextFieldIdentifier: IdentifierSpec? ) { val fields = controller.fields - Row( - Modifier - .height(IntrinsicSize.Min) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - fields.forEachIndexed { index, field -> - val lastItem = index != fields.size - 1 - SectionFieldElementUI( - enabled, - field, - Modifier.fillMaxWidth( - (1f / fields.size.toFloat()).takeIf { lastItem } ?: 1f - ) - ) - if (!lastItem) { - VeriticalDivider( - color = PaymentsTheme.colors.colorComponentBorder, - thickness = PaymentsTheme.shapes.borderStrokeWidth + + val numVisibleFields = fields.filter { !hiddenIdentifiers.contains(it.identifier) }.size + + // Only draw the row if the items in the row are not hidden, otherwise the entire + // section will fail to draw + if (fields.map { it.identifier }.any { !hiddenIdentifiers.contains(it) }) { + // An attempt was made to do this with a row, and a vertical divider created with a box. + // The row had a height of IntrinsicSize.Min, and the box/vertical divider filled the height + // when adding in the trailing icon this broke and caused the overall height of the row to + // increase. By using the constraint layout the vertical divider does not negatively effect + // the size of the row. + ConstraintLayout { + // Create references for the composables to constrain + val fieldRefs = fields.map { createRef() } + val dividerRefs = fields.map { createRef() } + + fields.forEachIndexed { index, field -> + SectionFieldElementUI( + enabled, + field, + hiddenIdentifiers = hiddenIdentifiers, + lastTextFieldIdentifier = lastTextFieldIdentifier, + modifier = Modifier + .constrainAs(fieldRefs[index]) { + if (index == 0) { + start.linkTo(parent.start) + } else { + start.linkTo(dividerRefs[index - 1].end) + } + top.linkTo(parent.top) + } + .fillMaxWidth( + (1f / numVisibleFields.toFloat()) + ) ) + + if (!hiddenIdentifiers.contains(field.identifier) && index != (fields.size - 1)) { + Divider( + modifier = Modifier + .constrainAs(dividerRefs[index]) { + start.linkTo(fieldRefs[index].end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + height = (Dimension.fillToConstraints) + } + .padding( + horizontal = PaymentsTheme.shapes.borderStrokeWidth + ) + .width(PaymentsTheme.shapes.borderStrokeWidth) + .background(PaymentsTheme.colors.colorComponentBorder) + ) + } } } } } - -@Composable -internal fun VeriticalDivider( - color: Color, - thickness: Dp = 1.dp, -) { - Box( - modifier = Modifier - .fillMaxHeight() - .width(thickness) - .background(color) - ) -} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SaveForFutureUseController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SaveForFutureUseController.kt index b5e3d3a2089..34405104c06 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SaveForFutureUseController.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SaveForFutureUseController.kt @@ -13,8 +13,9 @@ class SaveForFutureUseController( identifiersRequiredForFutureUse: List = emptyList(), saveForFutureUseInitialValue: Boolean ) : InputController { - override val label: Int = + override val label: Flow = MutableStateFlow( R.string.save_for_future_payments_with_merchant_name + ) private val _saveForFutureUse = MutableStateFlow(saveForFutureUseInitialValue) val saveForFutureUse: Flow = _saveForFutureUse override val fieldValue: Flow = saveForFutureUse.map { it.toString() } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SaveForFutureUseElementUI.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SaveForFutureUseElementUI.kt index 1d0ede72d37..dbad959e85c 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SaveForFutureUseElementUI.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SaveForFutureUseElementUI.kt @@ -28,6 +28,7 @@ fun SaveForFutureUseElementUI( ) { val controller = element.controller val checked by controller.saveForFutureUse.collectAsState(true) + val label by controller.label.collectAsState(null) val resources = LocalContext.current.resources val description = stringResource( @@ -61,11 +62,13 @@ fun SaveForFutureUseElementUI( onCheckedChange = null, // needs to be null for accessibility on row click to work enabled = enabled ) - H6Text( - text = resources.getString(controller.label, element.merchantName), - modifier = Modifier - .padding(start = 4.dp) - .align(Alignment.CenterVertically) - ) + label?.let { + H6Text( + text = resources.getString(it, element.merchantName), + modifier = Modifier + .padding(start = 4.dp) + .align(Alignment.CenterVertically) + ) + } } } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionElement.kt index cb5de7d5082..2232f268a16 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionElement.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionElement.kt @@ -23,4 +23,14 @@ data class SectionElement( combine(fields.map { it.getFormFieldValueFlow() }) { it.toList().flatten() } + + override fun getTextFieldIdentifiers(): Flow> = + combine( + fields + .map { + it.getTextFieldIdentifiers() + } + ) { + it.toList().flatten() + } } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionElementUI.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionElementUI.kt index e938eb84719..996abb67c76 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionElementUI.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionElementUI.kt @@ -16,6 +16,7 @@ fun SectionElementUI( enabled: Boolean, element: SectionElement, hiddenIdentifiers: List, + lastTextFieldIdentifier: IdentifierSpec? ) { if (!hiddenIdentifiers.contains(element.identifier)) { val controller = element.controller @@ -32,7 +33,12 @@ fun SectionElementUI( Section(controller.label, sectionErrorString) { element.fields.forEachIndexed { index, field -> - SectionFieldElementUI(enabled, field) + SectionFieldElementUI( + enabled, + field, + hiddenIdentifiers = hiddenIdentifiers, + lastTextFieldIdentifier = lastTextFieldIdentifier + ) if (index != element.fields.size - 1) { Divider( color = PaymentsTheme.colors.colorComponentBorder, diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionFieldElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionFieldElement.kt index 57b607a6008..0b6adc57bba 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionFieldElement.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionFieldElement.kt @@ -11,4 +11,5 @@ sealed interface SectionFieldElement { fun getFormFieldValueFlow(): Flow>> fun sectionFieldErrorController(): SectionFieldErrorController fun setRawValue(rawValuesMap: Map) + fun getTextFieldIdentifiers(): Flow> } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionFieldElementUI.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionFieldElementUI.kt index 994f850d77d..f2b5907c5e3 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionFieldElementUI.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionFieldElementUI.kt @@ -2,39 +2,60 @@ package com.stripe.android.ui.core.elements import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction @Composable internal fun SectionFieldElementUI( enabled: Boolean, field: SectionFieldElement, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + hiddenIdentifiers: List? = null, + lastTextFieldIdentifier: IdentifierSpec?, ) { - when (val controller = field.sectionFieldErrorController()) { - is TextFieldController -> { - TextField( - textFieldController = controller, - enabled = enabled, - modifier = modifier - ) - } - is DropdownFieldController -> { - DropDown( - controller.label, - controller, - enabled - ) - } - is AddressController -> { - AddressElementUI( - enabled, - controller - ) - } - is RowController -> { - RowElementUI( - enabled, - controller - ) + if (hiddenIdentifiers?.contains(field.identifier) == false) { + when (val controller = field.sectionFieldErrorController()) { + is TextFieldController -> { + TextField( + textFieldController = controller, + enabled = enabled, + modifier = modifier, + imeAction = if (lastTextFieldIdentifier == field.identifier) { + ImeAction.Done + } else { + ImeAction.Next + } + ) + } + is DropdownFieldController -> { + DropDown( + controller, + enabled + ) + } + is AddressController -> { + AddressElementUI( + enabled, + controller, + hiddenIdentifiers, + lastTextFieldIdentifier + ) + } + is RowController -> { + RowElementUI( + enabled, + controller, + hiddenIdentifiers, + lastTextFieldIdentifier + ) + } + is CardDetailsController -> { + CardDetailsElementUI( + enabled, + controller, + hiddenIdentifiers, + lastTextFieldIdentifier + ) + } } } } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionSingleFieldElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionSingleFieldElement.kt index c410ff966ff..0eeae650874 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionSingleFieldElement.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionSingleFieldElement.kt @@ -3,6 +3,7 @@ package com.stripe.android.ui.core.elements import androidx.annotation.RestrictTo import com.stripe.android.ui.core.forms.FormFieldEntry import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map /** @@ -31,4 +32,10 @@ sealed class SectionSingleFieldElement( override fun setRawValue(rawValuesMap: Map) { rawValuesMap[identifier]?.let { controller.onRawValueChange(it) } } + + override fun getTextFieldIdentifiers(): Flow> = + MutableStateFlow( + listOf(identifier).takeIf { controller is TextFieldController } + ?: emptyList() + ) } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionUI.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionUI.kt index a7056db66d9..23667a598fc 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionUI.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SectionUI.kt @@ -68,7 +68,9 @@ fun SectionCard( shape = PaymentsTheme.shapes.material.medium, modifier = modifier ) { - content() + Column { + content() + } } } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SimpleTextElement.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SimpleTextElement.kt index f8dede9e09c..dc0254ebd76 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SimpleTextElement.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SimpleTextElement.kt @@ -1,9 +1,6 @@ package com.stripe.android.ui.core.elements -import androidx.annotation.RestrictTo - -@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) -data class SimpleTextElement( +internal data class SimpleTextElement( override val identifier: IdentifierSpec, override val controller: TextFieldController ) : SectionSingleFieldElement(identifier) diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SimpleTextFieldConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SimpleTextFieldConfig.kt index 1074d0f48a0..d3aca9c31e5 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SimpleTextFieldConfig.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SimpleTextFieldConfig.kt @@ -5,6 +5,7 @@ import androidx.annotation.StringRes import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.VisualTransformation +import kotlinx.coroutines.flow.MutableStateFlow @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) class SimpleTextFieldConfig( @@ -14,6 +15,8 @@ class SimpleTextFieldConfig( ) : TextFieldConfig { override val debugLabel: String = "generic_text" override val visualTransformation: VisualTransformation? = null + override val trailingIcon: MutableStateFlow = MutableStateFlow(null) + override val loading: MutableStateFlow = MutableStateFlow(false) override fun determineState(input: String): TextFieldState = object : TextFieldState { override fun shouldShowError(hasFocus: Boolean) = false diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SimpleTextSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SimpleTextSpec.kt index c4ca251fa67..800b9d0dc76 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SimpleTextSpec.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/SimpleTextSpec.kt @@ -31,7 +31,7 @@ data class SimpleTextSpec( ): SectionSingleFieldElement = SimpleTextElement( this.identifier, - TextFieldController( + SimpleTextFieldController( SimpleTextFieldConfig( label = this.label, capitalization = this.capitalization, diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldConfig.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldConfig.kt index ef3e92907f9..ee16be4a639 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldConfig.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldConfig.kt @@ -4,6 +4,7 @@ import androidx.annotation.RestrictTo import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.VisualTransformation +import kotlinx.coroutines.flow.StateFlow @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) sealed interface TextFieldConfig { @@ -22,6 +23,10 @@ sealed interface TextFieldConfig { /** Transformation for changing visual output of the input field. */ val visualTransformation: VisualTransformation? + val trailingIcon: StateFlow + + val loading: StateFlow + /** This will determine the state of the field based on the text */ fun determineState(input: String): TextFieldState diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldController.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldController.kt index 26d2a5de244..1d1f0c77299 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldController.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldController.kt @@ -1,5 +1,6 @@ package com.stripe.android.ui.core.elements +import androidx.annotation.DrawableRes import androidx.annotation.RestrictTo import androidx.annotation.StringRes import androidx.compose.ui.text.input.KeyboardCapitalization @@ -12,24 +13,57 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +interface TextFieldController : InputController { + fun onValueChange(displayFormatted: String) + fun onFocusChange(newHasFocus: Boolean) + + val debugLabel: String + val trailingIcon: Flow + val capitalization: KeyboardCapitalization + val keyboardType: KeyboardType + override val label: Flow + val visualTransformation: VisualTransformation + override val showOptionalLabel: Boolean + val fieldState: Flow + override val fieldValue: Flow + val visibleError: Flow + val loading: Flow + // This dictates how the accessibility reader reads the text in the field. + // Default this to _fieldValue to read the field normally + val contentDescription: Flow +} + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) +data class TextFieldIcon( + @DrawableRes + val idRes: Int, + @StringRes + val contentDescription: Int? = null, + + /** If it is an icon that should be tinted to match the text the value should be true */ + val isIcon: Boolean +) + /** * This class will provide the onValueChanged and onFocusChanged functionality to the field's * composable. These functions will update the observables as needed. It is responsible for * exposing immutable observers for its data */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX) -class TextFieldController constructor( +class SimpleTextFieldController constructor( private val textFieldConfig: TextFieldConfig, override val showOptionalLabel: Boolean = false, initialValue: String? = null -) : InputController, SectionFieldErrorController { - val capitalization: KeyboardCapitalization = textFieldConfig.capitalization - val keyboardType: KeyboardType = textFieldConfig.keyboard - val visualTransformation = textFieldConfig.visualTransformation ?: VisualTransformation.None +) : TextFieldController, SectionFieldErrorController { + override val trailingIcon: Flow = textFieldConfig.trailingIcon + override val capitalization: KeyboardCapitalization = textFieldConfig.capitalization + override val keyboardType: KeyboardType = textFieldConfig.keyboard + override val visualTransformation = + textFieldConfig.visualTransformation ?: VisualTransformation.None - @StringRes - override val label: Int = textFieldConfig.label - val debugLabel = textFieldConfig.debugLabel + override val label: Flow = MutableStateFlow(textFieldConfig.label) + override val debugLabel = textFieldConfig.debugLabel /** This is all the information that can be observed on the element */ private val _fieldValue = MutableStateFlow("") @@ -37,11 +71,16 @@ class TextFieldController constructor( override val rawFieldValue: Flow = _fieldValue.map { textFieldConfig.convertToRaw(it) } + override val contentDescription: Flow = _fieldValue + private val _fieldState = MutableStateFlow(Blank) + override val fieldState: Flow = _fieldState + + override val loading: Flow = textFieldConfig.loading private val _hasFocus = MutableStateFlow(false) - val visibleError: Flow = + override val visibleError: Flow = combine(_fieldState, _hasFocus) { fieldState, hasFocus -> fieldState.shouldShowError(hasFocus) } @@ -53,8 +92,6 @@ class TextFieldController constructor( _fieldState.value.getError()?.takeIf { visibleError } } - val isFull: Flow = _fieldState.map { it.isFull() } - override val isComplete: Flow = _fieldState.map { it.isValid() || (!it.isValid() && showOptionalLabel && it.isBlank()) } @@ -71,7 +108,7 @@ class TextFieldController constructor( /** * This is called when the value changed to is a display value. */ - fun onValueChange(displayFormatted: String) { + override fun onValueChange(displayFormatted: String) { _fieldValue.value = textFieldConfig.filter(displayFormatted) // Should be filtered value @@ -85,7 +122,7 @@ class TextFieldController constructor( onValueChange(textFieldConfig.convertFromRaw(rawValue)) } - fun onFocusChange(newHasFocus: Boolean) { + override fun onFocusChange(newHasFocus: Boolean) { _hasFocus.value = newHasFocus } } diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldUI.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldUI.kt index 247bbde815f..f0da4b3c452 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldUI.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/TextFieldUI.kt @@ -1,9 +1,13 @@ package com.stripe.android.ui.core.elements import android.util.Log +import android.view.KeyEvent +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon import androidx.compose.material.TextField import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable @@ -14,20 +18,22 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.editableText +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.ImeAction import com.stripe.android.ui.core.PaymentsTheme import com.stripe.android.ui.core.R -/** This is a helpful method for setting the next action based on the nextFocus Requester **/ -internal fun imeAction(nextFocusRequester: FocusRequester?): ImeAction = nextFocusRequester?.let { - ImeAction.Next -} ?: ImeAction.Done - /** * This is focused on converting an `Element` into what is displayed in a textField. * - some focus logic @@ -38,13 +44,17 @@ internal fun imeAction(nextFocusRequester: FocusRequester?): ImeAction = nextFoc internal fun TextField( textFieldController: TextFieldController, modifier: Modifier = Modifier, - enabled: Boolean, + imeAction: ImeAction, + enabled: Boolean ) { Log.d("Construct", "SimpleTextFieldElement ${textFieldController.debugLabel}") val focusManager = LocalFocusManager.current val value by textFieldController.fieldValue.collectAsState("") + val trailingIcon by textFieldController.trailingIcon.collectAsState(null) val shouldShowError by textFieldController.visibleError.collectAsState(false) + val loading by textFieldController.loading.collectAsState(false) + val contentDescription by textFieldController.contentDescription.collectAsState("") var hasFocus by rememberSaveable { mutableStateOf(false) } val colors = TextFieldDefaults.textFieldColors( @@ -62,6 +72,27 @@ internal fun TextField( unfocusedIndicatorColor = Color.Transparent, cursorColor = PaymentsTheme.colors.colorTextCursor ) + val fieldState by textFieldController.fieldState.collectAsState( + TextFieldStateConstants.Error.Blank + ) + val label by textFieldController.label.collectAsState( + null + ) + var processedIsFull by rememberSaveable { mutableStateOf(false) } + + /** + * This is setup so that when a field is full it still allows more characters + * to be entered, it just triggers next focus when the event happens. + */ + @Suppress("UNUSED_VALUE") + processedIsFull = if (fieldState == TextFieldStateConstants.Valid.Full) { + if (!processedIsFull) { + focusManager.moveFocus(FocusDirection.Next) + } + true + } else { + false + } TextField( value = value, @@ -72,37 +103,81 @@ internal fun TextField( text = if (textFieldController.showOptionalLabel) { stringResource( R.string.form_label_optional, - stringResource(textFieldController.label) + label?.let { stringResource(it) } ?: "" ) } else { - stringResource(textFieldController.label) - }, + label?.let { stringResource(it) } ?: "" + } ) }, modifier = modifier .fillMaxWidth() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && + event.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DEL && + value.isEmpty() + ) { + focusManager.moveFocus(FocusDirection.Previous) + true + } else { + false + } + } .onFocusChanged { if (hasFocus != it.isFocused) { textFieldController.onFocusChange(it.isFocused) } hasFocus = it.isFocused + } + .semantics { + this.contentDescription = contentDescription + this.editableText = AnnotatedString("") }, keyboardActions = KeyboardActions( onNext = { - if (!focusManager.moveFocus(FocusDirection.Down)) { - focusManager.clearFocus(true) - } + focusManager.moveFocus(FocusDirection.Next) + }, + onDone = { + focusManager.clearFocus(true) } ), visualTransformation = textFieldController.visualTransformation, keyboardOptions = KeyboardOptions( keyboardType = textFieldController.keyboardType, capitalization = textFieldController.capitalization, - imeAction = ImeAction.Next + imeAction = imeAction ), colors = colors, maxLines = 1, singleLine = true, - enabled = enabled + enabled = enabled, + trailingIcon = trailingIcon?.let { + { TrailingIcon(it, colors, loading) } + } ) } + +@Composable +internal fun TrailingIcon( + trailingIcon: TextFieldIcon, + colors: androidx.compose.material.TextFieldColors, + loading: Boolean +) { + if (loading) { + CircularProgressIndicator() + } else if (trailingIcon.isIcon) { + Icon( + painter = painterResource(id = trailingIcon.idRes), + contentDescription = trailingIcon.contentDescription?.let { + stringResource(trailingIcon.contentDescription) + } + ) + } else { + Image( + painter = painterResource(id = trailingIcon.idRes), + contentDescription = trailingIcon.contentDescription?.let { + stringResource(trailingIcon.contentDescription) + } + ) + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/menu/AndroidMenu.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/menu/AndroidMenu.kt new file mode 100644 index 00000000000..1fdd9cd0e10 --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/menu/AndroidMenu.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.stripe.android.ui.core.elements.menu.DropdownMenuContent +import com.stripe.android.ui.core.elements.menu.DropdownMenuItemContent +import com.stripe.android.ui.core.elements.menu.DropdownMenuPositionProvider +import com.stripe.android.ui.core.elements.menu.MenuDefaults +import com.stripe.android.ui.core.elements.menu.calculateTransformOrigin + +/** + * Material Design dropdown menu. + * + * A dropdown menu is a compact way of displaying multiple choices. It appears upon interaction with + * an element (such as an icon or button) or when users perform a specific action. + * + * ![Menus image](https://developer.android.com/images/reference/androidx/compose/material/menus.png) + * + * A [DropdownMenu] behaves similarly to a [Popup], and will use the position of the parent layout + * to position itself on screen. Commonly a [DropdownMenu] will be placed in a [Box] with a sibling + * that will be used as the 'anchor'. Note that a [DropdownMenu] by itself will not take up any + * space in a layout, as the menu is displayed in a separate window, on top of other content. + * + * The [content] of a [DropdownMenu] will typically be [DropdownMenuItem]s, as well as custom + * content. Using [DropdownMenuItem]s will result in a menu that matches the Material + * specification for menus. Also note that the [content] is placed inside a scrollable [Column], + * so using a [LazyColumn] as the root layout inside [content] is unsupported. + * + * [onDismissRequest] will be called when the menu should close - for example when there is a + * tap outside the menu, or when the back key is pressed. + * + * [DropdownMenu] changes its positioning depending on the available space, always trying to be + * fully visible. It will try to expand horizontally, depending on layout direction, to the end of + * its parent, then to the start of its parent, and then screen end-aligned. Vertically, it will + * try to expand to the bottom of its parent, then from the top of its parent, and then screen + * top-aligned. An [offset] can be provided to adjust the positioning of the menu for cases when + * the layout bounds of its parent do not coincide with its visual bounds. Note the offset will + * be applied in the direction in which the menu will decide to expand. + * + * Example usage: + * @sample androidx.compose.material.samples.MenuSample + * + * @param expanded Whether the menu is currently open and visible to the user + * @param onDismissRequest Called when the user requests to dismiss the menu, such as by + * tapping outside the menu's bounds + * @param offset [DpOffset] to be added to the position of the menu + */ +@Suppress("ModifierParameter") +@Composable +internal fun DropdownMenu( + expanded: Boolean, + initialFirstVisibleItemIndex: Int, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + offset: DpOffset = DpOffset(0.dp, 0.dp), + properties: PopupProperties = PopupProperties(focusable = true), + content: LazyListScope.() -> Unit +) { + val expandedStates = remember { MutableTransitionState(false) } + expandedStates.targetState = expanded + + if (expandedStates.currentState || expandedStates.targetState) { + val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) } + val density = LocalDensity.current + val popupPositionProvider = DropdownMenuPositionProvider( + offset, + density + ) { parentBounds, menuBounds -> + transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds) + } + + Popup( + onDismissRequest = onDismissRequest, + popupPositionProvider = popupPositionProvider, + properties = properties + ) { + DropdownMenuContent( + expandedStates = expandedStates, + initialFirstVisibleItemIndex = initialFirstVisibleItemIndex, + transformOriginState = transformOriginState, + modifier = modifier, + content = content + ) + } + } +} + +/** + * Material Design dropdown menu item. + * + * + * Example usage: + * @sample androidx.compose.material.samples.MenuSample + * + * @param onClick Called when the menu item was clicked + * @param modifier The modifier to be applied to the menu item + * @param enabled Controls the enabled state of the menu item - when `false`, the menu item + * will not be clickable and [onClick] will not be invoked + * @param contentPadding the padding applied to the content of this menu item + * @param interactionSource the [MutableInteractionSource] representing the stream of + * [Interaction]s for this DropdownMenuItem. You can create and pass in your own remembered + * [MutableInteractionSource] if you want to observe [Interaction]s and customize the + * appearance / behavior of this DropdownMenuItem in different [Interaction]s. + */ +@Composable +internal fun DropdownMenuItem( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable RowScope.() -> Unit +) { + DropdownMenuItemContent( + onClick = onClick, + modifier = modifier, + enabled = enabled, + contentPadding = contentPadding, + interactionSource = interactionSource, + content = content + ) +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/menu/Menu.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/menu/Menu.kt new file mode 100644 index 00000000000..fde5c11588e --- /dev/null +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/menu/Menu.kt @@ -0,0 +1,313 @@ +package com.stripe.android.ui.core.elements.menu + +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Card +import androidx.compose.material.ContentAlpha +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupPositionProvider +import com.stripe.android.ui.core.PaymentsTheme +import kotlin.math.max +import kotlin.math.min + +@Suppress("ModifierParameter") +@Composable +internal fun DropdownMenuContent( + expandedStates: MutableTransitionState, + transformOriginState: MutableState, + initialFirstVisibleItemIndex: Int, + modifier: Modifier = Modifier, + content: LazyListScope.() -> Unit +) { + // Menu open/close animation. + val transition = updateTransition(expandedStates, "DropDownMenu") + + val scale by transition.animateFloat( + transitionSpec = { + if (false isTransitioningTo true) { + // Dismissed to expanded + tween( + durationMillis = InTransitionDuration, + easing = LinearOutSlowInEasing + ) + } else { + // Expanded to dismissed. + tween( + durationMillis = 1, + delayMillis = OutTransitionDuration - 1 + ) + } + }, label = "menu-scale" + ) { + if (it) { + // Menu is expanded. + 1f + } else { + // Menu is dismissed. + 0.8f + } + } + + val alpha by transition.animateFloat( + transitionSpec = { + if (false isTransitioningTo true) { + // Dismissed to expanded + tween(durationMillis = 30) + } else { + // Expanded to dismissed. + tween(durationMillis = OutTransitionDuration) + } + }, label = "menu-alpha" + ) { + if (it) { + // Menu is expanded. + 1f + } else { + // Menu is dismissed. + 0f + } + } + + // TODO: Make sure this gets the rounded corner values + Card( + border = BorderStroke( + PaymentsTheme.shapes.borderStrokeWidth, + PaymentsTheme.colors.colorComponentBorder + ), + modifier = Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + transformOrigin = transformOriginState.value + }, + elevation = MenuElevation + ) { + val lazyListState = rememberLazyListState( + initialFirstVisibleItemIndex = initialFirstVisibleItemIndex + ) + + LazyColumn( + modifier = modifier + .padding(vertical = DropdownMenuVerticalPadding), + state = lazyListState, + content = content + ) + } +} + +@Composable +internal fun DropdownMenuItemContent( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + contentPadding: PaddingValues = MenuDefaults.DropdownMenuItemContentPadding, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable RowScope.() -> Unit +) { + // TODO(popam, b/156911853): investigate replacing this Row with ListItem + Row( + modifier = modifier + .clickable( + enabled = enabled, + onClick = onClick, + interactionSource = interactionSource, + indication = rememberRipple(true) + ) + .fillMaxWidth() + // Preferred min and max width used during the intrinsic measurement. + .sizeIn( + minWidth = DropdownMenuItemDefaultMaxWidth, // use the max width for both + maxWidth = DropdownMenuItemDefaultMaxWidth, + minHeight = DropdownMenuItemDefaultMinHeight + ) + .padding(contentPadding), + verticalAlignment = Alignment.CenterVertically + ) { + val typography = MaterialTheme.typography + ProvideTextStyle(typography.subtitle1) { + val contentAlpha = if (enabled) ContentAlpha.high else ContentAlpha.disabled + CompositionLocalProvider(LocalContentAlpha provides contentAlpha) { + content() + } + } + } +} + +/** + * Contains default values used for [DropdownMenuItem]. + */ +internal object MenuDefaults { + /** + * Default padding used for [DropdownMenuItem]. + */ + val DropdownMenuItemContentPadding = PaddingValues( + horizontal = DropdownMenuItemHorizontalPadding, + vertical = 0.dp + ) +} + +// Size defaults. +private val MenuElevation = 8.dp +internal val MenuVerticalMargin = 48.dp +internal val DropdownMenuItemHorizontalPadding = 16.dp +internal val DropdownMenuVerticalPadding = 8.dp +internal val DropdownMenuItemDefaultMinWidth = 112.dp +internal val DropdownMenuItemDefaultMaxWidth = 280.dp +internal val DropdownMenuItemDefaultMinHeight = 48.dp + +// Menu open/close animation. +internal const val InTransitionDuration = 120 +internal const val OutTransitionDuration = 75 + +internal fun calculateTransformOrigin( + parentBounds: IntRect, + menuBounds: IntRect +): TransformOrigin { + val pivotX = when { + menuBounds.left >= parentBounds.right -> 0f + menuBounds.right <= parentBounds.left -> 1f + menuBounds.width == 0 -> 0f + else -> { + val intersectionCenter = + ( + max(parentBounds.left, menuBounds.left) + + min(parentBounds.right, menuBounds.right) + ) / 2 + (intersectionCenter - menuBounds.left).toFloat() / menuBounds.width + } + } + val pivotY = when { + menuBounds.top >= parentBounds.bottom -> 0f + menuBounds.bottom <= parentBounds.top -> 1f + menuBounds.height == 0 -> 0f + else -> { + val intersectionCenter = + ( + max(parentBounds.top, menuBounds.top) + + min(parentBounds.bottom, menuBounds.bottom) + ) / 2 + (intersectionCenter - menuBounds.top).toFloat() / menuBounds.height + } + } + return TransformOrigin(pivotX, pivotY) +} + +// Menu positioning. + +/** + * Calculates the position of a Material [DropdownMenu]. + */ +// TODO(popam): Investigate if this can/should consider the app window size rather than screen size +@Immutable +internal data class DropdownMenuPositionProvider( + val contentOffset: DpOffset, + val density: Density, + val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> } +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset { + // The min margin above and below the menu, relative to the screen. + val verticalMargin = with(density) { MenuVerticalMargin.roundToPx() } + // The content offset specified using the dropdown offset parameter. + val contentOffsetX = with(density) { contentOffset.x.roundToPx() } + val contentOffsetY = with(density) { contentOffset.y.roundToPx() } + + // Compute horizontal position. + val toRight = anchorBounds.left + contentOffsetX + val toLeft = anchorBounds.right - contentOffsetX - popupContentSize.width + val toDisplayRight = windowSize.width - popupContentSize.width + val toDisplayLeft = 0 + val x = if (layoutDirection == LayoutDirection.Ltr) { + sequenceOf( + toRight, + toLeft, + // If the anchor gets outside of the window on the left, we want to position + // toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight. + if (anchorBounds.left >= 0) toDisplayRight else toDisplayLeft + ) + } else { + sequenceOf( + toLeft, + toRight, + // If the anchor gets outside of the window on the right, we want to position + // toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft. + if (anchorBounds.right <= windowSize.width) toDisplayLeft else toDisplayRight + ) + }.firstOrNull { + it >= 0 && it + popupContentSize.width <= windowSize.width + } ?: toLeft + + // Compute vertical position. + val toBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin) + val toTop = anchorBounds.top - contentOffsetY - popupContentSize.height + val toCenter = anchorBounds.top - popupContentSize.height / 2 + val toDisplayBottom = windowSize.height - popupContentSize.height - verticalMargin + val y = sequenceOf(toBottom, toTop, toCenter, toDisplayBottom).firstOrNull { + it >= verticalMargin && + it + popupContentSize.height <= windowSize.height - verticalMargin + } ?: toTop + + onPositionCalculated( + anchorBounds, + IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height) + ) + return IntOffset(x, y) + } +} diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/AfterpayClearpaySpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/AfterpayClearpaySpec.kt index 62a575e5a1a..160a5993361 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/AfterpayClearpaySpec.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/AfterpayClearpaySpec.kt @@ -10,6 +10,7 @@ import com.stripe.android.ui.core.elements.LayoutSpec import com.stripe.android.ui.core.elements.SectionSpec import com.stripe.android.ui.core.elements.SimpleTextSpec import com.stripe.android.ui.core.elements.billingParams +import com.stripe.android.ui.core.elements.supportedBillingCountries @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) val AfterpayClearpayParamKey: MutableMap = mutableMapOf( @@ -29,7 +30,10 @@ internal val afterpayClearpayEmailSection = internal val afterpayClearpayBillingSection = SectionSpec( IdentifierSpec.Generic("address_section"), - AddressSpec(IdentifierSpec.Generic("address")), + AddressSpec( + IdentifierSpec.Generic("address"), + supportedBillingCountries + ), R.string.billing_details ) diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/CardSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/CardSpec.kt index 42c9874abb3..2679c067965 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/CardSpec.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/CardSpec.kt @@ -1,8 +1,47 @@ package com.stripe.android.ui.core.forms +import androidx.annotation.RestrictTo +import com.stripe.android.ui.core.R +import com.stripe.android.ui.core.elements.CardBillingSpec +import com.stripe.android.ui.core.elements.CardDetailsSpec +import com.stripe.android.ui.core.elements.IdentifierSpec import com.stripe.android.ui.core.elements.LayoutSpec import com.stripe.android.ui.core.elements.SaveForFutureUseSpec +import com.stripe.android.ui.core.elements.SectionSpec +import com.stripe.android.ui.core.elements.billingParams +import com.stripe.android.ui.core.elements.supportedBillingCountries -internal val card = LayoutSpec.create( +internal val cardParams: MutableMap = mutableMapOf( + "number" to null, + "exp_month" to null, + "exp_year" to null, + "cvc" to null, +) + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +val CardParamKey: MutableMap = mutableMapOf( + "type" to "card", + "billing_details" to billingParams, + "card" to cardParams +) + +internal val creditDetailsSection = SectionSpec( + IdentifierSpec.Generic("credit_details_section"), + CardDetailsSpec, + R.string.card_information +) + +internal val creditBillingSection = SectionSpec( + IdentifierSpec.Generic("credit_billing_section"), + CardBillingSpec( + countryCodes = supportedBillingCountries + ), + R.string.billing_details +) + +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +val CardForm = LayoutSpec.create( + creditDetailsSection, + creditBillingSection, SaveForFutureUseSpec(emptyList()) ) diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/SepaDebitSpec.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/SepaDebitSpec.kt index 03cd409ba5b..84bf09cca0d 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/SepaDebitSpec.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/SepaDebitSpec.kt @@ -12,6 +12,7 @@ import com.stripe.android.ui.core.elements.SaveForFutureUseSpec import com.stripe.android.ui.core.elements.SectionSpec import com.stripe.android.ui.core.elements.SimpleTextSpec import com.stripe.android.ui.core.elements.billingParams +import com.stripe.android.ui.core.elements.supportedBillingCountries internal val sepaDebitParams: MutableMap = mutableMapOf( "iban" to null @@ -42,7 +43,10 @@ internal val sepaDebitMandate = MandateTextSpec( ) internal val sepaBillingSection = SectionSpec( IdentifierSpec.Generic("billing_section"), - AddressSpec(IdentifierSpec.Generic("address")), + AddressSpec( + IdentifierSpec.Generic("address"), + countryCodes = supportedBillingCountries + ), R.string.billing_details ) diff --git a/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/TransformSpecToElements.kt b/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/TransformSpecToElements.kt index a0a493c0d8c..b270615743f 100644 --- a/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/TransformSpecToElements.kt +++ b/payments-ui-core/src/main/java/com/stripe/android/ui/core/forms/TransformSpecToElements.kt @@ -1,5 +1,6 @@ package com.stripe.android.ui.core.forms +import android.content.Context import com.stripe.android.ui.core.Amount import com.stripe.android.ui.core.address.AddressFieldElementRepository import com.stripe.android.ui.core.elements.AddressSpec @@ -10,6 +11,8 @@ import com.stripe.android.ui.core.elements.AuBecsDebitMandateTextSpec import com.stripe.android.ui.core.elements.BankDropdownSpec import com.stripe.android.ui.core.elements.BankRepository import com.stripe.android.ui.core.elements.BsbSpec +import com.stripe.android.ui.core.elements.CardBillingSpec +import com.stripe.android.ui.core.elements.CardDetailsSpec import com.stripe.android.ui.core.elements.CountrySpec import com.stripe.android.ui.core.elements.EmailSpec import com.stripe.android.ui.core.elements.EmptyFormElement @@ -41,7 +44,8 @@ class TransformSpecToElements( private val amount: Amount?, private val country: String?, private val saveForFutureUseInitialValue: Boolean, - private val merchantName: String + private val merchantName: String, + private val context: Context ) { fun transform( list: List @@ -124,6 +128,8 @@ class TransformSpecToElements( currencyCode, country ) + is CardDetailsSpec -> it.transform(context) + is CardBillingSpec -> it.transform(addressRepository) is AuBankAccountNumberSpec -> it.transform() is BsbSpec -> it.transform() } diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/CardNumberFixtures.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/CardNumberFixtures.kt new file mode 100644 index 00000000000..d9108aabf97 --- /dev/null +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/CardNumberFixtures.kt @@ -0,0 +1,57 @@ +package com.stripe.android.ui.core + +import com.stripe.android.cards.CardNumber + +/** + * See [Basic test card numbers](https://stripe.com/docs/testing#cards) + */ +internal object CardNumberFixtures { + const val AMEX_NO_SPACES = "378282246310005" + const val AMEX_WITH_SPACES = "3782 822463 10005" + val AMEX_BIN = AMEX_NO_SPACES.take(6) + val AMEX = CardNumber.Unvalidated(AMEX_NO_SPACES) + + const val VISA_NO_SPACES = "4242424242424242" + const val VISA_WITH_SPACES = "4242 4242 4242 4242" + val VISA_BIN = VISA_NO_SPACES.take(6) + val VISA = CardNumber.Unvalidated(VISA_NO_SPACES) + + const val VISA_DEBIT_NO_SPACES = "4000056655665556" + const val VISA_DEBIT_WITH_SPACES = "4000 0566 5566 5556" + val VISA_DEBIT = CardNumber.Unvalidated(VISA_DEBIT_NO_SPACES) + + const val MASTERCARD_NO_SPACES = "5555555555554444" + const val MASTERCARD_WITH_SPACES = "5555 5555 5555 4444" + val MASTERCARD_BIN = MASTERCARD_NO_SPACES.take(6) + val MASTERCARD = CardNumber.Unvalidated(MASTERCARD_NO_SPACES) + + const val DINERS_CLUB_14_NO_SPACES = "36227206271667" + const val DINERS_CLUB_14_WITH_SPACES = "3622 720627 1667" + val DINERS_CLUB_14_BIN = DINERS_CLUB_14_NO_SPACES.take(6) + val DINERS_CLUB_14 = CardNumber.Unvalidated(DINERS_CLUB_14_NO_SPACES) + + const val DINERS_CLUB_16_NO_SPACES = "3056930009020004" + const val DINERS_CLUB_16_WITH_SPACES = "3056 9300 0902 0004" + val DINERS_CLUB_16_BIN = DINERS_CLUB_16_NO_SPACES.take(6) + val DINERS_CLUB_16 = CardNumber.Unvalidated(DINERS_CLUB_16_NO_SPACES) + + const val DISCOVER_NO_SPACES = "6011000990139424" + const val DISCOVER_WITH_SPACES = "6011 0009 9013 9424" + val DISCOVER_BIN = DISCOVER_NO_SPACES.take(6) + val DISCOVER = CardNumber.Unvalidated(DISCOVER_NO_SPACES) + + const val JCB_NO_SPACES = "3566002020360505" + const val JCB_WITH_SPACES = "3566 0020 2036 0505" + val JCB_BIN = JCB_NO_SPACES.take(6) + val JCB = CardNumber.Unvalidated(JCB_NO_SPACES) + + const val UNIONPAY_NO_SPACES = "6200000000000005" + const val UNIONPAY_WITH_SPACES = "6200 0000 0000 0005" + val UNIONPAY_BIN = UNIONPAY_NO_SPACES.take(6) + val UNIONPAY = CardNumber.Unvalidated(UNIONPAY_NO_SPACES) + + const val UNIONPAY_19_NO_SPACES = "6200500000000000004" + const val UNIONPAY_19_WITH_SPACES = "6200 5000 0000 0000 004" + val UNIONPAY_19_BIN = UNIONPAY_19_NO_SPACES.take(6) + val UNIONPAY_19 = CardNumber.Unvalidated(UNIONPAY_19_NO_SPACES) +} diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/address/TransformAddressToElementTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/address/TransformAddressToElementTest.kt index feeb332fc8e..9439f763e42 100644 --- a/payments-ui-core/src/test/java/com/stripe/android/ui/core/address/TransformAddressToElementTest.kt +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/address/TransformAddressToElementTest.kt @@ -10,6 +10,8 @@ import com.stripe.android.ui.core.elements.RowElement import com.stripe.android.ui.core.elements.SectionSingleFieldElement import com.stripe.android.ui.core.elements.SimpleTextSpec import com.stripe.android.ui.core.elements.TextFieldController +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import org.junit.Test import java.io.File import java.security.InvalidParameterException @@ -17,7 +19,7 @@ import java.security.InvalidParameterException class TransformAddressToElementTest { @Test - fun `Read US Json`() { + fun `Read US Json`() = runBlocking { val addressSchema = readFile("src/main/assets/addressinfo/US.json")!! val simpleTextList = addressSchema.transformToElementList() @@ -57,7 +59,7 @@ class TransformAddressToElementTest { IdentifierSpec.PostalCode, R.string.address_label_zip_code, KeyboardCapitalization.None, - KeyboardType.Number, + KeyboardType.NumberPassword, showOptionalLabel = false ) @@ -85,7 +87,7 @@ class TransformAddressToElementTest { ) } - private fun verifySimpleTextSpecInTextFieldController( + private suspend fun verifySimpleTextSpecInTextFieldController( textElement: SectionSingleFieldElement, simpleTextSpec: SimpleTextSpec ) { @@ -96,7 +98,7 @@ class TransformAddressToElementTest { assertThat(actualController.keyboardType).isEqualTo( simpleTextSpec.keyboardType ) - assertThat(actualController.label).isEqualTo( + assertThat(actualController.label.first()).isEqualTo( simpleTextSpec.label ) } diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/AddressControllerTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/AddressControllerTest.kt index d9b1f4ecf6b..2a7ae3f276d 100644 --- a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/AddressControllerTest.kt +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/AddressControllerTest.kt @@ -13,11 +13,11 @@ import org.robolectric.shadows.ShadowLooper @RunWith(RobolectricTestRunner::class) class AddressControllerTest { - private val emailController = TextFieldController( + private val emailController = SimpleTextFieldController( EmailConfig() ) private val ibanController = - TextFieldController( + SimpleTextFieldController( IbanConfig() ) private val sectionFieldElementFlow = MutableStateFlow( diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/AddressElementTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/AddressElementTest.kt index 185baa1e5b7..119829097a2 100644 --- a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/AddressElementTest.kt +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/AddressElementTest.kt @@ -31,7 +31,7 @@ class AddressElementTest { listOf( EmailElement( IdentifierSpec.Email, - TextFieldController(EmailConfig()) + SimpleTextFieldController(EmailConfig()) ) ) ) @@ -40,7 +40,7 @@ class AddressElementTest { listOf( IbanElement( IdentifierSpec.Generic("iban"), - TextFieldController(IbanConfig()) + SimpleTextFieldController(IbanConfig()) ) ) ) @@ -79,7 +79,7 @@ class AddressElementTest { emailController = ( (addressElement.fields.first()[1] as SectionSingleFieldElement) - .controller as TextFieldController + .controller as SimpleTextFieldController ) emailController.onValueChange("12invalidiban") ShadowLooper.runUiThreadTasksIncludingDelayedTasks() diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardBillingAddressElementTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardBillingAddressElementTest.kt new file mode 100644 index 00000000000..a4be31cc3e2 --- /dev/null +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardBillingAddressElementTest.kt @@ -0,0 +1,114 @@ +package com.stripe.android.ui.core.elements + +import android.app.Application +import androidx.lifecycle.asLiveData +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth +import com.stripe.android.ui.core.address.AddressFieldElementRepository +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class CardBillingAddressElementTest { + private val addressFieldElementRepository = AddressFieldElementRepository( + ApplicationProvider.getApplicationContext().resources + ) + val dropdownFieldController = DropdownFieldController( + CountryConfig(emptySet()) + ) + val cardBillingElement = CardBillingAddressElement( + IdentifierSpec.Generic("billing_element"), + addressFieldElementRepository, + emptySet(), + dropdownFieldController + ) + + init { + // We want to use fields that are easy to set in error + addressFieldElementRepository.add( + "US", + listOf( + EmailElement( + IdentifierSpec.Email, + SimpleTextFieldController(EmailConfig()) + ) + ) + ) + addressFieldElementRepository.add( + "JP", + listOf( + IbanElement( + IdentifierSpec.Generic("iban"), + SimpleTextFieldController(IbanConfig()) + ) + ) + ) + } + + @Test + fun `Verify that when US is selected postal is not hidden`() { + val hiddenIdFlowValues = mutableListOf>() + cardBillingElement.hiddenIdentifiers.asLiveData() + .observeForever { + hiddenIdFlowValues.add(it) + } + + dropdownFieldController.onRawValueChange("US") + verifyPostalShown(hiddenIdFlowValues[0]) + } + + @Test + fun `Verify that when GB is selected postal is not hidden`() { + val hiddenIdFlowValues = mutableListOf>() + cardBillingElement.hiddenIdentifiers.asLiveData() + .observeForever { + hiddenIdFlowValues.add(it) + } + + dropdownFieldController.onRawValueChange("GB") + verifyPostalShown(hiddenIdFlowValues[1]) + } + + @Test + fun `Verify that when CA is selected postal is not hidden`() { + val hiddenIdFlowValues = mutableListOf>() + cardBillingElement.hiddenIdentifiers.asLiveData() + .observeForever { + hiddenIdFlowValues.add(it) + } + + dropdownFieldController.onRawValueChange("CA") + verifyPostalShown(hiddenIdFlowValues[0]) + } + + @Test + fun `Verify that when DE is selected postal IS hidden`() { + val hiddenIdFlowValues = mutableListOf>() + cardBillingElement.hiddenIdentifiers.asLiveData() + .observeForever { + hiddenIdFlowValues.add(it) + } + + dropdownFieldController.onRawValueChange("DE") + verifyPostalHidden(hiddenIdFlowValues[1]) + } + + fun verifyPostalShown(hiddenIdentifiers: List) { + Truth.assertThat(hiddenIdentifiers).doesNotContain(IdentifierSpec.PostalCode) + Truth.assertThat(hiddenIdentifiers).doesNotContain(IdentifierSpec.Country) + Truth.assertThat(hiddenIdentifiers).contains(IdentifierSpec.Line1) + Truth.assertThat(hiddenIdentifiers).contains(IdentifierSpec.Line2) + Truth.assertThat(hiddenIdentifiers).contains(IdentifierSpec.State) + Truth.assertThat(hiddenIdentifiers).contains(IdentifierSpec.City) + } + + fun verifyPostalHidden(hiddenIdentifiers: List) { + Truth.assertThat(hiddenIdentifiers).doesNotContain(IdentifierSpec.Country) + Truth.assertThat(hiddenIdentifiers).contains(IdentifierSpec.PostalCode) + Truth.assertThat(hiddenIdentifiers).contains(IdentifierSpec.Line1) + Truth.assertThat(hiddenIdentifiers).contains(IdentifierSpec.Line2) + Truth.assertThat(hiddenIdentifiers).contains(IdentifierSpec.State) + Truth.assertThat(hiddenIdentifiers).contains(IdentifierSpec.City) + } +} diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardDetailsControllerTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardDetailsControllerTest.kt new file mode 100644 index 00000000000..4a0cd695e51 --- /dev/null +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardDetailsControllerTest.kt @@ -0,0 +1,45 @@ +package com.stripe.android.ui.core.elements + +import androidx.appcompat.view.ContextThemeWrapper +import androidx.lifecycle.asLiveData +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth +import com.stripe.android.ui.core.R +import com.stripe.android.utils.TestUtils.idleLooper +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CardDetailsControllerTest { + + private val context = ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.StripeDefaultTheme) + + @Test + fun `Verify the first field in error is returned in error flow`() { + val cardController = CardDetailsController(context) + + val flowValues = mutableListOf() + cardController.error.asLiveData() + .observeForever { + flowValues.add(it) + } + + cardController.numberElement.controller.onValueChange("4242424242424243") + cardController.cvcElement.controller.onValueChange("123") + cardController.expirationDateElement.controller.onValueChange("13") + + idleLooper() + + Truth.assertThat(flowValues[flowValues.size - 1]?.errorMessage).isEqualTo( + R.string.invalid_card_number + ) + + cardController.numberElement.controller.onValueChange("4242424242424242") + idleLooper() + + Truth.assertThat(flowValues[flowValues.size - 1]?.errorMessage).isEqualTo( + R.string.incomplete_expiry_date + ) + } +} diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardDetailsElementTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardDetailsElementTest.kt new file mode 100644 index 00000000000..cf3bc041414 --- /dev/null +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardDetailsElementTest.kt @@ -0,0 +1,49 @@ +package com.stripe.android.ui.core.elements + +import androidx.appcompat.view.ContextThemeWrapper +import androidx.lifecycle.asLiveData +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth +import com.stripe.android.ui.core.R +import com.stripe.android.ui.core.forms.FormFieldEntry +import com.stripe.android.utils.TestUtils.idleLooper +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class CardDetailsElementTest { + + private val context = ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.StripeDefaultTheme) + + @Test + fun `test form field values returned and expiration date parsing`() { + val cardController = CardDetailsController(context) + val cardDetailsElement = CardDetailsElement( + IdentifierSpec.Generic("card_details"), + context, + cardController + ) + + val flowValues = mutableListOf>>() + cardDetailsElement.getFormFieldValueFlow().asLiveData() + .observeForever { + flowValues.add(it) + } + + cardDetailsElement.controller.numberElement.controller.onValueChange("4242424242424242") + cardDetailsElement.controller.cvcElement.controller.onValueChange("321") + cardDetailsElement.controller.expirationDateElement.controller.onValueChange("130") + + idleLooper() + + Truth.assertThat(flowValues[flowValues.size - 1]).isEqualTo( + listOf( + IdentifierSpec.Generic("number") to FormFieldEntry("4242424242424242", true), + IdentifierSpec.Generic("cvc") to FormFieldEntry("321", true), + IdentifierSpec.Generic("exp_month") to FormFieldEntry("1", true), + IdentifierSpec.Generic("exp_year") to FormFieldEntry("30", true), + ) + ) + } +} diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardNumberConfigTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardNumberConfigTest.kt new file mode 100644 index 00000000000..9d8098ca27c --- /dev/null +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardNumberConfigTest.kt @@ -0,0 +1,95 @@ +package com.stripe.android.ui.core.elements + +import androidx.compose.ui.text.AnnotatedString +import com.google.common.truth.Truth +import com.stripe.android.model.CardBrand +import com.stripe.android.ui.core.CardNumberFixtures +import com.stripe.android.ui.core.R +import org.junit.Test + +class CardNumberConfigTest { + private val cardNumberConfig = CardNumberConfig() + + @Test + fun `visualTransformation formats entered value`() { + Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.VISA_NO_SPACES)).text) + .isEqualTo(AnnotatedString(CardNumberFixtures.VISA_WITH_SPACES)) + + Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.AMEX_NO_SPACES)).text) + .isEqualTo(AnnotatedString(CardNumberFixtures.AMEX_WITH_SPACES)) + + Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.DISCOVER_NO_SPACES)).text) + .isEqualTo(AnnotatedString(CardNumberFixtures.DISCOVER_WITH_SPACES)) + + Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.DINERS_CLUB_14_NO_SPACES)).text) + .isEqualTo(AnnotatedString(CardNumberFixtures.DINERS_CLUB_14_WITH_SPACES)) + + Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.DINERS_CLUB_16_NO_SPACES)).text) + .isEqualTo(AnnotatedString(CardNumberFixtures.DINERS_CLUB_16_WITH_SPACES)) + + Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.JCB_NO_SPACES)).text) + .isEqualTo(AnnotatedString(CardNumberFixtures.JCB_WITH_SPACES)) + + Truth.assertThat(cardNumberConfig.visualTransformation.filter(AnnotatedString(CardNumberFixtures.UNIONPAY_NO_SPACES)).text) + .isEqualTo(AnnotatedString(CardNumberFixtures.UNIONPAY_WITH_SPACES)) + } + + @Test + fun `only numbers are allowed in the field`() { + Truth.assertThat(cardNumberConfig.filter("123^@Number[\uD83E\uDD57.")) + .isEqualTo("123") + } + + @Test + fun `blank Number returns blank state`() { + Truth.assertThat(cardNumberConfig.determineState(CardBrand.Visa, "", CardBrand.Visa.getMaxLengthForCardNumber(""))) + .isEqualTo(TextFieldStateConstants.Error.Blank) + } + + @Test + fun `card brand is invalid`() { + val state = cardNumberConfig.determineState(CardBrand.Unknown, "0", CardBrand.Unknown.getMaxLengthForCardNumber("0")) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Invalid::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.invalid_card_number) + } + + @Test + fun `incomplete number is in incomplete state`() { + val state = cardNumberConfig.determineState(CardBrand.Visa, "12", CardBrand.Visa.getMaxLengthForCardNumber("12")) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Incomplete::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.invalid_card_number) + } + + @Test + fun `card number is too long`() { + val state = cardNumberConfig.determineState(CardBrand.Visa, "1234567890123456789", CardBrand.Visa.getMaxLengthForCardNumber("1234567890123456789")) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Invalid::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.invalid_card_number) + } + + @Test + fun `card number has invalid luhn`() { + val state = cardNumberConfig.determineState(CardBrand.Visa, "4242424242424243", CardBrand.Visa.getMaxLengthForCardNumber("4242424242424243")) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Invalid::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.invalid_card_number) + } + + @Test + fun `card number is valid`() { + val state = cardNumberConfig.determineState(CardBrand.Visa, "4242424242424242", CardBrand.Visa.getMaxLengthForCardNumber("4242424242424242")) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Valid.Full::class.java) + } +} diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardNumberControllerTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardNumberControllerTest.kt new file mode 100644 index 00000000000..fd68a0acc0d --- /dev/null +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardNumberControllerTest.kt @@ -0,0 +1,144 @@ +package com.stripe.android.ui.core.elements + +import androidx.lifecycle.asLiveData +import com.google.common.truth.Truth.assertThat +import com.stripe.android.cards.CardAccountRangeRepository +import com.stripe.android.cards.CardNumber +import com.stripe.android.cards.StaticCardAccountRangeSource +import com.stripe.android.model.AccountRange +import com.stripe.android.ui.core.R +import com.stripe.android.ui.core.forms.FormFieldEntry +import com.stripe.android.utils.TestUtils.idleLooper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class CardNumberControllerTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + private val cardNumberController = CardNumberController( + CardNumberConfig(), FakeCardAccountRangeRepository(), testDispatcher + ) + + @After + fun cleanup() { + Dispatchers.resetMain() + } + + @Test + fun `When invalid card number verify visible error`() { + + val errorFlowValues = mutableListOf() + cardNumberController.error.asLiveData() + .observeForever { + errorFlowValues.add(it) + } + + cardNumberController.onValueChange("012") + idleLooper() + + assertThat(errorFlowValues[errorFlowValues.size - 1]?.errorMessage) + .isEqualTo(R.string.invalid_card_number) + } + + @Test + fun `Verify get the form field value correctly`() { + val formFieldValuesFlow = mutableListOf() + cardNumberController.formFieldValue.asLiveData() + .observeForever { + formFieldValuesFlow.add(it) + } + + cardNumberController.onValueChange("4242") + idleLooper() + + assertThat(formFieldValuesFlow[formFieldValuesFlow.size - 1]?.isComplete) + .isFalse() + assertThat(formFieldValuesFlow[formFieldValuesFlow.size - 1]?.value) + .isEqualTo("4242") + + cardNumberController.onValueChange("4242424242424242") + idleLooper() + + assertThat(formFieldValuesFlow[formFieldValuesFlow.size - 1]?.isComplete) + .isTrue() + assertThat(formFieldValuesFlow[formFieldValuesFlow.size - 1]?.value) + .isEqualTo("4242424242424242") + } + + @Test + fun `Verify error is visible based on the focus`() { + // incomplete + val visibleErrorFlow = mutableListOf() + cardNumberController.visibleError.asLiveData() + .observeForever { + visibleErrorFlow.add(it) + } + + cardNumberController.onFocusChange(true) + cardNumberController.onValueChange("4242") + idleLooper() + + assertThat(visibleErrorFlow[visibleErrorFlow.size - 1]) + .isFalse() + + cardNumberController.onFocusChange(false) + idleLooper() + + assertThat(visibleErrorFlow[visibleErrorFlow.size - 1]) + .isTrue() + } + + @Test + fun `Entering VISA BIN does not call accountRangeRepository`() { + var repositoryCalls = 0 + val cardNumberController = CardNumberController( + CardNumberConfig(), + object : CardAccountRangeRepository { + private val staticCardAccountRangeSource = StaticCardAccountRangeSource() + override suspend fun getAccountRange( + cardNumber: CardNumber.Unvalidated + ): AccountRange? { + repositoryCalls++ + return cardNumber.bin?.let { + staticCardAccountRangeSource.getAccountRange(cardNumber) + } + } + + override val loading: Flow = flowOf(false) + }, + testDispatcher + ) + cardNumberController.onValueChange("42424242424242424242") + idleLooper() + assertThat(repositoryCalls).isEqualTo(0) + } + + @Test + fun `Entering valid 19 digit UnionPay BIN returns accountRange of 19`() { + cardNumberController.onValueChange("6216828050000000000") + idleLooper() + assertThat(cardNumberController.accountRangeService.accountRange!!.panLength).isEqualTo(19) + } + + private class FakeCardAccountRangeRepository : CardAccountRangeRepository { + private val staticCardAccountRangeSource = StaticCardAccountRangeSource() + override suspend fun getAccountRange( + cardNumber: CardNumber.Unvalidated + ): AccountRange? { + return cardNumber.bin?.let { + staticCardAccountRangeSource.getAccountRange(cardNumber) + } + } + + override val loading: Flow = flowOf(false) + } +} diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CountryConfigTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CountryConfigTest.kt index f0af8b3bc11..c8359e305b2 100644 --- a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CountryConfigTest.kt +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CountryConfigTest.kt @@ -28,4 +28,35 @@ class CountryConfigTest { ).getDisplayItems()[0] ).isEqualTo("Austria") } + + @Test + fun `test country list `() { + val defaultCountries = CountryConfig( + onlyShowCountryCodes = emptySet(), + locale = Locale.US + ).getDisplayItems() + val supportedCountries = CountryConfig( + onlyShowCountryCodes = supportedBillingCountries, + locale = Locale.US + ).getDisplayItems() + + val excludedCountries = setOf( + "American Samoa", "Christmas Island", "Cocos (Keeling) Islands", "Cuba", + "Heard & McDonald Islands", "Iran", "Marshall Islands", "Micronesia", + "Norfolk Island", "North Korea", "Northern Mariana Islands", "Palau", "Sudan", "Syria", + "U.S. Outlying Islands", "U.S. Virgin Islands" + ) + + excludedCountries.forEach { + assertThat(supportedCountries.contains(it)).isFalse() + } + + assertThat( + defaultCountries.size + ).isEqualTo(249) + + assertThat( + supportedCountries.size + ).isEqualTo(233) + } } diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CvcConfigTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CvcConfigTest.kt new file mode 100644 index 00000000000..9089cc30f5a --- /dev/null +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CvcConfigTest.kt @@ -0,0 +1,60 @@ +package com.stripe.android.ui.core.elements + +import com.google.common.truth.Truth +import com.stripe.android.model.CardBrand +import com.stripe.android.ui.core.R +import org.junit.Test + +class CvcConfigTest { + private val cvcConfig = CvcConfig() + + @Test + fun `only numbers are allowed in the field`() { + Truth.assertThat(cvcConfig.filter("123^@Number[\uD83E\uDD57.")) + .isEqualTo("123") + } + + @Test + fun `blank Number returns blank state`() { + Truth.assertThat(cvcConfig.determineState(CardBrand.Visa, "", CardBrand.Visa.maxCvcLength)) + .isEqualTo(TextFieldStateConstants.Error.Blank) + } + + @Test + fun `card brand is invalid`() { + val state = cvcConfig.determineState(CardBrand.Unknown, "0", CardBrand.Unknown.maxCvcLength) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Valid.Limitless::class.java) + } + + @Test + fun `incomplete number is in incomplete state`() { + val state = cvcConfig.determineState(CardBrand.Visa, "12", CardBrand.Visa.maxCvcLength) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Incomplete::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.invalid_cvc) + } + + @Test + fun `cvc is too long`() { + val state = cvcConfig.determineState(CardBrand.Visa, "1234567890123456789", CardBrand.Visa.maxCvcLength) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Invalid::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.invalid_cvc) + } + + @Test + fun `cvc is valid`() { + var state = cvcConfig.determineState(CardBrand.Visa, "123", CardBrand.Visa.maxCvcLength) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Valid.Full::class.java) + + state = cvcConfig.determineState(CardBrand.AmericanExpress, "1234", CardBrand.AmericanExpress.maxCvcLength) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Valid.Full::class.java) + } +} diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CvcControllerTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CvcControllerTest.kt new file mode 100644 index 00000000000..33b0f7d74c7 --- /dev/null +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CvcControllerTest.kt @@ -0,0 +1,83 @@ +package com.stripe.android.ui.core.elements + +import androidx.lifecycle.asLiveData +import com.google.common.truth.Truth.assertThat +import com.stripe.android.model.CardBrand +import com.stripe.android.ui.core.R +import com.stripe.android.ui.core.forms.FormFieldEntry +import com.stripe.android.utils.TestUtils.idleLooper +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class CvcControllerTest { + private val cardBrandFlow = MutableStateFlow(CardBrand.Visa) + private val cvcController = CvcController( + CvcConfig(), + cardBrandFlow + ) + + @Test + fun `When invalid card number verify visible error`() { + val errorFlowValues = mutableListOf() + cvcController.error.asLiveData() + .observeForever { + errorFlowValues.add(it) + } + + cvcController.onValueChange("12") + idleLooper() + + assertThat(errorFlowValues[errorFlowValues.size - 1]?.errorMessage) + .isEqualTo(R.string.invalid_cvc) + } + + @Test + fun `Verify get the form field value correctly`() { + val formFieldValuesFlow = mutableListOf() + cvcController.formFieldValue.asLiveData() + .observeForever { + formFieldValuesFlow.add(it) + } + cvcController.onValueChange("13") + idleLooper() + + assertThat(formFieldValuesFlow[formFieldValuesFlow.size - 1]?.isComplete) + .isFalse() + assertThat(formFieldValuesFlow[formFieldValuesFlow.size - 1]?.value) + .isEqualTo("13") + + cvcController.onValueChange("123") + idleLooper() + + assertThat(formFieldValuesFlow[formFieldValuesFlow.size - 1]?.isComplete) + .isTrue() + assertThat(formFieldValuesFlow[formFieldValuesFlow.size - 1]?.value) + .isEqualTo("123") + } + + @Test + fun `Verify error is visible based on the focus`() { + // incomplete + val visibleErrorFlow = mutableListOf() + cvcController.visibleError.asLiveData() + .observeForever { + visibleErrorFlow.add(it) + } + + cvcController.onFocusChange(true) + cvcController.onValueChange("12") + idleLooper() + + assertThat(visibleErrorFlow[visibleErrorFlow.size - 1]) + .isFalse() + + cvcController.onFocusChange(false) + idleLooper() + + assertThat(visibleErrorFlow[visibleErrorFlow.size - 1]) + .isTrue() + } +} diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/DateConfigTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/DateConfigTest.kt new file mode 100644 index 00000000000..5fb2842aec4 --- /dev/null +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/DateConfigTest.kt @@ -0,0 +1,216 @@ +package com.stripe.android.ui.core.elements + +import com.google.common.truth.Truth +import com.stripe.android.ui.core.R +import org.junit.Test +import java.util.Calendar + +class DateConfigTest { + private val dateConfig = DateConfig() + + @Test + fun `only numbers are allowed in the field`() { + Truth.assertThat(dateConfig.filter("123^@Numbe/-r[\uD83E\uDD57.")) + .isEqualTo("123") + } + + @Test + fun `blank number returns blank state`() { + Truth.assertThat(dateConfig.determineState("")) + .isEqualTo(TextFieldStateConstants.Error.Blank) + } + + @Test + fun `incomplete number is in incomplete state`() { + val state = dateConfig.determineState("12") + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Incomplete::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.incomplete_expiry_date) + } + + @Test + fun `date is too long`() { + val state = dateConfig.determineState("1234567890123456789") + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Invalid::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.incomplete_expiry_date) + } + + @Test + fun `date invalid month and 2 digit year`() { + val state = dateConfig.determineState("1955") + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Invalid::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.incomplete_expiry_date) + } + + @Test + fun `date in the past`() { + val state = dateConfig.determineState("1299") + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Invalid::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.invalid_expiry_year) + } + + @Test + fun `date in the near past`() { + val state = dateConfig.determineState("1220") + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Invalid::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.invalid_expiry_year) + } + + @Test + fun `current month and year`() { + val state = dateConfig.determineState( + String.format( + "%d%d", + get1BasedCurrentMonth(), + Calendar.getInstance().get(Calendar.YEAR) % 100 + ) + ) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Valid.Full::class.java) + } + + @Test + fun `current month + 1 and year`() { + val state = dateConfig.determineState( + String.format( + "%d%d", + get1BasedCurrentMonth() + 1 % 12, + Calendar.getInstance().get(Calendar.YEAR) % 100 + ) + ) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Valid.Full::class.java) + } + + @Test + fun `current month and year + 1`() { + val state = dateConfig.determineState( + String.format( + "%d%d", + get1BasedCurrentMonth(), + (Calendar.getInstance().get(Calendar.YEAR) + 1) % 100 + ) + ) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Valid.Full::class.java) + } + + @Test + fun `current month - 1 and year`() { + var previousMonth = get1BasedCurrentMonth() - 1 + if (previousMonth == 0) { + previousMonth = 12 + } + val state = dateConfig.determineState( + String.format( + "%d%d", + previousMonth, + Calendar.getInstance().get(Calendar.YEAR) % 100 + ) + ) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Invalid::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.invalid_expiry_month) + } + + @Test + fun `current month and year - 1`() { + val state = dateConfig.determineState( + String.format( + "%d%d", + get1BasedCurrentMonth(), + (Calendar.getInstance().get(Calendar.YEAR) - 1) % 100 + ) + ) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Invalid::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.invalid_expiry_year) + } + + @Test + fun `card expire 51 years from now`() { + val currentYear = Calendar.getInstance().get(Calendar.YEAR) + val state = DateConfig.determineTextFieldState( + 3, + (currentYear + 51) % 100, + 2, + currentYear + ) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Invalid::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.invalid_expiry_year) + } + + @Test + fun `card expire 50 years from now`() { + val currentYear = Calendar.getInstance().get(Calendar.YEAR) + val state = DateConfig.determineTextFieldState( + 3, + (currentYear + 50) % 100, + 2, + currentYear + ) + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Valid.Full::class.java) + } + + private fun get1BasedCurrentMonth() = Calendar.getInstance().get(Calendar.MONTH) + 1 + + @Test + fun `date is valid 2 digit month and 2 digit year`() { + val state = dateConfig.determineState("1255") + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Valid.Full::class.java) + } + + @Test + fun `date is invalid 2X month and 2 digit year`() { + val state = dateConfig.determineState("2123") + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Error.Invalid::class.java) + Truth.assertThat( + state.getError()?.errorMessage + ).isEqualTo(R.string.incomplete_expiry_date) + } + + @Test + fun `date is valid one digit month two digit year`() { + val state = dateConfig.determineState("130") + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Valid.Full::class.java) + } + + @Test + fun `date is valid 0X month two digit year`() { + val state = dateConfig.determineState("0130") + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Valid.Full::class.java) + } + + @Test + fun `date is valid 2X month and 2 digit year`() { + val state = dateConfig.determineState("223") + Truth.assertThat(state) + .isInstanceOf(TextFieldStateConstants.Valid.Full::class.java) + } +} diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/DropdownFieldControllerTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/DropdownFieldControllerTest.kt index f3eef629641..9738b80f9e4 100644 --- a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/DropdownFieldControllerTest.kt +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/DropdownFieldControllerTest.kt @@ -3,6 +3,7 @@ package com.stripe.android.ui.core.elements import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runBlockingTest import org.junit.Test import java.util.Locale @@ -24,8 +25,8 @@ class DropdownFieldControllerTest { } @Test - fun `Verify label gets the label from the config`() { - assertThat(controller.label).isEqualTo(countryConfig.label) + fun `Verify label gets the label from the config`() = runBlockingTest { + assertThat(controller.label.first()).isEqualTo(countryConfig.label) } @Test diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/ExpiryDateVisualTransformationTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/ExpiryDateVisualTransformationTest.kt new file mode 100644 index 00000000000..64b72671bf3 --- /dev/null +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/ExpiryDateVisualTransformationTest.kt @@ -0,0 +1,33 @@ +package com.stripe.android.ui.core.elements + +import androidx.compose.ui.text.AnnotatedString +import com.google.common.truth.Truth +import org.junit.Test + +internal class ExpiryDateVisualTransformationTest { + private val transform = ExpiryDateVisualTransformation() + + @Test + fun `verify 19 get separated between 1 and 9`() { + Truth.assertThat(transform.filter(AnnotatedString("19")).text.text) + .isEqualTo("1 / 9") + } + + @Test + fun `verify 123 get separated between 2 and 3`() { + Truth.assertThat(transform.filter(AnnotatedString("123")).text.text) + .isEqualTo("12 / 3") + } + + @Test + fun `verify 093 get separated between 9 and 3`() { + Truth.assertThat(transform.filter(AnnotatedString("093")).text.text) + .isEqualTo("09 / 3") + } + + @Test + fun `verify 53 get separated between 5 and 3`() { + Truth.assertThat(transform.filter(AnnotatedString("53")).text.text) + .isEqualTo("5 / 3") + } +} diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/TextFieldControllerTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/TextFieldControllerTest.kt index 097308a9d06..ef4d6bb18a3 100644 --- a/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/TextFieldControllerTest.kt +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/TextFieldControllerTest.kt @@ -59,9 +59,9 @@ internal class TextFieldControllerTest { val controller = createControllerWithState() var isFull = false - controller.isFull.asLiveData() + controller.fieldState.asLiveData() .observeForever { - isFull = it + isFull = it.isFull() } controller.onValueChange("full") @@ -73,9 +73,9 @@ internal class TextFieldControllerTest { val controller = createControllerWithState() var isFull = false - controller.isFull.asLiveData() + controller.fieldState.asLiveData() .observeForever { - isFull = it + isFull = it.isFull() } controller.onValueChange("limitless") @@ -214,7 +214,7 @@ internal class TextFieldControllerTest { on { filter("1a2b3c4d") } doReturn "1234" } - val controller = TextFieldController(config) + val controller = SimpleTextFieldController(config) controller.onValueChange("1a2b3c4d") @@ -223,7 +223,7 @@ internal class TextFieldControllerTest { private fun createControllerWithState( showOptionalLabel: Boolean = false - ): TextFieldController { + ): SimpleTextFieldController { val config: TextFieldConfig = mock { on { determineState("full") } doReturn Full on { filter("full") } doReturn "full" @@ -247,7 +247,7 @@ internal class TextFieldControllerTest { on { label } doReturn R.string.address_label_name } - return TextFieldController(config, showOptionalLabel) + return SimpleTextFieldController(config, showOptionalLabel) } companion object { diff --git a/payments-ui-core/src/test/java/com/stripe/android/ui/core/forms/TransformSpecToElementTest.kt b/payments-ui-core/src/test/java/com/stripe/android/ui/core/forms/TransformSpecToElementTest.kt index 80724afaee8..fccba843a28 100644 --- a/payments-ui-core/src/test/java/com/stripe/android/ui/core/forms/TransformSpecToElementTest.kt +++ b/payments-ui-core/src/test/java/com/stripe/android/ui/core/forms/TransformSpecToElementTest.kt @@ -1,7 +1,9 @@ package com.stripe.android.ui.core.forms +import androidx.appcompat.view.ContextThemeWrapper import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType +import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.stripe.android.ui.core.R import com.stripe.android.ui.core.elements.BankDropdownSpec @@ -34,6 +36,8 @@ import java.io.File internal class TransformSpecToElementTest { + private val context = ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.StripeDefaultTheme) + private val nameSection = SectionSpec( IdentifierSpec.Generic("name_section"), SimpleTextSpec.NAME @@ -63,7 +67,8 @@ internal class TransformSpecToElementTest { amount = null, country = "DE", saveForFutureUseInitialValue = true, - merchantName = "Merchant, Inc." + merchantName = "Merchant, Inc.", + context ) } @@ -88,7 +93,7 @@ internal class TransformSpecToElementTest { } @Test - fun `Adding a country section sets up the section and country elements correctly`() { + fun `Adding a country section sets up the section and country elements correctly`() = runBlocking { val countrySection = SectionSpec( IdentifierSpec.Generic("country_section"), CountrySpec(onlyShowCountryCodes = setOf("AT")) @@ -104,7 +109,7 @@ internal class TransformSpecToElementTest { assertThat(countryElement.controller.displayItems[0]).isEqualTo("Austria") // Verify the correct config is setup for the controller - assertThat(countryElement.controller.label).isEqualTo(CountryConfig().label) + assertThat(countryElement.controller.label.first()).isEqualTo(CountryConfig().label) assertThat(countrySectionElement.identifier.value).isEqualTo("country_section") @@ -112,7 +117,7 @@ internal class TransformSpecToElementTest { } @Test - fun `Adding a ideal bank section sets up the section and country elements correctly`() { + fun `Adding a ideal bank section sets up the section and country elements correctly`() = runBlocking { val idealSection = SectionSpec( IdentifierSpec.Generic("ideal_section"), IDEAL_BANK_CONFIG @@ -125,7 +130,7 @@ internal class TransformSpecToElementTest { val idealElement = idealSectionElement.fields[0] as SimpleDropdownElement // Verify the correct config is setup for the controller - assertThat(idealElement.controller.label).isEqualTo(R.string.ideal_bank) + assertThat(idealElement.controller.label.first()).isEqualTo(R.string.ideal_bank) assertThat(idealSectionElement.identifier.value).isEqualTo("ideal_section") @@ -133,7 +138,7 @@ internal class TransformSpecToElementTest { } @Test - fun `Add a name section spec sets up the name element correctly`() { + fun `Add a name section spec sets up the name element correctly`() = runBlocking { val formElement = transformSpecToElements.transform( listOf(nameSection) ) @@ -142,7 +147,7 @@ internal class TransformSpecToElementTest { .fields[0] as SimpleTextElement // Verify the correct config is setup for the controller - assertThat(nameElement.controller.label).isEqualTo(NameConfig().label) + assertThat(nameElement.controller.label.first()).isEqualTo(NameConfig().label) assertThat(nameElement.identifier.value).isEqualTo("name") assertThat(nameElement.controller.capitalization).isEqualTo(KeyboardCapitalization.Words) @@ -150,7 +155,7 @@ internal class TransformSpecToElementTest { } @Test - fun `Add a simple text section spec sets up the text element correctly`() { + fun `Add a simple text section spec sets up the text element correctly`() = runBlocking { val formElement = transformSpecToElements.transform( listOf( SectionSpec( @@ -170,13 +175,13 @@ internal class TransformSpecToElementTest { as SimpleTextElement // Verify the correct config is setup for the controller - assertThat(nameElement.controller.label).isEqualTo(R.string.address_label_name) + assertThat(nameElement.controller.label.first()).isEqualTo(R.string.address_label_name) assertThat(nameElement.identifier.value).isEqualTo("simple") assertThat(nameElement.controller.showOptionalLabel).isTrue() } @Test - fun `Add a email section spec sets up the email element correctly`() { + fun `Add a email section spec sets up the email element correctly`() = runBlocking { val formElement = transformSpecToElements.transform( listOf(emailSection) ) @@ -185,7 +190,7 @@ internal class TransformSpecToElementTest { val emailElement = emailSectionElement.fields[0] as EmailElement // Verify the correct config is setup for the controller - assertThat(emailElement.controller.label).isEqualTo(EmailConfig().label) + assertThat(emailElement.controller.label.first()).isEqualTo(EmailConfig().label) assertThat(emailElement.identifier.value).isEqualTo("email") } diff --git a/payments-ui-core/src/test/java/com/stripe/android/utils/TestUtils.kt b/payments-ui-core/src/test/java/com/stripe/android/utils/TestUtils.kt new file mode 100644 index 00000000000..68f73f898d0 --- /dev/null +++ b/payments-ui-core/src/test/java/com/stripe/android/utils/TestUtils.kt @@ -0,0 +1,16 @@ +package com.stripe.android.utils + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import org.robolectric.shadows.ShadowLooper + +object TestUtils { + @JvmStatic + fun idleLooper() = ShadowLooper.idleMainLooper() + + fun viewModelFactoryFor(viewModel: ViewModel) = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return viewModel as T + } + } +} diff --git a/paymentsheet/api/paymentsheet.api b/paymentsheet/api/paymentsheet.api index 5b42ef79631..db42c717c8b 100644 --- a/paymentsheet/api/paymentsheet.api +++ b/paymentsheet/api/paymentsheet.api @@ -531,11 +531,11 @@ public final class com/stripe/android/paymentsheet/forms/FormViewModel_Factory_M } public final class com/stripe/android/paymentsheet/forms/TransformSpecToElement_Factory : dagger/internal/Factory { - public fun (Ljavax/inject/Provider;Ljavax/inject/Provider;)V - public static fun create (Ljavax/inject/Provider;Ljavax/inject/Provider;)Lcom/stripe/android/paymentsheet/forms/TransformSpecToElement_Factory; + public fun (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)V + public static fun create (Ljavax/inject/Provider;Ljavax/inject/Provider;Ljavax/inject/Provider;)Lcom/stripe/android/paymentsheet/forms/TransformSpecToElement_Factory; public fun get ()Lcom/stripe/android/paymentsheet/forms/TransformSpecToElement; public synthetic fun get ()Ljava/lang/Object; - public static fun newInstance (Lcom/stripe/android/ui/core/forms/resources/ResourceRepository;Lcom/stripe/android/paymentsheet/paymentdatacollection/FormFragmentArguments;)Lcom/stripe/android/paymentsheet/forms/TransformSpecToElement; + public static fun newInstance (Lcom/stripe/android/ui/core/forms/resources/ResourceRepository;Lcom/stripe/android/paymentsheet/paymentdatacollection/FormFragmentArguments;Landroid/content/Context;)Lcom/stripe/android/paymentsheet/forms/TransformSpecToElement; } public final class com/stripe/android/paymentsheet/injection/DaggerFlowControllerComponent : com/stripe/android/paymentsheet/injection/FlowControllerComponent { diff --git a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_amex.xml b/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_amex.xml deleted file mode 100644 index e50c01b5dcc..00000000000 --- a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_amex.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_dinersclub.xml b/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_dinersclub.xml deleted file mode 100644 index 416fb684b80..00000000000 --- a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_dinersclub.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_discover.xml b/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_discover.xml deleted file mode 100644 index 374aa0b5722..00000000000 --- a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_discover.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_jcb.xml b/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_jcb.xml deleted file mode 100644 index b592f418136..00000000000 --- a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_jcb.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_mastercard.xml b/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_mastercard.xml deleted file mode 100644 index 77292d5bac1..00000000000 --- a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_mastercard.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - diff --git a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_unionpay.xml b/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_unionpay.xml deleted file mode 100644 index aef0462df8a..00000000000 --- a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_unionpay.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - diff --git a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_unknown.xml b/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_unknown.xml deleted file mode 100644 index dc1f7b9afdd..00000000000 --- a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_unknown.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_visa.xml b/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_visa.xml deleted file mode 100644 index a2901e62ec5..00000000000 --- a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_card_visa.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_cvc.xml b/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_cvc.xml deleted file mode 100644 index 53c3b6dc418..00000000000 --- a/paymentsheet/res/drawable-night/stripe_ic_paymentsheet_cvc.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - diff --git a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_amex.xml b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_amex.xml index ce56d4c99b6..9c928ced3fe 100644 --- a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_amex.xml +++ b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_amex.xml @@ -1,9 +1,12 @@ - + + diff --git a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_dinersclub.xml b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_dinersclub.xml index b3269441e37..abdaa11a5a1 100644 --- a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_dinersclub.xml +++ b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_dinersclub.xml @@ -1,9 +1,13 @@ - + + diff --git a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_discover.xml b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_discover.xml index 1e6156c27e4..f35d9428ddb 100644 --- a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_discover.xml +++ b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_discover.xml @@ -1,39 +1,39 @@ - - - - - - - - - - - - - + + + + + + + + + + + diff --git a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_jcb.xml b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_jcb.xml index 8278cfd976a..c420b1477e9 100644 --- a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_jcb.xml +++ b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_jcb.xml @@ -1,21 +1,24 @@ - - - - - + + + + + + diff --git a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_mastercard.xml b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_mastercard.xml index 79dbd315e21..4cf39aa152d 100644 --- a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_mastercard.xml +++ b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_mastercard.xml @@ -1,18 +1,21 @@ - - - - + + + + + diff --git a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_unionpay.xml b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_unionpay.xml index 34d30261335..36565a437ce 100644 --- a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_unionpay.xml +++ b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_unionpay.xml @@ -1,18 +1,22 @@ - - - - + + + + + diff --git a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_unknown.xml b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_unknown.xml index 1afc7e359c1..3cf5034dc52 100644 --- a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_unknown.xml +++ b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_unknown.xml @@ -1,24 +1,28 @@ - - - - - + + + + + + diff --git a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_visa.xml b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_visa.xml index 692ea2f4f53..0428f1bbf38 100644 --- a/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_visa.xml +++ b/paymentsheet/res/drawable/stripe_ic_paymentsheet_card_visa.xml @@ -1,9 +1,17 @@ - + + + diff --git a/paymentsheet/res/drawable/stripe_ic_paymentsheet_cvc.xml b/paymentsheet/res/drawable/stripe_ic_paymentsheet_cvc.xml deleted file mode 100644 index 93881980afd..00000000000 --- a/paymentsheet/res/drawable/stripe_ic_paymentsheet_cvc.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - diff --git a/paymentsheet/res/values-b+es+419/strings.xml b/paymentsheet/res/values-b+es+419/strings.xml index a0d2b242fb4..8e625098fe5 100644 --- a/paymentsheet/res/values-b+es+419/strings.xml +++ b/paymentsheet/res/values-b+es+419/strings.xml @@ -19,8 +19,6 @@ Se produjo un error interno. Agregar otro - - Información de la tarjeta País o región diff --git a/paymentsheet/res/values-ca-rES/strings.xml b/paymentsheet/res/values-ca-rES/strings.xml index 269fc97abd2..ed1b2cd4c2b 100644 --- a/paymentsheet/res/values-ca-rES/strings.xml +++ b/paymentsheet/res/values-ca-rES/strings.xml @@ -19,8 +19,6 @@ S\'ha produït un error intern. + Afegir - - Informació de targeta País o regió diff --git a/paymentsheet/res/values-cs-rCZ/strings.xml b/paymentsheet/res/values-cs-rCZ/strings.xml index 47ead6d755e..2fa7f9d374d 100644 --- a/paymentsheet/res/values-cs-rCZ/strings.xml +++ b/paymentsheet/res/values-cs-rCZ/strings.xml @@ -19,8 +19,6 @@ Došlo k interní chybě. + Přidat - - Informace o kartě Země nebo region diff --git a/paymentsheet/res/values-da/strings.xml b/paymentsheet/res/values-da/strings.xml index f9aba07871b..22f61c9d18c 100644 --- a/paymentsheet/res/values-da/strings.xml +++ b/paymentsheet/res/values-da/strings.xml @@ -19,8 +19,6 @@ Der opstod en intern fejl. + Tilføj - - Kortoplysninger Land eller region diff --git a/paymentsheet/res/values-de/strings.xml b/paymentsheet/res/values-de/strings.xml index 954d5953aef..137b5dbdc80 100644 --- a/paymentsheet/res/values-de/strings.xml +++ b/paymentsheet/res/values-de/strings.xml @@ -19,8 +19,6 @@ Es ist ein interner Fehler aufgetreten. + Hinzufügen - - Kartendaten Land oder Region diff --git a/paymentsheet/res/values-el-rGR/strings.xml b/paymentsheet/res/values-el-rGR/strings.xml index f21f74f6dea..e8227178ebb 100644 --- a/paymentsheet/res/values-el-rGR/strings.xml +++ b/paymentsheet/res/values-el-rGR/strings.xml @@ -19,8 +19,6 @@ Προέκυψε εσωτερικό σφάλμα. + Προσθήκη - - Στοιχεία κάρτας Χώρα ή περιοχή diff --git a/paymentsheet/res/values-en-rGB/strings.xml b/paymentsheet/res/values-en-rGB/strings.xml index 13624665205..8a71f8da3f3 100644 --- a/paymentsheet/res/values-en-rGB/strings.xml +++ b/paymentsheet/res/values-en-rGB/strings.xml @@ -19,8 +19,6 @@ An internal error occurred. + Add - - Card information Country or region diff --git a/paymentsheet/res/values-es/strings.xml b/paymentsheet/res/values-es/strings.xml index 778e19e0178..00e3c02e6a4 100644 --- a/paymentsheet/res/values-es/strings.xml +++ b/paymentsheet/res/values-es/strings.xml @@ -19,8 +19,6 @@ Se ha producido un error interno. + Añadir más datos - - Información de la tarjeta País o región diff --git a/paymentsheet/res/values-et-rEE/strings.xml b/paymentsheet/res/values-et-rEE/strings.xml index bfd2efc4cb4..532f84ca3bc 100644 --- a/paymentsheet/res/values-et-rEE/strings.xml +++ b/paymentsheet/res/values-et-rEE/strings.xml @@ -19,8 +19,6 @@ Ilmnes sisemine tõrge. + lisa - - Kaardi andmed Riik või regioon diff --git a/paymentsheet/res/values-fi/strings.xml b/paymentsheet/res/values-fi/strings.xml index b9256c84960..e4f65ca2f45 100644 --- a/paymentsheet/res/values-fi/strings.xml +++ b/paymentsheet/res/values-fi/strings.xml @@ -19,8 +19,6 @@ Sisäinen virhe on tapahtunut. + Lisää - - Kortin tiedot Maa tai alue diff --git a/paymentsheet/res/values-fil/strings.xml b/paymentsheet/res/values-fil/strings.xml index e22ebad54ed..9526018352b 100644 --- a/paymentsheet/res/values-fil/strings.xml +++ b/paymentsheet/res/values-fil/strings.xml @@ -19,8 +19,6 @@ May nangyaring internal na error. +Magdagdag - - Impormasyon ng kard Bansa o rehiyon diff --git a/paymentsheet/res/values-fr-rCA/strings.xml b/paymentsheet/res/values-fr-rCA/strings.xml index 3af1e81cf22..549c8894699 100644 --- a/paymentsheet/res/values-fr-rCA/strings.xml +++ b/paymentsheet/res/values-fr-rCA/strings.xml @@ -19,8 +19,6 @@ Une erreur interne s\'est produite. + Ajouter - - Informations de la carte Pays ou région diff --git a/paymentsheet/res/values-fr/strings.xml b/paymentsheet/res/values-fr/strings.xml index f1f0d6db76f..5117ef9c5e4 100644 --- a/paymentsheet/res/values-fr/strings.xml +++ b/paymentsheet/res/values-fr/strings.xml @@ -19,8 +19,6 @@ Une erreur interne s\'est produite. + Ajouter - - Informations concernant la carte bancaire Pays ou région diff --git a/paymentsheet/res/values-hr/strings.xml b/paymentsheet/res/values-hr/strings.xml index 77574e5c7fe..0acc75946c2 100644 --- a/paymentsheet/res/values-hr/strings.xml +++ b/paymentsheet/res/values-hr/strings.xml @@ -19,8 +19,6 @@ Došlo je do pogreške sustava. + Dodaj - - Informacije o kartici Zemlja ili regija diff --git a/paymentsheet/res/values-hu/strings.xml b/paymentsheet/res/values-hu/strings.xml index cb2eda673ad..b8620c462f5 100644 --- a/paymentsheet/res/values-hu/strings.xml +++ b/paymentsheet/res/values-hu/strings.xml @@ -19,8 +19,6 @@ Belső hiba történt. + Hozzáadás - - Kártyaadatok Ország vagy régió diff --git a/paymentsheet/res/values-in/strings.xml b/paymentsheet/res/values-in/strings.xml index 5a0808ea36d..91dc2bd63e0 100644 --- a/paymentsheet/res/values-in/strings.xml +++ b/paymentsheet/res/values-in/strings.xml @@ -19,8 +19,6 @@ Terjadi kesalahan internal. + Tambahkan - - Informasi kartu Negara atau wilayah diff --git a/paymentsheet/res/values-it/strings.xml b/paymentsheet/res/values-it/strings.xml index 565d8e35f7f..f7c7656b482 100644 --- a/paymentsheet/res/values-it/strings.xml +++ b/paymentsheet/res/values-it/strings.xml @@ -19,8 +19,6 @@ Si è verificato un errore interno. + Aggiungi - - Dati della carta Paese o area geografica diff --git a/paymentsheet/res/values-ja/strings.xml b/paymentsheet/res/values-ja/strings.xml index db732129bdd..c7993815e93 100644 --- a/paymentsheet/res/values-ja/strings.xml +++ b/paymentsheet/res/values-ja/strings.xml @@ -19,8 +19,6 @@ 内部エラーが発生しました。 + 追加 - - カード情報 国または地域 diff --git a/paymentsheet/res/values-ko/strings.xml b/paymentsheet/res/values-ko/strings.xml index 28667e82bc2..9bcec27291c 100644 --- a/paymentsheet/res/values-ko/strings.xml +++ b/paymentsheet/res/values-ko/strings.xml @@ -19,8 +19,6 @@ 내부 오류가 발생했습니다. + 추가 - - 카드 정보 국가 또는 지역 diff --git a/paymentsheet/res/values-lt-rLT/strings.xml b/paymentsheet/res/values-lt-rLT/strings.xml index 0de6874f6af..d200898e544 100644 --- a/paymentsheet/res/values-lt-rLT/strings.xml +++ b/paymentsheet/res/values-lt-rLT/strings.xml @@ -19,8 +19,6 @@ Įvyko vidinė klaida. + Pridėti - - Kortelės duomenys Šalis arba regionas diff --git a/paymentsheet/res/values-lv-rLV/strings.xml b/paymentsheet/res/values-lv-rLV/strings.xml index 0ef78b40565..ea7ee37fd7f 100644 --- a/paymentsheet/res/values-lv-rLV/strings.xml +++ b/paymentsheet/res/values-lv-rLV/strings.xml @@ -19,8 +19,6 @@ Radās iekšēja kļūda. + Pievienot - - Kartes informācija Valsts vai reģions diff --git a/paymentsheet/res/values-ms-rMY/strings.xml b/paymentsheet/res/values-ms-rMY/strings.xml index 64a06764ece..d6bd35923e4 100644 --- a/paymentsheet/res/values-ms-rMY/strings.xml +++ b/paymentsheet/res/values-ms-rMY/strings.xml @@ -19,8 +19,6 @@ Ada ralat dalaman berlaku. + Tambah - - Maklumat kad Negara atau rantau diff --git a/paymentsheet/res/values-mt/strings.xml b/paymentsheet/res/values-mt/strings.xml index d71cdd5ebb8..62bfe0f931b 100644 --- a/paymentsheet/res/values-mt/strings.xml +++ b/paymentsheet/res/values-mt/strings.xml @@ -19,8 +19,6 @@ Seħħ żball intern. + Żid - - L-informazzjoni tal-kard Pajjiż jew reġjun diff --git a/paymentsheet/res/values-nb/strings.xml b/paymentsheet/res/values-nb/strings.xml index e1e39176fa0..4a111f634aa 100644 --- a/paymentsheet/res/values-nb/strings.xml +++ b/paymentsheet/res/values-nb/strings.xml @@ -19,8 +19,6 @@ Det oppstod en intern feil. + Legg til - - Kortinformasjon Land eller region diff --git a/paymentsheet/res/values-nl/strings.xml b/paymentsheet/res/values-nl/strings.xml index 4fdec5dcd11..71b51420e6c 100644 --- a/paymentsheet/res/values-nl/strings.xml +++ b/paymentsheet/res/values-nl/strings.xml @@ -19,8 +19,6 @@ Er is een interne fout opgetreden. + Toevoegen - - Kaartgegevens Land of regio diff --git a/paymentsheet/res/values-nn-rNO/strings.xml b/paymentsheet/res/values-nn-rNO/strings.xml index 8e7f00b33b6..3d3b88409cc 100644 --- a/paymentsheet/res/values-nn-rNO/strings.xml +++ b/paymentsheet/res/values-nn-rNO/strings.xml @@ -19,8 +19,6 @@ Ein intern feil oppstod. + Legg til - - Kortinformasjon Land eller region diff --git a/paymentsheet/res/values-pl-rPL/strings.xml b/paymentsheet/res/values-pl-rPL/strings.xml index 769a59c840a..4f1e7816656 100644 --- a/paymentsheet/res/values-pl-rPL/strings.xml +++ b/paymentsheet/res/values-pl-rPL/strings.xml @@ -19,8 +19,6 @@ Wystąpił wewnętrzny błąd. + Dodaj - - Dane karty Kraj lub region diff --git a/paymentsheet/res/values-pt-rBR/strings.xml b/paymentsheet/res/values-pt-rBR/strings.xml index 2ffdbfd5939..b7d500c8673 100644 --- a/paymentsheet/res/values-pt-rBR/strings.xml +++ b/paymentsheet/res/values-pt-rBR/strings.xml @@ -19,8 +19,6 @@ Ocorreu um erro interno. + Adicionar - - Dados do cartão País ou região diff --git a/paymentsheet/res/values-pt-rPT/strings.xml b/paymentsheet/res/values-pt-rPT/strings.xml index 3248b99e29d..ce6b5e1d839 100644 --- a/paymentsheet/res/values-pt-rPT/strings.xml +++ b/paymentsheet/res/values-pt-rPT/strings.xml @@ -19,8 +19,6 @@ Ocorreu um erro interno. + Adicionar - - Informações do cartão País ou região diff --git a/paymentsheet/res/values-ro-rRO/strings.xml b/paymentsheet/res/values-ro-rRO/strings.xml index 4a33262a63d..f6792356ea8 100644 --- a/paymentsheet/res/values-ro-rRO/strings.xml +++ b/paymentsheet/res/values-ro-rRO/strings.xml @@ -19,8 +19,6 @@ A apărut o eroare internă. + Adăugare - - Informații privind cardul Țară sau regiune diff --git a/paymentsheet/res/values-ru/strings.xml b/paymentsheet/res/values-ru/strings.xml index a9b2525afca..b31fd7bcd60 100644 --- a/paymentsheet/res/values-ru/strings.xml +++ b/paymentsheet/res/values-ru/strings.xml @@ -19,8 +19,6 @@ Произошла внутренняя ошибка. + Добавить - - Данные платежной карты Страна или регион diff --git a/paymentsheet/res/values-sk-rSK/strings.xml b/paymentsheet/res/values-sk-rSK/strings.xml index b0e523398a5..39cba8558e3 100644 --- a/paymentsheet/res/values-sk-rSK/strings.xml +++ b/paymentsheet/res/values-sk-rSK/strings.xml @@ -19,8 +19,6 @@ Vyskytla sa interná chyba. + Pridať - - Informácie o karte Krajina alebo región diff --git a/paymentsheet/res/values-sl-rSI/strings.xml b/paymentsheet/res/values-sl-rSI/strings.xml index ad4d3e24398..5654bdee6cb 100644 --- a/paymentsheet/res/values-sl-rSI/strings.xml +++ b/paymentsheet/res/values-sl-rSI/strings.xml @@ -19,8 +19,6 @@ Prišlo je do notranje napake. + Dodaj - - Podatki o kartici Država ali regija diff --git a/paymentsheet/res/values-sv/strings.xml b/paymentsheet/res/values-sv/strings.xml index 401d24ff786..179af4e924f 100644 --- a/paymentsheet/res/values-sv/strings.xml +++ b/paymentsheet/res/values-sv/strings.xml @@ -19,8 +19,6 @@ Ett internt fel uppstod. + Lägg till - - Kortinformation Land eller region diff --git a/paymentsheet/res/values-th/strings.xml b/paymentsheet/res/values-th/strings.xml index 034cd282855..f4d4c45e005 100644 --- a/paymentsheet/res/values-th/strings.xml +++ b/paymentsheet/res/values-th/strings.xml @@ -19,8 +19,6 @@ เกิดข้อผิดพลาดภายใน + เพิ่ม - - ข้อมูลบัตร ประเทศหรือภูมิภาค diff --git a/paymentsheet/res/values-tr/strings.xml b/paymentsheet/res/values-tr/strings.xml index 587747a859a..65ff9adb2b7 100644 --- a/paymentsheet/res/values-tr/strings.xml +++ b/paymentsheet/res/values-tr/strings.xml @@ -19,8 +19,6 @@ Dâhili bir hata oluştu. + Ekle - - Kart bilgileri Ülke veya bölge diff --git a/paymentsheet/res/values-vi/strings.xml b/paymentsheet/res/values-vi/strings.xml index a18158b9cb2..572ce185548 100644 --- a/paymentsheet/res/values-vi/strings.xml +++ b/paymentsheet/res/values-vi/strings.xml @@ -19,8 +19,6 @@ Đã xảy ra lỗi nội bộ. + Thêm - - Thông tin thẻ Quốc gia hoặc khu vực diff --git a/paymentsheet/res/values-zh-rHK/strings.xml b/paymentsheet/res/values-zh-rHK/strings.xml index e4425a64782..dee2b819d41 100644 --- a/paymentsheet/res/values-zh-rHK/strings.xml +++ b/paymentsheet/res/values-zh-rHK/strings.xml @@ -19,8 +19,6 @@ 發生了內部錯誤。 + 添加 - - 銀行卡資訊 國家或地區 diff --git a/paymentsheet/res/values-zh-rTW/strings.xml b/paymentsheet/res/values-zh-rTW/strings.xml index 63ad83b91dd..a1a682084f8 100644 --- a/paymentsheet/res/values-zh-rTW/strings.xml +++ b/paymentsheet/res/values-zh-rTW/strings.xml @@ -19,8 +19,6 @@ 發生了內部錯誤。 + 添加 - - 金融卡資訊 國家或地區 diff --git a/paymentsheet/res/values-zh/strings.xml b/paymentsheet/res/values-zh/strings.xml index 4d2141a247a..8040fde9c41 100644 --- a/paymentsheet/res/values-zh/strings.xml +++ b/paymentsheet/res/values-zh/strings.xml @@ -19,8 +19,6 @@ 发生了内部错误。 + 添加 - - 银行卡信息 国家或地区 diff --git a/paymentsheet/res/values/donottranslate.xml b/paymentsheet/res/values/donottranslate.xml index b198cb59904..235ebc92a23 100644 --- a/paymentsheet/res/values/donottranslate.xml +++ b/paymentsheet/res/values/donottranslate.xml @@ -15,4 +15,7 @@ TEST MODE + + + Card information diff --git a/paymentsheet/res/values/strings.xml b/paymentsheet/res/values/strings.xml index b6fa8ccdfa5..c18fd8cc394 100644 --- a/paymentsheet/res/values/strings.xml +++ b/paymentsheet/res/values/strings.xml @@ -19,8 +19,6 @@ An internal error occurred. + Add - - Card information Country or region diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt index 9fbbe1fc531..aab053dd5c5 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/BaseAddPaymentMethodFragment.kt @@ -25,7 +25,6 @@ import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetAddPaymen import com.stripe.android.paymentsheet.forms.FormFieldValues import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.model.SupportedPaymentMethod -import com.stripe.android.paymentsheet.paymentdatacollection.CardDataCollectionFragment import com.stripe.android.paymentsheet.paymentdatacollection.ComposeFormDataCollectionFragment import com.stripe.android.paymentsheet.paymentdatacollection.FormFragmentArguments import com.stripe.android.paymentsheet.paymentdatacollection.TransformToPaymentMethodCreateParams @@ -212,14 +211,7 @@ internal abstract class BaseAddPaymentMethodFragment : Fragment() { companion object { private fun fragmentForPaymentMethod(paymentMethod: SupportedPaymentMethod) = - when (paymentMethod) { - SupportedPaymentMethod.Card -> { - CardDataCollectionFragment::class.java - } - else -> { - ComposeFormDataCollectionFragment::class.java - } - } + ComposeFormDataCollectionFragment::class.java private val transformToPaymentMethodCreateParams = TransformToPaymentMethodCreateParams() diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsAdapter.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsAdapter.kt index d285fff6faf..fd3f172030b 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsAdapter.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/PaymentOptionsAdapter.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -531,6 +532,7 @@ internal fun PaymentOptionUi( Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize() ) { Image( painter = painterResource(iconRes), diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/FormPreview.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/FormPreview.kt deleted file mode 100644 index 6fa8adaa66b..00000000000 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/FormPreview.kt +++ /dev/null @@ -1,140 +0,0 @@ -package com.stripe.android.paymentsheet.forms - -import android.annotation.SuppressLint -import androidx.compose.runtime.Composable -import com.stripe.android.core.injection.DUMMY_INJECTOR_KEY -import com.stripe.android.paymentsheet.PaymentSheet -import com.stripe.android.paymentsheet.model.SupportedPaymentMethod -import com.stripe.android.paymentsheet.paymentdatacollection.FormFragmentArguments -import com.stripe.android.ui.core.address.AddressFieldElementRepository -import com.stripe.android.ui.core.elements.BankRepository -import com.stripe.android.ui.core.elements.SupportedBankType -import com.stripe.android.ui.core.forms.SofortForm -import com.stripe.android.ui.core.forms.resources.StaticResourceRepository -import kotlinx.coroutines.flow.MutableStateFlow - -/** - * This will render a preview of the form in IntelliJ. It can't access resources, and - * it must exist in src/main, not src/test. - */ -// @Preview AGP: 7.0.0 will not cause a lint error, until then it is commented out -@SuppressLint("VisibleForTests") -@Composable -internal fun FormInternalPreview() { - val formElements = SofortForm.items - val addressFieldElementRepository = AddressFieldElementRepository(null) - val bankRepository = BankRepository(null) - - addressFieldElementRepository.initialize("ZZ", ZZ_ADDRESS) - - bankRepository.initialize( - mapOf( - SupportedBankType.Ideal to IDEAL_BANKS, - SupportedBankType.Eps to EPS_Banks, - SupportedBankType.P24 to P24_BANKS - ) - ) - - FormInternal( - MutableStateFlow(emptyList()), - MutableStateFlow(true), - MutableStateFlow( - TransformSpecToElement( - StaticResourceRepository( - bankRepository, - addressFieldElementRepository - ), - FormFragmentArguments( - SupportedPaymentMethod.Bancontact, - showCheckbox = false, - showCheckboxControlledFields = true, - merchantName = "Merchant, Inc.", - billingDetails = PaymentSheet.BillingDetails( - address = PaymentSheet.Address( - line1 = "123 Main Street", - line2 = null, - city = "San Francisco", - state = "CA", - postalCode = "94111", - country = "DE", - ), - email = "email", - name = "Jenny Rosen", - phone = "+18008675309" - ), - injectorKey = DUMMY_INJECTOR_KEY - ) - ).transform(formElements) - ) - ) -} - -private val EPS_Banks = """ - [ - { - "value": "arzte_und_apotheker_bank", - "text": "Ärzte- und Apothekerbank", - "icon": "arzte_und_apotheker_bank" - }, - { - "value": "austrian_anadi_bank_ag", - "text": "Austrian Anadi Bank AG", - "icon": "austrian_anadi_bank_ag" - }, - { - "value": "bank_austria", - "text": "Bank Austria" - } - ] - -""".trimIndent().byteInputStream() - -private val IDEAL_BANKS = """ - [ - { - "value": "abn_amro", - "icon": "abn_amro", - "text": "ABN Amro" - }, - { - "value": "asn_bank", - "icon": "asn_bank", - "text": "ASN Bank" - } - ] -""".trimIndent().byteInputStream() - -private val P24_BANKS = """ - [ - { - "value": "alior_bank", - "icon": "alior_bank", - "text": "Alior Bank" - }, - { - "value": "bank_millennium", - "icon": "bank_millennium", - "text": "Bank Millenium" - } - ] -""".trimIndent().byteInputStream() - -private val ZZ_ADDRESS = """ - [ - { - "type": "addressLine1", - "required": true - }, - { - "type": "addressLine2", - "required": false - }, - { - "type": "locality", - "required": true, - "schema": { - "nameType": "city" - } - } - ] -""".trimIndent().byteInputStream() diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/FormUI.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/FormUI.kt index 2c92b0aab8f..6e4e0e00481 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/FormUI.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/FormUI.kt @@ -40,7 +40,8 @@ internal fun Form(formViewModel: FormViewModel) { FormInternal( formViewModel.hiddenIdentifiers, formViewModel.enabled, - formViewModel.elements + formViewModel.elements, + formViewModel.lastTextFieldIdentifier ) } @@ -48,26 +49,30 @@ internal fun Form(formViewModel: FormViewModel) { internal fun FormInternal( hiddenIdentifiersFlow: Flow>, enabledFlow: Flow, - elementsFlow: Flow?> + elementsFlow: Flow?>, + lastTextFieldIdentifierFlow: Flow ) { val hiddenIdentifiers by hiddenIdentifiersFlow.collectAsState(emptyList()) val enabled by enabledFlow.collectAsState(true) val elements by elementsFlow.collectAsState(null) + val lastTextFieldIdentifier by lastTextFieldIdentifierFlow.collectAsState(null) Column( modifier = Modifier.fillMaxWidth(1f) ) { elements?.let { - it.forEach { element -> + it.forEachIndexed { index, element -> if (!hiddenIdentifiers.contains(element.identifier)) { when (element) { - is SectionElement -> SectionElementUI(enabled, element, hiddenIdentifiers) - is StaticTextElement -> StaticElementUI(element) - is SaveForFutureUseElement -> SaveForFutureUseElementUI( + is SectionElement -> SectionElementUI( enabled, - element + element, + hiddenIdentifiers, + lastTextFieldIdentifier ) + is StaticTextElement -> StaticElementUI(element) + is SaveForFutureUseElement -> SaveForFutureUseElementUI(enabled, element) is AfterpayClearpayHeaderElement -> AfterpayClearpayElementUI( enabled, element diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/FormViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/FormViewModel.kt index 1b9d0031fe6..46ecb6b0721 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/FormViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/FormViewModel.kt @@ -1,5 +1,6 @@ package com.stripe.android.paymentsheet.forms +import android.content.Context import android.content.res.Resources import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel @@ -11,10 +12,12 @@ import com.stripe.android.paymentsheet.injection.DaggerFormViewModelComponent import com.stripe.android.paymentsheet.injection.FormViewModelSubcomponent import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.paymentdatacollection.FormFragmentArguments +import com.stripe.android.ui.core.elements.CardBillingAddressElement import com.stripe.android.ui.core.elements.FormElement import com.stripe.android.ui.core.elements.LayoutSpec import com.stripe.android.ui.core.elements.MandateTextElement import com.stripe.android.ui.core.elements.SaveForFutureUseElement +import com.stripe.android.ui.core.elements.SectionElement import com.stripe.android.ui.core.elements.SectionSpec import com.stripe.android.ui.core.forms.resources.ResourceRepository import kotlinx.coroutines.FlowPreview @@ -48,10 +51,12 @@ internal class FormViewModel @Inject internal constructor( internal class Factory( val config: FormFragmentArguments, val resource: Resources, - var layout: LayoutSpec + var layout: LayoutSpec, + private val contextSupplier: () -> Context ) : ViewModelProvider.Factory, Injectable { internal data class FallbackInitializeParam( - val resource: Resources + val resource: Resources, + val context: Context ) @Inject @@ -59,7 +64,8 @@ internal class FormViewModel @Inject internal constructor( @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - injectWithFallback(config.injectorKey, FallbackInitializeParam(resource)) + val context = contextSupplier() + injectWithFallback(config.injectorKey, FallbackInitializeParam(resource, context)) return subComponentBuilderProvider.get() .formFragmentArguments(config) .layout(layout) @@ -68,6 +74,7 @@ internal class FormViewModel @Inject internal constructor( override fun fallbackInitialize(arg: FallbackInitializeParam) { DaggerFormViewModelComponent.builder() + .context(arg.context) .resources(arg.resource) .build() .inject(this) @@ -117,13 +124,26 @@ internal class FormViewModel @Inject internal constructor( } } + private val cardBillingElement = elements + .map { elementsList -> + elementsList + ?.filterIsInstance() + ?.flatMap { it.fields } + ?.filterIsInstance() + ?.firstOrNull() + } + internal val hiddenIdentifiers = combine( saveForFutureUseVisible, saveForFutureUseElement.map { it?.controller?.hiddenIdentifiers ?: flowOf(emptyList()) + }.flattenConcat(), + cardBillingElement.map { + it?.hiddenIdentifiers ?: flowOf(emptyList()) }.flattenConcat() - ) { showFutureUse, hiddenIdentifiers -> + ) { showFutureUse, saveFutureUseIdentifiers, cardBillingIdentifiers -> + val hiddenIdentifiers = saveFutureUseIdentifiers.plus(cardBillingIdentifiers) // For hidden *section* identifiers, list of identifiers of elements in the section val identifiers = sectionToFieldIdentifierMap .filter { idControllerPair -> @@ -187,4 +207,18 @@ internal class FormViewModel @Inject internal constructor( showingMandate, userRequestedReuse ).filterFlow() + + private val textFieldControllerIdsFlow = elements.filterNotNull().map { elementsList -> + combine(elementsList.map { it.getTextFieldIdentifiers() }) { + it.toList().flatten() + } + }.flattenConcat() + val lastTextFieldIdentifier = combine( + hiddenIdentifiers, + textFieldControllerIdsFlow + ) { hiddenIds, textFieldControllerIds -> + textFieldControllerIds.lastOrNull { + !hiddenIds.contains(it) + } + } } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/TransformSpecToElement.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/TransformSpecToElement.kt index b769afc08be..badf8a952bc 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/TransformSpecToElement.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/forms/TransformSpecToElement.kt @@ -1,5 +1,6 @@ package com.stripe.android.paymentsheet.forms +import android.content.Context import com.stripe.android.paymentsheet.paymentdatacollection.FormFragmentArguments import com.stripe.android.paymentsheet.paymentdatacollection.getInitialValuesMap import com.stripe.android.ui.core.elements.FormItemSpec @@ -12,7 +13,8 @@ import javax.inject.Inject */ internal class TransformSpecToElement @Inject constructor( resourceRepository: ResourceRepository, - formFragmentArguments: FormFragmentArguments + formFragmentArguments: FormFragmentArguments, + context: Context ) { private val transformSpecToElements = TransformSpecToElements( @@ -21,7 +23,8 @@ internal class TransformSpecToElement @Inject constructor( amount = formFragmentArguments.amount, country = formFragmentArguments.billingDetails?.address?.country, saveForFutureUseInitialValue = formFragmentArguments.showCheckboxControlledFields, - merchantName = formFragmentArguments.merchantName + merchantName = formFragmentArguments.merchantName, + context = context ) internal fun transform(list: List) = transformSpecToElements.transform(list) diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/injection/FormViewModelComponent.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/injection/FormViewModelComponent.kt index 232faabb472..af3e375d101 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/injection/FormViewModelComponent.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/injection/FormViewModelComponent.kt @@ -1,5 +1,6 @@ package com.stripe.android.paymentsheet.injection +import android.content.Context import android.content.res.Resources import com.stripe.android.core.injection.CoroutineContextModule import com.stripe.android.paymentsheet.forms.FormViewModel @@ -19,6 +20,9 @@ internal interface FormViewModelComponent { @Component.Builder interface Builder { + @BindsInstance + fun context(context: Context): Builder + @BindsInstance fun resources(resources: Resources): Builder diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/model/SupportedPaymentMethod.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/model/SupportedPaymentMethod.kt index 8382db75fbb..5c9287baffa 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/model/SupportedPaymentMethod.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/model/SupportedPaymentMethod.kt @@ -15,6 +15,8 @@ import com.stripe.android.paymentsheet.forms.AfterpayClearpayRequirement import com.stripe.android.paymentsheet.forms.AuBecsDebitRequirement import com.stripe.android.paymentsheet.forms.BancontactRequirement import com.stripe.android.paymentsheet.forms.CardRequirement +import com.stripe.android.ui.core.forms.CardForm +import com.stripe.android.ui.core.forms.CardParamKey import com.stripe.android.paymentsheet.forms.Delayed import com.stripe.android.paymentsheet.forms.EpsRequirement import com.stripe.android.paymentsheet.forms.GiropayRequirement @@ -30,7 +32,6 @@ import com.stripe.android.paymentsheet.forms.ShippingAddress import com.stripe.android.paymentsheet.forms.SofortRequirement import com.stripe.android.ui.core.elements.LayoutFormDescriptor import com.stripe.android.ui.core.elements.LayoutSpec -import com.stripe.android.ui.core.elements.SaveForFutureUseSpec import com.stripe.android.ui.core.forms.AffirmForm import com.stripe.android.ui.core.forms.AffirmParamKey import com.stripe.android.ui.core.forms.AfterpayClearpayForm @@ -100,10 +101,8 @@ internal sealed class SupportedPaymentMethod( R.string.stripe_paymentsheet_payment_method_card, R.drawable.stripe_ic_paymentsheet_pm_card, CardRequirement, - mutableMapOf(), - LayoutSpec.create( - SaveForFutureUseSpec(emptyList()) - ) + CardParamKey, + CardForm ) @Parcelize diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragment.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragment.kt deleted file mode 100644 index 4db877ab12b..00000000000 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragment.kt +++ /dev/null @@ -1,413 +0,0 @@ -package com.stripe.android.paymentsheet.paymentdatacollection - -import android.os.Bundle -import android.util.TypedValue -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.widget.CheckBox -import android.widget.LinearLayout -import android.widget.Space -import android.widget.TextView -import androidx.annotation.VisibleForTesting -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.view.ContextThemeWrapper -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.stripe.android.core.model.Country -import com.stripe.android.core.model.CountryCode -import com.stripe.android.model.Address -import com.stripe.android.model.PaymentMethodCreateParams -import com.stripe.android.paymentsheet.PaymentOptionContract -import com.stripe.android.paymentsheet.PaymentOptionsViewModel -import com.stripe.android.paymentsheet.PaymentSheetActivity -import com.stripe.android.paymentsheet.PaymentSheetViewModel -import com.stripe.android.paymentsheet.R -import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetAddCardBinding -import com.stripe.android.paymentsheet.databinding.StripeHorizontalDividerBinding -import com.stripe.android.paymentsheet.databinding.StripeVerticalDividerBinding -import com.stripe.android.paymentsheet.model.PaymentSelection -import com.stripe.android.paymentsheet.ui.BillingAddressView -import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel -import com.stripe.android.view.CardInputListener -import com.stripe.android.view.CardMultilineWidget - -/** - * A [Fragment] for collecting data for a new card payment method. - */ -internal class CardDataCollectionFragment : Fragment() { - private lateinit var cardMultilineWidget: CardMultilineWidget - private lateinit var billingAddressView: BillingAddressView - private lateinit var cardErrors: TextView - private lateinit var billingErrors: TextView - private lateinit var saveCardCheckbox: CheckBox - private lateinit var bottomSpace: Space - - /** - * A [PaymentMethodCreateParams] instance if card and billing address details are valid; - * otherwise, `null`. - */ - private val paymentMethodParams: PaymentMethodCreateParams? - get() { - val cardParams = billingAddressView.address.value?.let { billingAddress -> - cardMultilineWidget.cardParams?.also { cardParams -> - cardParams.address = billingAddress - } - } - - return cardParams?.let { - PaymentMethodCreateParams.createCard(it) - } - } - - @VisibleForTesting - internal lateinit var sheetViewModel: BaseSheetViewModel<*> - - private val addCardViewModel: AddCardViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (requireNotNull( - requireArguments().getParcelable(PaymentSheetActivity.EXTRA_STARTER_ARGS) - ) is PaymentOptionContract.Args - ) { - sheetViewModel = ViewModelProvider( - requireActivity(), - PaymentOptionsViewModel.Factory( - { requireActivity().application }, - { - requireNotNull( - requireArguments().getParcelable( - PaymentSheetActivity.EXTRA_STARTER_ARGS - ) - ) - }, - (activity as? AppCompatActivity) ?: this - ) - ).get(PaymentOptionsViewModel::class.java) - } else { - sheetViewModel = ViewModelProvider( - requireActivity(), - PaymentSheetViewModel.Factory( - { requireActivity().application }, - { - requireNotNull( - requireArguments().getParcelable( - PaymentSheetActivity.EXTRA_STARTER_ARGS - ) - ) - }, - (activity as? AppCompatActivity) ?: this - ) - ).get(PaymentSheetViewModel::class.java) - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val themedInflater = inflater.cloneInContext( - ContextThemeWrapper(requireActivity(), R.style.StripePaymentSheetAddPaymentMethodTheme) - ) - - return themedInflater.inflate( - R.layout.fragment_paymentsheet_add_card, - container, - false - ) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - val viewBinding = FragmentPaymentsheetAddCardBinding.bind(view) - cardMultilineWidget = viewBinding.cardMultilineWidget - billingAddressView = viewBinding.billingAddress - cardErrors = viewBinding.cardErrors - billingErrors = viewBinding.billingErrors - saveCardCheckbox = viewBinding.saveCardCheckbox - bottomSpace = viewBinding.bottomSpace - - // This must be done prior to setting up the card widget or the save card checkbox won't - // populate correctly. - populateFieldsFromArguments() - populateFieldsFromNewCard() - setupCardWidget() - - billingAddressView.address.observe(viewLifecycleOwner) { - // update selection whenever billing address changes - updateSelection() - } - - cardMultilineWidget.setCardValidCallback { isValid, _ -> - // update selection whenever card details changes - addCardViewModel.isCardValid = isValid - updateSelection() - } - - cardMultilineWidget.setCardInputListener(object : CardInputListener { - override fun onFocusChange(focusField: CardInputListener.FocusField) {} - - override fun onCardComplete() {} - - override fun onExpirationComplete() {} - - override fun onCvcComplete() { - // move to first field when CVC is complete - billingAddressView.focusFirstField() - } - - override fun onPostalCodeComplete() {} - }) - - sheetViewModel.processing.observe(viewLifecycleOwner) { isProcessing -> - saveCardCheckbox.isEnabled = !isProcessing - cardMultilineWidget.isEnabled = !isProcessing - billingAddressView.isEnabled = !isProcessing - } - - setupSaveCardCheckbox() - } - - private fun updateSelection() { - val validCard = if (addCardViewModel.isCardValid) { - paymentMethodParams?.let { params -> - PaymentSelection.New.Card( - params, - cardMultilineWidget.getBrand(), - customerRequestedSave = shouldSaveCard() - ) - } - } else { - null - } - - // If you open a new unsaved card, edit it, go to the list view and come back the edited - // card should be shown, this means that the new card must be updated - validCard?.let { - sheetViewModel.newCard = validCard - } - sheetViewModel.updateSelection(validCard) - } - - private fun setupCardWidget() { - setOf( - cardMultilineWidget.cardNumberEditText, - cardMultilineWidget.expiryDateEditText, - cardMultilineWidget.cvcEditText - ).forEach { editText -> - editText.setTextSize( - TypedValue.COMPLEX_UNIT_PX, - resources.getDimension(R.dimen.stripe_paymentsheet_form_textsize) - ) - editText.setTextColor( - ContextCompat.getColor( - requireActivity(), - R.color.stripe_paymentsheet_textinput_color - ) - ) - - editText.setBackgroundResource(android.R.color.transparent) - - editText.setErrorColor( - ContextCompat.getColor(requireActivity(), R.color.stripe_paymentsheet_form_error) - ) - } - - cardMultilineWidget.expiryDateEditText.setIncludeSeparatorGaps(true) - cardMultilineWidget.setExpirationDatePlaceholderRes(null) - cardMultilineWidget.expiryTextInputLayout.hint = - getString(R.string.stripe_paymentsheet_expiration_date_hint) - cardMultilineWidget.cardNumberTextInputLayout.placeholderText = null - cardMultilineWidget.setCvcPlaceholderText("") - - cardMultilineWidget.cvcEditText.imeOptions = EditorInfo.IME_ACTION_NEXT - - // add vertical divider between expiry date and CVC - cardMultilineWidget.secondRowLayout.addView( - StripeVerticalDividerBinding.inflate( - layoutInflater, - cardMultilineWidget.secondRowLayout, - false - ).root, - 1 - ) - - // add horizontal divider between card number and other fields - cardMultilineWidget.addView( - StripeHorizontalDividerBinding.inflate( - layoutInflater, - cardMultilineWidget, - false - ).root, - 1 - ) - - val layoutMarginHorizontal = resources.getDimensionPixelSize( - R.dimen.stripe_paymentsheet_cardwidget_margin_horizontal - ) - val layoutMarginVertical = - resources.getDimensionPixelSize(R.dimen.stripe_paymentsheet_cardwidget_margin_vertical) - setOf( - cardMultilineWidget.cardNumberTextInputLayout, - cardMultilineWidget.expiryTextInputLayout, - cardMultilineWidget.cvcInputLayout - ).forEach { layout -> - layout.updateLayoutParams { - marginStart = layoutMarginHorizontal - marginEnd = layoutMarginHorizontal - topMargin = layoutMarginVertical - bottomMargin = layoutMarginVertical - } - layout.isErrorEnabled = false - layout.error = null - } - - cardMultilineWidget.setCvcIcon(R.drawable.stripe_ic_paymentsheet_cvc) - - cardMultilineWidget.setCardNumberErrorListener { errorMessage -> - onCardError( - AddCardViewModel.Field.Number, - errorMessage - ) - } - cardMultilineWidget.setExpirationDateErrorListener { errorMessage -> - onCardError( - AddCardViewModel.Field.Date, - errorMessage - ) - } - cardMultilineWidget.setCvcErrorListener { errorMessage -> - onCardError( - AddCardViewModel.Field.Cvc, - errorMessage - ) - } - cardMultilineWidget.setPostalCodeErrorListener(null) - - billingAddressView.postalCodeViewListener = - object : BillingAddressView.PostalCodeViewListener { - override fun onLosingFocus(country: Country?, isPostalValid: Boolean) { - val shouldToggleBillingError = - !isPostalValid && !billingAddressView.postalCodeView.text.isNullOrEmpty() - billingErrors.text = if (shouldToggleBillingError) { - if (country == null || CountryCode.isUS(country.code)) { - getString(R.string.address_zip_invalid) - } else { - getString(R.string.address_postal_code_invalid) - } - } else { - null - } - billingErrors.isVisible = !billingErrors.text.isNullOrEmpty() - } - - override fun onGainingFocus(country: Country?, isPostalValid: Boolean) { - // Always hide error field when user starts editing postal code - billingErrors.isVisible = false - } - - override fun onCountryChanged(country: Country?, isPostalValid: Boolean) { - billingErrors.text = null - billingErrors.isVisible = false - } - } - } - - private fun populateFieldsFromNewCard() { - val paymentMethodCreateParams = sheetViewModel.newCard?.paymentMethodCreateParams - saveCardCheckbox.isChecked = sheetViewModel.newCard?.customerRequestedSave == - PaymentSelection.CustomerRequestedSave.RequestReuse - cardMultilineWidget.populate(paymentMethodCreateParams?.card) - billingAddressView.populate(paymentMethodCreateParams?.billingDetails?.address) - } - - private fun populateFieldsFromArguments() { - requireArguments().getParcelable( - ComposeFormDataCollectionFragment.EXTRA_CONFIG - )?.billingDetails?.address?.also { - billingAddressView.populate( - Address(it.city, it.country, it.line1, it.line2, it.postalCode, it.state) - ) - } - } - - private fun onCardError( - field: AddCardViewModel.Field, - errorMessage: String? - ) { - addCardViewModel.cardErrors[field] = errorMessage - - val error = AddCardViewModel.Field.values() - .map { addCardViewModel.cardErrors[it] } - .firstOrNull { !it.isNullOrBlank() } - - cardErrors.text = error - cardErrors.isVisible = error != null - } - - private fun setupSaveCardCheckbox() { - saveCardCheckbox.text = getString( - R.string.stripe_paymentsheet_save_this_card_with_merchant_name, - sheetViewModel.merchantName - ) - requireArguments().getParcelable( - ComposeFormDataCollectionFragment.EXTRA_CONFIG - )?.let { args -> - saveCardCheckbox.isChecked = false - saveCardCheckbox.isVisible = args.showCheckbox - } - sheetViewModel.newCard?.customerRequestedSave?.also { - if (saveCardCheckbox.isVisible) { - saveCardCheckbox.isChecked = - it == PaymentSelection.CustomerRequestedSave.RequestReuse - } - } - - bottomSpace.isVisible = !saveCardCheckbox.isVisible - - saveCardCheckbox.setOnCheckedChangeListener { _, _ -> - onSaveCardCheckboxChanged() - } - } - - private fun onSaveCardCheckboxChanged() { - val selection = sheetViewModel.selection.value - if (selection is PaymentSelection.New.Card) { - val newCardSelection = selection.copy(customerRequestedSave = shouldSaveCard()) - sheetViewModel.updateSelection(newCardSelection) - sheetViewModel.newCard = newCardSelection - } - } - - private fun shouldSaveCard() = - if (saveCardCheckbox.isVisible) { - if (saveCardCheckbox.isChecked) { - PaymentSelection.CustomerRequestedSave.RequestReuse - } else { - PaymentSelection.CustomerRequestedSave.RequestNoReuse - } - } else { - PaymentSelection.CustomerRequestedSave.NoRequest - } - - internal class AddCardViewModel : ViewModel() { - var isCardValid: Boolean = false - - val cardErrors = mutableMapOf() - - enum class Field { - Number, - Date, - Cvc - } - } -} diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/ComposeFormDataCollectionFragment.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/ComposeFormDataCollectionFragment.kt index 37afbdf1c45..e427b7c5a34 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/ComposeFormDataCollectionFragment.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/paymentdatacollection/ComposeFormDataCollectionFragment.kt @@ -44,7 +44,8 @@ internal class ComposeFormDataCollectionFragment : Fragment() { requireArguments().getParcelable( EXTRA_CONFIG ) - ) + ), + contextSupplier = { requireContext() } ) } diff --git a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/viewmodels/BaseSheetViewModel.kt b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/viewmodels/BaseSheetViewModel.kt index 79dcd8668d2..06035c6336f 100644 --- a/paymentsheet/src/main/java/com/stripe/android/paymentsheet/viewmodels/BaseSheetViewModel.kt +++ b/paymentsheet/src/main/java/com/stripe/android/paymentsheet/viewmodels/BaseSheetViewModel.kt @@ -27,7 +27,6 @@ import com.stripe.android.paymentsheet.model.FragmentConfig import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.model.SavedSelection import com.stripe.android.paymentsheet.model.SupportedPaymentMethod -import com.stripe.android.paymentsheet.paymentdatacollection.CardDataCollectionFragment import com.stripe.android.paymentsheet.repositories.CustomerRepository import com.stripe.android.ui.core.Amount import com.stripe.android.ui.core.forms.resources.ResourceRepository diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsAddPaymentMethodFragmentTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsAddPaymentMethodFragmentTest.kt index 176f6967cb9..bc256c51761 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsAddPaymentMethodFragmentTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsAddPaymentMethodFragmentTest.kt @@ -22,7 +22,6 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock -import org.mockito.kotlin.times import org.robolectric.RobolectricTestRunner @ExperimentalCoroutinesApi @@ -128,7 +127,7 @@ internal class PaymentOptionsAddPaymentMethodFragmentTest : PaymentOptionsViewMo viewModel.setStripeIntent(args.stripeIntent) TestUtils.idleLooper() if (registerInjector) { - registerViewModel(args.injectorKey, viewModel) + registerViewModel(args.injectorKey, viewModel, createFormViewModel()) } launchFragmentInContainer( bundleOf( diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsViewModelTestInjection.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsViewModelTestInjection.kt index f3ff9a135f5..cff65a7698c 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsViewModelTestInjection.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentOptionsViewModelTestInjection.kt @@ -10,6 +10,8 @@ import com.stripe.android.core.injection.InjectorKey import com.stripe.android.core.injection.WeakMapInjectorRegistry import com.stripe.android.model.PaymentMethod import com.stripe.android.paymentsheet.analytics.EventReporter +import com.stripe.android.paymentsheet.forms.FormViewModel +import com.stripe.android.paymentsheet.injection.FormViewModelSubcomponent import com.stripe.android.paymentsheet.injection.PaymentOptionsViewModelSubcomponent import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -62,14 +64,24 @@ internal open class PaymentOptionsViewModelTestInjection { ) } + @ExperimentalCoroutinesApi + fun createFormViewModel(): FormViewModel = runBlocking { + FormViewModel( + layout = mock(), + config = mock(), + resourceRepository = mock(), + transformSpecToElement = mock() + ) + } + fun registerViewModel( @InjectorKey injectorKey: String, - viewModel: PaymentOptionsViewModel + viewModel: PaymentOptionsViewModel, + formViewModel: FormViewModel ) { val mockBuilder = mock() val mockSubcomponent = mock() val mockSubComponentBuilderProvider = mock>() - whenever(mockBuilder.build()).thenReturn(mockSubcomponent) whenever(mockBuilder.savedStateHandle(any())).thenReturn(mockBuilder) whenever(mockBuilder.application(any())).thenReturn(mockBuilder) @@ -77,11 +89,23 @@ internal open class PaymentOptionsViewModelTestInjection { whenever(mockSubcomponent.viewModel).thenReturn(viewModel) whenever(mockSubComponentBuilderProvider.get()).thenReturn(mockBuilder) + val mockFormBuilder = mock() + val mockFormSubcomponent = mock() + val mockFormSubComponentBuilderProvider = mock>() + whenever(mockFormBuilder.build()).thenReturn(mockFormSubcomponent) + whenever(mockFormBuilder.formFragmentArguments(any())).thenReturn(mockFormBuilder) + whenever(mockFormBuilder.layout(any())).thenReturn(mockFormBuilder) + whenever(mockFormSubcomponent.viewModel).thenReturn(formViewModel) + whenever(mockFormSubComponentBuilderProvider.get()).thenReturn(mockFormBuilder) + injector = object : Injector { override fun inject(injectable: Injectable<*>) { (injectable as? PaymentOptionsViewModel.Factory)?.let { injectable.subComponentBuilderProvider = mockSubComponentBuilderProvider } + (injectable as? FormViewModel.Factory)?.let { + injectable.subComponentBuilderProvider = mockFormSubComponentBuilderProvider + } } } WeakMapInjectorRegistry.register(injector, injectorKey) diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetActivityTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetActivityTest.kt index 78d8f2a811c..62054202e80 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetActivityTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetActivityTest.kt @@ -11,8 +11,9 @@ import androidx.test.core.app.ApplicationProvider import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.common.truth.Truth.assertThat import com.stripe.android.ApiKeyFixtures -import com.stripe.android.core.Logger import com.stripe.android.PaymentConfiguration +import com.stripe.android.core.Logger +import com.stripe.android.core.injection.DUMMY_INJECTOR_KEY import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncher import com.stripe.android.googlepaylauncher.GooglePayPaymentMethodLauncherContract import com.stripe.android.googlepaylauncher.injection.GooglePayPaymentMethodLauncherFactory @@ -23,7 +24,6 @@ import com.stripe.android.model.PaymentIntentFixtures import com.stripe.android.model.PaymentMethod import com.stripe.android.model.PaymentMethodCreateParamsFixtures import com.stripe.android.model.PaymentMethodFixtures -import com.stripe.android.core.injection.DUMMY_INJECTOR_KEY import com.stripe.android.model.PaymentMethodOptionsParams import com.stripe.android.payments.paymentlauncher.PaymentResult import com.stripe.android.payments.paymentlauncher.StripePaymentLauncherAssistedFactory @@ -89,8 +89,6 @@ internal class PaymentSheetActivityTest { @BeforeTest fun before() { - Dispatchers.setMain(testDispatcher) - PaymentConfiguration.init( context, ApiKeyFixtures.FAKE_PUBLISHABLE_KEY @@ -178,6 +176,9 @@ internal class PaymentSheetActivityTest { fun `updates buy button state on add payment`() { val scenario = activityScenario() scenario.launch(intent).onActivity { activity -> + // Based on previously run tests the viewModel might have a different selection state saved + viewModel.updateSelection(null) + viewModel.transitionTo( PaymentSheetViewModel.TransitionTarget.AddPaymentMethodFull( FragmentConfigFixtures.DEFAULT @@ -186,9 +187,9 @@ internal class PaymentSheetActivityTest { idleLooper() // Initially empty card + assertThat(activity.viewBinding.googlePayButton.isVisible).isFalse() assertThat(activity.viewBinding.buyButton.isVisible).isTrue() assertThat(activity.viewBinding.buyButton.isEnabled).isFalse() - assertThat(activity.viewBinding.googlePayButton.isVisible).isFalse() // Update to Google Pay viewModel.updateSelection(PaymentSelection.GooglePay) @@ -413,6 +414,7 @@ internal class PaymentSheetActivityTest { @Test fun `Verify FinishProcessing state calls the callback`() { + Dispatchers.setMain(testDispatcher) val scenario = activityScenario() scenario.launch(intent).onActivity { // wait for bottom sheet to animate in @@ -458,6 +460,7 @@ internal class PaymentSheetActivityTest { @Test fun `Verify FinishProcessing state calls the callback on google pay view state observer`() { + Dispatchers.setMain(testDispatcher) val scenario = activityScenario() scenario.launch(intent).onActivity { viewModel.checkoutIdentifier = CheckoutIdentifier.SheetBottomGooglePay @@ -498,6 +501,7 @@ internal class PaymentSheetActivityTest { @Test fun `Verify ProcessResult state closes the sheet`() { + Dispatchers.setMain(testDispatcher) val scenario = activityScenario() scenario.launch(intent).onActivity { activity -> // wait for bottom sheet to animate in @@ -520,6 +524,7 @@ internal class PaymentSheetActivityTest { @Test fun `successful payment should dismiss bottom sheet`() { + Dispatchers.setMain(testDispatcher) val scenario = activityScenario(viewModel) scenario.launch(intent).onActivity { activity -> // wait for bottom sheet to animate in diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetAddPaymentMethodFragmentTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetAddPaymentMethodFragmentTest.kt index 22037c0ee2b..b7bfd67b0ad 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetAddPaymentMethodFragmentTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/PaymentSheetAddPaymentMethodFragmentTest.kt @@ -29,7 +29,6 @@ import com.stripe.android.paymentsheet.model.FragmentConfigFixtures import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.paymentsheet.model.PaymentSheetViewState import com.stripe.android.paymentsheet.model.SupportedPaymentMethod -import com.stripe.android.paymentsheet.paymentdatacollection.CardDataCollectionFragment import com.stripe.android.paymentsheet.paymentdatacollection.ComposeFormDataCollectionFragment import com.stripe.android.paymentsheet.paymentdatacollection.FormFragmentArguments import com.stripe.android.paymentsheet.viewmodels.BaseSheetViewModel @@ -132,6 +131,7 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT } @Test + // TODO: Intermittent failure fun `when back to Ready state should update PaymentSelection`() { createFragment( paymentMethods = listOf(PaymentMethodFixtures.CARD_PAYMENT_METHOD) @@ -149,25 +149,26 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT idleLooper() // Start with null PaymentSelection because the card entered is invalid + paymentSelections.forEach { + println(it?.toString()) + } assertThat(paymentSelections.size) - .isEqualTo(1) - assertThat(paymentSelections[0]) - .isNull() + .isEqualTo(0) viewBinding.googlePayButton.performClick() // Updates PaymentSelection to Google Pay assertThat(paymentSelections.size) - .isEqualTo(2) - assertThat(paymentSelections[1]) + .isEqualTo(1) + assertThat(paymentSelections[0]) .isEqualTo(PaymentSelection.GooglePay) fragment.sheetViewModel._viewState.value = PaymentSheetViewState.Reset(null) // Back to Ready state, should return to null PaymentSelection assertThat(paymentSelections.size) - .isEqualTo(3) - assertThat(paymentSelections[2]) + .isEqualTo(2) + assertThat(paymentSelections[1]) .isNull() } } @@ -258,8 +259,6 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT fragment.sheetViewModel._viewState.value = PaymentSheetViewState.Reset() - idleLooper() - assertThat(fragment.sheetViewModel.selection.value).isEqualTo(lastPaymentMethod) } } @@ -273,8 +272,6 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT fragment.sheetViewModel._viewState.value = PaymentSheetViewState.Reset(BaseSheetViewModel.UserErrorMessage(errorMessage)) - idleLooper() - assertThat(viewBinding.message.isVisible).isTrue() assertThat(viewBinding.message.text).isEqualTo(errorMessage) @@ -296,8 +293,6 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT fragment.sheetViewModel._viewState.value = PaymentSheetViewState.Reset(BaseSheetViewModel.UserErrorMessage(errorMessage)) - idleLooper() - assertThat(viewBinding.message.isVisible).isTrue() assertThat(viewBinding.message.text).isEqualTo(errorMessage) @@ -331,7 +326,7 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT fragment.childFragmentManager.findFragmentById( viewBinding.paymentMethodFragmentContainer.id ) - ).isInstanceOf(CardDataCollectionFragment::class.java) + ).isInstanceOf(ComposeFormDataCollectionFragment::class.java) fragment.onPaymentMethodSelected(SupportedPaymentMethod.Bancontact) idleLooper() @@ -406,7 +401,7 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT fragment.childFragmentManager.findFragmentById( viewBinding.paymentMethodFragmentContainer.id ) - ).isInstanceOf(CardDataCollectionFragment::class.java) + ).isInstanceOf(ComposeFormDataCollectionFragment::class.java) fragment.onPaymentMethodSelected(SupportedPaymentMethod.Card) @@ -416,7 +411,7 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT viewBinding.paymentMethodFragmentContainer.id ) - assertThat(addedFragment).isInstanceOf(CardDataCollectionFragment::class.java) + assertThat(addedFragment).isInstanceOf(ComposeFormDataCollectionFragment::class.java) assertThat( addedFragment?.arguments?.getParcelable( ComposeFormDataCollectionFragment.EXTRA_CONFIG @@ -450,7 +445,7 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT viewBinding.paymentMethodFragmentContainer.id ) - assertThat(addedFragment).isInstanceOf(CardDataCollectionFragment::class.java) + assertThat(addedFragment).isInstanceOf(ComposeFormDataCollectionFragment::class.java) assertThat( addedFragment?.arguments?.getParcelable( @@ -476,7 +471,7 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT fragment.childFragmentManager.findFragmentById( viewBinding.paymentMethodFragmentContainer.id ) - ).isInstanceOf(CardDataCollectionFragment::class.java) + ).isInstanceOf(ComposeFormDataCollectionFragment::class.java) var paymentSelection: PaymentSelection? = null fragment.sheetViewModel.selection.observeForever { @@ -490,7 +485,7 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT fragment.onPaymentMethodSelected(SupportedPaymentMethod.Card) idleLooper() - assertThat(paymentSelection).isNull() + assertThat(paymentSelection).isInstanceOf(PaymentSelection.Saved::class.java) } } @@ -504,7 +499,7 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT fragment.childFragmentManager.findFragmentById( viewBinding.paymentMethodFragmentContainer.id ) - ).isInstanceOf(CardDataCollectionFragment::class.java) + ).isInstanceOf(ComposeFormDataCollectionFragment::class.java) fragment.onPaymentMethodSelected(SupportedPaymentMethod.Card) @@ -514,7 +509,7 @@ internal class PaymentSheetAddPaymentMethodFragmentTest : PaymentSheetViewModelT viewBinding.paymentMethodFragmentContainer.id ) - assertThat(addedFragment).isInstanceOf(CardDataCollectionFragment::class.java) + assertThat(addedFragment).isInstanceOf(ComposeFormDataCollectionFragment::class.java) assertThat( addedFragment?.arguments?.getParcelable( ComposeFormDataCollectionFragment.EXTRA_CONFIG diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/forms/CompleteFormFieldValueFilterTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/forms/CompleteFormFieldValueFilterTest.kt index c6283943e0c..4c2ae3ecd88 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/forms/CompleteFormFieldValueFilterTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/forms/CompleteFormFieldValueFilterTest.kt @@ -1,32 +1,26 @@ package com.stripe.android.paymentsheet.forms import com.google.common.truth.Truth.assertThat +import com.stripe.android.ui.core.elements.SimpleTextFieldController import com.stripe.android.paymentsheet.model.PaymentSelection import com.stripe.android.ui.core.elements.EmailConfig import com.stripe.android.ui.core.elements.EmailElement import com.stripe.android.ui.core.elements.IdentifierSpec import com.stripe.android.ui.core.elements.SectionController import com.stripe.android.ui.core.elements.SectionElement -import com.stripe.android.ui.core.elements.TextFieldController import com.stripe.android.ui.core.forms.FormFieldEntry import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import org.junit.Test @ExperimentalCoroutinesApi class CompleteFormFieldValueFilterTest { - private val emailController = TextFieldController(EmailConfig()) - private val emailSection = SectionElement( - identifier = IdentifierSpec.Generic("email_section"), - EmailElement( - IdentifierSpec.Email, - emailController - ), - SectionController(emailController.label, listOf(emailController)) - ) + private val emailController = SimpleTextFieldController(EmailConfig()) + private var emailSection: SectionElement private val hiddenIdentifersFlow = MutableStateFlow>(emptyList()) @@ -44,6 +38,19 @@ class CompleteFormFieldValueFilterTest { userRequestedReuse = MutableStateFlow(PaymentSelection.CustomerRequestedSave.NoRequest) ) + init { + runBlocking { + emailSection = SectionElement( + identifier = IdentifierSpec.Generic("email_section"), + EmailElement( + IdentifierSpec.Email, + emailController + ), + SectionController(emailController.label.first(), listOf(emailController)) + ) + } + } + @Test fun `With only some complete controllers and no hidden values the flow value is null`() { runTest { diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/forms/FormViewModelTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/forms/FormViewModelTest.kt index a10badc63c5..43099760a92 100644 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/forms/FormViewModelTest.kt +++ b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/forms/FormViewModelTest.kt @@ -3,6 +3,7 @@ package com.stripe.android.paymentsheet.forms import android.app.Application import android.content.Context import androidx.annotation.StringRes +import androidx.appcompat.view.ContextThemeWrapper import androidx.lifecycle.asLiveData import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat @@ -28,6 +29,7 @@ import com.stripe.android.ui.core.elements.SaveForFutureUseSpec import com.stripe.android.ui.core.elements.SectionElement import com.stripe.android.ui.core.elements.SectionSingleFieldElement import com.stripe.android.ui.core.elements.SectionSpec +import com.stripe.android.ui.core.elements.SimpleTextFieldController import com.stripe.android.ui.core.elements.SimpleTextSpec.Companion.NAME import com.stripe.android.ui.core.elements.TextFieldController import com.stripe.android.ui.core.forms.SepaDebitForm @@ -58,10 +60,17 @@ import javax.inject.Provider internal class FormViewModelTest { private val emailSection = SectionSpec(IdentifierSpec.Generic("email_section"), EmailSpec) + private val nameSection = SectionSpec( + IdentifierSpec.Generic("name_section"), + NAME + ) private val countrySection = SectionSpec( IdentifierSpec.Generic("country_section"), CountrySpec() ) + private val context = ContextThemeWrapper( + ApplicationProvider.getApplicationContext(), com.stripe.android.ui.core.R.style.StripeDefaultTheme + ) private val resourceRepository = StaticResourceRepository( @@ -96,8 +105,8 @@ internal class FormViewModelTest { val factory = FormViewModel.Factory( config, ApplicationProvider.getApplicationContext().resources, - SofortForm, - ) + SofortForm + ) { ApplicationProvider.getApplicationContext() } val factorySpy = spy(factory) val createdViewModel = factorySpy.create(FormViewModel::class.java) verify(factorySpy, times(0)).fallbackInitialize(any()) @@ -114,7 +123,7 @@ internal class FormViewModelTest { config, ApplicationProvider.getApplicationContext().resources, SofortForm - ) + ) { ApplicationProvider.getApplicationContext() } val factorySpy = spy(factory) assertNotNull(factorySpy.create(FormViewModel::class.java)) verify(factorySpy).fallbackInitialize( @@ -135,7 +144,7 @@ internal class FormViewModelTest { ), args, resourceRepository = resourceRepository, - transformSpecToElement = TransformSpecToElement(resourceRepository, args) + transformSpecToElement = TransformSpecToElement(resourceRepository, args, context) ) val values = mutableListOf() @@ -161,7 +170,7 @@ internal class FormViewModelTest { ), args, resourceRepository = resourceRepository, - transformSpecToElement = TransformSpecToElement(resourceRepository, args) + transformSpecToElement = TransformSpecToElement(resourceRepository, args, context) ) val values = mutableListOf>() @@ -189,7 +198,7 @@ internal class FormViewModelTest { ), args, resourceRepository = resourceRepository, - transformSpecToElement = TransformSpecToElement(resourceRepository, args) + transformSpecToElement = TransformSpecToElement(resourceRepository, args, context) ) val values = mutableListOf>() @@ -207,6 +216,55 @@ internal class FormViewModelTest { assertThat(values[1][1]).isEqualTo(IdentifierSpec.Email) } + @ExperimentalCoroutinesApi + @Test + fun `Verify if there are no text fields nothing is hidden`() = runTest { + // Here we have just a country, no text fields. + val args = COMPOSE_FRAGMENT_ARGS + val formViewModel = FormViewModel( + LayoutSpec.create( + countrySection + ), + args, + resourceRepository = resourceRepository, + transformSpecToElement = TransformSpecToElement(resourceRepository, args, context) + ) + + // Verify formFieldValues does not contain email + assertThat(formViewModel.lastTextFieldIdentifier.first()?.value).isEqualTo( + null + ) + } + + @ExperimentalCoroutinesApi + @Test + fun `Verify if the last text field is hidden the second to last text field is the last display text field`() = runTest { + // Here we have one hidden and one required field, country will always be in the result, + // and name only if saveForFutureUse is true + val args = COMPOSE_FRAGMENT_ARGS + val formViewModel = FormViewModel( + LayoutSpec.create( + nameSection, + emailSection, + countrySection, + SaveForFutureUseSpec(listOf(emailSection)) + ), + args, + resourceRepository = resourceRepository, + transformSpecToElement = TransformSpecToElement(resourceRepository, args, context) + ) + + val saveForFutureUseController = formViewModel.elements.first()!!.map { it.controller } + .filterIsInstance(SaveForFutureUseController::class.java).first() + + saveForFutureUseController.onValueChange(false) + + // Verify formFieldValues does not contain email + assertThat(formViewModel.lastTextFieldIdentifier.first()?.value).isEqualTo( + nameSection.fields.first().identifier.value + ) + } + @ExperimentalCoroutinesApi @Test fun `Verify if a field is hidden and valid it is not in the completeFormValues`() = runTest { @@ -221,7 +279,7 @@ internal class FormViewModelTest { ), args, resourceRepository = resourceRepository, - transformSpecToElement = TransformSpecToElement(resourceRepository, args) + transformSpecToElement = TransformSpecToElement(resourceRepository, args, context) ) val saveForFutureUseController = formViewModel.elements.first()!!.map { it.controller } @@ -261,7 +319,7 @@ internal class FormViewModelTest { ), args, resourceRepository = resourceRepository, - transformSpecToElement = TransformSpecToElement(resourceRepository, args) + transformSpecToElement = TransformSpecToElement(resourceRepository, args, context) ) val saveForFutureUseController = formViewModel.elements.first()!!.map { it.controller } @@ -310,7 +368,7 @@ internal class FormViewModelTest { SofortForm, args, resourceRepository = resourceRepository, - transformSpecToElement = TransformSpecToElement(resourceRepository, args) + transformSpecToElement = TransformSpecToElement(resourceRepository, args, context) ) val nameElement = @@ -358,7 +416,7 @@ internal class FormViewModelTest { SepaDebitForm, args, resourceRepository = resourceRepository, - transformSpecToElement = TransformSpecToElement(resourceRepository, args) + transformSpecToElement = TransformSpecToElement(resourceRepository, args, context) ) getSectionFieldTextControllerWithLabel( @@ -429,7 +487,7 @@ internal class FormViewModelTest { SepaDebitForm, args, resourceRepository = resourceRepository, - transformSpecToElement = TransformSpecToElement(resourceRepository, args) + transformSpecToElement = TransformSpecToElement(resourceRepository, args, context) ) getSectionFieldTextControllerWithLabel( @@ -462,7 +520,7 @@ internal class FormViewModelTest { // Fill all address values except line2 val addressControllers = AddressControllers.create(formViewModel) val populateAddressControllers = addressControllers.controllers - .filter { it.label != R.string.address_label_address_line2 } + .filter { it.label.first() != R.string.address_label_address_line2 } populateAddressControllers .forEachIndexed { index, textFieldController -> textFieldController.onValueChange("1234") @@ -499,7 +557,7 @@ internal class FormViewModelTest { .filterIsInstance() .map { it.controller } .filterIsInstance() - .firstOrNull { it.label == label } + .firstOrNull { it.label.first() == label } private data class AddressControllers( val controllers: List @@ -547,15 +605,15 @@ internal class FormViewModelTest { ?.first() return addressElementFields ?.filterIsInstance() - ?.map { (it.controller as? TextFieldController) } - ?.firstOrNull { it?.label == label } + ?.map { (it.controller as? SimpleTextFieldController) } + ?.firstOrNull { it?.label?.first() == label } ?: addressElementFields ?.asSequence() ?.filterIsInstance() ?.map { it.fields } ?.flatten() - ?.map { (it.controller as? TextFieldController) } - ?.firstOrNull { it?.label == label } + ?.map { (it.controller as? SimpleTextFieldController) } + ?.firstOrNull { it?.label?.first() == label } } } } diff --git a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragmentTest.kt b/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragmentTest.kt deleted file mode 100644 index 14ad022c630..00000000000 --- a/paymentsheet/src/test/java/com/stripe/android/paymentsheet/paymentdatacollection/CardDataCollectionFragmentTest.kt +++ /dev/null @@ -1,527 +0,0 @@ -package com.stripe.android.paymentsheet.paymentdatacollection - -import android.content.Context -import androidx.core.os.bundleOf -import androidx.core.view.isVisible -import androidx.fragment.app.testing.launchFragmentInContainer -import androidx.lifecycle.Lifecycle -import androidx.test.core.app.ApplicationProvider -import com.google.common.truth.Truth.assertThat -import com.stripe.android.ApiKeyFixtures -import com.stripe.android.PaymentConfiguration -import com.stripe.android.core.injection.WeakMapInjectorRegistry -import com.stripe.android.core.model.CountryCode -import com.stripe.android.model.CardBrand -import com.stripe.android.model.PaymentIntent -import com.stripe.android.model.PaymentIntentFixtures -import com.stripe.android.model.PaymentMethod -import com.stripe.android.model.PaymentMethodCreateParamsFixtures -import com.stripe.android.model.StripeIntent -import com.stripe.android.paymentsheet.PaymentSheetActivity -import com.stripe.android.paymentsheet.PaymentSheetContract -import com.stripe.android.paymentsheet.PaymentSheetFixtures -import com.stripe.android.paymentsheet.PaymentSheetFixtures.COMPOSE_FRAGMENT_ARGS -import com.stripe.android.paymentsheet.PaymentSheetViewModelTestInjection -import com.stripe.android.paymentsheet.R -import com.stripe.android.paymentsheet.databinding.FragmentPaymentsheetAddCardBinding -import com.stripe.android.paymentsheet.databinding.StripeBillingAddressLayoutBinding -import com.stripe.android.paymentsheet.forms.FormViewModel -import com.stripe.android.paymentsheet.forms.TransformSpecToElement -import com.stripe.android.paymentsheet.model.FragmentConfig -import com.stripe.android.paymentsheet.model.FragmentConfigFixtures -import com.stripe.android.paymentsheet.model.PaymentSelection -import com.stripe.android.paymentsheet.model.SupportedPaymentMethod -import com.stripe.android.ui.core.Amount -import com.stripe.android.utils.TestUtils.idleLooper -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.kotlin.mock -import org.robolectric.RobolectricTestRunner - -@ExperimentalCoroutinesApi -@FlowPreview -@RunWith(RobolectricTestRunner::class) -internal class CardDataCollectionFragmentTest : PaymentSheetViewModelTestInjection() { - private val context: Context = ApplicationProvider.getApplicationContext() - - @Before - fun setup() { - PaymentConfiguration.init( - context, - ApiKeyFixtures.FAKE_PUBLISHABLE_KEY - ) - } - - @After - override fun after() { - super.after() - } - - @Test - fun `required billing fields should not be visible`() { - createFragment { _, viewBinding -> - val billingBinding = StripeBillingAddressLayoutBinding.bind(viewBinding.billingAddress) - assertThat(billingBinding.address1Divider.isVisible).isFalse() - assertThat(billingBinding.address1Layout.isVisible).isFalse() - assertThat(viewBinding.billingAddress.address1View.isVisible).isFalse() - - assertThat(billingBinding.address2Divider.isVisible).isFalse() - assertThat(billingBinding.address2Layout.isVisible).isFalse() - assertThat(viewBinding.billingAddress.address2View.isVisible).isFalse() - - assertThat(billingBinding.cityLayout.isVisible).isFalse() - assertThat(viewBinding.billingAddress.cityView.isVisible).isFalse() - - assertThat(billingBinding.stateDivider.isVisible).isFalse() - assertThat(billingBinding.stateLayout.isVisible).isFalse() - assertThat(viewBinding.billingAddress.stateView.isVisible).isFalse() - } - } - - @Test - fun `paymentMethodParams with valid input should return object with expected billing details`() { - createFragment { fragment, viewBinding -> - viewBinding.cardMultilineWidget.setCardNumber("4242424242424242") - viewBinding.cardMultilineWidget.setExpiryDate(1, 2030) - viewBinding.cardMultilineWidget.setCvcCode("123") - viewBinding.billingAddress.countryLayout.setSelectedCountryCode(CountryCode.US) - viewBinding.billingAddress.postalCodeView.setText("94107") - - val paymentSelections = mutableListOf() - fragment.sheetViewModel.selection.observeForever { paymentSelection -> - if (paymentSelection != null) { - paymentSelections.add(paymentSelection) - } - } - - val newCard = paymentSelections.first() as PaymentSelection.New.Card - assertThat(newCard.paymentMethodCreateParams.billingDetails) - .isEqualTo( - PaymentMethod.BillingDetails( - com.stripe.android.model.Address( - country = "US", - postalCode = "94107" - ) - ) - ) - } - } - - @Test - fun `selection without customer config and valid card entered should create expected PaymentSelection`() { - createFragment(PaymentSheetFixtures.ARGS_WITHOUT_CONFIG) { fragment, viewBinding -> - viewBinding.saveCardCheckbox.isChecked = false - - var paymentSelection: PaymentSelection? = null - fragment.sheetViewModel.selection.observeForever { - paymentSelection = it - } - - // If no customer config checked must be false - viewBinding.saveCardCheckbox.isChecked = false - - viewBinding.cardMultilineWidget.setCardNumber("4242424242424242") - viewBinding.cardMultilineWidget.setExpiryDate(1, 2030) - viewBinding.cardMultilineWidget.setCvcCode("123") - viewBinding.billingAddress.countryView.setText("United States") - viewBinding.billingAddress.postalCodeView.setText("94107") - - val newPaymentSelection = paymentSelection as PaymentSelection.New.Card - assertThat(newPaymentSelection.customerRequestedSave) - .isEqualTo(PaymentSelection.CustomerRequestedSave.RequestNoReuse) - assertThat(fragment.sheetViewModel.newCard) - .isEqualTo(paymentSelection) - } - } - - @Test - fun `relaunching the fragment populates the fields with saved card`() { - createFragment( - PaymentSheetFixtures.ARGS_WITHOUT_CONFIG, - newCard = PaymentSelection.New.Card( - PaymentMethodCreateParamsFixtures.DEFAULT_CARD, - CardBrand.Discover, - PaymentSelection.CustomerRequestedSave.RequestNoReuse - ) - ) { fragment, viewBinding -> - - var paymentSelection: PaymentSelection? = null - fragment.sheetViewModel.selection.observeForever { - paymentSelection = it - } - - viewBinding.cardMultilineWidget.setCardNumber("4242424242424242") - viewBinding.cardMultilineWidget.setExpiryDate(1, 2030) - viewBinding.cardMultilineWidget.setCvcCode("123") - viewBinding.billingAddress.countryView.setText("United States") - viewBinding.billingAddress.postalCodeView.setText("94107") - - val newPaymentSelection = paymentSelection as PaymentSelection.New.Card - assertThat(newPaymentSelection.customerRequestedSave) - .isEqualTo(PaymentSelection.CustomerRequestedSave.RequestNoReuse) - } - } - - @Test - fun `launching with arguments populates the fields`() { - createFragment( - fragmentArgs = COMPOSE_FRAGMENT_ARGS.copy( - showCheckboxControlledFields = true, - showCheckbox = true - ) - ) { _, viewBinding -> - assertThat(viewBinding.billingAddress.postalCodeView.text.toString()) - .isEqualTo("94111") - assertThat(viewBinding.billingAddress.address1View.text.toString()) - .isEqualTo("123 Main Street") - assertThat(viewBinding.billingAddress.address2View.text.toString()) - .isEqualTo("") - assertThat(viewBinding.billingAddress.cityView.text.toString()) - .isEqualTo("San Francisco") - assertThat(viewBinding.billingAddress.stateView.text.toString()) - .isEqualTo("CA") - assertThat(viewBinding.billingAddress.countryView.text.toString()) - .isEqualTo("Germany") - assertThat(viewBinding.saveCardCheckbox.isVisible).isTrue() - assertThat(viewBinding.saveCardCheckbox.isChecked).isFalse() - } - } - - @Test - fun `launching with billing details populates the fields`() { - createFragment(fragmentArgs = COMPOSE_FRAGMENT_ARGS) { _, viewBinding -> - assertThat(viewBinding.billingAddress.postalCodeView.text.toString()) - .isEqualTo("94111") - assertThat(viewBinding.billingAddress.address1View.text.toString()) - .isEqualTo("123 Main Street") - assertThat(viewBinding.billingAddress.address2View.text.toString()) - .isEqualTo("") - assertThat(viewBinding.billingAddress.cityView.text.toString()) - .isEqualTo("San Francisco") - assertThat(viewBinding.billingAddress.stateView.text.toString()) - .isEqualTo("CA") - assertThat(viewBinding.billingAddress.countryView.text.toString()) - .isEqualTo("Germany") - } - } - - @Test - fun `selection when save card checkbox enabled and then valid card entered should create expected PaymentSelection`() { - createFragment { fragment, viewBinding -> - assertThat(viewBinding.saveCardCheckbox.isVisible) - .isTrue() - viewBinding.saveCardCheckbox.isChecked = false - - var paymentSelection: PaymentSelection? = null - fragment.sheetViewModel.selection.observeForever { - paymentSelection = it - } - - viewBinding.saveCardCheckbox.isChecked = true - - viewBinding.cardMultilineWidget.setCardNumber("4242424242424242") - viewBinding.cardMultilineWidget.setExpiryDate(1, 2030) - viewBinding.cardMultilineWidget.setCvcCode("123") - viewBinding.billingAddress.countryView.setText("United States") - viewBinding.billingAddress.postalCodeView.setText("94107") - - val newPaymentSelection = paymentSelection as PaymentSelection.New.Card - assertThat(newPaymentSelection.customerRequestedSave) - .isEqualTo(PaymentSelection.CustomerRequestedSave.RequestReuse) - assertThat(fragment.sheetViewModel.newCard) - .isEqualTo(paymentSelection) - } - } - - @Test - fun `selection when valid card entered and then save card checkbox enabled should create expected PaymentSelection`() { - createFragment { fragment, viewBinding -> - assertThat(viewBinding.saveCardCheckbox.isVisible) - .isTrue() - viewBinding.saveCardCheckbox.isChecked = false - - var paymentSelection: PaymentSelection? = null - fragment.sheetViewModel.selection.observeForever { - paymentSelection = it - } - - viewBinding.cardMultilineWidget.setCardNumber("4242424242424242") - viewBinding.cardMultilineWidget.setExpiryDate(1, 2030) - viewBinding.cardMultilineWidget.setCvcCode("123") - viewBinding.billingAddress.countryView.setText("United States") - viewBinding.billingAddress.postalCodeView.setText("94107") - - viewBinding.saveCardCheckbox.isChecked = true - - val newPaymentSelection = paymentSelection as PaymentSelection.New.Card - assertThat(newPaymentSelection.customerRequestedSave) - .isEqualTo(PaymentSelection.CustomerRequestedSave.RequestReuse) - - assertThat(fragment.sheetViewModel.newCard?.brand) - .isEqualTo(CardBrand.Visa) - } - } - - @Test - fun `checkbox text should reflect merchant display name`() { - createFragment { _, viewBinding -> - assertThat(viewBinding.saveCardCheckbox.text) - .isEqualTo("Save this card for future Merchant, Inc. payments") - } - } - - @Test - fun `cardErrors should react to input validity`() { - createFragment { _, viewBinding -> - assertThat(viewBinding.cardErrors.isVisible) - .isFalse() - - viewBinding.cardMultilineWidget.setCardNumber("4242424242424249") - viewBinding.cardMultilineWidget.setExpiryDate(1, 2010) - assertThat(viewBinding.cardErrors.text) - .isEqualTo("Your card's number is invalid.") - assertThat(viewBinding.cardErrors.isVisible) - .isTrue() - - viewBinding.cardMultilineWidget.setCardNumber("4242424242424242") - assertThat(viewBinding.cardErrors.text) - .isEqualTo("Your card's expiration year is invalid.") - assertThat(viewBinding.cardErrors.isVisible) - .isTrue() - - viewBinding.cardMultilineWidget.setExpiryDate(1, 2030) - assertThat(viewBinding.cardErrors.text.toString()) - .isEmpty() - assertThat(viewBinding.cardErrors.isVisible) - .isFalse() - } - } - - @Test - fun `make sure when add card fields are edited newcard is updated`() { - createFragment { fragment, viewBinding -> - assertThat(viewBinding.cardErrors.isVisible) - .isFalse() - - viewBinding.cardMultilineWidget.setCardNumber("4242424242424242") - viewBinding.cardMultilineWidget.setExpiryDate(1, 2030) - viewBinding.cardMultilineWidget.setCvcCode("123") - viewBinding.billingAddress.countryView.setText("United States") - viewBinding.billingAddress.postalCodeView.setText("94107") - - viewBinding.saveCardCheckbox.isChecked = true - - assertThat(fragment.sheetViewModel.newCard?.brand) - .isEqualTo(CardBrand.Visa) - - viewBinding.cardMultilineWidget.setCardNumber("378282246310005") - - assertThat(fragment.sheetViewModel.newCard?.brand) - .isEqualTo(CardBrand.AmericanExpress) - } - } - - @Test - fun `when postal code is valid then billing error is invisible`() { - createFragment { _, viewBinding -> - assertThat(viewBinding.cardErrors.isVisible) - .isFalse() - - viewBinding.billingAddress.countryLayout.setSelectedCountryCode(CountryCode.US) - viewBinding.billingAddress.postalCodeView.setText("94107") - - assertThat(viewBinding.billingErrors.text.toString()) - .isEmpty() - assertThat(viewBinding.billingErrors.isVisible) - .isFalse() - } - } - - @Test - fun `when US zip code is invalid and losing focus then billing error is visible with correct error message`() { - createFragment { _, viewBinding -> - assertThat(viewBinding.cardErrors.isVisible) - .isFalse() - - viewBinding.billingAddress.countryLayout.setSelectedCountryCode(CountryCode.US) - viewBinding.billingAddress.postalCodeView.setText("123") - requireNotNull( - viewBinding.billingAddress.postalCodeView.getParentOnFocusChangeListener() - ).onFocusChange( - viewBinding.billingAddress.postalCodeView, - false - ) - idleLooper() - - assertThat(viewBinding.billingErrors.text.toString()) - .isEqualTo(context.getString(R.string.address_zip_invalid)) - assertThat(viewBinding.billingErrors.isVisible) - .isTrue() - } - } - - @Test - fun `when US zip code is valid and losing focus then billing error is invisible`() { - createFragment { _, viewBinding -> - assertThat(viewBinding.cardErrors.isVisible) - .isFalse() - - viewBinding.billingAddress.countryLayout.setSelectedCountryCode(CountryCode.US) - viewBinding.billingAddress.postalCodeView.setText("94107") - requireNotNull( - viewBinding.billingAddress.postalCodeView.getParentOnFocusChangeListener() - ).onFocusChange( - viewBinding.billingAddress.postalCodeView, - false - ) - idleLooper() - - assertThat(viewBinding.billingErrors.text.toString()).isEmpty() - assertThat(viewBinding.billingErrors.isVisible) - .isFalse() - } - } - - @Test - fun `when Canada postal code is valid and losing focus then billing error is invisible`() { - createFragment { _, viewBinding -> - assertThat(viewBinding.cardErrors.isVisible) - .isFalse() - - viewBinding.billingAddress.countryLayout.setSelectedCountryCode(CountryCode.CA) - viewBinding.billingAddress.postalCodeView.setText("A1G9Z9") - requireNotNull( - viewBinding.billingAddress.postalCodeView.getParentOnFocusChangeListener() - ).onFocusChange( - viewBinding.billingAddress.postalCodeView, - false - ) - idleLooper() - - assertThat(viewBinding.billingErrors.text.toString()).isEmpty() - assertThat(viewBinding.billingErrors.isVisible) - .isFalse() - } - } - - @Test - fun `when zip code is empty and losing focus then billing error is invisible`() { - createFragment { _, viewBinding -> - assertThat(viewBinding.cardErrors.isVisible) - .isFalse() - - viewBinding.billingAddress.countryLayout.setSelectedCountryCode(CountryCode.US) - viewBinding.billingAddress.postalCodeView.setText("") - requireNotNull( - viewBinding.billingAddress.postalCodeView.getParentOnFocusChangeListener() - ).onFocusChange( - viewBinding.billingAddress.postalCodeView, - false - ) - idleLooper() - - assertThat(viewBinding.billingErrors.text.toString()).isEmpty() - assertThat(viewBinding.billingErrors.isVisible) - .isFalse() - } - } - - @Test - fun `empty merchant display name shows correct message`() { - createFragment(PaymentSheetFixtures.ARGS_WITHOUT_CONFIG) { _, viewBinding -> - assertThat(viewBinding.saveCardCheckbox.text) - .isEqualTo( - context.getString( - R.string.stripe_paymentsheet_save_this_card_with_merchant_name, - "com.stripe.android.paymentsheet.test" - ) - ) - } - } - - @Test - fun `non-empty merchant display name shows correct message`() { - createFragment(PaymentSheetFixtures.ARGS_CUSTOMER_WITHOUT_GOOGLEPAY) { _, viewBinding -> - assertThat(viewBinding.saveCardCheckbox.text) - .isEqualTo( - context.getString( - R.string.stripe_paymentsheet_save_this_card_with_merchant_name, - PaymentSheetFixtures.MERCHANT_DISPLAY_NAME - ) - ) - } - } - - private fun createFragment( - args: PaymentSheetContract.Args = PaymentSheetFixtures.ARGS_CUSTOMER_WITH_GOOGLEPAY, - fragmentConfig: FragmentConfig? = FragmentConfigFixtures.DEFAULT, - stripeIntent: StripeIntent = PaymentIntentFixtures.PI_WITH_SHIPPING, - newCard: PaymentSelection.New.Card? = null, - fragmentArgs: FormFragmentArguments = FormFragmentArguments( - SupportedPaymentMethod.Card, - injectorKey = args.injectorKey, - showCheckbox = true, - showCheckboxControlledFields = true, - merchantName = args.config?.merchantDisplayName ?: "com.stripe.android.paymentsheet", - billingDetails = args.config?.defaultBillingDetails, - amount = (stripeIntent as? PaymentIntent)?.let { - Amount( - it.amount ?: 0, - it.currency ?: "usd", - ) - } - ), - onReady: (CardDataCollectionFragment, FragmentPaymentsheetAddCardBinding) -> Unit - ) { - assertThat(WeakMapInjectorRegistry.staticCacheMap.size).isEqualTo(0) - val viewModel = createViewModel( - stripeIntent as PaymentIntent, - customerRepositoryPMs = emptyList(), - injectorKey = args.injectorKey, - args = args - ) - viewModel.newCard = newCard - idleLooper() - - val formFragmentArguments = FormFragmentArguments( - fragmentArgs.paymentMethod, - showCheckbox = fragmentArgs.showCheckbox, - showCheckboxControlledFields = fragmentArgs.showCheckboxControlledFields, - merchantName = fragmentArgs.merchantName, - amount = fragmentArgs.amount, - billingDetails = fragmentArgs.billingDetails, - injectorKey = args.injectorKey - ) - val formViewModel = FormViewModel( - layout = fragmentArgs.paymentMethod.formSpec, - config = formFragmentArguments, - resourceRepository = mock(), - transformSpecToElement = TransformSpecToElement(mock(), formFragmentArguments) - ) - - registerViewModel(args.injectorKey, viewModel, formViewModel) - - launchFragmentInContainer( - bundleOf( - PaymentSheetActivity.EXTRA_FRAGMENT_CONFIG to fragmentConfig, - PaymentSheetActivity.EXTRA_STARTER_ARGS to args, - ComposeFormDataCollectionFragment.EXTRA_CONFIG to fragmentArgs, - ), - R.style.StripePaymentSheetDefaultTheme, - initialState = Lifecycle.State.INITIALIZED - ) - .moveToState(Lifecycle.State.STARTED) - .onFragment { fragment -> - onReady( - fragment, - FragmentPaymentsheetAddCardBinding.bind( - requireNotNull(fragment.view) - ) - ) - } - } -}