Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce CardEditUIHandler for the CardEditUI #10462

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.stripe.android.paymentsheet.ui

import com.stripe.android.paymentsheet.CardUpdateParams

/**
* Represents the editable details of a card payment method.
*
* @property cardBrandChoice The currently selected card brand choice.
*/
internal data class CardDetailsEntry(
val cardBrandChoice: CardBrandChoice,
) {
/**
* Determines if the card details have changed compared to the provided values.
*
* @param originalCardBrandChoice The card brand choice to compare against.
* @return True if any of the card details have changed, false otherwise.
*/
fun hasChanged(
originalCardBrandChoice: CardBrandChoice,
): Boolean {
return originalCardBrandChoice != this.cardBrandChoice
}
}

/**
* Converts the CardDetailsEntry to CardUpdateParams.
*
* @return CardUpdateParams containing the updated card brand.
*/
internal fun CardDetailsEntry.toUpdateParams(): CardUpdateParams {
return CardUpdateParams(cardBrand = cardBrandChoice.brand)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.stripe.android.paymentsheet.ui

import androidx.compose.runtime.Immutable
import com.stripe.android.CardBrandFilter
import com.stripe.android.model.CardBrand
import com.stripe.android.model.PaymentMethod
import com.stripe.android.paymentsheet.CardUpdateParams
import kotlinx.coroutines.flow.StateFlow

internal typealias CardDetailsCallback = (CardUpdateParams?) -> Unit

internal typealias BrandChoiceCallback = (CardBrand) -> Unit

/**
* Interface for handling UI interactions when editing card details.
*/
internal interface CardEditUIHandler {
/**
* The card being edited.
*/
val card: PaymentMethod.Card

/**
* Filter for determining which card brands are available.
*/
val cardBrandFilter: CardBrandFilter

/**
* Icon resource ID for the payment method.
*/
val paymentMethodIcon: Int

/**
* Whether to show the card brand dropdown.
*/
val showCardBrandDropdown: Boolean

/**
* Current state of the card edit UI.
*/
val state: StateFlow<State>

/**
* Callback for when the card brand choice changes.
*/
val onBrandChoiceChanged: BrandChoiceCallback

/**
* Callback for when card details change. It provides the values needed to
* update the card, if any.
*/
val onCardDetailsChanged: CardDetailsCallback

/**
* Handle a change in the selected card brand. This will be called from the UI when
* the users selects a card brand.
*
* @param cardBrandChoice The newly selected card brand choice
*/
fun onBrandChoiceChanged(cardBrandChoice: CardBrandChoice)

/**
* Represents the current state of the card edit UI.
*
* @property card The current card details
* @property selectedCardBrand The currently selected card brand
*/
@Immutable
data class State(
val card: PaymentMethod.Card,
val selectedCardBrand: CardBrandChoice
)

/**
* Factory for creating CardEditUIHandler instances.
*/
fun interface Factory {
/**
* Create a new CardEditUIHandler instance.
*
* @param card The card to edit
* @param cardBrandFilter Filter for available card brands
* @param showCardBrandDropdown Whether to show the card brand dropdown
* @param paymentMethodIcon Icon resource for the payment method
* @param onCardDetailsChanged Callback for card value changes
*/
fun create(
card: PaymentMethod.Card,
cardBrandFilter: CardBrandFilter,
showCardBrandDropdown: Boolean,
paymentMethodIcon: Int,
onCardDetailsChanged: CardDetailsCallback
): CardEditUIHandler
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package com.stripe.android.paymentsheet.ui

import com.stripe.android.CardBrandFilter
import com.stripe.android.model.PaymentMethod
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

internal class DefaultCardEditUIHandler(
override val card: PaymentMethod.Card,
override val cardBrandFilter: CardBrandFilter,
override val paymentMethodIcon: Int,
override val showCardBrandDropdown: Boolean,
private val scope: CoroutineScope,
override val onBrandChoiceChanged: BrandChoiceCallback,
override val onCardDetailsChanged: CardDetailsCallback
) : CardEditUIHandler {
private val cardDetailsEntry = MutableStateFlow(
value = buildDefaultCardEntry()
)

override val state: StateFlow<CardEditUIHandler.State> = cardDetailsEntry.mapLatest { inputState ->
uiState(inputState.cardBrandChoice)
}.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = uiState()
)

init {
scope.launch {
cardDetailsEntry.collectLatest { state ->
val newParams = state.takeIf {
it.hasChanged(
originalCardBrandChoice = defaultCardBrandChoice(),
)
}?.toUpdateParams()
onCardDetailsChanged(newParams)
}
}
}

override fun onBrandChoiceChanged(cardBrandChoice: CardBrandChoice) {
if (cardBrandChoice != state.value.selectedCardBrand) {
onBrandChoiceChanged(cardBrandChoice.brand)
}
cardDetailsEntry.update {
it.copy(
cardBrandChoice = cardBrandChoice
)
}
}

private fun buildDefaultCardEntry(): CardDetailsEntry {
return CardDetailsEntry(
cardBrandChoice = defaultCardBrandChoice()
)
}

private fun defaultCardBrandChoice() = card.getPreferredChoice(cardBrandFilter)

private fun uiState(cardBrandChoice: CardBrandChoice = defaultCardBrandChoice()): CardEditUIHandler.State {
return CardEditUIHandler.State(
card = card,
selectedCardBrand = cardBrandChoice
)
}

class Factory(
private val scope: CoroutineScope,
private val onBrandChoiceChanged: BrandChoiceCallback,
) : CardEditUIHandler.Factory {
override fun create(
card: PaymentMethod.Card,
cardBrandFilter: CardBrandFilter,
showCardBrandDropdown: Boolean,
paymentMethodIcon: Int,
onCardDetailsChanged: CardDetailsCallback
): CardEditUIHandler {
return DefaultCardEditUIHandler(
card = card,
cardBrandFilter = cardBrandFilter,
paymentMethodIcon = paymentMethodIcon,
showCardBrandDropdown = showCardBrandDropdown,
scope = scope,
onBrandChoiceChanged = onBrandChoiceChanged,
onCardDetailsChanged = onCardDetailsChanged
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import java.util.UUID
import java.util.concurrent.ThreadLocalRandom

internal object PaymentMethodFixtures {
private val CARD = PaymentMethod.Card(
val CARD = PaymentMethod.Card(
brand = CardBrand.Visa,
checks = PaymentMethod.Card.Checks(
addressLine1Check = "unchecked",
Expand All @@ -34,7 +34,7 @@ internal object PaymentMethodFixtures {
wallet = null
)

private val CARD_WITH_NETWORKS = CARD.copy(
val CARD_WITH_NETWORKS = CARD.copy(
displayBrand = "cartes_bancaires",
networks = PaymentMethod.Card.Networks(
available = setOf("visa", "cartes_bancaires"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.stripe.android.paymentsheet.ui

import com.google.common.truth.Truth.assertThat
import com.stripe.android.CardBrandFilter
import com.stripe.android.DefaultCardBrandFilter
import com.stripe.android.model.CardBrand
import com.stripe.android.model.PaymentMethod
import com.stripe.android.model.PaymentMethodFixtures
import com.stripe.android.paymentsheet.CardUpdateParams
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
internal class DefaultCardEditUIHandlerTest {

@Test
fun testInitialStateForCardWithNetworks() {
val handler = handler()

val state = handler.uiState
assertThat(state.card).isEqualTo(PaymentMethodFixtures.CARD_WITH_NETWORKS)
assertThat(state.selectedCardBrand.brand).isEqualTo(CardBrand.CartesBancaires)
}

@Test
fun testInitialStateForCardWithNoNetworks() {
val handler = handler(card = PaymentMethodFixtures.CARD)

val state = handler.uiState
assertThat(state.card).isEqualTo(PaymentMethodFixtures.CARD)
assertThat(state.selectedCardBrand.brand).isEqualTo(CardBrand.Unknown)
}

@Test
fun stateIsUpdateWhenNewCardBrandIsSelected() {
val handler = handler()

assertThat(handler.selectedBrand).isEqualTo(CardBrand.CartesBancaires)

handler.brandChanged(CardBrand.Visa)

assertThat(handler.selectedBrand).isEqualTo(CardBrand.Visa)
}

@Test
fun cardUpdateParamsIsUpdatedWhenNewCardBrandIsSelected() {
var cardUpdateParams: CardUpdateParams? = null
val handler = handler(
onCardDetailsChanged = {
cardUpdateParams = it
}
)

assertThat(handler.selectedBrand).isEqualTo(CardBrand.CartesBancaires)

handler.brandChanged(CardBrand.Visa)

assertThat(cardUpdateParams).isEqualTo(cardUpdateParams(cardBrand = CardBrand.Visa))

handler.brandChanged(CardBrand.CartesBancaires)

assertThat(cardUpdateParams).isNull()
}

@Test
fun brandChangedCallbackIsOnlyInvokedForNewBrandSelection() {
var newBrandChoice: CardBrand? = null
val handler = handler(
onBrandChoiceChanged = {
newBrandChoice = it
}
)

assertThat(handler.selectedBrand).isEqualTo(CardBrand.CartesBancaires)

handler.brandChanged(CardBrand.CartesBancaires)

assertThat(newBrandChoice).isNull()

handler.brandChanged(CardBrand.Visa)

assertThat(newBrandChoice).isEqualTo(CardBrand.Visa)
}

private fun DefaultCardEditUIHandler.brandChanged(cardBrand: CardBrand) {
onBrandChoiceChanged(CardBrandChoice(brand = cardBrand, enabled = true))
}

private val DefaultCardEditUIHandler.uiState
get() = this.state.value

private val DefaultCardEditUIHandler.selectedBrand
get() = uiState.selectedCardBrand.brand

private fun cardUpdateParams(
expiryMonth: Int? = null,
expiryYear: Int? = null,
cardBrand: CardBrand? = null,
billingDetails: PaymentMethod.BillingDetails? = null
): CardUpdateParams {
return CardUpdateParams(
expiryMonth = expiryMonth,
expiryYear = expiryYear,
cardBrand = cardBrand,
billingDetails = billingDetails
)
}

private fun handler(
card: PaymentMethod.Card = PaymentMethodFixtures.CARD_WITH_NETWORKS,
cardBrandFilter: CardBrandFilter = DefaultCardBrandFilter,
showCardBrandDropdown: Boolean = true,
onBrandChoiceChanged: (CardBrand) -> Unit = {},
onCardDetailsChanged: (CardUpdateParams?) -> Unit = {}
): DefaultCardEditUIHandler {
return DefaultCardEditUIHandler(
card = card,
cardBrandFilter = cardBrandFilter,
paymentMethodIcon = 0,
showCardBrandDropdown = showCardBrandDropdown,
scope = TestScope(UnconfinedTestDispatcher()),
onBrandChoiceChanged = onBrandChoiceChanged,
onCardDetailsChanged = onCardDetailsChanged
)
}
}
Loading