diff --git a/app/src/main/graphql/schema.graphqls b/app/src/main/graphql/schema.graphqls index 0adeb1db13..43e3683575 100644 --- a/app/src/main/graphql/schema.graphqls +++ b/app/src/main/graphql/schema.graphqls @@ -92,7 +92,7 @@ type Query { """ Fetches an order given its id. """ - order(id: ID!): Order + order(id: ID!): Order! photoForEditor(id: ID!): Photo @@ -796,9 +796,11 @@ enum Feature { multiple_shipfrom_locations_2024 - separate_payment_section + pledge_redemption_unified_creator_ui react_backed_projects + + copy_addons } """ @@ -986,6 +988,11 @@ type CreditCard { """ lastFour: String! + """ + The card type, but in lowercase. + """ + lowercaseType: LowercaseCreditCardTypes! + """ The card's payment type. """ @@ -1031,6 +1038,25 @@ enum CreditCardTypes { UNIONPAY } +""" +Credit card types. +""" +enum LowercaseCreditCardTypes { + amex + + discover + + jcb + + mastercard + + visa + + diners + + unionpay +} + """ Credit card payment types. """ @@ -3099,11 +3125,6 @@ type Cart { """ projectQuestions: [CartQuestion!]! - """ - Whether the cart could be finalized or is missing required info - """ - readyToBeFinalized: Boolean! - """ Whether or not this cart needs an address to be finalized """ @@ -4516,6 +4537,16 @@ type Order implements Node { """ cart: Cart + """ + Whether there is required action for the backer to take in their pledge manager + """ + checkoutRequired: Boolean! + + """ + The state of checkout (taking into account order and cart status) + """ + checkoutState: CheckoutStateEnum! + """ Whether or not the order would be auto-exemptable if the order had no cross-sells. """ @@ -4536,6 +4567,11 @@ type Order implements Node { """ finalized: Boolean! + """ + Whether or not the order was finalized but non-exempt. + """ + finalizedAndNotExempt: Boolean! + id: ID! """ @@ -4554,9 +4590,9 @@ type Order implements Node { project: Project! """ - The cost of shipping + The cost of shipping, formatted """ - shippingAmount: Int + shippingAmountFormatted: String """ The order's state, e.g. draft, collected, dropped, etc. @@ -4569,9 +4605,14 @@ type Order implements Node { total: Int """ - The total tax amount for the order + The total cost for the order including taxes and shipping, formatted + """ + totalFormatted: String + + """ + The total tax amount for the order, formatted """ - totalTax: Int + totalTaxFormatted: String } """ @@ -4608,66 +4649,72 @@ enum OrderStateEnum { An add-on reward included in an Order during Pledge Redemption as a Cross Sell. """ type OrderCrossSell { - """ - The content type of the reward - """ - contentsType: String! + id: ID! """ - Description of the add-on + The orderable of the cross sell """ - description: String! - - id: ID! + orderable: Orderable! """ - The add-on name. + The quantity of the add-on in the Order """ - name: String + quantity: Int! """ - Configuration for the add-on + The total price of the cross-sell """ - orderableConfig: OrderableConfig! + totalPriceFormatted: String! """ - The ID of the orderable + The price of one unit of the cross-sell """ - orderableId: String! + unitPriceFormatted: String! +} +union Orderable = Reward + +""" +The state of checkout, e.g. complete, in progress, incomplete. +""" +enum CheckoutStateEnum { """ - The quantity of the add-on in the Order + complete """ - quantity: Int! + complete """ - The shipping preference of the reward + in_progress """ - shippingPreference: String! + in_progress """ - Shipping rates for all shippable countries, - including those that are children of superregions + not_started """ - shippingRatesExpanded: [ShippingRate!]! + not_started } type PaymentIncrement { amount: Money! - id: ID! + id: ID - paymentIncrementableId: ID! + paymentIncrementableId: ID - paymentIncrementableType: String! + paymentIncrementableType: String - scheduledCollection: DateTime! + scheduledCollection: ISO8601DateTime! state: String! stateReason: String } +""" +An ISO 8601-encoded datetime +""" +scalar ISO8601DateTime + """ An adjustment summary. """ @@ -4760,11 +4807,6 @@ enum AdjustmentStatusType { CANCELLED } -""" -An ISO 8601-encoded datetime -""" -scalar ISO8601DateTime - """ Metadata for adjustment to order """ @@ -16273,12 +16315,12 @@ input ConfirmOrderAddressInput { Autogenerated return type of CreateWave """ type CreateWavePayload { - checkoutWave: CheckoutWave! - """ A unique identifier for the client performing the mutation. """ clientMutationId: String + + project: Project! } """ diff --git a/app/src/main/java/com/kickstarter/features/pledgeredemption/ui/PledgeRedemptionActivity.kt b/app/src/main/java/com/kickstarter/features/pledgeredemption/ui/PledgeRedemptionActivity.kt index c739ad5138..3494ad8536 100644 --- a/app/src/main/java/com/kickstarter/features/pledgeredemption/ui/PledgeRedemptionActivity.kt +++ b/app/src/main/java/com/kickstarter/features/pledgeredemption/ui/PledgeRedemptionActivity.kt @@ -5,7 +5,6 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import com.kickstarter.features.pledgeredemption.viewmodels.PledgeRedemptionViewModel -import com.kickstarter.libs.featureflag.FlagKey import com.kickstarter.libs.utils.extensions.getEnvironment import com.kickstarter.libs.utils.extensions.isDarkModeEnabled import com.kickstarter.mock.factories.RewardFactory @@ -65,8 +64,6 @@ class PledgeRedemptionActivity : ComponentActivity() { newPaymentMethodClicked = { }, onDisclaimerItemClicked = {}, onAccountabilityLinkClicked = {}, - isPlotEnabled = env.featureFlagClient() - ?.getBoolean(FlagKey.ANDROID_PLEDGE_OVER_TIME) ?: false, ) } } diff --git a/app/src/main/java/com/kickstarter/libs/KSCurrency.kt b/app/src/main/java/com/kickstarter/libs/KSCurrency.kt index d78b0181ba..547a318592 100644 --- a/app/src/main/java/com/kickstarter/libs/KSCurrency.kt +++ b/app/src/main/java/com/kickstarter/libs/KSCurrency.kt @@ -67,6 +67,35 @@ class KSCurrency(private val currentConfig: CurrentConfigTypeV2) { return NumberUtils.format(currencyOptions.value() ?: 0F, numberOptions).trimAllWhitespace() } + /** + * Returns a currency string appropriate to the user's locale and location relative to a project. + * + * @param initialValue Value to display, local to the project's currency. + * @param projectCurrency The project currency. + * @param projectCurrenctCurrency The project current currency. + * @param excludeCurrencyCode If true, hide the US currency code for US users only. + */ + @JvmOverloads + fun format( + initialValue: Double, + projectCurrency: String?, + projectCurrentCurrency: String?, + excludeCurrencyCode: Boolean = true, + roundingMode: RoundingMode = RoundingMode.DOWN, + currentCurrency: Boolean = false + ): String { + val country = (if (currentCurrency) projectCurrentCurrency else projectCurrency)?.let { findByCurrencyCode(it) } ?: return "" + val roundedValue = getRoundedValue(initialValue, roundingMode) + val currencyOptions = currencyOptions(roundedValue, country, excludeCurrencyCode) + val numberOptions = NumberOptions.builder() + .currencyCode(currencyOptions.currencyCode()) + .currencySymbol(currencyOptions.currencySymbol()) + .roundingMode(roundingMode) + .precision(NumberUtils.precision(initialValue, roundingMode)) + .build() + return NumberUtils.format(currencyOptions.value() ?: 0F, numberOptions).trimAllWhitespace() + } + /** * Returns a currency string appropriate to the user's locale and preferred currency. * diff --git a/app/src/main/java/com/kickstarter/libs/utils/RewardViewUtils.kt b/app/src/main/java/com/kickstarter/libs/utils/RewardViewUtils.kt index f6df2c64a6..c6e822ae04 100644 --- a/app/src/main/java/com/kickstarter/libs/utils/RewardViewUtils.kt +++ b/app/src/main/java/com/kickstarter/libs/utils/RewardViewUtils.kt @@ -88,6 +88,31 @@ object RewardViewUtils { return spannableString } + /** + * Returns a SpannableString representing currency that shrinks currency symbol if it's necessary. + * Special case: US people looking at US currency just get the currency symbol. + * + */ + fun styleCurrency(value: Double, projectCurrency: String?, projectCurrentCurrency: String?, ksCurrency: KSCurrency): SpannableString { + val formattedCurrency = ksCurrency.format(initialValue = value, projectCurrency = projectCurrency, projectCurrentCurrency = projectCurrentCurrency, roundingMode = RoundingMode.HALF_UP) + val spannableString = SpannableString(formattedCurrency) + + val country = projectCurrency?.let { + Country.findByCurrencyCode(it) ?: return spannableString + } ?: return spannableString + + val currencyNeedsCode = ksCurrency.currencyNeedsCode(country, true) + val currencySymbolToDisplay = ksCurrency.getCurrencySymbol(country, true).trimAllWhitespace() + + if (currencyNeedsCode) { + val startOfSymbol = formattedCurrency.indexOf(currencySymbolToDisplay) + val endOfSymbol = startOfSymbol + currencySymbolToDisplay.length + spannableString.setSpan(RelativeSizeSpan(.7f), startOfSymbol, endOfSymbol, Spanned.SPAN_INCLUSIVE_INCLUSIVE) + } + + return spannableString + } + /** * Returns a String representing currency based on given currency code and symbol ex. $12 USD */ diff --git a/app/src/main/java/com/kickstarter/libs/utils/extensions/StringExt.kt b/app/src/main/java/com/kickstarter/libs/utils/extensions/StringExt.kt index 8baadf8f73..b69401c3e0 100644 --- a/app/src/main/java/com/kickstarter/libs/utils/extensions/StringExt.kt +++ b/app/src/main/java/com/kickstarter/libs/utils/extensions/StringExt.kt @@ -239,6 +239,16 @@ fun String.format(key1: String, value1: String?): String { } return this.replace(substitutions) } + +fun String.format(key1: String, value1: String?, key2: String, value2: String?): String { + val substitutions: HashMap = object : HashMap() { + init { + put(key1, value1) + put(key2, value2) + } + } + return this.replace(substitutions) +} fun String.replace(substitutions: Map): String { val builder = StringBuilder() for (key in substitutions.keys) { diff --git a/app/src/main/java/com/kickstarter/mock/factories/PaymentIncrementFactory.kt b/app/src/main/java/com/kickstarter/mock/factories/PaymentIncrementFactory.kt new file mode 100644 index 0000000000..bd7cfa311d --- /dev/null +++ b/app/src/main/java/com/kickstarter/mock/factories/PaymentIncrementFactory.kt @@ -0,0 +1,63 @@ +package com.kickstarter.mock.factories + +import com.kickstarter.models.Amount +import com.kickstarter.models.PaymentIncrement +import com.kickstarter.type.CurrencyCode +import org.joda.time.DateTime + +class PaymentIncrementFactory { + companion object { + + fun paymentIncrement( + amount: Amount, + paymentIncrementableId: String, + paymentIncrementableType: String, + scheduledCollection: DateTime, + state: PaymentIncrement.State, + stateReason: String?, + ): PaymentIncrement { + return PaymentIncrement.builder() + .amount(amount) + .paymentIncrementableId(paymentIncrementableId) + .paymentIncrementableType(paymentIncrementableType) + .scheduledCollection(scheduledCollection) + .state(state) + .stateReason(stateReason) + .build() + } + + fun amount( + amount: String?, + currencySymbol: String?, + currencyCode: CurrencyCode?, + ): Amount { + return Amount.builder() + .amount(amount) + .currencySymbol(currencySymbol) + .currencyCode(currencyCode) + .build() + } + + fun incrementUsdUncollected(dateTime: DateTime, amount: String): PaymentIncrement { + return paymentIncrement( + amount = amount(amount, "$", CurrencyCode.USD), + scheduledCollection = dateTime, + paymentIncrementableId = "", + paymentIncrementableType = "", + state = PaymentIncrement.State.UNATTEMPTED, + stateReason = "" + ) + } + + fun incrementUsdCollected(dateTime: DateTime, amount: String): PaymentIncrement { + return paymentIncrement( + amount = amount(amount, "$", CurrencyCode.USD), + scheduledCollection = dateTime, + paymentIncrementableId = "", + paymentIncrementableType = "", + state = PaymentIncrement.State.COLLECTED, + stateReason = "" + ) + } + } +} diff --git a/app/src/main/java/com/kickstarter/mock/factories/PaymentPlanFactory.kt b/app/src/main/java/com/kickstarter/mock/factories/PaymentPlanFactory.kt new file mode 100644 index 0000000000..badb38c8ed --- /dev/null +++ b/app/src/main/java/com/kickstarter/mock/factories/PaymentPlanFactory.kt @@ -0,0 +1,38 @@ +package com.kickstarter.mock.factories + +import com.kickstarter.models.PaymentIncrement +import com.kickstarter.models.PaymentPlan +import com.kickstarter.models.PaymentPlan.Companion.builder + +class PaymentPlanFactory { + companion object { + + fun paymentPlan( + paymentIncrements: List?, + amountIsPledgeOverTimeEligible: Boolean, + projectIsPledgeOverTimeAllowed: Boolean, + ): PaymentPlan { + return builder() + .paymentIncrements(paymentIncrements) + .amountIsPledgeOverTimeEligible(amountIsPledgeOverTimeEligible) + .projectIsPledgeOverTimeAllowed(projectIsPledgeOverTimeAllowed) + .build() + } + + fun eligibleAllowedPaymentPlan(paymentIncrements: List): PaymentPlan { + return this.paymentPlan( + paymentIncrements = paymentIncrements, + amountIsPledgeOverTimeEligible = true, + projectIsPledgeOverTimeAllowed = true + ) + } + + fun ineligibleAllowedPaymentPlan(): PaymentPlan { + return this.paymentPlan( + paymentIncrements = null, + amountIsPledgeOverTimeEligible = false, + projectIsPledgeOverTimeAllowed = true + ) + } + } +} diff --git a/app/src/main/java/com/kickstarter/models/Money.kt b/app/src/main/java/com/kickstarter/models/Amount.kt similarity index 95% rename from app/src/main/java/com/kickstarter/models/Money.kt rename to app/src/main/java/com/kickstarter/models/Amount.kt index 01d8671e44..062b5318cf 100644 --- a/app/src/main/java/com/kickstarter/models/Money.kt +++ b/app/src/main/java/com/kickstarter/models/Amount.kt @@ -5,7 +5,7 @@ import com.kickstarter.type.CurrencyCode import kotlinx.parcelize.Parcelize @Parcelize -data class Money( +data class Amount( val amount: String?, val currencyCode: CurrencyCode?, val currencySymbol: String?, @@ -24,7 +24,7 @@ data class Money( fun amount(amount: String?) = apply { this.amount = amount } fun currencyCode(currencyCode: CurrencyCode?) = apply { this.currencyCode = currencyCode } fun currencySymbol(currencySymbol: String?) = apply { this.currencySymbol = currencySymbol } - fun build() = Money( + fun build() = Amount( amount = amount, currencyCode = currencyCode, currencySymbol = currencySymbol @@ -33,7 +33,7 @@ data class Money( override fun equals(obj: Any?): Boolean { var equals = super.equals(obj) - if (obj is Money) { + if (obj is Amount) { equals = amount() == obj.amount() && currencyCode() == obj.currencyCode() && currencySymbol() == obj.currencySymbol() diff --git a/app/src/main/java/com/kickstarter/models/PaymentIncrement.kt b/app/src/main/java/com/kickstarter/models/PaymentIncrement.kt index 1b3c40cf62..8ef641bde4 100644 --- a/app/src/main/java/com/kickstarter/models/PaymentIncrement.kt +++ b/app/src/main/java/com/kickstarter/models/PaymentIncrement.kt @@ -6,7 +6,7 @@ import org.joda.time.DateTime @Parcelize data class PaymentIncrement( - val amount: Money, + val amount: Amount, val paymentIncrementableId: String, val paymentIncrementableType: String, val scheduledCollection: DateTime, @@ -22,14 +22,14 @@ data class PaymentIncrement( @Parcelize data class Builder( - private var amount: Money = Money.builder().build(), + private var amount: Amount = Amount.builder().build(), private var paymentIncrementableId: String = "", private var paymentIncrementableType: String = "", private var scheduledCollection: DateTime = DateTime.now(), private var state: State = State.UNKNOWN, private var stateReason: String? = null ) : Parcelable { - fun amount(amount: Money) = apply { this.amount = amount } + fun amount(amount: Amount) = apply { this.amount = amount } fun paymentIncrementableId(paymentIncrementableId: String) = apply { this.paymentIncrementableId = paymentIncrementableId } fun paymentIncrementableType(paymentIncrementableType: String) = apply { this.paymentIncrementableType = paymentIncrementableType } fun scheduledCollection(scheduledCollection: DateTime) = apply { this.scheduledCollection = scheduledCollection } diff --git a/app/src/main/java/com/kickstarter/models/extensions/StoredCardExt.kt b/app/src/main/java/com/kickstarter/models/extensions/StoredCardExt.kt index 178b5b1b23..3d9fe82ad0 100644 --- a/app/src/main/java/com/kickstarter/models/extensions/StoredCardExt.kt +++ b/app/src/main/java/com/kickstarter/models/extensions/StoredCardExt.kt @@ -30,7 +30,8 @@ fun StoredCard.getBackingData( amount: String, locationId: String?, rewards: List, - cookieRefTag: RefTag? + cookieRefTag: RefTag?, + incremental: Boolean? = null, ): CreateBackingData { return if (this.isFromPaymentSheet()) { CreateBackingData( @@ -40,7 +41,8 @@ fun StoredCard.getBackingData( locationId = locationId, rewardsIds = rewards, refTag = if (cookieRefTag?.tag()?.isNotEmpty() == true) cookieRefTag else null, - stripeCardId = this.stripeCardId() + stripeCardId = this.stripeCardId(), + incremental = incremental ) } else { CreateBackingData( @@ -50,7 +52,8 @@ fun StoredCard.getBackingData( locationId = locationId, rewardsIds = rewards, refTag = if (cookieRefTag?.tag()?.isNotEmpty() == true) cookieRefTag else null, - stripeCardId = this.stripeCardId() + stripeCardId = this.stripeCardId(), + incremental = incremental ) } } diff --git a/app/src/main/java/com/kickstarter/services/transformers/GraphQLTransformers.kt b/app/src/main/java/com/kickstarter/services/transformers/GraphQLTransformers.kt index ea43616e76..a030fefeb3 100644 --- a/app/src/main/java/com/kickstarter/services/transformers/GraphQLTransformers.kt +++ b/app/src/main/java/com/kickstarter/services/transformers/GraphQLTransformers.kt @@ -26,6 +26,7 @@ import com.kickstarter.libs.utils.extensions.isPresent import com.kickstarter.libs.utils.extensions.negate import com.kickstarter.mock.factories.RewardFactory import com.kickstarter.models.AiDisclosure +import com.kickstarter.models.Amount import com.kickstarter.models.Avatar import com.kickstarter.models.Backing import com.kickstarter.models.Category @@ -33,7 +34,6 @@ import com.kickstarter.models.Comment import com.kickstarter.models.EnvironmentalCommitment import com.kickstarter.models.Item import com.kickstarter.models.Location -import com.kickstarter.models.Money import com.kickstarter.models.PaymentIncrement import com.kickstarter.models.PaymentPlan import com.kickstarter.models.PaymentSource @@ -1007,7 +1007,7 @@ fun paymentPlanTransformer(buildPaymentPlanResponse: BuildPaymentPlanQuery.Payme val paymentIncrements = buildPaymentPlanResponse.paymentIncrements?.map { - val money = Money.builder() + val amount = Amount.builder() .amount(it.amount.amount.amount) .currencyCode(it.amount.amount.currency) .currencySymbol(it.amount.amount.symbol) @@ -1016,7 +1016,7 @@ fun paymentPlanTransformer(buildPaymentPlanResponse: BuildPaymentPlanQuery.Payme val scheduledCollection = it.scheduledCollection PaymentIncrement.builder() - .amount(money) + .amount(amount) .scheduledCollection(scheduledCollection) .build() } diff --git a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/CheckoutScreen.kt b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/CheckoutScreen.kt index b688032cf1..61b9f596f9 100644 --- a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/CheckoutScreen.kt +++ b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/CheckoutScreen.kt @@ -1,5 +1,6 @@ package com.kickstarter.ui.activities.compose.projectpage +import CollectionOptions import CollectionPlan import android.content.res.Configuration import androidx.compose.foundation.Image @@ -14,7 +15,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable @@ -51,16 +51,20 @@ import androidx.compose.ui.unit.sp import androidx.core.text.HtmlCompat import com.kickstarter.R import com.kickstarter.libs.Environment +import com.kickstarter.libs.KSCurrency import com.kickstarter.libs.KSString import com.kickstarter.libs.utils.DateTimeUtils import com.kickstarter.libs.utils.RewardUtils import com.kickstarter.libs.utils.RewardViewUtils import com.kickstarter.libs.utils.extensions.acceptedCardType +import com.kickstarter.libs.utils.extensions.format import com.kickstarter.libs.utils.extensions.hrefUrlFromTranslation import com.kickstarter.libs.utils.extensions.isNotNull +import com.kickstarter.libs.utils.extensions.parseToDouble import com.kickstarter.libs.utils.extensions.stringsFromHtmlTranslation import com.kickstarter.mock.factories.RewardFactory import com.kickstarter.mock.factories.StoredCardFactory +import com.kickstarter.models.PaymentIncrement import com.kickstarter.models.Project import com.kickstarter.models.Reward import com.kickstarter.models.ShippingRule @@ -180,6 +184,7 @@ fun CheckoutScreen( project: Project, email: String?, ksString: KSString? = null, + ksCurrency: KSCurrency? = null, selectedRewardsAndAddOns: List = listOf(), rewardsList: List> = listOf(), shippingAmount: Double = 0.0, @@ -194,7 +199,11 @@ fun CheckoutScreen( onDisclaimerItemClicked: (disclaimerItem: DisclaimerItems) -> Unit, onAccountabilityLinkClicked: () -> Unit, onChangedPaymentMethod: (StoredCard?) -> Unit = {}, - isPlotEnabled: Boolean + onCollectionPlanSelected: (CollectionOptions) -> Unit = {}, + isPlotEnabled: Boolean = false, + isPlotEligible: Boolean = false, + isIncrementalPledge: Boolean = false, + paymentIncrements: List? = null, ) { val selectedOption = remember { mutableStateOf( @@ -361,7 +370,6 @@ fun CheckoutScreen( } Column( modifier = Modifier - .systemBarsPadding() .verticalScroll(rememberScrollState()) .padding(padding) ) { @@ -376,7 +384,7 @@ fun CheckoutScreen( color = colors.kds_black, ) - Spacer(modifier = Modifier.height(dimensions.paddingMediumSmall)) + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) if (isPlotEnabled) { Text( modifier = Modifier.padding( @@ -389,7 +397,14 @@ fun CheckoutScreen( ) Spacer(modifier = Modifier.height(dimensions.paddingMediumSmall)) - CollectionPlan(isEligible = true) + CollectionPlan( + isEligible = isPlotEligible, + changeCollectionPlan = onCollectionPlanSelected, + paymentIncrements = paymentIncrements, + ksCurrency = ksCurrency, + projectCurrency = project.currency(), + projectCurrentCurrency = project.currentCurrency() + ) Spacer(modifier = Modifier.height(dimensions.paddingMediumSmall)) Text( modifier = Modifier.padding( @@ -552,8 +567,18 @@ fun CheckoutScreen( "project_deadline", project.deadline()?.let { DateTimeUtils.longDate(it) } ) ?: "" val plotDisclaimerText = - stringResource(R.string.If_the_project_reaches_its_funding_goal_you_will_be_charged_total_on_project_deadline_and_receive_proof_of_pledge) + stringResource(R.string.fpo_if_the_project_reaches_its_funding_goal_the_first_charge_of_first_charge_will_be_collected_on_date).format( + key1 = "amount", + ksCurrency?.let { RewardViewUtils.styleCurrency(value = paymentIncrements?.first()?.amount?.amount.parseToDouble(), projectCurrency = project.currency(), projectCurrentCurrency = project.currentCurrency(), ksCurrency = it).toString() }, + key2 = "project_deadline", + paymentIncrements?.first()?.scheduledCollection?.let { + DateTimeUtils.mediumDate( + it + ) + } + ) val isNoReward = selectedReward?.let { RewardUtils.isNoReward(it) } ?: false + if (!isNoReward) { ItemizedRewardListContainer( ksString = ksString, @@ -567,8 +592,8 @@ fun CheckoutScreen( totalBonusSupport = totalBonusSupportString, deliveryDateString = deliveryDateString, rewardsHaveShippables = rewardsHaveShippables, - disclaimerText = if (isPlotEnabled) plotDisclaimerText else disclaimerText, - plotSelected = false + disclaimerText = if (isIncrementalPledge) plotDisclaimerText else disclaimerText, + plotSelected = isIncrementalPledge ) } else { // - For noReward, totalAmount = bonusAmount as there is no reward @@ -578,8 +603,8 @@ fun CheckoutScreen( initialBonusSupport = initialBonusSupportString, totalBonusSupport = totalAmountString, shippingAmount = shippingAmount, - disclaimerText = if (isPlotEnabled) plotDisclaimerText else disclaimerText, - plotSelected = false + disclaimerText = if (isIncrementalPledge) plotDisclaimerText else disclaimerText, + plotSelected = isIncrementalPledge ) } diff --git a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/ProjectPledgeButtonAndFragmentContainer.kt b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/ProjectPledgeButtonAndFragmentContainer.kt index 286cb0d88d..92e8652828 100644 --- a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/ProjectPledgeButtonAndFragmentContainer.kt +++ b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/ProjectPledgeButtonAndFragmentContainer.kt @@ -293,7 +293,6 @@ fun ProjectPledgeButtonAndFragmentContainer( isLoading = isLoading, onDisclaimerItemClicked = onDisclaimerItemClicked, onAccountabilityLinkClicked = onAccountabilityLinkClicked, - isPlotEnabled = false, ) } } diff --git a/app/src/main/java/com/kickstarter/ui/compose/designsystem/KSDimensions.kt b/app/src/main/java/com/kickstarter/ui/compose/designsystem/KSDimensions.kt index e7296869f2..ff42209d92 100644 --- a/app/src/main/java/com/kickstarter/ui/compose/designsystem/KSDimensions.kt +++ b/app/src/main/java/com/kickstarter/ui/compose/designsystem/KSDimensions.kt @@ -58,7 +58,9 @@ data class KSDimensions( val countryInputWidth: Dp = Dp.Unspecified, val storedCardImageHeight: Dp = Dp.Unspecified, val storedCardImageWidth: Dp = Dp.Unspecified, - val alertIconSize: Dp = Dp.Unspecified + val alertIconSize: Dp = Dp.Unspecified, + val plotChargeItemWidth: Dp = Dp.Unspecified, + ) val LocalKSCustomDimensions = staticCompositionLocalOf { @@ -116,5 +118,6 @@ val KSStandardDimensions = KSDimensions( countryInputWidth = 164.dp, storedCardImageHeight = 40.dp, storedCardImageWidth = 64.dp, - alertIconSize = 14.dp + alertIconSize = 14.dp, + plotChargeItemWidth = 100.dp ) diff --git a/app/src/main/java/com/kickstarter/ui/fragments/CrowdfundCheckoutFragment.kt b/app/src/main/java/com/kickstarter/ui/fragments/CrowdfundCheckoutFragment.kt index e63adf9858..60c7e93311 100644 --- a/app/src/main/java/com/kickstarter/ui/fragments/CrowdfundCheckoutFragment.kt +++ b/app/src/main/java/com/kickstarter/ui/fragments/CrowdfundCheckoutFragment.kt @@ -92,6 +92,10 @@ class CrowdfundCheckoutFragment : Fragment() { val totalAmount = checkoutStates.checkoutTotal val shippingRule = checkoutStates.shippingRule val bonus = checkoutStates.bonusAmount + val showPlotWidget = checkoutStates.showPlotWidget + val plotEligible = checkoutStates.plotEligible + val paymentIncrements = checkoutStates.paymentIncrements + val isIncrementalPledge = checkoutStates.isIncrementalPledge val pledgeData = viewModel.getPledgeData() val pledgeReason = viewModel.getPledgeReason() ?: PledgeReason.PLEDGE @@ -126,6 +130,9 @@ class CrowdfundCheckoutFragment : Fragment() { } } + val plotIsVisible = showPlotWidget && environment?.featureFlagClient() + ?.getBoolean(FlagKey.ANDROID_PLEDGE_OVER_TIME) ?: false && pledgeReason == PledgeReason.PLEDGE + KSTheme { CheckoutScreen( rewardsList = getRewardListAndPrices(rwList, environment, project), @@ -155,8 +162,15 @@ class CrowdfundCheckoutFragment : Fragment() { onChangedPaymentMethod = { paymentMethodSelected -> viewModel.userChangedPaymentMethodSelected(paymentMethodSelected) }, - isPlotEnabled = environment.featureFlagClient() - ?.getBoolean(FlagKey.ANDROID_PLEDGE_OVER_TIME) ?: false && pledgeReason == PledgeReason.PLEDGE, + ksCurrency = environment.ksCurrency(), + isPlotEnabled = plotIsVisible, + isPlotEligible = plotEligible, + paymentIncrements = paymentIncrements, + isIncrementalPledge = isIncrementalPledge, + onCollectionPlanSelected = { + collectionOptions -> + viewModel.collectionPlanSelected(collectionOptions) + } ) } } diff --git a/app/src/main/java/com/kickstarter/ui/views/compose/checkout/CollectionPlan.kt b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/CollectionPlan.kt index fb820530c2..93bd98934a 100644 --- a/app/src/main/java/com/kickstarter/ui/views/compose/checkout/CollectionPlan.kt +++ b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/CollectionPlan.kt @@ -29,10 +29,18 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.kickstarter.R +import com.kickstarter.libs.KSCurrency +import com.kickstarter.libs.utils.DateTimeUtils +import com.kickstarter.libs.utils.RewardViewUtils +import com.kickstarter.libs.utils.extensions.format +import com.kickstarter.libs.utils.extensions.parseToDouble +import com.kickstarter.mock.factories.PaymentIncrementFactory +import com.kickstarter.models.PaymentIncrement import com.kickstarter.ui.compose.designsystem.KSTheme import com.kickstarter.ui.compose.designsystem.KSTheme.colors import com.kickstarter.ui.compose.designsystem.KSTheme.dimensions import com.kickstarter.ui.compose.designsystem.KSTheme.typography +import org.joda.time.DateTime enum class CollectionPlanTestTags { OPTION_PLEDGE_IN_FULL, @@ -42,6 +50,8 @@ enum class CollectionPlanTestTags { EXPANDED_DESCRIPTION_TEXT, TERMS_OF_USE_TEXT, CHARGE_ITEM, + RADIO_BUTTON, + CHARGE_SCHEDULE } enum class CollectionOptions { @@ -49,6 +59,8 @@ enum class CollectionOptions { PLEDGE_OVER_TIME, } +private val PLOT_MINIMUM_AMOUNT = "$125" + @Preview( name = "Light Eligible - Pledge in Full Selected", uiMode = Configuration.UI_MODE_NIGHT_NO, @@ -62,7 +74,7 @@ fun PreviewPledgeInFullSelected() { KSTheme { CollectionPlan( isEligible = true, - initialSelectedOption = CollectionOptions.PLEDGE_IN_FULL.name + initialSelectedOption = CollectionOptions.PLEDGE_IN_FULL ) } } @@ -80,7 +92,13 @@ fun PreviewPledgeOverTimeSelected() { KSTheme { CollectionPlan( isEligible = true, - initialSelectedOption = CollectionOptions.PLEDGE_OVER_TIME.name + initialSelectedOption = CollectionOptions.PLEDGE_OVER_TIME, + paymentIncrements = listOf( + PaymentIncrementFactory.incrementUsdCollected(DateTime.now(), "150"), + PaymentIncrementFactory.incrementUsdCollected(DateTime.now().plusWeeks(2), "150"), + PaymentIncrementFactory.incrementUsdCollected(DateTime.now().plusWeeks(4), "150"), + PaymentIncrementFactory.incrementUsdCollected(DateTime.now().plusWeeks(6), "150"), + ) ) } } @@ -92,7 +110,7 @@ fun PreviewNotEligibleComponent() { KSTheme { CollectionPlan( isEligible = false, - initialSelectedOption = CollectionOptions.PLEDGE_IN_FULL.name + initialSelectedOption = CollectionOptions.PLEDGE_IN_FULL, ) } } @@ -100,29 +118,46 @@ fun PreviewNotEligibleComponent() { @Composable fun CollectionPlan( isEligible: Boolean, - initialSelectedOption: String = CollectionOptions.PLEDGE_IN_FULL.name + initialSelectedOption: CollectionOptions = CollectionOptions.PLEDGE_IN_FULL, + changeCollectionPlan: (CollectionOptions) -> Unit = {}, + paymentIncrements: List? = null, + plotMinimum: String? = null, + ksCurrency: KSCurrency? = null, + projectCurrency: String? = null, + projectCurrentCurrency: String? = null ) { var selectedOption by remember { mutableStateOf(initialSelectedOption) } + changeCollectionPlan.invoke(selectedOption) + + val onOptionSelected: (CollectionOptions) -> Unit = { + selectedOption = it + changeCollectionPlan.invoke(it) + } Column(modifier = Modifier.padding(start = dimensions.paddingMedium, end = dimensions.paddingMedium)) { PledgeOption( optionText = stringResource(id = R.string.fpo_pledge_in_full), - selected = selectedOption == CollectionOptions.PLEDGE_IN_FULL.name, - onSelect = { selectedOption = CollectionOptions.PLEDGE_IN_FULL.name }, - modifier = Modifier.testTag(CollectionPlanTestTags.OPTION_PLEDGE_IN_FULL.name) + selected = selectedOption == CollectionOptions.PLEDGE_IN_FULL, + onSelect = { onOptionSelected.invoke(CollectionOptions.PLEDGE_IN_FULL) }, + modifier = Modifier.testTag(CollectionPlanTestTags.OPTION_PLEDGE_IN_FULL.name), ) Spacer(Modifier.height(dimensions.paddingSmall)) PledgeOption( modifier = Modifier.testTag(CollectionPlanTestTags.OPTION_PLEDGE_OVER_TIME.name), optionText = stringResource(id = R.string.fpo_pledge_over_time), - selected = selectedOption == CollectionOptions.PLEDGE_OVER_TIME.name, + selected = selectedOption == CollectionOptions.PLEDGE_OVER_TIME, description = if (isEligible) stringResource(id = R.string.fpo_you_will_be_charged_for_your_pledge_over_four_payments_at_no_extra_cost) else null, onSelect = { - if (isEligible) selectedOption = CollectionOptions.PLEDGE_OVER_TIME.name + if (isEligible) onOptionSelected.invoke(CollectionOptions.PLEDGE_OVER_TIME) }, - isExpanded = selectedOption == CollectionOptions.PLEDGE_OVER_TIME.name && isEligible, + isExpanded = selectedOption == CollectionOptions.PLEDGE_OVER_TIME && isEligible, isSelectable = isEligible, showBadge = !isEligible, + paymentIncrements = paymentIncrements, + plotMinimum = plotMinimum, + ksCurrency = ksCurrency, + projectCurrency = projectCurrency, + projectCurrentCurrency = projectCurrentCurrency, ) } } @@ -137,14 +172,19 @@ fun PledgeOption( isExpanded: Boolean = false, isSelectable: Boolean = true, showBadge: Boolean = false, + plotMinimum: String? = null, + paymentIncrements: List? = null, + ksCurrency: KSCurrency? = null, + projectCurrency: String? = null, + projectCurrentCurrency: String? = null, ) { Column( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(dimensions.radiusSmall)) - .background(colors.kds_white) + .background(colors.backgroundSurfacePrimary) .clickable(enabled = isSelectable, onClick = onSelect) - .padding(bottom = dimensions.paddingSmall, end = dimensions.paddingMedium) + .padding(end = dimensions.paddingMedium) .semantics { this.selected = selected } .then( if (!isSelectable) Modifier.padding( @@ -158,8 +198,9 @@ fun PledgeOption( modifier = Modifier.padding(start = dimensions.paddingSmall) ) { Column { + var radioButtonModifier = if (!isSelectable) Modifier.padding(end = dimensions.paddingMediumSmall) else Modifier RadioButton( - modifier = if (!isSelectable) Modifier.padding(end = dimensions.paddingMediumSmall) else Modifier, + modifier = radioButtonModifier.testTag(CollectionPlanTestTags.RADIO_BUTTON.name), selected = selected, onClick = onSelect.takeIf { isSelectable }, colors = RadioButtonDefaults.colors( @@ -172,32 +213,31 @@ fun PledgeOption( Text( modifier = Modifier.padding( top = if (isSelectable) dimensions.paddingMedium else dimensions.dialogButtonSpacing, - bottom = dimensions.paddingSmall ), text = optionText, style = typography.subheadlineMedium, - color = if (isSelectable) colors.kds_black else colors.textDisabled + color = if (isSelectable) colors.textPrimary else colors.textDisabled ) if (showBadge) { - Spacer(modifier = Modifier.height(dimensions.paddingXSmall)) - PledgeBadge() + Spacer(modifier = Modifier.height(dimensions.paddingSmall)) + PledgeBadge(plotMinimum = plotMinimum) } else if (description != null) { + Spacer(modifier = Modifier.height(dimensions.paddingSmall)) Text( modifier = Modifier - .padding(bottom = dimensions.paddingSmall) + .padding(bottom = dimensions.paddingMedium) .testTag(CollectionPlanTestTags.DESCRIPTION_TEXT.name), text = description, style = typography.caption2, - color = colors.textDisabled + color = colors.textSecondary ) } if (isExpanded) { - Spacer(modifier = Modifier.height(dimensions.paddingSmall)) Text( modifier = Modifier.testTag(CollectionPlanTestTags.EXPANDED_DESCRIPTION_TEXT.name), text = stringResource(id = R.string.fpo_the_first_charge_will_be_24_hours_after_the_project_ends_successfully), style = typography.caption2, - color = colors.textDisabled + color = colors.textSecondary ) Spacer(modifier = Modifier.height(dimensions.paddingXSmall)) Text( @@ -206,7 +246,9 @@ fun PledgeOption( style = typography.caption2, color = colors.textAccentGreen ) - ChargeSchedule() + if (!paymentIncrements.isNullOrEmpty()) { + ChargeSchedule(paymentIncrements, ksCurrency, projectCurrency, projectCurrentCurrency) + } } } } @@ -214,7 +256,7 @@ fun PledgeOption( } @Composable -fun PledgeBadge(modifier: Modifier = Modifier) { +fun PledgeBadge(modifier: Modifier = Modifier, plotMinimum: String?) { Box( modifier = modifier .background( @@ -230,9 +272,7 @@ fun PledgeBadge(modifier: Modifier = Modifier) { ) { Text( modifier = Modifier.testTag(CollectionPlanTestTags.BADGE_TEXT.name), - text = stringResource( - id = R.string.fpo_available_for_pledges_over_150 - ), + text = stringResource(id = R.string.fpo_available_for_pledges_over_amount).format("amount", plotMinimum ?: PLOT_MINIMUM_AMOUNT), style = typography.body2Medium, color = colors.textDisabled ) @@ -240,16 +280,22 @@ fun PledgeBadge(modifier: Modifier = Modifier) { } @Composable -fun ChargeSchedule() { +fun ChargeSchedule(paymentIncrements: List, ksCurrency: KSCurrency?, projectCurrency: String? = null, projectCurrentCurrency: String? = null) { + var count = 0 Column( modifier = Modifier + .testTag(CollectionPlanTestTags.CHARGE_SCHEDULE.name) .fillMaxWidth() .padding(top = 12.dp) ) { - ChargeItem("Charge 1", "Aug 11, 2024", "$250") - ChargeItem("Charge 2", "Aug 15, 2024", "$250") - ChargeItem("Charge 3", "Aug 29, 2024", "$250") - ChargeItem("Charge 4", "Sep 12, 2024", "$250") + paymentIncrements.forEach { paymentIncrement -> + ksCurrency?.let { + count++ + val formattedAmount = RewardViewUtils.styleCurrency(value = paymentIncrement.amount.amount.parseToDouble(), ksCurrency = it, projectCurrency = projectCurrency, projectCurrentCurrency = projectCurrentCurrency).toString() + val chargeString = stringResource(R.string.fpo_charge_count).format(key1 = "number", value1 = count.toString()) + ChargeItem(title = chargeString, date = DateTimeUtils.mediumDate(paymentIncrement.scheduledCollection), amount = formattedAmount) + } + } } } @@ -263,12 +309,13 @@ fun ChargeItem(title: String, date: String, amount: String) { Column(modifier = Modifier.padding(bottom = dimensions.paddingMediumLarge)) { Text( modifier = Modifier.testTag(CollectionPlanTestTags.CHARGE_ITEM.name), - text = title, style = typography.body2Medium + text = title, + style = typography.body2Medium, + color = colors.textPrimary ) Row(modifier = Modifier.padding(top = dimensions.paddingXSmall)) { - Text(text = date, color = colors.textSecondary, style = typography.footnote) - Spacer(modifier = Modifier.width(dimensions.paddingXLarge)) + Text(modifier = Modifier.width(dimensions.plotChargeItemWidth), text = date, color = colors.textSecondary, style = typography.footnote) Text(text = amount, color = colors.textSecondary, style = typography.footnote) } } diff --git a/app/src/main/java/com/kickstarter/ui/views/compose/checkout/PaymentSchedule.kt b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/PaymentSchedule.kt index 7bba2048e5..df0df44ff5 100644 --- a/app/src/main/java/com/kickstarter/ui/views/compose/checkout/PaymentSchedule.kt +++ b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/PaymentSchedule.kt @@ -28,7 +28,7 @@ import androidx.compose.ui.unit.dp import com.kickstarter.R import com.kickstarter.libs.utils.DateTimeUtils import com.kickstarter.libs.utils.extensions.parseToDouble -import com.kickstarter.models.Money +import com.kickstarter.models.Amount import com.kickstarter.models.PaymentIncrement import com.kickstarter.ui.compose.designsystem.KSTheme import com.kickstarter.ui.compose.designsystem.KSTheme.colors @@ -48,7 +48,7 @@ enum class PaymentScheduleTestTags { val samplePaymentIncrements = listOf( PaymentIncrement( - amount = Money.builder().amount("34.00").build(), + amount = Amount.builder().amount("34.00").build(), state = PaymentIncrement.State.UNATTEMPTED, paymentIncrementableId = "1", paymentIncrementableType = "pledge", @@ -56,7 +56,7 @@ val samplePaymentIncrements = listOf( stateReason = "" ), PaymentIncrement( - amount = Money.builder().amount("25.00").build(), + amount = Amount.builder().amount("25.00").build(), state = PaymentIncrement.State.COLLECTED, paymentIncrementableId = "2", paymentIncrementableType = "pledge", @@ -64,7 +64,7 @@ val samplePaymentIncrements = listOf( stateReason = "" ), PaymentIncrement( - amount = Money.builder().amount("45.00").build(), + amount = Amount.builder().amount("45.00").build(), state = PaymentIncrement.State.UNATTEMPTED, paymentIncrementableId = "3", paymentIncrementableType = "pledge", @@ -72,7 +72,7 @@ val samplePaymentIncrements = listOf( stateReason = "" ), PaymentIncrement( - amount = Money.builder().amount("52.00").build(), + amount = Amount.builder().amount("52.00").build(), state = PaymentIncrement.State.COLLECTED, paymentIncrementableId = "4", paymentIncrementableType = "pledge", diff --git a/app/src/main/java/com/kickstarter/ui/views/compose/checkout/PledgeItemizedDetails.kt b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/PledgeItemizedDetails.kt index 6602c5e126..c2bae8238f 100644 --- a/app/src/main/java/com/kickstarter/ui/views/compose/checkout/PledgeItemizedDetails.kt +++ b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/PledgeItemizedDetails.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.kickstarter.R import com.kickstarter.libs.KSString +import com.kickstarter.libs.utils.extensions.format import com.kickstarter.ui.compose.designsystem.KSDividerLineGrey import com.kickstarter.ui.compose.designsystem.KSTheme import com.kickstarter.ui.compose.designsystem.KSTheme.colors @@ -315,7 +316,7 @@ fun ItemizedRewardListContainer( } Text( modifier = Modifier.testTag(PledgeItemizedDetailsTestTag.DISCLAIMER_TEXT.name), - text = stringResource(id = R.string.fpo_charged_as_4_payments), + text = stringResource(id = R.string.fpo_charged_as_4_payments).format(key1 = "number", value1 = "4"), style = typography.footnote, color = colors.textPrimary ) diff --git a/app/src/main/java/com/kickstarter/viewmodels/projectpage/CrowdfundCheckoutViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/projectpage/CrowdfundCheckoutViewModel.kt index 626cc98487..ed51885d8b 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/projectpage/CrowdfundCheckoutViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/CrowdfundCheckoutViewModel.kt @@ -1,5 +1,6 @@ package com.kickstarter.viewmodels.projectpage +import CollectionOptions import android.os.Bundle import android.util.Pair import androidx.lifecycle.ViewModel @@ -16,7 +17,9 @@ import com.kickstarter.libs.utils.extensions.pledgeAmountTotal import com.kickstarter.libs.utils.extensions.rewardsAndAddOnsList import com.kickstarter.libs.utils.extensions.shippingCostIfShipping import com.kickstarter.models.Backing +import com.kickstarter.models.BuildPaymentPlanData import com.kickstarter.models.Location +import com.kickstarter.models.PaymentIncrement import com.kickstarter.models.Project import com.kickstarter.models.Reward import com.kickstarter.models.ShippingRule @@ -58,7 +61,11 @@ data class CheckoutUIState( val isPledgeButtonEnabled: Boolean = true, val selectedPaymentMethod: StoredCard = StoredCard.builder().build(), val bonusAmount: Double = 0.0, - val shippingRule: ShippingRule? = null + val shippingRule: ShippingRule? = null, + val showPlotWidget: Boolean = false, + val plotEligible: Boolean = false, + val isIncrementalPledge: Boolean = false, + val paymentIncrements: List? = null ) data class PaymentSheetPresenterState(val setupClientId: String = "") @@ -85,6 +92,10 @@ class CrowdfundCheckoutViewModel(val environment: Environment, bundle: Bundle? = private var totalAmount = 0.0 private var bonusAmount = 0.0 private var thirdPartyEventSent = Pair(false, "") + private var incrementalPledge = false + private var showPlotWidget: Boolean = false + private var plotEligible: Boolean = false + private var paymentIncrements: List? = null private var errorAction: (message: String?) -> Unit = {} @@ -171,6 +182,7 @@ class CrowdfundCheckoutViewModel(val environment: Environment, bundle: Bundle? = collectUserInformation() sendPageViewedEvent() + buildPaymentPlan() } } @@ -252,6 +264,24 @@ class CrowdfundCheckoutViewModel(val environment: Environment, bundle: Bundle? = } } + private fun buildPaymentPlan() { + scope.launch(dispatcher) { + apolloClient.buildPaymentPlan(BuildPaymentPlanData(pledgeData?.projectData()?.project()?.slug() ?: "", pledgeData?.checkoutTotalAmount().toString() ?: "")).asFlow() + .onStart { + emitCurrentState(isLoading = true) + }.catch { + errorAction.invoke(it.message) + emitCurrentState(isLoading = false) + } + .collectLatest { + showPlotWidget = it.projectIsPledgeOverTimeAllowed + plotEligible = it.amountIsPledgeOverTimeEligible + paymentIncrements = it.paymentIncrements + emitCurrentState(isLoading = false) + } + } + } + fun provideErrorAction(errorAction: (message: String?) -> Unit) { this.errorAction = errorAction } @@ -300,7 +330,11 @@ class CrowdfundCheckoutViewModel(val environment: Environment, bundle: Bundle? = isPledgeButtonEnabled = !isLoading, selectedPaymentMethod = selectedPaymentMethod, bonusAmount = bonusAmount, - shippingRule = shippingRule + shippingRule = shippingRule, + plotEligible = plotEligible, + showPlotWidget = showPlotWidget, + paymentIncrements = paymentIncrements, + isIncrementalPledge = incrementalPledge ) ) } @@ -330,6 +364,18 @@ class CrowdfundCheckoutViewModel(val environment: Environment, bundle: Bundle? = } } + fun collectionPlanSelected(collectionOption: CollectionOptions) { + incrementalPledge = + when (collectionOption) { + CollectionOptions.PLEDGE_IN_FULL -> false + CollectionOptions.PLEDGE_OVER_TIME -> true + } + + scope.launch { + emitCurrentState(isLoading = false) + } + } + fun userChangedPaymentMethodSelected(paymentMethodSelected: StoredCard?) { paymentMethodSelected?.let { selectedPaymentMethod = it @@ -386,7 +432,8 @@ class CrowdfundCheckoutViewModel(val environment: Environment, bundle: Bundle? = amount = pledgeData?.checkoutTotalAmount().toString(), locationId = if (shouldNotSendId) null else locationID, rewards = RewardUtils.extendAddOns(pledgeData?.rewardsAndAddOnsList() ?: emptyList()), - cookieRefTag = refTag + cookieRefTag = refTag, + incremental = incrementalPledge ) this.apolloClient.createBacking(backingData).asFlow() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9fdade9ae3..14e59c4134 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -95,16 +95,17 @@ Pledge Over Time Collection Plan Payment - Available for pledges over $150 + Charge %{number} + Available for pledges over %{amount} See our Terms of Use You will be charged for your pledge over four payments, at no extra cost. The first charge will be 24 hours after the project ends successfully, then every 2 weeks until fully paid. When this option is selected no further edits can be made to your pledge. Charged as 4 payments - If the project reaches its funding goal, the first charge of $20 will be collected on March 15, 2024. + If the project reaches its funding goal, the first charge of %{amount} will be collected on %{project_deadline}. Terms of Use Collected Unattempted Payment schedule - You have selected Pledge Over Time. If the project reaches its funding goal, the first charge of $20 will be collected on March 15, 2024. + You have selected Pledge Over Time. If the project reaches its funding goal, the first charge of %{amount} will be collected on %{date}. diff --git a/app/src/test/java/com/kickstarter/models/StoredCardTest.kt b/app/src/test/java/com/kickstarter/models/StoredCardTest.kt index 3e3a260335..1a4eed89c8 100644 --- a/app/src/test/java/com/kickstarter/models/StoredCardTest.kt +++ b/app/src/test/java/com/kickstarter/models/StoredCardTest.kt @@ -112,13 +112,13 @@ class StoredCardTest : TestCase() { @Test fun getBackingDataFromPaymentInfo() { val storedCard = StoredCardFactory.visa() - val backingData = storedCard.getBackingData(ProjectFactory.project(), "", locationId = null, rewards = listOf(RewardFactory.reward()), cookieRefTag = null) + val backingData = storedCard.getBackingData(ProjectFactory.project(), "", locationId = null, rewards = listOf(RewardFactory.reward()), cookieRefTag = null, false) assertEquals(backingData.setupIntentClientSecret, null) assertEquals(backingData.paymentSourceId, storedCard.id()) val storedCardFromPaymentSheet = StoredCardFactory.fromPaymentSheetCard() - val backingDataFromPaymentSheet = storedCard.getBackingData(ProjectFactory.project(), "", locationId = null, rewards = listOf(RewardFactory.reward()), cookieRefTag = null) + val backingDataFromPaymentSheet = storedCard.getBackingData(ProjectFactory.project(), "", locationId = null, rewards = listOf(RewardFactory.reward()), cookieRefTag = null, false) assertEquals(backingDataFromPaymentSheet.setupIntentClientSecret, storedCardFromPaymentSheet.clientSetupId()) assertEquals(backingDataFromPaymentSheet.paymentSourceId, null) } @@ -126,7 +126,7 @@ class StoredCardTest : TestCase() { @Test fun getBackingDataRefTagEmpty() { val storedCard = StoredCardFactory.visa() - val backingData = storedCard.getBackingData(ProjectFactory.project(), "", locationId = null, rewards = listOf(RewardFactory.reward()), cookieRefTag = RefTag.Builder().build()) + val backingData = storedCard.getBackingData(ProjectFactory.project(), "", locationId = null, rewards = listOf(RewardFactory.reward()), cookieRefTag = RefTag.Builder().build(), false) assertEquals(backingData.refTag, null) } @@ -134,7 +134,7 @@ class StoredCardTest : TestCase() { @Test fun getBackingDataRefTagWithValue() { val storedCard = StoredCardFactory.visa() - val backingData = storedCard.getBackingData(ProjectFactory.project(), "", locationId = null, rewards = listOf(RewardFactory.reward()), cookieRefTag = RefTag.Builder().tag("Tag").build()) + val backingData = storedCard.getBackingData(ProjectFactory.project(), "", locationId = null, rewards = listOf(RewardFactory.reward()), cookieRefTag = RefTag.Builder().tag("Tag").build(), false) assertEquals(backingData.refTag, "Tag") } diff --git a/app/src/test/java/com/kickstarter/ui/activities/compose/CollectionPlanTest.kt b/app/src/test/java/com/kickstarter/ui/activities/compose/CollectionPlanTest.kt index c14ec1eba2..96bc97ca58 100644 --- a/app/src/test/java/com/kickstarter/ui/activities/compose/CollectionPlanTest.kt +++ b/app/src/test/java/com/kickstarter/ui/activities/compose/CollectionPlanTest.kt @@ -5,18 +5,30 @@ import CollectionPlan import CollectionPlanTestTags import android.content.Context import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertHasNoClickAction import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelectable import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isDisplayed import androidx.compose.ui.test.isNotDisplayed +import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.test.platform.app.InstrumentationRegistry import com.kickstarter.KSRobolectricTestCase import com.kickstarter.R +import com.kickstarter.libs.KSCurrency +import com.kickstarter.libs.utils.extensions.format +import com.kickstarter.libs.utils.extensions.isNull +import com.kickstarter.mock.MockCurrentConfigV2 +import com.kickstarter.mock.factories.ConfigFactory +import com.kickstarter.mock.factories.PaymentIncrementFactory import com.kickstarter.ui.compose.designsystem.KSTheme +import org.joda.time.DateTime import org.junit.Before import org.junit.Test @@ -34,16 +46,22 @@ class CollectionPlanTest : KSRobolectricTestCase() { private val pledgeOverTimeOption get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.OPTION_PLEDGE_OVER_TIME.name) private val descriptionText - get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.DESCRIPTION_TEXT.name) + get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.DESCRIPTION_TEXT.name, useUnmergedTree = true) private val badgeText - get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.BADGE_TEXT.name) + get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.BADGE_TEXT.name, useUnmergedTree = true) private val expandedText - get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.EXPANDED_DESCRIPTION_TEXT.name) + get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.EXPANDED_DESCRIPTION_TEXT.name, useUnmergedTree = true) private val termsText - get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.TERMS_OF_USE_TEXT.name) + get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.TERMS_OF_USE_TEXT.name, useUnmergedTree = true) + private val chargeItemsList + get() = composeTestRule.onAllNodesWithTag(CollectionPlanTestTags.CHARGE_ITEM.name, useUnmergedTree = true) + private val chargeSchedule + get() = composeTestRule.onNodeWithTag(CollectionPlanTestTags.CHARGE_SCHEDULE.name, useUnmergedTree = true) + private val radioButtons + get() = composeTestRule.onAllNodesWithTag(CollectionPlanTestTags.RADIO_BUTTON.name, useUnmergedTree = true) @Test - fun testPledgeInFullOptionSelected() { + fun `test isEligible true, pledge in full option selected`() { val pledgeInFullText = context.getString(R.string.fpo_pledge_in_full) val pledgeOverTimeText = context.getString(R.string.fpo_pledge_over_time) val descriptionTextValue = @@ -51,7 +69,7 @@ class CollectionPlanTest : KSRobolectricTestCase() { composeTestRule.setContent { KSTheme { - CollectionPlan(isEligible = true, initialSelectedOption = CollectionOptions.PLEDGE_IN_FULL.name) + CollectionPlan(isEligible = true, initialSelectedOption = CollectionOptions.PLEDGE_IN_FULL) } } @@ -60,15 +78,15 @@ class CollectionPlanTest : KSRobolectricTestCase() { // Assert "Pledge in Full" option is displayed with correct text and is selected pledgeInFullOption.assertIsDisplayed().assert(hasText(pledgeInFullText)).assertIsSelected() - // Assert "Pledge Over Time" option is not displayed // Assert "Pledge Over Time" option is displayed with correct text and is not selected pledgeOverTimeOption.assertIsDisplayed().assert(hasText(pledgeOverTimeText)) - .assertIsNotSelected() + .assertIsNotSelected().assertIsSelectable() + + radioButtons.assertCountEquals(2) + radioButtons[0].assertHasClickAction() + radioButtons[1].assertHasClickAction() - composeTestRule.onNodeWithTag( - CollectionPlanTestTags.DESCRIPTION_TEXT.name, - useUnmergedTree = true - ) + descriptionText .assertIsDisplayed() .assert(hasText(descriptionTextValue)) @@ -79,7 +97,7 @@ class CollectionPlanTest : KSRobolectricTestCase() { } @Test - fun testPledgeOverTimeOptionSelected() { + fun `test isEligible true, pledge over time option selected`() { val pledgeInFullText = context.getString(R.string.fpo_pledge_in_full) val pledgeOverTimeText = context.getString(R.string.fpo_pledge_over_time) val descriptionTextValue = @@ -87,9 +105,23 @@ class CollectionPlanTest : KSRobolectricTestCase() { val extendedTextValue = context.getString(R.string.fpo_the_first_charge_will_be_24_hours_after_the_project_ends_successfully) val termsOfUseTextValue = context.getString(R.string.fpo_see_our_terms_of_use) + val config = ConfigFactory.configForUSUser() + val currentConfig = MockCurrentConfigV2() + currentConfig.config(config) + composeTestRule.setContent { KSTheme { - CollectionPlan(isEligible = true, initialSelectedOption = CollectionOptions.PLEDGE_OVER_TIME.name) + CollectionPlan( + isEligible = true, + initialSelectedOption = CollectionOptions.PLEDGE_OVER_TIME, + ksCurrency = KSCurrency(currentConfig), + paymentIncrements = listOf( + PaymentIncrementFactory.incrementUsdUncollected(DateTime.now(), "$50"), + PaymentIncrementFactory.incrementUsdUncollected(DateTime.now(), "$50"), + PaymentIncrementFactory.incrementUsdUncollected(DateTime.now(), "$50"), + PaymentIncrementFactory.incrementUsdUncollected(DateTime.now(), "$50"), + ) + ) } } @@ -103,27 +135,28 @@ class CollectionPlanTest : KSRobolectricTestCase() { pledgeOverTimeOption.assertIsDisplayed().assert(hasText(pledgeOverTimeText)) .assertIsSelected() - composeTestRule.onNodeWithTag( - CollectionPlanTestTags.DESCRIPTION_TEXT.name, - useUnmergedTree = true - ) + descriptionText .assertIsDisplayed() .assert(hasText(descriptionTextValue)) - composeTestRule.onNodeWithTag( - CollectionPlanTestTags.EXPANDED_DESCRIPTION_TEXT.name, - useUnmergedTree = true - ) + expandedText .assertIsDisplayed() .assert(hasText(extendedTextValue)) - composeTestRule.onNodeWithTag( - CollectionPlanTestTags.TERMS_OF_USE_TEXT.name, - useUnmergedTree = true - ) + termsText .assertIsDisplayed() .assert(hasText(termsOfUseTextValue)) + chargeSchedule + .assertIsDisplayed() + + radioButtons.assertCountEquals(2) + radioButtons[0].assertHasClickAction() + radioButtons[1].assertHasClickAction() + + chargeItemsList.assertCountEquals(4) + chargeItemsList[0].assert(hasText(context.getString(R.string.fpo_charge_count).format(key1 = "number", value1 = "1"))) + // Not eligible badge should not be displayed badgeText.assertIsNotDisplayed() } @@ -134,7 +167,7 @@ class CollectionPlanTest : KSRobolectricTestCase() { val pledgeOverTimeText = context.getString(R.string.fpo_pledge_over_time) composeTestRule.setContent { KSTheme { - CollectionPlan(isEligible = false, initialSelectedOption = CollectionOptions.PLEDGE_IN_FULL.name) + CollectionPlan(isEligible = false, initialSelectedOption = CollectionOptions.PLEDGE_IN_FULL) } } @@ -147,29 +180,23 @@ class CollectionPlanTest : KSRobolectricTestCase() { pledgeOverTimeOption.assertIsDisplayed().assert(hasText(pledgeOverTimeText)) .assertIsNotSelected() - composeTestRule.onNodeWithTag( - CollectionPlanTestTags.DESCRIPTION_TEXT.name, - useUnmergedTree = true - ) + descriptionText .assertIsNotDisplayed() - composeTestRule.onNodeWithTag( - CollectionPlanTestTags.EXPANDED_DESCRIPTION_TEXT.name, - useUnmergedTree = true - ) + expandedText .isNotDisplayed() - composeTestRule.onNodeWithTag( - CollectionPlanTestTags.TERMS_OF_USE_TEXT.name, - useUnmergedTree = true - ) + termsText .isNotDisplayed() + chargeItemsList[0].isNull() + + radioButtons.assertCountEquals(2) + radioButtons[0].assertHasClickAction() + radioButtons[1].assertHasNoClickAction() + // Assert that other elements are not displayed - composeTestRule.onNodeWithTag( - CollectionPlanTestTags.BADGE_TEXT.name, - useUnmergedTree = true - ) + badgeText .isDisplayed() } } diff --git a/app/src/test/java/com/kickstarter/ui/activities/compose/PaymentScheduleTest.kt b/app/src/test/java/com/kickstarter/ui/activities/compose/PaymentScheduleTest.kt index 84d2272623..e6ba372c8d 100644 --- a/app/src/test/java/com/kickstarter/ui/activities/compose/PaymentScheduleTest.kt +++ b/app/src/test/java/com/kickstarter/ui/activities/compose/PaymentScheduleTest.kt @@ -12,7 +12,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.test.platform.app.InstrumentationRegistry import com.kickstarter.KSRobolectricTestCase import com.kickstarter.R -import com.kickstarter.models.Money +import com.kickstarter.models.Amount import com.kickstarter.models.PaymentIncrement import com.kickstarter.models.PaymentIncrement.State import com.kickstarter.ui.compose.designsystem.KSTheme @@ -46,7 +46,7 @@ class PaymentScheduleTest : KSRobolectricTestCase() { private val samplePaymentIncrements = listOf( PaymentIncrement( - amount = Money.builder().amount("3400").build(), + amount = Amount.builder().amount("3400").build(), state = State.UNATTEMPTED, paymentIncrementableId = "1", paymentIncrementableType = "pledge", @@ -54,7 +54,7 @@ class PaymentScheduleTest : KSRobolectricTestCase() { stateReason = "" ), PaymentIncrement( - amount = Money.builder().amount("2500").build(), + amount = Amount.builder().amount("2500").build(), state = State.COLLECTED, paymentIncrementableId = "2", paymentIncrementableType = "pledge", @@ -62,7 +62,7 @@ class PaymentScheduleTest : KSRobolectricTestCase() { stateReason = "" ), PaymentIncrement( - amount = Money.builder().amount("4500").build(), + amount = Amount.builder().amount("4500").build(), state = State.UNATTEMPTED, paymentIncrementableId = "3", paymentIncrementableType = "pledge", @@ -70,7 +70,7 @@ class PaymentScheduleTest : KSRobolectricTestCase() { stateReason = "" ), PaymentIncrement( - amount = Money.builder().amount("5200").build(), + amount = Amount.builder().amount("5200").build(), state = State.COLLECTED, paymentIncrementableId = "4", paymentIncrementableType = "pledge", diff --git a/app/src/test/java/com/kickstarter/viewmodels/CrowdfundCheckoutViewModelTest.kt b/app/src/test/java/com/kickstarter/viewmodels/CrowdfundCheckoutViewModelTest.kt index db69634de9..a03bb92a24 100644 --- a/app/src/test/java/com/kickstarter/viewmodels/CrowdfundCheckoutViewModelTest.kt +++ b/app/src/test/java/com/kickstarter/viewmodels/CrowdfundCheckoutViewModelTest.kt @@ -14,6 +14,8 @@ import com.kickstarter.libs.utils.extensions.rewardsAndAddOnsList import com.kickstarter.libs.utils.extensions.shippingCostIfShipping import com.kickstarter.mock.MockFeatureFlagClient import com.kickstarter.mock.factories.BackingFactory +import com.kickstarter.mock.factories.PaymentIncrementFactory +import com.kickstarter.mock.factories.PaymentPlanFactory import com.kickstarter.mock.factories.PaymentSourceFactory import com.kickstarter.mock.factories.ProjectDataFactory import com.kickstarter.mock.factories.ProjectFactory @@ -23,7 +25,9 @@ import com.kickstarter.mock.factories.ShippingRulesEnvelopeFactory import com.kickstarter.mock.factories.StoredCardFactory import com.kickstarter.mock.factories.UserFactory import com.kickstarter.mock.services.MockApolloClientV2 +import com.kickstarter.models.BuildPaymentPlanData import com.kickstarter.models.Checkout +import com.kickstarter.models.PaymentPlan import com.kickstarter.models.Project import com.kickstarter.models.Reward import com.kickstarter.models.StoredCard @@ -49,6 +53,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.joda.time.DateTime import org.junit.Test import org.mockito.Mockito @@ -158,6 +163,120 @@ class CrowdfundCheckoutViewModelTest : KSRobolectricTestCase() { segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) } + @Test + fun `test new pledge, when user switched to plot, ui state should have true incremental value`() = runTest { + val shippingRules = ShippingRulesEnvelopeFactory.shippingRules().shippingRules() + val reward = RewardFactory.rewardWithShipping().toBuilder() + .shippingRules(shippingRules = shippingRules) + .build() + + val addOns1 = RewardFactory.rewardWithShipping() + .toBuilder() + .isAddOn(true) + .build() + + val addOn2 = RewardFactory.addOn() + .toBuilder() + .shippingRules(shippingRules) + .build() + + val addOnsList = listOf(addOns1, addOn2) + + val project = ProjectFactory.project().toBuilder() + .rewards(listOf(reward)) + .build() + + val cards = listOf(StoredCardFactory.visa(), StoredCardFactory.discoverCard(), StoredCardFactory.fromPaymentSheetCard()) + + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + + val projectData = ProjectDataFactory.project(project) + + val bundle = Bundle() + + val pledgeData = PledgeData.with( + PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE), + projectData, + reward, + addOnsList, + ShippingRuleFactory.usShippingRule(), + bonusAmount = 3.0 + ) + + bundle.putParcelable( + ArgumentsKey.PLEDGE_PLEDGE_DATA, + pledgeData + ) + bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) + + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { + return Observable.just(cards) + } + + override fun userPrivacy(): Observable { + return Observable.just( + UserPrivacy("", "hola@ksr.com", true, true, true, true, "USD") + ) + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val uiState = mutableListOf() + + var errorActionCount = 0 + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.provideErrorAction { + errorActionCount++ + } + viewModel.provideBundle(bundle) + + viewModel.crowdfundCheckoutUIState.toList(uiState) + } + advanceUntilIdle() + + assertEquals(uiState.size, 3) + + // default incremental value should be false + assertEquals(uiState.last().shippingAmount, pledgeData.shippingCostIfShipping()) + assertEquals(uiState.last().checkoutTotal, pledgeData.checkoutTotalAmount()) + assertEquals(uiState.last().bonusAmount, 3.0) + assertEquals(uiState.last().shippingRule, pledgeData.shippingRule()) + assertEquals(uiState.last().selectedPaymentMethod.id(), cards.last().id()) + assertEquals(uiState.last().storeCards, cards) + assertEquals(uiState.last().userEmail, "hola@ksr.com") + assertEquals(uiState.last().selectedRewards, pledgeData.rewardsAndAddOnsList()) + assertEquals(uiState.last().isIncrementalPledge, false) + + assertEquals(errorActionCount, 0) + + segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) + + backgroundScope.launch(dispatcher) { + viewModel.collectionPlanSelected(CollectionOptions.PLEDGE_OVER_TIME) + viewModel.pledgeOrUpdatePledge() + + viewModel.crowdfundCheckoutUIState.toList(uiState) + } + + assertEquals(uiState.last().shippingAmount, pledgeData.shippingCostIfShipping()) + assertEquals(uiState.last().checkoutTotal, pledgeData.checkoutTotalAmount()) + assertEquals(uiState.last().bonusAmount, 3.0) + assertEquals(uiState.last().shippingRule, pledgeData.shippingRule()) + assertEquals(uiState.last().selectedPaymentMethod.id(), cards.last().id()) + assertEquals(uiState.last().storeCards, cards) + assertEquals(uiState.last().userEmail, "hola@ksr.com") + assertEquals(uiState.last().selectedRewards, pledgeData.rewardsAndAddOnsList()) + assertEquals(uiState.last().isIncrementalPledge, true) + } + @Test fun `test user hits pledges button with rw + addOns + bonus support with shipping`() = runTest { // - The test reward with shipping @@ -258,6 +377,191 @@ class CrowdfundCheckoutViewModelTest : KSRobolectricTestCase() { segmentTrack.assertValues(EventName.PAGE_VIEWED.eventName, EventName.CTA_CLICKED.eventName) } + @Test + fun `test ui state when pledge amount does not meet PLOT minimum`() = runTest { + // - The test reward with shipping + val shippingRules = ShippingRulesEnvelopeFactory.shippingRules().shippingRules() + val reward = RewardFactory.rewardWithShipping().toBuilder() + .pledgeAmount(10.0) + .shippingRules(shippingRules = shippingRules) + .build() + + val addOns1 = RewardFactory.rewardWithShipping() + .toBuilder() + .pledgeAmount(10.0) + .isAddOn(true) + .build() + + // - AddOns shipping same as the reward + val addOnsList = listOf(addOns1) + + val project = ProjectFactory.project().toBuilder() + .rewards(listOf(reward)) + .build() + + val cards = listOf(StoredCardFactory.visa(), StoredCardFactory.discoverCard(), StoredCardFactory.fromPaymentSheetCard()) + + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + + val projectData = ProjectDataFactory.project(project) + + val bundle = Bundle() + + val pledgeData = PledgeData.with( + PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE), + projectData, + reward, + addOnsList, + ShippingRuleFactory.usShippingRule(), + bonusAmount = 3.0 + ) + + bundle.putParcelable( + ArgumentsKey.PLEDGE_PLEDGE_DATA, + pledgeData + ) + bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) + + // - Network mocks + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { + return Observable.just(cards) + } + + override fun buildPaymentPlan(buildPaymentPlanData: BuildPaymentPlanData): Observable { + return Observable.just( + PaymentPlanFactory + .ineligibleAllowedPaymentPlan() + ) + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val uiState = mutableListOf() + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.provideBundle(bundle) + + viewModel.crowdfundCheckoutUIState.toList(uiState) + } + advanceUntilIdle() + + // default incremental value should be false + assertEquals(uiState.last().shippingAmount, pledgeData.shippingCostIfShipping()) + assertEquals(uiState.last().checkoutTotal, pledgeData.checkoutTotalAmount()) + assertEquals(uiState.last().bonusAmount, 3.0) + assertEquals(uiState.last().shippingRule, pledgeData.shippingRule()) + assertEquals(uiState.last().selectedPaymentMethod.id(), cards.last().id()) + assertEquals(uiState.last().storeCards, cards) + assertEquals(uiState.last().selectedRewards, pledgeData.rewardsAndAddOnsList()) + assertEquals(uiState.last().isIncrementalPledge, false) + assertEquals(uiState.last().plotEligible, false) + assertEquals(uiState.last().showPlotWidget, true) + } + + @Test + fun `test ui state when pledge amount meets PLOT minimum`() = runTest { + // - The test reward with shipping + val shippingRules = ShippingRulesEnvelopeFactory.shippingRules().shippingRules() + val reward = RewardFactory.rewardWithShipping().toBuilder() + .pledgeAmount(10.0) + .shippingRules(shippingRules = shippingRules) + .build() + + val addOns1 = RewardFactory.rewardWithShipping() + .toBuilder() + .pledgeAmount(10.0) + .isAddOn(true) + .build() + + // - AddOns shipping same as the reward + val addOnsList = listOf(addOns1) + + val project = ProjectFactory.project().toBuilder() + .rewards(listOf(reward)) + .build() + + val cards = listOf(StoredCardFactory.visa(), StoredCardFactory.discoverCard(), StoredCardFactory.fromPaymentSheetCard()) + + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + + val projectData = ProjectDataFactory.project(project) + + val bundle = Bundle() + + val pledgeData = PledgeData.with( + PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE), + projectData, + reward, + addOnsList, + ShippingRuleFactory.usShippingRule(), + bonusAmount = 3.0 + ) + + bundle.putParcelable( + ArgumentsKey.PLEDGE_PLEDGE_DATA, + pledgeData + ) + bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) + + val paymentPlan = PaymentPlanFactory + .eligibleAllowedPaymentPlan( + listOf( + PaymentIncrementFactory.incrementUsdUncollected(DateTime.now(), "50.00"), + PaymentIncrementFactory.incrementUsdUncollected(DateTime.now(), "50.00") + ) + ) + // - Network mocks + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { + return Observable.just(cards) + } + + override fun buildPaymentPlan(buildPaymentPlanData: BuildPaymentPlanData): Observable { + return Observable.just( + paymentPlan + ) + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val uiState = mutableListOf() + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.provideBundle(bundle) + + viewModel.crowdfundCheckoutUIState.toList(uiState) + } + advanceUntilIdle() + + // default incremental value should be false + assertEquals(uiState.last().shippingAmount, pledgeData.shippingCostIfShipping()) + assertEquals(uiState.last().checkoutTotal, pledgeData.checkoutTotalAmount()) + assertEquals(uiState.last().bonusAmount, 3.0) + assertEquals(uiState.last().shippingRule, pledgeData.shippingRule()) + assertEquals(uiState.last().selectedPaymentMethod.id(), cards.last().id()) + assertEquals(uiState.last().storeCards, cards) + assertEquals(uiState.last().selectedRewards, pledgeData.rewardsAndAddOnsList()) + assertEquals(uiState.last().isIncrementalPledge, false) + assertEquals(uiState.last().plotEligible, true) + assertEquals(uiState.last().showPlotWidget, true) + assertEquals(uiState.last().paymentIncrements, paymentPlan.paymentIncrements) + } + @Test fun `test sendThirdPartyEvent when a payment method selected`() = runTest {