diff --git a/app/internal_version_code.txt b/app/internal_version_code.txt index 1d0c2d41ff..34b013927d 100644 --- a/app/internal_version_code.txt +++ b/app/internal_version_code.txt @@ -1 +1 @@ -2014150908 +2014150913 diff --git a/app/internal_version_name.txt b/app/internal_version_name.txt index 6f0e719e09..b7bc73b808 100644 --- a/app/internal_version_name.txt +++ b/app/internal_version_name.txt @@ -1 +1 @@ -3.23.0 \ No newline at end of file +3.24.0 \ No newline at end of file diff --git a/app/src/main/graphql/fragments.graphql b/app/src/main/graphql/fragments.graphql index a7c95c7e42..83dc2d17be 100644 --- a/app/src/main/graphql/fragments.graphql +++ b/app/src/main/graphql/fragments.graphql @@ -300,6 +300,9 @@ fragment reward on Reward { convertedAmount{ ... amount } + shippingRules { + ... shippingRule + } shippingPreference remainingQuantity limit @@ -307,6 +310,11 @@ fragment reward on Reward { startsAt endsAt rewardType + allowedAddons { + nodes { + id + } + } localReceiptLocation { ... location } @@ -360,6 +368,12 @@ fragment shippingRule on ShippingRule { location { ... location } + estimatedMin { + amount + } + estimatedMax { + amount + } } diff --git a/app/src/main/java/com/kickstarter/libs/KSCurrency.kt b/app/src/main/java/com/kickstarter/libs/KSCurrency.kt index 1e75f9a4b8..2b1ee6249d 100644 --- a/app/src/main/java/com/kickstarter/libs/KSCurrency.kt +++ b/app/src/main/java/com/kickstarter/libs/KSCurrency.kt @@ -3,11 +3,33 @@ package com.kickstarter.libs import com.kickstarter.libs.models.Country import com.kickstarter.libs.models.Country.Companion.findByCurrencyCode import com.kickstarter.libs.utils.NumberUtils +import com.kickstarter.libs.utils.ProjectViewUtils import com.kickstarter.libs.utils.extensions.trimAllWhitespace import com.kickstarter.models.Project import java.math.RoundingMode import kotlin.jvm.JvmOverloads +/** + * Currency symbol, which can be positioned at start or end of amount depending on country + */ +fun KSCurrency?.getCurrencySymbols(project: Project): Pair { + val currencySymbolStartAndEnd = this?.let { + val symbolAndStart = ProjectViewUtils.currencySymbolAndPosition( + project, + this + ) + val symbol = symbolAndStart.first + val symbolAtStart = symbolAndStart.second + if (symbolAtStart) { + Pair(symbol.toString(), "") + } else { + Pair("", symbol.toString()) + } + } ?: Pair("", "") + + return currencySymbolStartAndEnd +} + class KSCurrency(private val currentConfig: CurrentConfigType) { /** * Returns a currency string appropriate to the user's locale and location relative to a project. diff --git a/app/src/main/java/com/kickstarter/libs/utils/RewardUtils.kt b/app/src/main/java/com/kickstarter/libs/utils/RewardUtils.kt index 43018233e0..adabd6b6eb 100644 --- a/app/src/main/java/com/kickstarter/libs/utils/RewardUtils.kt +++ b/app/src/main/java/com/kickstarter/libs/utils/RewardUtils.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.Pair import com.kickstarter.R import com.kickstarter.libs.KSString +import com.kickstarter.libs.models.Country import com.kickstarter.libs.utils.extensions.isNonZero import com.kickstarter.models.Project import com.kickstarter.models.Reward @@ -14,6 +15,12 @@ import kotlin.math.max object RewardUtils { + fun minPledgeAmount(reward: Reward, project: Project): Double { + return Country.findByCurrencyCode(project.currency())?.minPledge?.toDouble() ?: 0.0 + } + + fun maxPledgeAmount(reward: Reward, project: Project): Double = Country.findByCurrencyCode(project.currency())?.maxPledge?.toDouble() ?: 0.0 + /** * Returns `true` if the reward has backers, `false` otherwise. */ @@ -66,6 +73,10 @@ object RewardUtils { return rewardsItems != null && rewardsItems.isNotEmpty() } + fun shipsWorldwide(reward: Reward): Boolean = reward.shippingPreference().equals(Reward.ShippingPreference.UNRESTRICTED.name, ignoreCase = true) + + fun shipsToRestrictedLocations(reward: Reward): Boolean = reward.shippingPreference().equals(Reward.ShippingPreference.RESTRICTED.name, ignoreCase = true) + /** * Returns `true` if the reward has a limit set, and the limit has not been reached, `false` otherwise. */ @@ -221,4 +232,25 @@ object RewardUtils { fun getFinalBonusSupportAmount(addedBonusSupport: Double, initialBonusSupport: Double): Double { return if (addedBonusSupport > 0) addedBonusSupport else initialBonusSupport } + + /** For the checkout we need to send a list repeating as much addOns items + * as the user has selected: + * User selection [R, 2xa, 3xb] + * Checkout data [R, a, a, b, b, b] + */ + fun extendAddOns(flattenedList: List): List { + val mutableList = mutableListOf() + + flattenedList.map { + if (!it.isAddOn()) mutableList.add(it) + else { + val q = it.quantity() ?: 1 + for (i in 1..q) { + mutableList.add(it) + } + } + } + + return mutableList.toList() + } } 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 b7f2ae2039..5b82cfa309 100644 --- a/app/src/main/java/com/kickstarter/libs/utils/RewardViewUtils.kt +++ b/app/src/main/java/com/kickstarter/libs/utils/RewardViewUtils.kt @@ -14,9 +14,11 @@ import com.kickstarter.libs.KSCurrency import com.kickstarter.libs.KSString import com.kickstarter.libs.models.Country import com.kickstarter.libs.utils.extensions.isBacked +import com.kickstarter.libs.utils.extensions.isNull import com.kickstarter.libs.utils.extensions.trimAllWhitespace import com.kickstarter.models.Project import com.kickstarter.models.Reward +import com.kickstarter.models.ShippingRule import java.math.RoundingMode object RewardViewUtils { @@ -139,4 +141,111 @@ object RewardViewUtils { maxInputAmountWithCurrency ) ?: "" } + + /** + * Return the string for the estimated shipping costs for a given shipping rule + * + * Ex. "About $10-$15" or "About $10-%15 each" + */ + fun getEstimatedShippingCostString( + context: Context, + ksCurrency: KSCurrency, + ksString: KSString, + project: Project, + rewards: List = listOf(), + selectedShippingRule: ShippingRule, + multipleQuantitiesAllowed: Boolean, + useUserPreference: Boolean, + useAbout: Boolean + ): String { + var min = "" + var max = "" + var minTotal = 0.0 + var maxtotal = 0.0 + rewards.forEach { reward -> + if (!RewardUtils.isDigital(reward) && RewardUtils.shipsToRestrictedLocations(reward) && !RewardUtils.isLocalPickup(reward)) { + reward.shippingRules()?.filter { + it.location()?.id() == selectedShippingRule.location()?.id() + }?.map { + minTotal += (it.estimatedMin() * (reward.quantity() ?: 1)) + maxtotal += (it.estimatedMax() * (reward.quantity() ?: 1)) + } + } + + if (RewardUtils.shipsWorldwide(reward) && !reward.shippingRules().isNullOrEmpty()) { + reward.shippingRules()?.first()?.let { + minTotal += (it.estimatedMin() * (reward.quantity() ?: 1)) + maxtotal += (it.estimatedMax() * (reward.quantity() ?: 1)) + } + } + } + if (minTotal <= 0 || maxtotal <= 0) return "" + + min = if (useUserPreference) { + ksCurrency.formatWithUserPreference(minTotal, project, RoundingMode.HALF_UP, precision = 2) + } else { + ksCurrency.format(minTotal, project, RoundingMode.HALF_UP) + } + max = if (useUserPreference) { + ksCurrency.formatWithUserPreference(maxtotal, project, RoundingMode.HALF_UP, precision = 2) + } else { + ksCurrency.format(maxtotal, project, RoundingMode.HALF_UP) + } + + if (min.isEmpty() || max.isEmpty()) return "" + + // TODO: Replace with defined string + val minToMaxString = if (multipleQuantitiesAllowed) "$min-$max each" else "$min-$max" + + return if (useAbout) { + ksString.format( + context.getString(R.string.About_reward_amount), + "reward_amount", + minToMaxString + ) + } else { + minToMaxString + } + } + + /** + * Returns a string for the shipping costs for add-on cards + * + * Ex. " + $5 each" + */ + fun getAddOnShippingAmountString( + context: Context, + project: Project, + reward: Reward, + rewardShippingRules: List?, + ksCurrency: KSCurrency?, + ksString: KSString?, + selectedShippingRule: ShippingRule + ): String { + if (rewardShippingRules.isNullOrEmpty() || ksCurrency.isNull() || ksString.isNull()) return "" + val shippingAmount = + if (!RewardUtils.isDigital(reward) && RewardUtils.isShippable(reward) && !RewardUtils.isLocalPickup(reward)) { + var cost = 0.0 + rewardShippingRules.filter { + it.location()?.id() == selectedShippingRule.location()?.id() + }.map { + cost += it.cost() + } + if (cost > 0) ksCurrency?.format(cost, project) + else "" + } else { + "" + } + if (shippingAmount.isNullOrEmpty()) return "" + val rewardAndShippingString = + context.getString(R.string.reward_amount_plus_shipping_cost_each) + val stringSections = rewardAndShippingString.split("+") + val shippingString = " +" + stringSections[1] + val ammountAndShippingString = ksString?.format( + shippingString, + "shipping_cost", + shippingAmount + ) + return ammountAndShippingString ?: "" + } } diff --git a/app/src/main/java/com/kickstarter/libs/utils/extensions/ConfigExtension.kt b/app/src/main/java/com/kickstarter/libs/utils/extensions/ConfigExtension.kt index 184f1e8257..8c32341dc8 100644 --- a/app/src/main/java/com/kickstarter/libs/utils/extensions/ConfigExtension.kt +++ b/app/src/main/java/com/kickstarter/libs/utils/extensions/ConfigExtension.kt @@ -5,6 +5,7 @@ import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.kickstarter.libs.Config import com.kickstarter.libs.preferences.StringPreferenceType +import com.kickstarter.models.ShippingRule import org.json.JSONArray /** @@ -71,7 +72,6 @@ fun Config.syncUserFeatureFlagsFromPref(featuresFlagPreference: StringPreference /** * set the saved feature flags in to config feature object */ - fun Config.setUserFeatureFlagsPrefWithFeatureFlag( featuresFlagPreference: StringPreferenceType?, featureName: String, @@ -96,3 +96,30 @@ fun Config.setUserFeatureFlagsPrefWithFeatureFlag( }?.toMap() ).build() } + +/** + * From a selected list of shipping rules, select the one that matches the config location + * if none matches return the first one. + * Config countryCode is based on IP location, + * example: if your network is within Canada, it will return Canada + * example: if your network is within Canada, but the given shipping Rules does not include + * Canada, it will return the first rule given in tha shipping rules list. + */ +fun Config.getDefaultLocationFrom(shippingRules: List): ShippingRule { + return if (shippingRules.isNotEmpty()) { + shippingRules.firstOrNull { it.location()?.country() == this.countryCode() } + ?: shippingRules.first() + } else { + ShippingRule.builder().build() + } +// val location = Location.builder() +// .id(23424814) +// .country("FK") +// .displayableName("Falkland Islands") +// .name("Falkland Islands") +// .build() +// return ShippingRule.builder() +// .id(23424814) +// .location(location) +// .build() +} diff --git a/app/src/main/java/com/kickstarter/libs/utils/extensions/FragmentExt.kt b/app/src/main/java/com/kickstarter/libs/utils/extensions/FragmentExt.kt index 09f7be8131..3ec76853cc 100644 --- a/app/src/main/java/com/kickstarter/libs/utils/extensions/FragmentExt.kt +++ b/app/src/main/java/com/kickstarter/libs/utils/extensions/FragmentExt.kt @@ -5,13 +5,17 @@ import androidx.fragment.app.Fragment import com.kickstarter.ui.ArgumentsKey import com.kickstarter.ui.data.PledgeData import com.kickstarter.ui.data.PledgeReason +import com.kickstarter.ui.fragments.CrowdfundCheckoutFragment import com.kickstarter.ui.fragments.PledgeFragment fun Fragment.selectPledgeFragment( pledgeData: PledgeData, pledgeReason: PledgeReason ): Fragment { - return PledgeFragment().withData(pledgeData, pledgeReason) + val fragment = if (pledgeReason == PledgeReason.FIX_PLEDGE) { + PledgeFragment() + } else CrowdfundCheckoutFragment() + return fragment.withData(pledgeData, pledgeReason) } fun Fragment.withData(pledgeData: PledgeData?, pledgeReason: PledgeReason?): Fragment { diff --git a/app/src/main/java/com/kickstarter/libs/utils/extensions/PledgeDataExt.kt b/app/src/main/java/com/kickstarter/libs/utils/extensions/PledgeDataExt.kt index 55de3758f3..687e8ff6b9 100644 --- a/app/src/main/java/com/kickstarter/libs/utils/extensions/PledgeDataExt.kt +++ b/app/src/main/java/com/kickstarter/libs/utils/extensions/PledgeDataExt.kt @@ -1,7 +1,79 @@ @file:JvmName("PledgeDataExt") package com.kickstarter.libs.utils.extensions +import com.kickstarter.libs.utils.RewardUtils +import com.kickstarter.models.Reward import com.kickstarter.ui.data.PledgeData +import com.kickstarter.ui.data.PledgeFlowContext + +fun PledgeData.locationId(): Long { + return if (RewardUtils.isShippable(this.reward())) { + return this.shippingRule()?.location()?.id() ?: -1 + } else -1 +} +/** + * Shipping cost associated to the selected shipping rule if the + * selected reward: + * Amount = RWShipping + AddOnShipping.shipping( xQ) + * + */ +fun PledgeData.shippingCostIfShipping(): Double { + val rwShippingCost = if (RewardUtils.isShippable(this.reward())) + this.shippingRule()?.cost() ?: 0.0 + else 0.0 + + var addOnsShippingCost = 0.0 + this.addOns()?.map { + if (RewardUtils.shipsWorldwide(it) || RewardUtils.shipsToRestrictedLocations(it)) { + addOnsShippingCost += (it.shippingRules()?.first()?.cost() ?: 0.0) * (it.quantity() ?: 0) + } else 0.0 + } + + return rwShippingCost + addOnsShippingCost +} + +/** + * Total checkout Amount = Reward + AddOns( xQ) + bonus + Shipping + */ +fun PledgeData.checkoutTotalAmount(): Double = this.pledgeAmountTotalPlusBonus() + this.shippingCostIfShipping() + +/** + * Total checkout Amount = Reward + AddOns( xQ) + bonus + */ +fun PledgeData.pledgeAmountTotalPlusBonus(): Double = this.pledgeAmountTotal() + this.bonusAmount() + +/** + * Total pledge Amount = Reward + AddOns( xQ) + */ +fun PledgeData.pledgeAmountTotal(): Double { + // - Avoid project miss configuration where the creator did not configured somehow the late pledge reward correctly + var latePledge = if (this.reward().latePledgeAmount() == 0.0) this.reward().minimum() else this.reward().latePledgeAmount() + var crowdfund = this.reward().pledgeAmount() + + if (this.pledgeFlowContext() == PledgeFlowContext.LATE_PLEDGES) { + this.addOns()?.map { + // - Avoid project miss configuration where the creator did not configured somehow the late pledge reward correctly + val amount = if (it.latePledgeAmount() == 0.0) it.minimum() else it.latePledgeAmount() + latePledge += amount * (it.quantity() ?: 0) + } + return latePledge + } else { + this.addOns()?.map { + crowdfund += it.pledgeAmount() * (it.quantity() ?: 0) + } + + return crowdfund + } +} + +fun PledgeData.rewardsAndAddOnsList(): List { + val list = mutableListOf() + + list.add(this.reward()) + list.addAll(this.addOns() ?: emptyList()) + + return list +} /** * Total count of selected add-ons (including multiple quantities of a single add-on) diff --git a/app/src/main/java/com/kickstarter/mock/factories/LocationFactory.kt b/app/src/main/java/com/kickstarter/mock/factories/LocationFactory.kt index c6890b4c0d..41e8758c8a 100644 --- a/app/src/main/java/com/kickstarter/mock/factories/LocationFactory.kt +++ b/app/src/main/java/com/kickstarter/mock/factories/LocationFactory.kt @@ -64,6 +64,17 @@ object LocationFactory { .build() } + @JvmStatic + fun canada(): Location { + return builder() + .id(6L) + .displayableName("Canada") + .name("Canada") + .country("CA") + .expandedCountry("Canada") + .build() + } + fun empty(): Location { return builder() .id(-1L) diff --git a/app/src/main/java/com/kickstarter/mock/factories/RewardFactory.kt b/app/src/main/java/com/kickstarter/mock/factories/RewardFactory.kt index d693002d97..be3030d3dd 100644 --- a/app/src/main/java/com/kickstarter/mock/factories/RewardFactory.kt +++ b/app/src/main/java/com/kickstarter/mock/factories/RewardFactory.kt @@ -43,6 +43,13 @@ object RewardFactory { .build() } + fun digitalReward(): Reward { + return reward().toBuilder() + .shippingType(Reward.SHIPPING_TYPE_NO_SHIPPING) + .shippingPreference("none") + .build() + } + @JvmStatic fun reward(): Reward { val description = "A digital download of the album and documentary." @@ -159,12 +166,18 @@ object RewardFactory { fun rewardWithShipping(): Reward { return reward().toBuilder() - .shippingPreference("unrestricted") + .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name) .shippingType(Reward.SHIPPING_TYPE_ANYWHERE) .estimatedDeliveryOn(ESTIMATED_DELIVERY) .build() } + fun rewardRestrictedShipping(): Reward { + return reward().toBuilder() + .shippingPreference(Reward.ShippingPreference.RESTRICTED.name) + .build() + } + fun singleLocationShipping(localizedLocationName: String): Reward { return reward().toBuilder() .shippingType(Reward.SHIPPING_TYPE_SINGLE_LOCATION) diff --git a/app/src/main/java/com/kickstarter/mock/factories/ShippingRuleFactory.kt b/app/src/main/java/com/kickstarter/mock/factories/ShippingRuleFactory.kt index 8e26056b08..f2db53c938 100644 --- a/app/src/main/java/com/kickstarter/mock/factories/ShippingRuleFactory.kt +++ b/app/src/main/java/com/kickstarter/mock/factories/ShippingRuleFactory.kt @@ -30,6 +30,14 @@ object ShippingRuleFactory { .build() } + fun canadaShippingRule(): ShippingRule { + return ShippingRule.builder() + .id(4L) + .cost(10.0) + .location(LocationFactory.canada()) + .build() + } + @JvmStatic fun emptyShippingRule(): ShippingRule { return ShippingRule.builder() diff --git a/app/src/main/java/com/kickstarter/mock/factories/ShippingRulesEnvelopeFactory.kt b/app/src/main/java/com/kickstarter/mock/factories/ShippingRulesEnvelopeFactory.kt index 568ae3ba28..11c497828a 100644 --- a/app/src/main/java/com/kickstarter/mock/factories/ShippingRulesEnvelopeFactory.kt +++ b/app/src/main/java/com/kickstarter/mock/factories/ShippingRulesEnvelopeFactory.kt @@ -10,7 +10,8 @@ object ShippingRulesEnvelopeFactory { listOf( ShippingRuleFactory.usShippingRule(), ShippingRuleFactory.germanyShippingRule(), - ShippingRuleFactory.mexicoShippingRule() + ShippingRuleFactory.mexicoShippingRule(), + ShippingRuleFactory.canadaShippingRule() ) ) .build() diff --git a/app/src/main/java/com/kickstarter/models/ShippingRule.kt b/app/src/main/java/com/kickstarter/models/ShippingRule.kt index b9cdc34649..d7def03326 100644 --- a/app/src/main/java/com/kickstarter/models/ShippingRule.kt +++ b/app/src/main/java/com/kickstarter/models/ShippingRule.kt @@ -7,25 +7,35 @@ import kotlinx.parcelize.Parcelize class ShippingRule private constructor( private val id: Long?, private val cost: Double, - private val location: Location? + private val location: Location?, + private val estimatedMin: Double, + private val estimatedMax: Double ) : Parcelable { fun id() = this.id fun cost() = this.cost fun location() = this.location + fun estimatedMin() = this.estimatedMin + fun estimatedMax() = this.estimatedMax @Parcelize data class Builder( private var id: Long? = -1L, private var cost: Double = 0.0, - private var location: Location? = Location.builder().build() + private var location: Location? = Location.builder().build(), + private var estimatedMin: Double = 0.0, + private var estimatedMax: Double = 0.0 ) : Parcelable { fun id(id: Long?) = apply { this.id = id } fun cost(cost: Double) = apply { this.cost = cost } fun location(location: Location?) = apply { this.location = location } + fun estimatedMin(estimatedMin: Double) = apply { this.estimatedMin = estimatedMin } + fun estimatedMax(estimatedMax: Double) = apply { this.estimatedMax = estimatedMax } fun build() = ShippingRule( id = id, cost = cost, - location = location + location = location, + estimatedMin = estimatedMin, + estimatedMax = estimatedMax ) } @@ -46,7 +56,9 @@ class ShippingRule private constructor( fun toBuilder() = Builder( id = id, cost = cost, - location = location + location = location, + estimatedMin = estimatedMin, + estimatedMax = estimatedMax ) companion object { diff --git a/app/src/main/java/com/kickstarter/services/mutations/UpdateBackingData.kt b/app/src/main/java/com/kickstarter/services/mutations/UpdateBackingData.kt index 90e888f311..8b7adf352e 100644 --- a/app/src/main/java/com/kickstarter/services/mutations/UpdateBackingData.kt +++ b/app/src/main/java/com/kickstarter/services/mutations/UpdateBackingData.kt @@ -2,6 +2,8 @@ package com.kickstarter.services.mutations import com.kickstarter.models.Backing import com.kickstarter.models.Reward +import com.kickstarter.models.StoredCard +import com.kickstarter.models.extensions.isFromPaymentSheet data class UpdateBackingData( val backing: Backing, @@ -11,3 +13,41 @@ data class UpdateBackingData( val paymentSourceId: String? = null, val intentClientSecret: String? = null ) + +/** + * Obtain the data model input that will be send to UpdateBacking mutation + * - When updating payment method with a new payment method using payment sheet + * - When updating payment method with a previously existing payment source + * - Updating any other parameter like location, amount or rewards + */ +fun getUpdateBackingData( + backing: Backing, + amount: String? = null, + locationId: String? = null, + rewardsList: List = listOf(), + pMethod: StoredCard? = null +): UpdateBackingData { + return pMethod?.let { card -> + // - Updating the payment method, a new one from PaymentSheet or already existing one + if (card.isFromPaymentSheet()) UpdateBackingData( + backing, + amount, + locationId, + rewardsList, + intentClientSecret = card.clientSetupId() + ) + else UpdateBackingData( + backing, + amount, + locationId, + rewardsList, + paymentSourceId = card.id() + ) + // - Updating amount, location or rewards + } ?: UpdateBackingData( + backing, + amount, + locationId, + rewardsList + ) +} 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 ce0774823e..2b26d8fc81 100644 --- a/app/src/main/java/com/kickstarter/services/transformers/GraphQLTransformers.kt +++ b/app/src/main/java/com/kickstarter/services/transformers/GraphQLTransformers.kt @@ -14,6 +14,7 @@ import com.kickstarter.features.pledgedprojectsoverview.data.PledgedProjectsOver import com.kickstarter.features.pledgedprojectsoverview.data.PledgedProjectsOverviewQueryData import com.kickstarter.features.pledgedprojectsoverview.ui.PPOCardViewType import com.kickstarter.libs.Permission +import com.kickstarter.libs.utils.extensions.isNotNull import com.kickstarter.libs.utils.extensions.negate import com.kickstarter.mock.factories.RewardFactory import com.kickstarter.models.AiDisclosure @@ -159,8 +160,14 @@ fun rewardTransformer( val limit = if (isAddOn) chooseLimit(rewardGr.limit(), rewardGr.limitPerBacker()) else rewardGr.limit() - val shippingRules = shippingRulesExpanded.map { - shippingRuleTransformer(it) + val shippingRules = if (shippingRulesExpanded.isNotEmpty()) { + shippingRulesExpanded.map { + shippingRuleTransformer(it) + } + } else { + rewardGr.shippingRules().map { + shippingRuleTransformer(it.fragments().shippingRule()) + } } val localReceiptLocation = locationTransformer(rewardGr.localReceiptLocation()?.fragments()?.location()) @@ -708,6 +715,7 @@ fun backingTransformer(backingGr: fragment.Backing?): Backing { val reward = backingGr?.reward()?.fragments()?.reward()?.let { reward -> return@let rewardTransformer( reward, + allowedAddons = reward.allowedAddons().isNotNull(), rewardItems = complexRewardItemsTransformer(items?.fragments()?.rewardItems()) ) } @@ -798,14 +806,18 @@ fun videoTransformer(video: fragment.Video?): Video { * @return ShippingRule */ fun shippingRuleTransformer(rule: fragment.ShippingRule): ShippingRule { - val cost = rule.cost()?.fragments()?.amount()?.amount()?.toDouble() ?: 0.0 + val cost = rule.cost()?.fragments()?.amount()?.amount()?.toDoubleOrNull() ?: 0.0 val location = rule.location()?.let { locationTransformer(it.fragments().location()) } + val estimatedMin = rule.estimatedMin()?.amount()?.toDoubleOrNull() ?: 0.0 + val estimatedMax = rule.estimatedMax()?.amount()?.toDoubleOrNull() ?: 0.0 return ShippingRule.builder() .cost(cost) .location(location) + .estimatedMin(estimatedMin) + .estimatedMax(estimatedMax) .build() } diff --git a/app/src/main/java/com/kickstarter/ui/ArgumentsKey.kt b/app/src/main/java/com/kickstarter/ui/ArgumentsKey.kt index e22485fae1..68b1e7af44 100644 --- a/app/src/main/java/com/kickstarter/ui/ArgumentsKey.kt +++ b/app/src/main/java/com/kickstarter/ui/ArgumentsKey.kt @@ -5,7 +5,7 @@ object ArgumentsKey { const val DISCOVERY_SORT_POSITION = "argument_discovery_position" const val NEW_CARD_MODAL = "com.kickstarter.ui.fragments.NewCardFragment.modal" const val NEW_CARD_PROJECT = "com.kickstarter.ui.fragments.NewCardFragment.project" - const val PLEDGE_PLEDGE_DATA = "com.kickstarter.ui.fragments.PledgeFragment.pledge_data" + const val PLEDGE_PLEDGE_DATA = "com.kickstarter.ui.fragments.PledgeFragment.pledge_data" // TODO: change once fix-pledge migrated const val PLEDGE_PLEDGE_REASON = "com.kickstarter.ui.fragments.PledgeFragment.pledge_reason" const val PROJECT_PAGER_POSITION = "com.kickstarter.ui.fragments.position" } diff --git a/app/src/main/java/com/kickstarter/ui/activities/ProjectPageActivity.kt b/app/src/main/java/com/kickstarter/ui/activities/ProjectPageActivity.kt index 74545c7bf8..2491600e48 100644 --- a/app/src/main/java/com/kickstarter/ui/activities/ProjectPageActivity.kt +++ b/app/src/main/java/com/kickstarter/ui/activities/ProjectPageActivity.kt @@ -60,7 +60,6 @@ import com.kickstarter.libs.utils.extensions.getPaymentSheetConfiguration import com.kickstarter.libs.utils.extensions.showLatePledgeFlow import com.kickstarter.libs.utils.extensions.toVisibility import com.kickstarter.models.Project -import com.kickstarter.models.Reward import com.kickstarter.models.User import com.kickstarter.models.chrome.ChromeTabsHelperActivity import com.kickstarter.ui.IntentKey @@ -71,7 +70,6 @@ import com.kickstarter.ui.data.ActivityResult.Companion.create import com.kickstarter.ui.data.CheckoutData import com.kickstarter.ui.data.LoginReason import com.kickstarter.ui.data.PledgeData -import com.kickstarter.ui.data.PledgeFlowContext import com.kickstarter.ui.data.PledgeReason import com.kickstarter.ui.data.ProjectData import com.kickstarter.ui.extensions.finishWithAnimation @@ -91,7 +89,6 @@ import com.kickstarter.ui.fragments.PledgeFragment import com.kickstarter.ui.fragments.RewardsFragment import com.kickstarter.viewmodels.projectpage.AddOnsViewModel import com.kickstarter.viewmodels.projectpage.CheckoutFlowViewModel -import com.kickstarter.viewmodels.projectpage.ConfirmDetailsViewModel import com.kickstarter.viewmodels.projectpage.LatePledgeCheckoutViewModel import com.kickstarter.viewmodels.projectpage.PagerTabConfig import com.kickstarter.viewmodels.projectpage.ProjectPageViewModel @@ -108,7 +105,6 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.CompositeDisposable import io.reactivex.schedulers.Schedulers import kotlinx.coroutines.launch -import type.CreditCardPaymentType const val REFRESH = "refresh" @@ -128,9 +124,6 @@ class ProjectPageActivity : private lateinit var rewardsSelectionViewModelFactory: RewardsSelectionViewModel.Factory private val rewardsSelectionViewModel: RewardsSelectionViewModel by viewModels { rewardsSelectionViewModelFactory } - private lateinit var confirmDetailsViewModelFactory: ConfirmDetailsViewModel.Factory - private val confirmDetailsViewModel: ConfirmDetailsViewModel by viewModels { confirmDetailsViewModelFactory } - private lateinit var latePledgeCheckoutViewModelFactory: LatePledgeCheckoutViewModel.Factory private val latePledgeCheckoutViewModel: LatePledgeCheckoutViewModel by viewModels { latePledgeCheckoutViewModelFactory } @@ -176,7 +169,6 @@ class ProjectPageActivity : viewModelFactory = ProjectPageViewModel.Factory(env) checkoutViewModelFactory = CheckoutFlowViewModel.Factory(env) rewardsSelectionViewModelFactory = RewardsSelectionViewModel.Factory(env) - confirmDetailsViewModelFactory = ConfirmDetailsViewModel.Factory(env) addOnsViewModelFactory = AddOnsViewModel.Factory(env) latePledgeCheckoutViewModelFactory = LatePledgeCheckoutViewModel.Factory(env) stripe = requireNotNull(env.stripe()) @@ -245,7 +237,6 @@ class ProjectPageActivity : if (fFLatePledge && it.project().showLatePledgeFlow()) { rewardsSelectionViewModel.provideProjectData(it) addOnsViewModel.provideProjectData(it) - confirmDetailsViewModel.provideProjectData(it) } }.addToDisposable(disposables) @@ -523,10 +514,15 @@ class ProjectPageActivity : val currentPage = flowUIState.currentPage val rewardSelectionUIState by rewardsSelectionViewModel.rewardSelectionUIState.collectAsStateWithLifecycle() + val shippingUIState by rewardsSelectionViewModel.shippingUIState.collectAsStateWithLifecycle() + val projectData = rewardSelectionUIState.project val indexOfBackedReward = rewardSelectionUIState.initialRewardIndex - val rewardsList = rewardSelectionUIState.rewardList + val rewardsList = shippingUIState.filteredRw + val rewardLoading = shippingUIState.loading val selectedReward = rewardSelectionUIState.selectedReward + val currentUserShippingRule = shippingUIState.selectedShippingRule + val shippingRules = shippingUIState.shippingRules rewardsSelectionViewModel.sendEvent(expanded, currentPage, projectData) LaunchedEffect(Unit) { @@ -536,53 +532,30 @@ class ProjectPageActivity : } val addOnsUIState by addOnsViewModel.addOnsUIState.collectAsStateWithLifecycle() - - val shippingSelectorIsGone = addOnsUIState.shippingSelectorIsGone - val currentUserShippingRule = addOnsUIState.currentShippingRule - val selectedAddOnsMap: MutableMap = addOnsUIState.currentAddOnsSelection val addOns = addOnsUIState.addOns - val shippingRules = addOnsUIState.shippingRules val addOnsIsLoading = addOnsUIState.isLoading - - LaunchedEffect(currentUserShippingRule) { - confirmDetailsViewModel.provideCurrentShippingRule(currentUserShippingRule) - } + val addOnCount = addOnsUIState.totalCount + val totalPledgeAmount = addOnsUIState.totalPledgeAmount addOnsViewModel.provideErrorAction { message -> showToastError(message) } - val confirmUiState by confirmDetailsViewModel.confirmDetailsUIState.collectAsStateWithLifecycle() - - val totalAmount: Double = confirmUiState.totalAmount - val rewardsAndAddOns = confirmUiState.rewardsAndAddOns - val shippingAmount = confirmUiState.shippingAmount - val initialBonusAmount = confirmUiState.initialBonusSupportAmount - val totalBonusSupportAmount = confirmUiState.finalBonusSupportAmount - val maxPledgeAmount = confirmUiState.maxPledgeAmount - val minStepAmount = confirmUiState.minStepAmount - val confirmDetailsIsLoading = confirmUiState.isLoading - - val checkoutPayment by confirmDetailsViewModel.checkoutPayment.collectAsStateWithLifecycle() + val checkoutPayment by latePledgeCheckoutViewModel.checkoutPayment.collectAsStateWithLifecycle() LaunchedEffect(checkoutPayment.id) { - if (checkoutPayment.id != 0L) checkoutFlowViewModel.onConfirmDetailsContinueClicked { - startLoginToutActivity() - } checkoutPayment.backing?.let { latePledgeCheckoutViewModel.provideCheckoutIdAndBacking(checkoutPayment.id, it) } } - confirmDetailsViewModel.provideErrorAction { message -> - showToastError(message) - } - val latePledgeCheckoutUIState by latePledgeCheckoutViewModel.latePledgeCheckoutUIState.collectAsStateWithLifecycle() val userStoredCards = latePledgeCheckoutUIState.storeCards val userEmail = latePledgeCheckoutUIState.userEmail val checkoutLoading = latePledgeCheckoutUIState.isLoading + val shippingAmount = latePledgeCheckoutUIState.shippingAmount + val checkoutTotal = latePledgeCheckoutUIState.checkoutTotal latePledgeCheckoutViewModel.provideErrorAction { message -> showToastError(message) @@ -624,14 +597,12 @@ class ProjectPageActivity : ) if (currentPage == 3) { - latePledgeCheckoutViewModel.sendPageViewedEvent( - projectData, - addOns, - currentUserShippingRule, - shippingAmount, - totalAmount, - totalBonusSupportAmount - ) + latePledgeCheckoutViewModel.sendPageViewedEvent() + } + + if (currentPage == 1) { + // Send pageViewed event when user navigates to AddOns Screen + addOnsViewModel.sendEvent() } } } @@ -643,12 +614,20 @@ class ProjectPageActivity : checkoutFlowViewModel.onBackPressed(pagerState.currentPage) }, pagerState = pagerState, - isLoading = addOnsIsLoading || confirmDetailsIsLoading || checkoutLoading, + isLoading = addOnsIsLoading || checkoutLoading || rewardLoading, onAddOnsContinueClicked = { - checkoutFlowViewModel.onAddOnsContinueClicked() + // - if user not logged at this point, start login Flow, and provide after login completed callback + checkoutFlowViewModel.onContinueClicked( + logInCallback = { startLoginToutActivity() }, + continueCallback = { + val dataAndReason = addOnsViewModel.getPledgeDataAndReason() + dataAndReason?.let { pData -> + latePledgeCheckoutViewModel.providePledgeData(pData.first) + } + } + ) }, currentShippingRule = currentUserShippingRule, - shippingSelectorIsGone = shippingSelectorIsGone, shippingRules = shippingRules, environment = getEnvironment(), initialRewardCarouselPosition = indexOfBackedReward, @@ -658,47 +637,32 @@ class ProjectPageActivity : onRewardSelected = { reward -> checkoutFlowViewModel.userRewardSelection(reward) addOnsViewModel.userRewardSelection(reward) + addOnsViewModel.provideSelectedShippingRule(currentUserShippingRule) rewardsSelectionViewModel.onUserRewardSelection(reward) - confirmDetailsViewModel.onUserSelectedReward(reward) latePledgeCheckoutViewModel.userRewardSelection(reward) }, - onAddOnAddedOrRemoved = { updateAddOnRewardCount -> - selectedAddOnsMap[updateAddOnRewardCount.keys.first()] = - updateAddOnRewardCount[updateAddOnRewardCount.keys.first()] ?: 0 - addOnsViewModel.onAddOnsAddedOrRemoved(selectedAddOnsMap) - - confirmDetailsViewModel.onUserUpdatedAddOns(selectedAddOnsMap) + onAddOnAddedOrRemoved = { quantityForId, rewardId -> + addOnsViewModel.updateSelection(rewardId, quantityForId) }, + totalSelectedAddOn = addOnCount, selectedReward = selectedReward, - totalAmount = totalAmount, - selectedRewardAndAddOnList = rewardsAndAddOns, - initialBonusSupportAmount = initialBonusAmount, - totalBonusSupportAmount = totalBonusSupportAmount, - maxPledgeAmount = maxPledgeAmount, - minStepAmount = minStepAmount, - onShippingRuleSelected = { shippingRule -> - addOnsViewModel.onShippingLocationChanged(shippingRule) + totalPledgeAmount = totalPledgeAmount, + totalBonusAmount = addOnsUIState.totalBonusAmount, + bonusAmountChanged = { bonusAmount -> + addOnsViewModel.bonusAmountUpdated(bonusAmount) }, - shippingAmount = shippingAmount, - onConfirmDetailsContinueClicked = { - confirmDetailsViewModel.onContinueClicked { - checkoutFlowViewModel.onConfirmDetailsContinueClicked { - startLoginToutActivity() - } - } + selectedRewardAndAddOnList = latePledgeCheckoutUIState.selectedRewards, + onShippingRuleSelected = { shippingRule -> + rewardsSelectionViewModel.selectedShippingRule(shippingRule) }, storedCards = userStoredCards, userEmail = userEmail, - onBonusSupportMinusClicked = { confirmDetailsViewModel.decrementBonusSupport() }, - onBonusSupportPlusClicked = { confirmDetailsViewModel.incrementBonusSupport() }, - onBonusSupportInputted = { input -> - confirmDetailsViewModel.inputBonusSupport(input) - }, - selectedAddOnsMap = selectedAddOnsMap, + shippingAmount = shippingAmount, + checkoutTotal = checkoutTotal, onPledgeCtaClicked = { selectedCard -> selectedCard?.apply { - latePledgeCheckoutViewModel.sendSubmitCTAEvent(projectData, addOns, currentUserShippingRule, shippingAmount, totalAmount, totalBonusSupportAmount) - latePledgeCheckoutViewModel.onPledgeButtonClicked(selectedCard = selectedCard, project = projectData.project(), totalAmount = totalAmount) + latePledgeCheckoutViewModel.sendSubmitCTAEvent() + latePledgeCheckoutViewModel.onPledgeButtonClicked(selectedCard = selectedCard) } }, onAddPaymentMethodClicked = { @@ -721,15 +685,14 @@ class ProjectPageActivity : LaunchedEffect(successfulPledge) { if (successfulPledge) { latePledgeCheckoutViewModel.onPledgeSuccess.collect { - val checkoutData = CheckoutData.builder() - .amount(totalAmount) - .id(checkoutPayment.id) - .paymentType(CreditCardPaymentType.CREDIT_CARD) - .bonusAmount(totalBonusSupportAmount) - .shippingAmount(shippingAmount) - .build() - val pledgeData = PledgeData.with(PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE), projectData, selectedReward) - showCreatePledgeSuccess(Pair(checkoutData, pledgeData)) + if (latePledgeCheckoutViewModel.getCheckoutData() != null && latePledgeCheckoutViewModel.getPledgeData() != null) { + showCreatePledgeSuccess( + Pair( + latePledgeCheckoutViewModel.getCheckoutData(), + latePledgeCheckoutViewModel.getPledgeData() + ) + ) + } checkoutFlowViewModel.onProjectSuccess() refreshProject() binding.pledgeContainerCompose.isGone = true @@ -1034,10 +997,6 @@ class ProjectPageActivity : binding.pledgeContainerLayout.pledgeToolbar.setOnMenuItemClickListener { when (it.itemId) { - R.id.update_pledge -> { - this.viewModel.inputs.updatePledgeClicked() - true - } R.id.rewards -> { this.viewModel.inputs.viewRewardsClicked() true diff --git a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/AddOnsContainer.kt b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/AddOnsContainer.kt index 7b36c07028..694df663f0 100644 --- a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/AddOnsContainer.kt +++ b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/AddOnsContainer.kt @@ -1,6 +1,5 @@ package com.kickstarter.ui.activities.compose.projectpage -import android.content.Context import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -18,14 +17,15 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Card import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.kickstarter.R -import com.kickstarter.libs.Environment -import com.kickstarter.libs.KSString -import com.kickstarter.libs.utils.extensions.isNull import com.kickstarter.ui.compose.designsystem.KSCoralBadge import com.kickstarter.ui.compose.designsystem.KSDividerLineGrey import com.kickstarter.ui.compose.designsystem.KSPrimaryBlackButton @@ -44,35 +44,39 @@ private fun AddOnsContainerPreview() { AddOnsContainer( title = "This Is A Test", amount = "$500", - shippingAmount = "$5 shipping each", conversionAmount = "About $500", + shippingAmount = " + $5 each", description = "This is just a test, don't worry about it, This is just a test, don't worry about it, This is just a test, don't worry about it, This is just a test, don't worry about it", includesList = listOf("this is item 1", "this is item 2", "this is item 3"), limit = 10, buttonEnabled = true, buttonText = "Add", - environment = Environment.builder().build(), - onItemAddedOrRemoved = { count -> }, - itemAddOnCount = 1 + estimatedShippingCost = "About $10-$15 each", + onItemAddedOrRemoved = { count, id -> }, + quantity = 1 ) } } @Composable fun AddOnsContainer( + rewardId: Long = 0, title: String, amount: String, - shippingAmount: String? = null, conversionAmount: String? = null, + shippingAmount: String? = null, description: String, includesList: List = listOf(), limit: Int = -1, buttonEnabled: Boolean, buttonText: String, - environment: Environment, - onItemAddedOrRemoved: (count: Int) -> Unit, - itemAddOnCount: Int + estimatedShippingCost: String? = null, + onItemAddedOrRemoved: (count: Int, id: Long) -> Unit, + quantity: Int = 0 ) { + + var count by remember { mutableStateOf(quantity) } + Card( modifier = Modifier.fillMaxWidth(), backgroundColor = colors.kds_white, @@ -86,12 +90,14 @@ fun AddOnsContainer( Text(text = title, style = typography.title2Bold, color = colors.kds_black) + Spacer(modifier = Modifier.height(dimensions.paddingXSmall)) + Row { Text(text = amount, style = typography.callout, color = colors.textAccentGreen) if (!shippingAmount.isNullOrEmpty()) { Text( - text = getShippingString(LocalContext.current, environment.ksString(), shippingAmount) ?: "", + text = shippingAmount, style = typography.callout, color = colors.textAccentGreen ) @@ -119,7 +125,7 @@ fun AddOnsContainer( if (includesList.isNotEmpty()) { Text( - text = "Includes", + text = stringResource(id = R.string.project_view_pledge_includes), style = typography.calloutMedium, color = colors.textSecondary ) @@ -152,19 +158,35 @@ fun AddOnsContainer( } } + if (!estimatedShippingCost.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(dimensions.paddingMediumLarge)) + Text( + text = stringResource(id = R.string.estimated_shipping_fpo), + color = colors.kds_support_400, + style = typography.calloutMedium + ) + + Text( + modifier = Modifier.padding(top = dimensions.radiusSmall), + text = estimatedShippingCost, + color = colors.kds_support_700, + style = typography.body2 + ) + } + if (limit > 0) { Spacer(Modifier.height(dimensions.paddingMedium)) - KSCoralBadge(text = "Limit $limit") } Spacer(Modifier.height(dimensions.paddingLarge)) - when (itemAddOnCount) { + when (count) { 0 -> { KSPrimaryBlackButton( onClickAction = { - onItemAddedOrRemoved(itemAddOnCount + 1) + count++ + onItemAddedOrRemoved(count, rewardId) }, text = buttonText, isEnabled = buttonEnabled @@ -179,11 +201,13 @@ fun AddOnsContainer( ) { KSStepper( onPlusClicked = { - onItemAddedOrRemoved(itemAddOnCount + 1) + count++ + onItemAddedOrRemoved(count, rewardId) }, - isPlusEnabled = itemAddOnCount < limit, + isPlusEnabled = count < limit, onMinusClicked = { - onItemAddedOrRemoved(itemAddOnCount - 1) + count-- + onItemAddedOrRemoved(count, rewardId) }, isMinusEnabled = true ) @@ -203,7 +227,7 @@ fun AddOnsContainer( ) ) { Text( - text = "$itemAddOnCount", + text = "$count", style = typography.callout, color = colors.textPrimary ) @@ -214,17 +238,3 @@ fun AddOnsContainer( } } } - -fun getShippingString(context: Context, ksString: KSString?, shippingAmount: String?): String? { - if (shippingAmount.isNullOrEmpty() || ksString.isNull()) return "" - val rewardAndShippingString = - context.getString(R.string.reward_amount_plus_shipping_cost_each) - val stringSections = rewardAndShippingString.split("+") - val shippingString = " +" + stringSections[1] - val ammountAndShippingString = ksString?.format( - shippingString, - "shipping_cost", - shippingAmount - ) - return ammountAndShippingString -} diff --git a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/AddOnsScreen.kt b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/AddOnsScreen.kt index 5ec92d7cf2..186537f5af 100644 --- a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/AddOnsScreen.kt +++ b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/AddOnsScreen.kt @@ -2,52 +2,35 @@ package com.kickstarter.ui.activities.compose.projectpage import android.content.res.Configuration import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.DropdownMenu -import androidx.compose.material.DropdownMenuItem -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.window.PopupProperties import com.kickstarter.R import com.kickstarter.libs.Environment -import com.kickstarter.libs.KSCurrency +import com.kickstarter.libs.getCurrencySymbols import com.kickstarter.libs.utils.RewardUtils -import com.kickstarter.models.Location +import com.kickstarter.libs.utils.RewardViewUtils +import com.kickstarter.mock.factories.RewardFactory import com.kickstarter.models.Project import com.kickstarter.models.Reward import com.kickstarter.models.ShippingRule @@ -57,6 +40,7 @@ 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 com.kickstarter.ui.views.compose.checkout.BonusSupportContainer import java.math.RoundingMode @Composable @@ -71,28 +55,13 @@ private fun AddOnsScreenPreview() { modifier = Modifier.padding(padding), environment = Environment.Builder().build(), lazyColumnListState = rememberLazyListState(), - countryList = listOf( - ShippingRule.builder() - .location(Location.builder().displayableName("United States").build()) - .build(), - ShippingRule.builder() - .location(Location.builder().displayableName("Japan").build()) - .build(), - ShippingRule.builder() - .location(Location.builder().displayableName("Korea").build()) - .build(), - ShippingRule.builder() - .location(Location.builder().displayableName("United States").build()) - .build() - ), - shippingSelectorIsGone = false, - onShippingRuleSelected = {}, - currentShippingRule = ShippingRule.builder().build(), - rewardItems = (0..10).map { + selectedReward = RewardFactory.reward().toBuilder().minimum(5.0).build(), + addOns = (0..10).map { Reward.builder() .title("Item Number $it") .description("This is a description for item $it") .id(it.toLong()) + .quantity(3) .convertedMinimum((100 * (it + 1)).toDouble()) .isAvailable(it != 0) .limit(if (it == 0) 1 else 10) @@ -103,9 +72,12 @@ private fun AddOnsScreenPreview() { .currency("USD") .currentCurrency("USD") .build(), - onItemAddedOrRemoved = {}, - selectedAddOnsMap = mutableMapOf(), - onContinueClicked = {} + onItemAddedOrRemoved = { q, l -> }, + bonusAmountChanged = {}, + onContinueClicked = {}, + addOnCount = 2, + totalPledgeAmount = 30.0, + totalBonusSupport = 5.0 ) } } @@ -113,24 +85,30 @@ private fun AddOnsScreenPreview() { @Composable fun AddOnsScreen( - modifier: Modifier, + modifier: Modifier = Modifier, environment: Environment, lazyColumnListState: LazyListState, - shippingSelectorIsGone: Boolean, - currentShippingRule: ShippingRule, - countryList: List, - onShippingRuleSelected: (ShippingRule) -> Unit, - rewardItems: List, + selectedReward: Reward, + addOns: List, project: Project, - onItemAddedOrRemoved: (Map) -> Unit, - selectedAddOnsMap: Map, + onItemAddedOrRemoved: (quantityForId: Int, rewardId: Long) -> Unit, + bonusAmountChanged: (amount: Double) -> Unit, isLoading: Boolean = false, - onContinueClicked: () -> Unit + currentShippingRule: ShippingRule = ShippingRule.builder().build(), + onContinueClicked: () -> Unit, + addOnCount: Int = 0, + totalPledgeAmount: Double, + totalBonusSupport: Double ) { - val interactionSource = remember { - MutableInteractionSource() - } - val addOnCount = getAddOnCount(selectedAddOnsMap) + val context = LocalContext.current + val currencySymbolStartAndEnd = environment.ksCurrency()?.getCurrencySymbols(project) + val totalAmountString = environment.ksCurrency()?.let { + RewardViewUtils.styleCurrency( + value = totalPledgeAmount, + project = project, + ksCurrency = it + ).toString() + } ?: "" Box( modifier = Modifier.fillMaxSize(), @@ -155,35 +133,57 @@ fun AddOnsScreen( .fillMaxWidth() .padding(dimensions.paddingMediumLarge) ) { - KSPrimaryGreenButton( - onClickAction = onContinueClicked, - text = - if (addOnCount == 0) stringResource(id = R.string.Skip_add_ons) - else { - when { - addOnCount == 1 -> environment.ksString()?.format( - stringResource(R.string.Continue_with_quantity_count_add_ons_one), - "quantity_count", - addOnCount.toString() - ) ?: "" + Column { + Row(modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(id = R.string.Total_amount), + style = typography.subheadlineMedium, + color = colors.textPrimary + ) - addOnCount > 1 -> environment.ksString()?.format( - stringResource(R.string.Continue_with_quantity_count_add_ons_many), - "quantity_count", - addOnCount.toString() - ) ?: "" + Spacer(modifier = Modifier.weight(1f)) - else -> stringResource(id = R.string.Skip_add_ons) - } - }, - isEnabled = true - ) + Text( + text = totalAmountString, + style = typography.subheadlineMedium, + color = colors.textPrimary + ) + } + + Spacer(modifier = Modifier.height(dimensions.paddingSmall)) + + KSPrimaryGreenButton( + onClickAction = onContinueClicked, + text = + if (addOnCount == 0) + stringResource(id = R.string.Continue) + else { + when { + addOnCount == 1 -> environment.ksString()?.format( + stringResource(R.string.Continue_with_quantity_count_add_ons_one), + "quantity_count", + addOnCount.toString() + ) ?: "" + + addOnCount > 1 -> environment.ksString()?.format( + stringResource(R.string.Continue_with_quantity_count_add_ons_many), + "quantity_count", + addOnCount.toString() + ) ?: "" + + else -> stringResource(id = R.string.Continue) + } + }, + isEnabled = true + ) + } } } } }, backgroundColor = colors.backgroundAccentGraySubtle ) { padding -> + LazyColumn( modifier = Modifier .fillMaxWidth() @@ -197,38 +197,45 @@ fun AddOnsScreen( state = lazyColumnListState ) { item { - Text( - text = stringResource(id = R.string.Customize_your_reward_with_optional_addons), - style = typography.title3Bold, - color = colors.textPrimary - ) - - if (!shippingSelectorIsGone) { - Spacer(modifier = Modifier.height(dimensions.paddingMediumLarge)) - + if (addOns.isNotEmpty()) { Text( - text = stringResource(id = R.string.Your_shipping_location), - style = typography.subheadlineMedium, - color = colors.textSecondary + text = stringResource(id = R.string.Customize_your_reward_with_optional_addons), + style = typography.title3Bold, + color = colors.textPrimary ) + } + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) - Spacer(modifier = Modifier.height(dimensions.paddingSmall)) + val initAmount = if (project.isBacking()) + project.backing()?.bonusAmount() ?: 0.0 + else if (RewardUtils.isNoReward(selectedReward)) + RewardUtils.minPledgeAmount(selectedReward, project) + else 0.0 - CountryInputWithDropdown( - interactionSource = interactionSource, - initialCountryInput = currentShippingRule.location()?.displayableName(), - countryList = countryList, - onShippingRuleSelected = onShippingRuleSelected - ) - } + BonusSupportContainer( + selectedReward = selectedReward, + initialAmount = initAmount, + maxAmount = RewardUtils.maxPledgeAmount(selectedReward, project), + minPledge = RewardUtils.minPledgeAmount(selectedReward, project), + totalAmount = totalPledgeAmount, + totalBonusSupport = totalBonusSupport, + currencySymbolAtStart = currencySymbolStartAndEnd?.first, + currencySymbolAtEnd = currencySymbolStartAndEnd?.second, + onBonusSupportPlusClicked = bonusAmountChanged, + onBonusSupportMinusClicked = bonusAmountChanged, + onBonusSupportInputted = bonusAmountChanged, + environment = environment + ) } items( - items = rewardItems + items = addOns ) { reward -> + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) AddOnsContainer( + rewardId = reward.id(), title = reward.title() ?: "", amount = environment.ksCurrency()?.format( reward.minimum(), @@ -248,26 +255,16 @@ fun AddOnsScreen( ) ) }, - shippingAmount = environment.ksCurrency()?.let { - getShippingCost( - reward = reward, - ksCurrency = it, - shippingRules = reward.shippingRules(), - selectedShippingRule = currentShippingRule, - project = project - ) - }, + shippingAmount = RewardViewUtils.getAddOnShippingAmountString( + context = context, + project = project, + reward = reward, + rewardShippingRules = reward.shippingRules(), + ksCurrency = environment.ksCurrency(), + ksString = environment.ksString(), + selectedShippingRule = currentShippingRule + ), description = reward.description() ?: "", - buttonEnabled = reward.isAvailable(), - buttonText = stringResource(id = R.string.Add), - limit = reward.limit() ?: -1, - onItemAddedOrRemoved = { count -> - val rewardSelections = mutableMapOf() - rewardSelections[reward] = count - - onItemAddedOrRemoved(rewardSelections) - }, - environment = environment, includesList = reward.addOnsItems()?.map { environment.ksString()?.format( "rewards_info_item_quantity_title", it.quantity(), @@ -275,7 +272,31 @@ fun AddOnsScreen( "title", it.item().name() ) ?: "" } ?: listOf(), - itemAddOnCount = selectedAddOnsMap[reward] ?: 0 + limit = reward.limit() ?: -1, + buttonEnabled = reward.isAvailable(), + buttonText = stringResource(id = R.string.Add), + estimatedShippingCost = + if (!RewardUtils.isDigital(reward) && RewardUtils.isShippable(reward) && !RewardUtils.isLocalPickup(reward)) { + environment.ksCurrency()?.let { ksCurrency -> + environment.ksString()?.let { ksString -> + RewardViewUtils.getEstimatedShippingCostString( + context = context, + ksCurrency = ksCurrency, + ksString = ksString, + project = project, + rewards = listOf(reward), + selectedShippingRule = currentShippingRule, + multipleQuantitiesAllowed = (reward.limit() ?: -1) > 1, + useUserPreference = false, + useAbout = true + ) + } + } + } else null, + onItemAddedOrRemoved = { quantityForId, rwId -> + onItemAddedOrRemoved(quantityForId, rwId) + }, + quantity = reward.quantity() ?: 0 ) } @@ -284,173 +305,16 @@ fun AddOnsScreen( } } } - - if (isLoading) { - Box( - modifier = Modifier - .fillMaxSize() - .background(colors.backgroundAccentGraySubtle.copy(alpha = 0.5f)), - contentAlignment = Alignment.Center - ) { - KSCircularProgressIndicator() - } - } } -} -private fun getAddOnCount(selectedAddOnsMap: Map): Int { - var totalAddOnsCount = 0 - selectedAddOnsMap.forEach { - totalAddOnsCount += it.value - } - return totalAddOnsCount -} -private fun getShippingCost( - reward: Reward, - ksCurrency: KSCurrency, - shippingRules: List?, - project: Project, - selectedShippingRule: ShippingRule -): String { - return if (shippingRules.isNullOrEmpty()) { - "" - } else if (!RewardUtils.isDigital(reward) && RewardUtils.isShippable(reward) && !RewardUtils.isLocalPickup(reward)) { - var cost = 0.0 - shippingRules.filter { - it.location()?.id() == selectedShippingRule.location()?.id() - }.map { - cost += it.cost() - } - if (cost > 0) ksCurrency.format(cost, project) - else "" - } else { - "" - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun CountryInputWithDropdown( - interactionSource: MutableInteractionSource, - initialCountryInput: String? = null, - countryList: List, - onShippingRuleSelected: (ShippingRule) -> Unit -) { - var countryListExpanded by remember { - mutableStateOf(false) - } - - var countryInput by remember(key1 = initialCountryInput) { - mutableStateOf(initialCountryInput ?: "") - } - - val focusManager = LocalFocusManager.current - - Column( - modifier = Modifier - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = { countryListExpanded = false } - ), - ) { - Box(contentAlignment = Alignment.TopStart) { - BasicTextField( - modifier = Modifier - .background(color = colors.backgroundSurfacePrimary) - .fillMaxWidth(0.6f), - value = countryInput, - onValueChange = { - countryInput = it - countryListExpanded = true - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done - ), - textStyle = typography.subheadlineMedium.copy(color = colors.textAccentGreenBold), - singleLine = false - ) { innerTextField -> - TextFieldDefaults.TextFieldDecorationBox( - value = countryInput, - innerTextField = innerTextField, - enabled = true, - singleLine = false, - visualTransformation = VisualTransformation.None, - interactionSource = interactionSource, - contentPadding = PaddingValues( - start = dimensions.paddingMedium, - top = dimensions.paddingSmall, - bottom = dimensions.paddingSmall, - end = dimensions.paddingMedium - ), - ) - } - - val shouldShowDropdown: Boolean = when { - countryListExpanded && countryInput.isNotEmpty() -> { - countryList.filter { - it.location()?.displayableName()?.lowercase() - ?.contains(countryInput.lowercase()) ?: false - }.isNotEmpty() - } - - else -> countryListExpanded - } - - DropdownMenu( - expanded = shouldShowDropdown, - onDismissRequest = { }, - modifier = Modifier - .width( - dimensions.countryInputWidth - ) - .heightIn(dimensions.none, dimensions.dropDownStandardWidth), - properties = PopupProperties(focusable = false) - ) { - if (countryInput.isNotEmpty()) { - countryList.filter { - it.location()?.displayableName()?.lowercase() - ?.contains(countryInput.lowercase()) ?: false - }.take(3).forEach { rule -> - DropdownMenuItem( - modifier = Modifier.background(color = colors.backgroundSurfacePrimary), - onClick = { - countryInput = - rule.location()?.displayableName() ?: "" - countryListExpanded = false - focusManager.clearFocus() - onShippingRuleSelected(rule) - } - ) { - Text( - text = rule.location()?.displayableName() ?: "", - style = typography.subheadlineMedium, - color = colors.textAccentGreenBold - ) - } - } - } else { - countryList.take(5).forEach { rule -> - DropdownMenuItem( - modifier = Modifier.background(color = colors.backgroundSurfacePrimary), - onClick = { - countryInput = - rule.location()?.displayableName() ?: "" - countryListExpanded = false - focusManager.clearFocus() - onShippingRuleSelected(rule) - } - ) { - Text( - text = rule.location()?.displayableName() ?: "", - style = typography.subheadlineMedium, - color = colors.textAccentGreenBold - ) - } - } - } - } + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(colors.backgroundAccentGraySubtle.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center + ) { + KSCircularProgressIndicator() } } } 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 ac291b2b80..3d0ec1cbfa 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 @@ -32,6 +32,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -50,6 +51,7 @@ import com.kickstarter.R import com.kickstarter.libs.Environment 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.hrefUrlFromTranslation @@ -73,7 +75,9 @@ 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 com.kickstarter.ui.compose.designsystem.kds_white +import com.kickstarter.ui.compose.designsystem.shapes import com.kickstarter.ui.data.PledgeReason +import com.kickstarter.ui.views.compose.checkout.ItemizedRewardListContainer import type.CreditCardTypes import java.math.RoundingMode import java.text.SimpleDateFormat @@ -116,7 +120,8 @@ fun CheckoutScreenPreview() { onPledgeCtaClicked = { }, newPaymentMethodClicked = { }, onDisclaimerItemClicked = {}, - onAccountabilityLinkClicked = {} + onAccountabilityLinkClicked = {}, + onChangedPaymentMethod = {} ) } } @@ -129,18 +134,20 @@ fun CheckoutScreen( project: Project, email: String?, ksString: KSString? = null, + selectedRewardsAndAddOns: List = listOf(), rewardsList: List> = listOf(), shippingAmount: Double = 0.0, pledgeReason: PledgeReason, totalAmount: Double, - currentShippingRule: ShippingRule, + currentShippingRule: ShippingRule?, totalBonusSupport: Double = 0.0, rewardsHaveShippables: Boolean, isLoading: Boolean = false, onPledgeCtaClicked: (selectedCard: StoredCard?) -> Unit, newPaymentMethodClicked: () -> Unit, onDisclaimerItemClicked: (disclaimerItem: DisclaimerItems) -> Unit, - onAccountabilityLinkClicked: () -> Unit + onAccountabilityLinkClicked: () -> Unit, + onChangedPaymentMethod: (StoredCard?) -> Unit = {} ) { val selectedOption = remember { mutableStateOf( @@ -152,10 +159,23 @@ fun CheckoutScreen( val onOptionSelected: (StoredCard?) -> Unit = { selectedOption.value = it + onChangedPaymentMethod.invoke(it) } + val totalAmountString = environment.ksCurrency()?.let { + RewardViewUtils.styleCurrency( + totalAmount, + project, + it + ).toString() + } ?: "" + // - After adding new payment method, selected card should be updated to the newly added - UpdateSelectedCardIfNewCardAdded(remember { mutableStateOf(storedCards.size) }, storedCards, onOptionSelected) + UpdateSelectedCardIfNewCardAdded( + remember { mutableStateOf(storedCards.size) }, + storedCards, + onOptionSelected + ) Box( modifier = Modifier.fillMaxSize(), @@ -195,7 +215,7 @@ fun CheckoutScreen( .fillMaxWidth(), onClickAction = { onPledgeCtaClicked(selectedOption.value) }, isEnabled = project.acceptedCardType(selectedOption.value?.type()) || selectedOption.value?.isFromPaymentSheet() ?: false, - text = if (pledgeReason == PledgeReason.PLEDGE) stringResource(id = R.string.Pledge) else stringResource( + text = if (pledgeReason == PledgeReason.PLEDGE || pledgeReason == PledgeReason.LATE_PLEDGE) stringResource(id = R.string.Pledge) + " $totalAmountString" else stringResource( id = R.string.Confirm ) ) @@ -210,10 +230,12 @@ fun CheckoutScreen( } } - if (!formattedEmailDisclaimerString.isNullOrEmpty()) { + if (!formattedEmailDisclaimerString.isNullOrEmpty() && project.isInPostCampaignPledgingPhase() == true) { Text( - text = formattedEmailDisclaimerString, textAlign = TextAlign.Center, - style = typography.caption2, color = colors.kds_support_400 + text = formattedEmailDisclaimerString, + textAlign = TextAlign.Center, + style = typography.caption2, + color = colors.kds_support_400 ) } @@ -236,22 +258,15 @@ fun CheckoutScreen( } ) { padding -> - val totalAmountString = environment.ksCurrency()?.let { - RewardViewUtils.styleCurrency( - totalAmount, - project, - it - ).toString() - } ?: "" - - val totalAmountConvertedString = if (project.currentCurrency() == project.currency()) "" else { - environment.ksCurrency()?.formatWithUserPreference( - totalAmount, - project, - RoundingMode.UP, - 2 - ) ?: "" - } + val totalAmountConvertedString = + if (project.currentCurrency() == project.currency()) "" else { + environment.ksCurrency()?.formatWithUserPreference( + totalAmount, + project, + RoundingMode.UP, + 2 + ) ?: "" + } val shippingAmountString = environment.ksCurrency()?.let { RewardViewUtils.styleCurrency( @@ -284,7 +299,7 @@ fun CheckoutScreen( totalAmountConvertedString ) ?: "About $totalAmountConvertedString" - val shippingLocation = currentShippingRule.location()?.displayableName() ?: "" + val shippingLocation = currentShippingRule?.location()?.displayableName() ?: "" val deliveryDateString = if (selectedReward?.estimatedDeliveryOn().isNotNull()) { stringResource(id = R.string.Estimated_delivery) + " " + DateTimeUtils.estimatedDeliveryOn( @@ -313,7 +328,8 @@ fun CheckoutScreen( Spacer(modifier = Modifier.height(dimensions.paddingMediumSmall)) storedCards.forEachIndexed { index, card -> - val isAvailable = project.acceptedCardType(card.type()) || card.isFromPaymentSheet() + val isAvailable = + project.acceptedCardType(card.type()) || card.isFromPaymentSheet() Card( backgroundColor = colors.kds_white, modifier = Modifier @@ -453,8 +469,14 @@ fun CheckoutScreen( Spacer(modifier = Modifier.height(dimensions.paddingMediumSmall)) - if (rewardsList.isNotEmpty()) { - + val resourceString = stringResource(R.string.If_the_project_reaches_its_funding_goal_you_will_be_charged_total_on_project_deadline) + val disclaimerText = environment.ksString()?.format( + resourceString, + "total", totalAmountString, + "project_deadline", project.deadline()?.let { DateTimeUtils.longDate(it) } + ) ?: "" + val isNoReward = selectedReward?.let { RewardUtils.isNoReward(it) } ?: false + if (!isNoReward) { ItemizedRewardListContainer( ksString = ksString, rewardsList = rewardsList, @@ -466,17 +488,58 @@ fun CheckoutScreen( initialBonusSupport = initialBonusSupportString, totalBonusSupport = totalBonusSupportString, deliveryDateString = deliveryDateString, - rewardsHaveShippables = rewardsHaveShippables + rewardsHaveShippables = rewardsHaveShippables, + disclaimerText = disclaimerText ) } else { + // - For noReward, totalAmount = bonusAmount as there is no reward ItemizedRewardListContainer( totalAmount = totalAmountString, totalAmountCurrencyConverted = aboutTotalString, initialBonusSupport = initialBonusSupportString, - totalBonusSupport = totalBonusSupportString, + totalBonusSupport = totalAmountString, shippingAmount = shippingAmount, + disclaimerText = disclaimerText ) } + + if (environment.ksCurrency().isNotNull() && environment.ksString().isNotNull() && currentShippingRule.isNotNull()) { + val estimatedShippingRangeString = + RewardViewUtils.getEstimatedShippingCostString( + context = LocalContext.current, + ksCurrency = environment.ksCurrency()!!, + ksString = environment.ksString()!!, + project = project, + rewards = selectedRewardsAndAddOns, + selectedShippingRule = currentShippingRule!!, + multipleQuantitiesAllowed = false, + useUserPreference = false, + useAbout = false + ) + + val estimatedShippingRangeConversionString = + if (project.currentCurrency() == project.currency()) null + else { + RewardViewUtils.getEstimatedShippingCostString( + context = LocalContext.current, + ksCurrency = environment.ksCurrency()!!, + ksString = environment.ksString()!!, + project = project, + rewards = selectedRewardsAndAddOns, + selectedShippingRule = currentShippingRule, + multipleQuantitiesAllowed = false, + useUserPreference = true, + useAbout = true + ) + } + + if (estimatedShippingRangeString.isNotEmpty()) { + KSEstimatedShippingCheckoutView( + estimatedShippingRange = estimatedShippingRangeString, + estimatedShippingRangeConversion = estimatedShippingRangeConversionString + ) + } + } } } @@ -748,3 +811,82 @@ fun KSCardElement(card: StoredCard, ksString: KSString?, isAvailable: Boolean) { } } } + +@Composable +@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +fun KSEstimatedShippingCheckoutViewPreview() { + KSTheme { + KSEstimatedShippingCheckoutView( + estimatedShippingRange = "€5-€10", + estimatedShippingRangeConversion = "About $6-$11" + ) + } +} + +@Composable +fun KSEstimatedShippingCheckoutView( + estimatedShippingRange: String, + estimatedShippingRangeConversion: String?, +) { + Card( + modifier = Modifier.padding(dimensions.paddingMediumLarge), + shape = shapes.medium, + backgroundColor = colors.backgroundSurfacePrimary + ) { + // TODO: Get these strings translations in place + Row { + Column(modifier = Modifier.weight(0.6f)) { + Text( + modifier = Modifier.padding( + start = dimensions.paddingLarge, + top = dimensions.paddingMedium + ), + text = stringResource(id = R.string.estimated_shipping_fpo), + style = typography.calloutMedium, + color = colors.textPrimary + ) + + Spacer(modifier = Modifier.height(dimensions.paddingSmall)) + + Text( + modifier = Modifier.padding( + start = dimensions.paddingLarge, + bottom = dimensions.paddingMedium + ), + text = stringResource(id = R.string.this_is_meant_to_give_you_fpo), + style = typography.caption2, + color = colors.textSecondary + ) + } + + Spacer(modifier = Modifier.width(dimensions.paddingMedium)) + + Column(modifier = Modifier.weight(0.4f), horizontalAlignment = Alignment.End) { + Text( + modifier = Modifier.padding( + end = dimensions.paddingLarge, + top = dimensions.paddingMedium + ), + text = estimatedShippingRange, + style = typography.calloutMedium, + color = colors.textPrimary + ) + + Spacer(modifier = Modifier.height(dimensions.paddingSmall)) + + if (!estimatedShippingRangeConversion.isNullOrEmpty()) { + Text( + modifier = Modifier.padding( + end = dimensions.paddingLarge, + bottom = dimensions.paddingMedium + ), + text = estimatedShippingRangeConversion, + style = typography.caption1, + color = colors.textSecondary + ) + } + } + } + } +} diff --git a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/ConfirmPledgeDetailsScreen.kt b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/ConfirmPledgeDetailsScreen.kt deleted file mode 100644 index 5de1c44d24..0000000000 --- a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/ConfirmPledgeDetailsScreen.kt +++ /dev/null @@ -1,827 +0,0 @@ -package com.kickstarter.ui.activities.compose.projectpage - -import android.content.res.Configuration -import androidx.compose.foundation.background -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -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.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.integerResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import com.kickstarter.R -import com.kickstarter.libs.Environment -import com.kickstarter.libs.KSString -import com.kickstarter.libs.utils.DateTimeUtils -import com.kickstarter.libs.utils.ProjectViewUtils -import com.kickstarter.libs.utils.RewardViewUtils -import com.kickstarter.libs.utils.extensions.isNotNull -import com.kickstarter.libs.utils.extensions.parseToDouble -import com.kickstarter.models.Project -import com.kickstarter.models.Reward -import com.kickstarter.models.ShippingRule -import com.kickstarter.ui.compose.designsystem.KSCircularProgressIndicator -import com.kickstarter.ui.compose.designsystem.KSDividerLineGrey -import com.kickstarter.ui.compose.designsystem.KSPrimaryGreenButton -import com.kickstarter.ui.compose.designsystem.KSStepper -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 com.kickstarter.ui.compose.designsystem.shapes -import java.math.RoundingMode - -@Composable -@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun ConfirmPledgeDetailsScreenPreviewNoRewards() { - KSTheme { - ConfirmPledgeDetailsScreen( - modifier = Modifier, - environment = Environment.builder().build(), - project = Project.builder().build(), - selectedReward = null, - onContinueClicked = {}, - rewardsContainAddOns = false, - rewardsHaveShippables = true, - currentShippingRule = ShippingRule.builder().build(), - totalAmount = 1.0, - initialBonusSupport = 1.0, - totalBonusSupport = 1.0, - maxPledgeAmount = 1000.0, - minPledgeStep = 1.0, - onShippingRuleSelected = {}, - onBonusSupportMinusClicked = {}, - onBonusSupportPlusClicked = {}, - onBonusSupportInputted = {} - ) - } -} - -@Composable -@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun ConfirmPledgeDetailsScreenPreviewNoRewardsWarning() { - KSTheme { - ConfirmPledgeDetailsScreen( - modifier = Modifier, - environment = Environment.builder().build(), - project = Project.builder().build(), - selectedReward = null, - onContinueClicked = {}, - rewardsContainAddOns = false, - rewardsHaveShippables = true, - currentShippingRule = ShippingRule.builder().build(), - totalAmount = 1001.0, - initialBonusSupport = 1.0, - totalBonusSupport = 1.0, - maxPledgeAmount = 1000.0, - minPledgeStep = 1.0, - onShippingRuleSelected = {}, - onBonusSupportMinusClicked = {}, - onBonusSupportPlusClicked = {}, - onBonusSupportInputted = {} - ) - } -} - -@Composable -@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun ConfirmPledgeDetailsScreenPreviewNoAddOnsOrBonusSupport() { - KSTheme { - ConfirmPledgeDetailsScreen( - modifier = Modifier, - environment = Environment.builder().build(), - project = Project.builder().build(), - selectedReward = Reward.builder().build(), - onContinueClicked = {}, - rewardsList = (1..2).map { - Pair("Cool Item $it", "$20") - }, - rewardsContainAddOns = false, - rewardsHaveShippables = true, - shippingAmount = 5.0, - currentShippingRule = ShippingRule.builder().build(), - totalAmount = 55.0, - initialBonusSupport = 0.0, - totalBonusSupport = 0.0, - maxPledgeAmount = 1000.0, - minPledgeStep = 1.0, - countryList = listOf(ShippingRule.builder().build()), - onShippingRuleSelected = {}, - onBonusSupportMinusClicked = {}, - onBonusSupportPlusClicked = {}, - onBonusSupportInputted = {} - ) - } -} - -@Composable -@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun ConfirmPledgeDetailsScreenPreviewAddOnsOnly() { - KSTheme { - ConfirmPledgeDetailsScreen( - modifier = Modifier, - environment = Environment.builder().build(), - project = Project.builder().build(), - selectedReward = Reward.builder().build(), - onContinueClicked = {}, - rewardsList = (1..5).map { - Pair("Cool Item $it", "$20") - }, - rewardsContainAddOns = true, - rewardsHaveShippables = true, - shippingAmount = 5.0, - currentShippingRule = ShippingRule.builder().build(), - totalAmount = 105.0, - initialBonusSupport = 0.0, - totalBonusSupport = 0.0, - maxPledgeAmount = 1000.0, - minPledgeStep = 1.0, - onShippingRuleSelected = {}, - onBonusSupportMinusClicked = {}, - onBonusSupportPlusClicked = {}, - onBonusSupportInputted = {} - ) - } -} - -@Composable -@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun ConfirmPledgeDetailsScreenPreviewBonusSupportOnly() { - KSTheme { - ConfirmPledgeDetailsScreen( - modifier = Modifier, - environment = Environment.builder().build(), - project = Project.builder().build(), - selectedReward = Reward.builder().build(), - onContinueClicked = {}, - rewardsList = (1..2).map { - Pair("Cool Item $it", "$20") - }, - rewardsContainAddOns = false, - rewardsHaveShippables = true, - shippingAmount = 5.0, - currentShippingRule = ShippingRule.builder().build(), - totalAmount = 55.0, - initialBonusSupport = 0.0, - totalBonusSupport = 10.0, - maxPledgeAmount = 1000.0, - minPledgeStep = 1.0, - countryList = listOf(ShippingRule.builder().build()), - onShippingRuleSelected = {}, - onBonusSupportMinusClicked = {}, - onBonusSupportPlusClicked = {}, - onBonusSupportInputted = {} - ) - } -} - -@Composable -@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) -private fun ConfirmPledgeDetailsScreenPreviewAddOnsAndBonusSupport() { - KSTheme { - ConfirmPledgeDetailsScreen( - modifier = Modifier, - environment = Environment.builder().build(), - project = Project.builder().build(), - selectedReward = Reward.builder().build(), - onContinueClicked = {}, - rewardsList = (1..5).map { - Pair("Cool Item $it", "$20") - }, - rewardsContainAddOns = true, - rewardsHaveShippables = true, - shippingAmount = 5.0, - currentShippingRule = ShippingRule.builder().build(), - totalAmount = 115.0, - initialBonusSupport = 0.0, - totalBonusSupport = 10.0, - maxPledgeAmount = 1000.0, - minPledgeStep = 1.0, - onShippingRuleSelected = {}, - onBonusSupportMinusClicked = {}, - onBonusSupportPlusClicked = {}, - onBonusSupportInputted = {} - ) - } -} - -@Composable -fun ConfirmPledgeDetailsScreen( - modifier: Modifier, - environment: Environment?, - project: Project, - selectedReward: Reward?, - onContinueClicked: () -> Unit, - rewardsList: List> = listOf(), - rewardsContainAddOns: Boolean, - rewardsHaveShippables: Boolean, - shippingAmount: Double = 0.0, - currentShippingRule: ShippingRule, - countryList: List = listOf(), - onShippingRuleSelected: (ShippingRule) -> Unit, - totalAmount: Double, - initialBonusSupport: Double, - totalBonusSupport: Double, - maxPledgeAmount: Double, - minPledgeStep: Double, - isLoading: Boolean = false, - onBonusSupportPlusClicked: () -> Unit, - onBonusSupportMinusClicked: () -> Unit, - onBonusSupportInputted: (input: Double) -> Unit -) { - val interactionSource = remember { - MutableInteractionSource() - } - - val totalAmountString = environment?.ksCurrency()?.let { - RewardViewUtils.styleCurrency( - totalAmount, - project, - it - ).toString() - } ?: "" - - val totalAmountConvertedString = environment?.ksCurrency()?.formatWithUserPreference( - totalAmount, - project, - RoundingMode.UP, - 2 - ) ?: "" - - val aboutTotalString = if (project.currentCurrency() == project.currency()) "" else { - environment?.ksString()?.format( - stringResource(id = R.string.About_reward_amount), - "reward_amount", - totalAmountConvertedString - ) ?: "About $totalAmountConvertedString" - } - - val shippingAmountString = environment?.ksCurrency()?.let { - RewardViewUtils.styleCurrency( - shippingAmount, - project, - it - ).toString() - } ?: "" - - val shippingLocation = currentShippingRule.location()?.displayableName() ?: "" - - val initialBonusSupportString = environment?.ksCurrency()?.let { - RewardViewUtils.styleCurrency( - initialBonusSupport, - project, - it - ).toString() - } ?: "" - - val totalBonusSupportString = environment?.ksCurrency()?.let { - RewardViewUtils.styleCurrency( - totalBonusSupport, - project, - it - ).toString() - } ?: "" - - // Currency symbol, which can be positioned at start or end of amount depending on country - val currencySymbolStartAndEnd = environment?.ksCurrency()?.let { - val symbolAndStart = ProjectViewUtils.currencySymbolAndPosition( - project, - it - ) - val symbol = symbolAndStart.first - val symbolAtStart = symbolAndStart.second - if (symbolAtStart) { - Pair(symbol.toString(), null) - } else { - Pair(null, symbol.toString()) - } - } ?: Pair(null, null) - - val deliveryDateString = if (selectedReward?.estimatedDeliveryOn().isNotNull()) { - DateTimeUtils.estimatedDeliveryOn( - requireNotNull( - selectedReward?.estimatedDeliveryOn() - ) - ) - } else "" - - val maxInputString = RewardViewUtils.getMaxInputString(LocalContext.current, selectedReward, maxPledgeAmount, totalAmount, totalBonusSupport, currencySymbolStartAndEnd, environment) - - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Scaffold( - modifier = modifier, - bottomBar = { - Column { - Surface( - modifier = Modifier - .fillMaxWidth(), - shape = RoundedCornerShape( - topStart = dimensions.radiusLarge, - topEnd = dimensions.radiusLarge - ), - color = colors.backgroundSurfacePrimary, - elevation = dimensions.elevationLarge, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(dimensions.paddingMediumLarge) - ) { - Column { - if (rewardsList.isNotEmpty()) { - Row(modifier = Modifier.fillMaxWidth()) { - Text( - text = stringResource(id = R.string.Total_amount), - style = typography.subheadlineMedium, - color = colors.textPrimary - ) - - Spacer(modifier = Modifier.weight(1f)) - - Text( - text = totalAmountString, - style = typography.subheadlineMedium, - color = colors.textPrimary - ) - } - - Spacer(modifier = Modifier.height(dimensions.paddingSmall)) - } - KSPrimaryGreenButton( - onClickAction = onContinueClicked, - text = stringResource(id = R.string.Continue), - isEnabled = true - ) - } - } - } - } - }, - backgroundColor = colors.backgroundAccentGraySubtle - ) { padding -> - LazyColumn( - modifier = Modifier - .padding(padding) - .fillMaxSize() - ) { - - item { - Text( - modifier = Modifier.padding( - start = dimensions.paddingMedium, - top = dimensions.paddingMedium - ), - text = stringResource(id = R.string.Confirm_your_pledge_details), - style = typography.title3Bold, - color = colors.textPrimary - ) - } - - if (rewardsList.isNotEmpty() && shippingLocation.isNotEmpty() && rewardsHaveShippables) { - item { - ShippingLocationView( - countryList, - rewardsContainAddOns, - interactionSource, - shippingLocation, - shippingAmountString, - onShippingRuleSelected - ) - } - } - - item { - BonusSupportContainer( - isForNoRewardPledge = rewardsList.isEmpty(), - initialBonusSupport = initialBonusSupport, - totalBonusSupport = totalBonusSupport, - currencySymbolAtStart = currencySymbolStartAndEnd.first, - currencySymbolAtEnd = currencySymbolStartAndEnd.second, - canAddMore = totalAmount + minPledgeStep <= maxPledgeAmount, - onBonusSupportPlusClicked = onBonusSupportPlusClicked, - onBonusSupportMinusClicked = onBonusSupportMinusClicked, - onBonusSupportInputted = onBonusSupportInputted - ) - - if (totalAmount > maxPledgeAmount) { - Text( - text = maxInputString, - textAlign = TextAlign.Right, - style = typography.footnoteMedium, - color = colors.textAccentRed, - modifier = Modifier - .fillMaxWidth() - .padding( - start = dimensions.paddingMedium, - end = dimensions.paddingMedium, - ) - ) - } - } - - if (rewardsList.isEmpty()) { - item { - Column(modifier = Modifier.padding(all = dimensions.paddingMedium)) { - KSDividerLineGrey() - - Spacer(modifier = Modifier.height(dimensions.paddingMedium)) - - Row { - Text( - text = stringResource(id = R.string.Total), - style = typography.headline, - color = colors.textPrimary - ) - - Spacer(modifier = Modifier.weight(1f)) - - Column(horizontalAlignment = Alignment.End) { - Text( - text = totalAmountString, - style = typography.headline, - color = colors.textPrimary - ) - - if (!aboutTotalString.isNullOrEmpty()) { - Spacer(modifier = Modifier.height(dimensions.paddingXSmall)) - - Text( - text = aboutTotalString, - style = typography.footnote, - color = colors.textPrimary - ) - } - } - } - } - } - } else { - item { - ItemizedRewardListContainer( - ksString = environment?.ksString(), - rewardsList = rewardsList, - shippingAmount = shippingAmount, - shippingAmountString = shippingAmountString, - initialShippingLocation = shippingLocation, - totalAmount = totalAmountString, - totalAmountCurrencyConverted = aboutTotalString, - initialBonusSupport = initialBonusSupportString, - totalBonusSupport = totalBonusSupportString, - deliveryDateString = deliveryDateString, - rewardsHaveShippables = rewardsHaveShippables - ) - } - } - } - } - - if (isLoading) { - Box( - modifier = Modifier - .fillMaxSize() - .background(colors.backgroundAccentGraySubtle.copy(alpha = 0.5f)), - contentAlignment = Alignment.Center - ) { - KSCircularProgressIndicator() - } - } - } -} - -@Composable -fun BonusSupportContainer( - isForNoRewardPledge: Boolean, - initialBonusSupport: Double, - totalBonusSupport: Double, - currencySymbolAtStart: String?, - currencySymbolAtEnd: String?, - canAddMore: Boolean, - onBonusSupportPlusClicked: () -> Unit, - onBonusSupportMinusClicked: () -> Unit, - onBonusSupportInputted: (input: Double) -> Unit -) { - val bonusAmountMaxDigits = integerResource(R.integer.max_length) - - Column( - modifier = Modifier.padding(all = dimensions.paddingMedium) - ) { - Text( - text = - if (isForNoRewardPledge) stringResource(id = R.string.Your_pledge_amount) - else stringResource(id = R.string.Bonus_support), - style = typography.subheadlineMedium, - color = colors.textPrimary - ) - - Spacer(modifier = Modifier.height(dimensions.paddingSmall)) - - if (!isForNoRewardPledge) { - Text( - text = stringResource(id = R.string.A_little_extra_to_help), - style = typography.body2, - color = colors.textSecondary - ) - Spacer(modifier = Modifier.height(dimensions.paddingSmall)) - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - KSStepper( - onPlusClicked = onBonusSupportPlusClicked, - isPlusEnabled = canAddMore, - onMinusClicked = onBonusSupportMinusClicked, - isMinusEnabled = initialBonusSupport != totalBonusSupport, - enabledButtonBackgroundColor = colors.kds_white - ) - - Spacer(modifier = Modifier.weight(1f)) - - if (!isForNoRewardPledge) { - Text(text = "+", style = typography.calloutMedium, color = colors.textSecondary) - - Spacer(modifier = Modifier.width(dimensions.paddingMediumSmall)) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .background( - color = colors.kds_white, - shape = shapes.small - ) - .padding( - start = dimensions.paddingXSmall, - top = dimensions.paddingXSmall, - bottom = dimensions.paddingXSmall, - end = dimensions.paddingXSmall - ), - - ) { - Text( - text = currencySymbolAtStart ?: "", - color = colors.textAccentGreen - ) - BasicTextField( - modifier = Modifier.width(IntrinsicSize.Min), - value = if (totalBonusSupport % 1.0 == 0.0) totalBonusSupport.toInt().toString() else totalBonusSupport.toString(), - onValueChange = { - if (it.length <= bonusAmountMaxDigits) onBonusSupportInputted(it.parseToDouble()) - }, - textStyle = typography.title1.copy(color = colors.textAccentGreen), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done - ), - cursorBrush = SolidColor(colors.iconSubtle) - - ) - Text( - text = currencySymbolAtEnd ?: "", - color = colors.textAccentGreen - ) - } - } - } -} - -@Composable -fun ItemizedRewardListContainer( - ksString: KSString? = null, - rewardsList: List> = listOf(), - shippingAmount: Double, - shippingAmountString: String = "", - initialShippingLocation: String = "", - totalAmount: String, - totalAmountCurrencyConverted: String = "", - initialBonusSupport: String, - totalBonusSupport: String, - deliveryDateString: String = "", - rewardsHaveShippables: Boolean = false -) { - Column( - modifier = Modifier - .fillMaxWidth() - .background(color = colors.backgroundSurfacePrimary) - .padding( - start = dimensions.paddingMedium, - end = dimensions.paddingMedium, - bottom = dimensions.paddingLarge, - top = dimensions.paddingMediumLarge - ) - ) { - Text( - text = stringResource(id = R.string.Your_pledge), - style = typography.headline, - color = colors.textPrimary - ) - - if (deliveryDateString.isNotEmpty()) { - Spacer(modifier = Modifier.height(dimensions.paddingXSmall)) - - Text( - text = deliveryDateString, - style = typography.caption1, - color = colors.textSecondary - ) - } - - Spacer(modifier = Modifier.height(dimensions.paddingMedium)) - - KSDividerLineGrey() - - rewardsList.forEach { - Spacer(modifier = Modifier.height(dimensions.paddingMedium)) - - Row { - Text( - text = it.first, - style = typography.subheadlineMedium, - color = colors.textSecondary - ) - - Spacer(modifier = Modifier.weight(1f)) - - Text( - text = it.second, - style = typography.subheadlineMedium, - color = colors.textSecondary - ) - } - - Spacer(modifier = Modifier.height(dimensions.paddingMedium)) - - KSDividerLineGrey() - } - - if (shippingAmount > 0 && initialShippingLocation.isNotEmpty() && rewardsHaveShippables) { - Spacer(modifier = Modifier.height(dimensions.paddingMedium)) - - Row { - Text( - text = ksString?.format( - stringResource(id = R.string.Shipping_to_country), - "country", - initialShippingLocation - ) ?: "Shipping: $initialShippingLocation", - style = typography.subheadlineMedium, - color = colors.textSecondary - ) - - Spacer(modifier = Modifier.weight(1f)) - - Text( - text = shippingAmountString, - style = typography.subheadlineMedium, - color = colors.textSecondary - ) - } - - Spacer(modifier = Modifier.height(dimensions.paddingMedium)) - - KSDividerLineGrey() - } - - if (totalBonusSupport != initialBonusSupport) { - Spacer(modifier = Modifier.height(dimensions.paddingMedium)) - - Row { - Text( - text = stringResource( - id = - if (rewardsList.isNotEmpty()) R.string.Bonus_support - else R.string.Pledge_without_a_reward - ), - style = typography.subheadlineMedium, - color = colors.textSecondary - ) - - Spacer(modifier = Modifier.weight(1f)) - - Text( - text = totalBonusSupport, - style = typography.subheadlineMedium, - color = colors.textSecondary - ) - } - - Spacer(modifier = Modifier.height(dimensions.paddingMedium)) - - KSDividerLineGrey() - } - - Spacer(modifier = Modifier.height(dimensions.paddingMedium)) - - Row { - Text( - text = stringResource(id = R.string.Total_amount), - style = typography.calloutMedium, - color = colors.textPrimary - ) - - Spacer(modifier = Modifier.weight(1f)) - - Column(horizontalAlignment = Alignment.End) { - Text( - text = totalAmount, - style = typography.subheadlineMedium, - color = colors.textPrimary - ) - - if (totalAmountCurrencyConverted.isNotEmpty()) { - Spacer(modifier = Modifier.height(dimensions.paddingXSmall)) - - Text( - text = totalAmountCurrencyConverted, - style = typography.footnote, - color = colors.textPrimary - ) - } - } - } - } -} - -@Composable -fun ShippingLocationView( - countryList: List, - rewardsContainAddOns: Boolean, - interactionSource: MutableInteractionSource, - shippingLocation: String, - shippingAmountString: String, - onShippingRuleSelected: (ShippingRule) -> Unit -) { - Column( - modifier = Modifier.padding( - start = dimensions.paddingMedium, - end = dimensions.paddingMedium, - top = dimensions.paddingMedium - ) - ) { - Text( - text = stringResource(id = R.string.Your_shipping_location), - style = typography.subheadlineMedium, - color = colors.textPrimary - ) - - Spacer(modifier = Modifier.height(dimensions.paddingMediumSmall)) - - Row(verticalAlignment = Alignment.CenterVertically) { - if (countryList.isNotEmpty() && !rewardsContainAddOns) { - CountryInputWithDropdown( - interactionSource = interactionSource, - initialCountryInput = shippingLocation, - countryList = countryList, - onShippingRuleSelected = onShippingRuleSelected - ) - } else { - Text( - text = shippingLocation, - style = typography.subheadline, - color = colors.textPrimary - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - Text( - text = "+ $shippingAmountString", - style = typography.title3, - color = colors.textSecondary - ) - } - } -} 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 bf01331949..e2f992aeeb 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 @@ -95,19 +95,18 @@ private fun ProjectPledgeButtonAndContainerPreview() { addOns = listOf(), project = Project.builder().build(), onRewardSelected = {}, - onAddOnAddedOrRemoved = {}, - selectedAddOnsMap = mapOf(), - totalAmount = 0.0, - shippingSelectorIsGone = false, + onAddOnAddedOrRemoved = { _, _ -> }, + totalSelectedAddOn = 0, + totalPledgeAmount = 0.0, + totalBonusAmount = 0.0, + bonusAmountChanged = { _ -> }, currentShippingRule = ShippingRule.builder().build(), onShippingRuleSelected = {}, - onConfirmDetailsContinueClicked = {}, selectedRewardAndAddOnList = listOf(), - onBonusSupportMinusClicked = {}, - onBonusSupportPlusClicked = {}, - onBonusSupportInputted = {}, storedCards = listOf(), userEmail = "test@test.test", + shippingAmount = 0.0, + checkoutTotal = 20.0, onPledgeCtaClicked = {}, onAddPaymentMethodClicked = {}, onDisclaimerItemClicked = {}, @@ -125,7 +124,6 @@ fun ProjectPledgeButtonAndFragmentContainer( pagerState: PagerState, isLoading: Boolean, onAddOnsContinueClicked: () -> Unit, - shippingSelectorIsGone: Boolean, shippingRules: List = listOf(), currentShippingRule: ShippingRule, environment: Environment?, @@ -134,23 +132,18 @@ fun ProjectPledgeButtonAndFragmentContainer( addOns: List, project: Project, onRewardSelected: (reward: Reward) -> Unit, - onAddOnAddedOrRemoved: (Map) -> Unit, - selectedAddOnsMap: Map, - totalAmount: Double, + onAddOnAddedOrRemoved: (quantityForId: Int, rewardId: Long) -> Unit, + totalSelectedAddOn: Int, + totalPledgeAmount: Double, + totalBonusAmount: Double, + bonusAmountChanged: (amount: Double) -> Unit, selectedReward: Reward? = null, onShippingRuleSelected: (ShippingRule) -> Unit, - initialBonusSupportAmount: Double = 0.0, - totalBonusSupportAmount: Double = 0.0, - maxPledgeAmount: Double = 0.0, - minStepAmount: Double = 0.0, - onConfirmDetailsContinueClicked: () -> Unit, - shippingAmount: Double = 0.0, selectedRewardAndAddOnList: List, - onBonusSupportPlusClicked: () -> Unit, - onBonusSupportMinusClicked: () -> Unit, - onBonusSupportInputted: (input: Double) -> Unit, storedCards: List, userEmail: String, + shippingAmount: Double, + checkoutTotal: Double, onPledgeCtaClicked: (selectedCard: StoredCard?) -> Unit, onAddPaymentMethodClicked: () -> Unit, onDisclaimerItemClicked: (disclaimerItem: DisclaimerItems) -> Unit, @@ -247,7 +240,10 @@ fun ProjectPledgeButtonAndFragmentContainer( rewards = rewardsList, project = project, onRewardSelected = onRewardSelected, - isLoading = isLoading + isLoading = isLoading, + countryList = shippingRules, + currentShippingRule = currentShippingRule, + onShippingRuleSelected = onShippingRuleSelected, ) } @@ -256,46 +252,16 @@ fun ProjectPledgeButtonAndFragmentContainer( modifier = Modifier, environment = environment ?: Environment.builder().build(), lazyColumnListState = rememberLazyListState(), - countryList = shippingRules, - shippingSelectorIsGone = shippingSelectorIsGone, - currentShippingRule = currentShippingRule, - onShippingRuleSelected = onShippingRuleSelected, - rewardItems = addOns, + selectedReward = selectedReward ?: Reward.builder().build(), + addOns = addOns, project = project, onItemAddedOrRemoved = onAddOnAddedOrRemoved, - selectedAddOnsMap = selectedAddOnsMap, onContinueClicked = onAddOnsContinueClicked, isLoading = isLoading, - ) - } - - 2 -> { - ConfirmPledgeDetailsScreen( - modifier = Modifier, - environment = environment ?: Environment.builder().build(), - project = project, - selectedReward = selectedReward, - onContinueClicked = onConfirmDetailsContinueClicked, - onShippingRuleSelected = onShippingRuleSelected, - totalAmount = totalAmount, - shippingAmount = shippingAmount, - currentShippingRule = currentShippingRule, - countryList = shippingRules, - initialBonusSupport = initialBonusSupportAmount, - totalBonusSupport = totalBonusSupportAmount, - maxPledgeAmount = maxPledgeAmount, - minPledgeStep = minStepAmount, - rewardsList = getRewardListAndPrices( - selectedRewardAndAddOnList, environment, project - ), - rewardsContainAddOns = selectedRewardAndAddOnList.any { it.isAddOn() }, - rewardsHaveShippables = selectedRewardAndAddOnList.any { - RewardUtils.isShippable(it) - }, - onBonusSupportPlusClicked = onBonusSupportPlusClicked, - onBonusSupportMinusClicked = onBonusSupportMinusClicked, - onBonusSupportInputted = onBonusSupportInputted, - isLoading = isLoading, + addOnCount = totalSelectedAddOn, + bonusAmountChanged = bonusAmountChanged, + totalPledgeAmount = totalPledgeAmount, + totalBonusSupport = totalBonusAmount ) } @@ -307,15 +273,16 @@ fun ProjectPledgeButtonAndFragmentContainer( project = project, email = userEmail, selectedReward = selectedReward, + selectedRewardsAndAddOns = selectedRewardAndAddOnList, rewardsList = getRewardListAndPrices( selectedRewardAndAddOnList, environment, project ), - pledgeReason = PledgeReason.PLEDGE, + pledgeReason = PledgeReason.LATE_PLEDGE, shippingAmount = shippingAmount, - totalAmount = totalAmount, - totalBonusSupport = totalBonusSupportAmount, + totalAmount = checkoutTotal, + totalBonusSupport = totalBonusAmount, currentShippingRule = currentShippingRule, rewardsHaveShippables = selectedRewardAndAddOnList.any { RewardUtils.isShippable(it) diff --git a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/RewardCarouselScreen.kt b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/RewardCarouselScreen.kt index be5c971e68..f046b34715 100644 --- a/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/RewardCarouselScreen.kt +++ b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/RewardCarouselScreen.kt @@ -3,6 +3,7 @@ package com.kickstarter.ui.activities.compose.projectpage import android.content.res.Configuration 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 @@ -18,6 +19,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -34,11 +36,16 @@ import com.kickstarter.libs.utils.extensions.isBacked import com.kickstarter.libs.utils.extensions.isNotNull import com.kickstarter.libs.utils.extensions.isNullOrZero import com.kickstarter.mock.factories.RewardsItemFactory +import com.kickstarter.mock.factories.ShippingRuleFactory +import com.kickstarter.models.Backing import com.kickstarter.models.Project import com.kickstarter.models.Reward +import com.kickstarter.models.ShippingRule import com.kickstarter.ui.compose.KSRewardCard import com.kickstarter.ui.compose.designsystem.KSCircularProgressIndicator import com.kickstarter.ui.compose.designsystem.KSTheme +import com.kickstarter.ui.compose.designsystem.KSTheme.dimensions +import com.kickstarter.ui.views.compose.checkout.ShippingSelector import org.joda.time.DateTime import java.math.RoundingMode @@ -82,7 +89,13 @@ fun RewardCarouselScreenPreview() { .currentCurrency("USD") .state(Project.STATE_LIVE) .build(), - onRewardSelected = {} + onRewardSelected = {}, + currentShippingRule = ShippingRuleFactory.usShippingRule(), + countryList = listOf( + ShippingRuleFactory.usShippingRule(), + ShippingRuleFactory.germanyShippingRule() + ), + onShippingRuleSelected = {} ) } } @@ -90,15 +103,22 @@ fun RewardCarouselScreenPreview() { @Composable fun RewardCarouselScreen( - modifier: Modifier, + modifier: Modifier = Modifier, lazyRowState: LazyListState, environment: Environment, rewards: List, project: Project, + backing: Backing? = null, isLoading: Boolean = false, - onRewardSelected: (reward: Reward) -> Unit + onRewardSelected: (reward: Reward) -> Unit, + countryList: List = emptyList(), + onShippingRuleSelected: (ShippingRule) -> Unit = {}, + currentShippingRule: ShippingRule = ShippingRule.builder().build() ) { val context = LocalContext.current + val interactionSource = remember { + MutableInteractionSource() + } Scaffold( modifier = modifier, @@ -114,8 +134,8 @@ fun RewardCarouselScreen( ), text = environment.ksString()?.let { it.format( - "Rewards_count_rewards", project.rewards()?.size ?: 0, - "rewards_count", NumberUtils.format(project.rewards()?.size ?: 0) + "Rewards_count_rewards", rewards.size, + "rewards_count", NumberUtils.format(rewards.size) ) } ?: "", color = KSTheme.colors.kds_support_400, @@ -124,172 +144,206 @@ fun RewardCarouselScreen( } } ) { padding -> - LazyRow( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight() - .padding(paddingValues = padding), - state = lazyRowState, - contentPadding = - PaddingValues( - start = KSTheme.dimensions.paddingMedium, - end = KSTheme.dimensions.paddingMedium, - top = KSTheme.dimensions.paddingMedium - ), - horizontalArrangement = Arrangement.spacedBy(KSTheme.dimensions.paddingMediumLarge) - ) { + Column { + if (isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(KSTheme.colors.backgroundAccentGraySubtle.copy(alpha = 0.5f)) + .clickable(enabled = false) { }, + contentAlignment = Alignment.Center + ) { + KSCircularProgressIndicator() + } + } + + if (countryList.isNotEmpty()) { + ShippingSelector( + modifier = Modifier + .padding(dimensions.paddingMedium), + interactionSource = interactionSource, + currentShippingRule = currentShippingRule, + countryList = countryList, + onShippingRuleSelected = onShippingRuleSelected + ) + } + + LazyRow( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(paddingValues = padding), + state = lazyRowState, + contentPadding = + PaddingValues( + start = KSTheme.dimensions.paddingMedium, + end = KSTheme.dimensions.paddingMedium, + top = KSTheme.dimensions.paddingMedium + ), + horizontalArrangement = Arrangement.spacedBy(KSTheme.dimensions.paddingMediumLarge) + ) { - items( - items = rewards, - ) { reward -> + items( + items = rewards, + ) { reward -> - val ctaButtonEnabled = when { - RewardUtils.isNoReward(reward) && (project.postCampaignPledgingEnabled() ?: false && project.isInPostCampaignPledgingPhase() ?: false) -> true - !reward.hasAddons() && project.backing()?.isBacked(reward) != true -> true - project.backing()?.rewardId() != reward.id() && RewardUtils.isAvailable( - project, - reward - ) && reward.isAvailable() -> true + val ctaButtonEnabled = when { + RewardUtils.isNoReward(reward) -> true + !reward.hasAddons() && backing?.isBacked(reward) != true -> true + backing?.rewardId() != reward.id() && RewardUtils.isAvailable( + project, + reward + ) && reward.isAvailable() -> true - reward.hasAddons() && project.backing() - ?.rewardId() == reward.id() && (project.isLive || (project.postCampaignPledgingEnabled() ?: false && project.isInPostCampaignPledgingPhase() ?: false)) && reward.isAvailable() -> true + reward.hasAddons() && backing?.rewardId() == reward.id() && (project.isLive || (project.postCampaignPledgingEnabled() ?: false && project.isInPostCampaignPledgingPhase() ?: false)) && reward.isAvailable() -> true - else -> false - } - val isBacked = project.backing()?.isBacked(reward) ?: false + else -> false + } + val isBacked = backing?.isBacked(reward) ?: false - val ctaButtonText = when { - ctaButtonEnabled -> R.string.Select - else -> R.string.No_longer_available - } + val ctaButtonText = when { + ctaButtonEnabled -> R.string.Select + else -> R.string.No_longer_available + } - val remaining = reward.remaining() ?: -1 + val remaining = reward.remaining() ?: -1 - if (RewardUtils.isNoReward(reward)) { - KSRewardCard( - isCTAButtonEnabled = ctaButtonEnabled, - ctaButtonText = stringResource(id = ctaButtonText), - title = if (isBacked) stringResource(id = R.string.You_pledged_without_a_reward) else stringResource( - id = R.string.Pledge_without_a_reward - ), - description = if (isBacked) stringResource(id = R.string.Thanks_for_bringing_this_project_one_step_closer_to_becoming_a_reality) else stringResource( - id = R.string.Back_it_because_you_believe_in_it - ), - onRewardSelectClicked = { onRewardSelected(reward) } - ) - } else { - KSRewardCard( - onRewardSelectClicked = { onRewardSelected(reward) }, - amount = environment.ksCurrency()?.let { - RewardViewUtils.styleCurrency( - reward.minimum(), - project, - it - ).toString() - }, - conversion = if (project.currentCurrency() == project.currency()) "" else { - val conversionAmount = environment.ksCurrency()?.format( - reward.convertedMinimum(), - project, - true, - RoundingMode.HALF_UP, - true - ) - environment.ksString()?.format(stringResource(id = R.string.About_reward_amount), "reward_amount", conversionAmount) - }, - description = reward.description(), - title = reward.title(), - backerCountBadgeText = - if (reward.backersCount().isNullOrZero()) "" - else { - environment.ksString()?.let { - it.format( - "rewards_info_backer_count_backers", - requireNotNull(reward.backersCount()), - "backer_count", - NumberUtils.format(requireNotNull(reward.backersCount())) + if (RewardUtils.isNoReward(reward)) { + KSRewardCard( + isCTAButtonEnabled = ctaButtonEnabled, + ctaButtonText = stringResource(id = ctaButtonText), + title = if (isBacked) stringResource(id = R.string.You_pledged_without_a_reward) else stringResource( + id = R.string.Pledge_without_a_reward + ), + description = if (isBacked) stringResource(id = R.string.Thanks_for_bringing_this_project_one_step_closer_to_becoming_a_reality) else stringResource( + id = R.string.Back_it_because_you_believe_in_it + ), + onRewardSelectClicked = { onRewardSelected(reward) } + ) + } else { + KSRewardCard( + onRewardSelectClicked = { onRewardSelected(reward) }, + amount = environment.ksCurrency()?.let { + RewardViewUtils.styleCurrency( + reward.minimum(), + project, + it + ).toString() + }, + conversion = if (project.currentCurrency() == project.currency()) "" else { + val conversionAmount = environment.ksCurrency()?.format( + reward.convertedMinimum(), + project, + true, + RoundingMode.HALF_UP, + true ) - } - }, - isCTAButtonEnabled = ctaButtonEnabled, - includes = if (RewardUtils.isItemized(reward) && !reward.rewardsItems() - .isNullOrEmpty() && environment.ksString().isNotNull() - ) { - reward.rewardsItems()?.map { rewardItems -> environment.ksString()?.format( - "rewards_info_item_quantity_title", rewardItems.quantity(), - "quantity", rewardItems.quantity().toString(), - "title", rewardItems.item().name() - ) ?: "" - } ?: emptyList() - } else { - emptyList() - }, - - estimatedDelivery = if (reward.estimatedDeliveryOn().isNotNull()) { - DateTimeUtils.estimatedDeliveryOn(requireNotNull(reward.estimatedDeliveryOn())) - } else "", - yourSelectionIsVisible = project.backing()?.isBacked(reward) ?: false, - localPickup = if (RewardUtils.isLocalPickup(reward) && !RewardUtils.isShippable( - reward - ) - ) { - reward.localReceiptLocation()?.displayableName() ?: "" - } else { - "" - }, - ctaButtonText = stringResource(id = ctaButtonText), - expirationDateText = - environment.ksString()?.let { - if (RewardUtils.deadlineCountdownValue(reward) <= 0) "" - else "" + RewardUtils.deadlineCountdownValue(reward) + " " + RewardUtils.deadlineCountdownDetail( - reward, - context, - it - ) - }, - shippingSummaryText = - environment.ksString()?.let { ksString -> - if (RewardUtils.isShippable(reward)) { - RewardUtils.shippingSummary(reward)?.let { - RewardViewUtils.shippingSummary( - context = context, - ksString = ksString, - it + stringResource(id = R.string.About_reward_amount), + "reward_amount", + conversionAmount + ) + }, + description = reward.description(), + title = reward.title(), + backerCountBadgeText = + if (reward.backersCount().isNullOrZero()) "" + else { + environment.ksString()?.let { + it.format( + "rewards_info_backer_count_backers", + requireNotNull(reward.backersCount()), + "backer_count", + NumberUtils.format(requireNotNull(reward.backersCount())) ) } + }, + isCTAButtonEnabled = ctaButtonEnabled, + includes = if (RewardUtils.isItemized(reward) && !reward.rewardsItems() + .isNullOrEmpty() && environment.ksString().isNotNull() + ) { + reward.rewardsItems()?.map { rewardItems -> + environment.ksString()?.format( + "rewards_info_item_quantity_title", rewardItems.quantity(), + "quantity", rewardItems.quantity().toString(), + "title", rewardItems.item().name() + ) ?: "" + } ?: emptyList() + } else { + emptyList() + }, + + estimatedDelivery = if (reward.estimatedDeliveryOn().isNotNull()) { + DateTimeUtils.estimatedDeliveryOn(requireNotNull(reward.estimatedDeliveryOn())) + } else "", + yourSelectionIsVisible = project.backing()?.isBacked(reward) ?: false, + localPickup = if (RewardUtils.isLocalPickup(reward) && !RewardUtils.isShippable( + reward + ) + ) { + reward.localReceiptLocation()?.displayableName() ?: "" } else { "" - } - }, - remainingText = - environment.ksString()?.let { ksString -> - if (!reward.isLimited()) { - if (remaining > 0) { - ksString.format( - stringResource(id = R.string.Left_count_left_few), - "left_count", - NumberUtils.format(remaining) - ) + }, + ctaButtonText = stringResource(id = ctaButtonText), + expirationDateText = + environment.ksString()?.let { + if (RewardUtils.deadlineCountdownValue(reward) <= 0) "" + else "" + RewardUtils.deadlineCountdownValue(reward) + " " + RewardUtils.deadlineCountdownDetail( + reward, + context, + it + ) + }, + shippingSummaryText = + environment.ksString()?.let { ksString -> + if (RewardUtils.isShippable(reward)) { + RewardUtils.shippingSummary(reward)?.let { + RewardViewUtils.shippingSummary( + context = context, + ksString = ksString, + it + ) + } + } else { + "" + } + }, + remainingText = + environment.ksString()?.let { ksString -> + if (!reward.isLimited()) { + if (remaining > 0) { + ksString.format( + stringResource(id = R.string.Left_count_left_few), + "left_count", + NumberUtils.format(remaining) + ) + } else "" } else "" - } else "" - }, - addonsPillVisible = reward.hasAddons() - ) + }, + estimatedShippingCost = + if (!RewardUtils.isDigital(reward) && RewardUtils.isShippable(reward) && !RewardUtils.isLocalPickup(reward)) { + environment.ksCurrency()?.let { ksCurrency -> + environment.ksString()?.let { ksString -> + RewardViewUtils.getEstimatedShippingCostString( + context = context, + ksCurrency = ksCurrency, + ksString = ksString, + project = project, + rewards = listOf(reward), + selectedShippingRule = currentShippingRule, + multipleQuantitiesAllowed = false, + useUserPreference = false, + useAbout = true + ) + } + } + } else null, + addonsPillVisible = reward.hasAddons() + ) + } } } } - - if (isLoading) { - Box( - modifier = Modifier - .fillMaxSize() - .background(KSTheme.colors.backgroundAccentGraySubtle.copy(alpha = 0.5f)) - .clickable(enabled = false) { }, - contentAlignment = Alignment.Center - ) { - KSCircularProgressIndicator() - } - } } } diff --git a/app/src/main/java/com/kickstarter/ui/adapters/BackingAddOnsAdapter.kt b/app/src/main/java/com/kickstarter/ui/adapters/BackingAddOnsAdapter.kt deleted file mode 100644 index 7fcc1d9521..0000000000 --- a/app/src/main/java/com/kickstarter/ui/adapters/BackingAddOnsAdapter.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.kickstarter.ui.adapters - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.annotation.LayoutRes -import com.kickstarter.R -import com.kickstarter.databinding.EmptyViewBinding -import com.kickstarter.databinding.ItemAddOnPledgeBinding -import com.kickstarter.models.Reward -import com.kickstarter.models.ShippingRule -import com.kickstarter.ui.data.ProjectData -import com.kickstarter.ui.viewholders.BackingAddOnViewHolder -import com.kickstarter.ui.viewholders.EmptyViewHolder -import com.kickstarter.ui.viewholders.KSViewHolder - -class BackingAddOnsAdapter(private val viewListener: BackingAddOnViewHolder.ViewListener) : KSAdapter() { - - init { - insertSection(SECTION_NO_ADD_ONS_AVAILABLE, emptyList()) - insertSection(SECTION_BACKING_ADD_ONS_CARD, emptyList()) - } - - override fun layout(sectionRow: SectionRow): Int = when (sectionRow.section()) { - SECTION_BACKING_ADD_ONS_CARD -> R.layout.item_add_on_pledge - SECTION_NO_ADD_ONS_AVAILABLE -> R.layout.item_empty_add_on - else -> 0 - } - - override fun viewHolder(@LayoutRes layout: Int, viewGroup: ViewGroup): KSViewHolder { - return when (layout) { - R.layout.item_empty_add_on -> EmptyViewHolder(EmptyViewBinding.inflate(LayoutInflater.from(viewGroup.context), viewGroup, false)) - R.layout.item_add_on_pledge -> BackingAddOnViewHolder(ItemAddOnPledgeBinding.inflate(LayoutInflater.from(viewGroup.context), viewGroup, false), viewListener) - else -> EmptyViewHolder(EmptyViewBinding.inflate(LayoutInflater.from(viewGroup.context), viewGroup, false)) - } - } - - fun populateDataForAddOns(rewards: List>) { - setSection(SECTION_BACKING_ADD_ONS_CARD, rewards) - notifyDataSetChanged() - } - - fun showEmptyState(isEmptyState: Boolean) { - if (isEmptyState) { - setSection(SECTION_NO_ADD_ONS_AVAILABLE, listOf(true)) - } else { - setSection(SECTION_NO_ADD_ONS_AVAILABLE, emptyList()) - } - notifyDataSetChanged() - } - - companion object { - private const val SECTION_NO_ADD_ONS_AVAILABLE = 0 - private const val SECTION_BACKING_ADD_ONS_CARD = 1 - } -} diff --git a/app/src/main/java/com/kickstarter/ui/adapters/RewardsAdapter.kt b/app/src/main/java/com/kickstarter/ui/adapters/RewardsAdapter.kt deleted file mode 100644 index c3e34440e0..0000000000 --- a/app/src/main/java/com/kickstarter/ui/adapters/RewardsAdapter.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.kickstarter.ui.adapters - -import android.util.Pair -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.annotation.LayoutRes -import com.kickstarter.R -import com.kickstarter.databinding.EmptyViewBinding -import com.kickstarter.databinding.ItemRewardBinding -import com.kickstarter.ui.data.ProjectData -import com.kickstarter.ui.viewholders.EmptyViewHolder -import com.kickstarter.ui.viewholders.KSViewHolder -import com.kickstarter.ui.viewholders.RewardViewHolder -import rx.Observable - -class RewardsAdapter(private val delegate: Delegate) : KSAdapter() { - - interface Delegate : RewardViewHolder.Delegate - - override fun layout(sectionRow: SectionRow): Int { - return R.layout.item_reward - } - - override fun viewHolder(@LayoutRes layout: Int, viewGroup: ViewGroup): KSViewHolder { - return when (layout) { - R.layout.item_reward -> RewardViewHolder(ItemRewardBinding.inflate(LayoutInflater.from(viewGroup.context), viewGroup, false), this.delegate) - else -> EmptyViewHolder(EmptyViewBinding.inflate(LayoutInflater.from(viewGroup.context), viewGroup, false)) - } - } - - fun populateRewards(projectData: ProjectData) { - sections().clear() - - val rewards = projectData.project().rewards() - - if (!rewards.isNullOrEmpty()) { - addSection( - Observable.from(rewards) - .map { reward -> Pair.create(projectData, reward) } - .toList().toBlocking().single() - ) - notifyDataSetChanged() - } - } -} diff --git a/app/src/main/java/com/kickstarter/ui/compose/KSRewardCard.kt b/app/src/main/java/com/kickstarter/ui/compose/KSRewardCard.kt index abf51ddde5..4e6db89db0 100644 --- a/app/src/main/java/com/kickstarter/ui/compose/KSRewardCard.kt +++ b/app/src/main/java/com/kickstarter/ui/compose/KSRewardCard.kt @@ -54,6 +54,7 @@ fun KSRewardCardPreview() { shippingSummaryText = "Anywhere", addonsPillVisible = true, remainingText = "5 left", + estimatedShippingCost = "About $10-$15", onRewardSelectClicked = { } ) } @@ -78,6 +79,7 @@ fun KSRewardCard( shippingSummaryText: String? = null, addonsPillVisible: Boolean = false, remainingText: String? = null, + estimatedShippingCost: String? = null, onRewardSelectClicked: () -> Unit ) { @@ -195,6 +197,23 @@ fun KSRewardCard( Spacer(modifier = Modifier.height(dimensions.paddingMediumLarge)) } + if (!estimatedShippingCost.isNullOrEmpty()) { + Text( + text = stringResource(id = R.string.estimated_shipping_fpo), + color = colors.kds_support_400, + style = typography.calloutMedium + ) + + Text( + modifier = Modifier.padding(top = dimensions.radiusSmall), + text = estimatedShippingCost, + color = colors.kds_support_700, + style = typography.body2 + ) + + Spacer(modifier = Modifier.height(dimensions.paddingMediumLarge)) + } + if (!estimatedDelivery.isNullOrEmpty()) { Text( text = stringResource(id = R.string.Estimated_delivery), @@ -265,7 +284,11 @@ fun KSRewardCard( if (isCTAButtonVisible) { KSPrimaryGreenButton( modifier = Modifier - .padding(bottom = dimensions.paddingMediumLarge, start = dimensions.paddingMediumLarge, end = dimensions.paddingMediumLarge) + .padding( + bottom = dimensions.paddingMediumLarge, + start = dimensions.paddingMediumLarge, + end = dimensions.paddingMediumLarge + ) .fillMaxWidth(), onClickAction = onRewardSelectClicked, isEnabled = isCTAButtonEnabled, diff --git a/app/src/main/java/com/kickstarter/ui/data/PledgeData.kt b/app/src/main/java/com/kickstarter/ui/data/PledgeData.kt index f3f8fb0623..4d811cd0ab 100644 --- a/app/src/main/java/com/kickstarter/ui/data/PledgeData.kt +++ b/app/src/main/java/com/kickstarter/ui/data/PledgeData.kt @@ -11,13 +11,15 @@ class PledgeData private constructor( private val projectData: ProjectData, private val addOns: List?, private val shippingRule: ShippingRule?, - private val reward: Reward + private val reward: Reward, + private val bonusAmount: Double ) : Parcelable { fun pledgeFlowContext() = this.pledgeFlowContext fun projectData() = this.projectData fun addOns() = this.addOns fun shippingRule() = this.shippingRule fun reward() = this.reward + fun bonusAmount() = this.bonusAmount @Parcelize data class Builder( @@ -25,19 +27,22 @@ class PledgeData private constructor( private var projectData: ProjectData = ProjectData.builder().build(), private var addOns: List? = null, private var shippingRule: ShippingRule? = null, - private var reward: Reward = Reward.builder().build() + private var reward: Reward = Reward.builder().build(), + private var bonusAmount: Double = 0.0 ) : Parcelable { fun pledgeFlowContext(pledgeFlowContext: PledgeFlowContext) = apply { this.pledgeFlowContext = pledgeFlowContext } fun projectData(projectData: ProjectData) = apply { this.projectData = projectData } fun reward(reward: Reward) = apply { this.reward = reward } fun addOns(addOns: List) = apply { this.addOns = addOns } fun shippingRule(shippingRule: ShippingRule) = apply { this.shippingRule = shippingRule } + fun bonusAmount(amount: Double) = apply { this.bonusAmount = amount } fun build() = PledgeData( pledgeFlowContext = pledgeFlowContext, projectData = projectData, reward = reward, addOns = addOns, - shippingRule = shippingRule + shippingRule = shippingRule, + bonusAmount = bonusAmount ) } @@ -46,7 +51,8 @@ class PledgeData private constructor( projectData = projectData, reward = reward, addOns = addOns, - shippingRule = shippingRule + shippingRule = shippingRule, + bonusAmount = bonusAmount ) override fun equals(other: Any?): Boolean { @@ -56,7 +62,8 @@ class PledgeData private constructor( projectData() == other.projectData() && reward() == other.reward() && addOns() == other.addOns() && - shippingRule() == other.shippingRule() + shippingRule() == other.shippingRule() && + bonusAmount() == other.bonusAmount() } return equals } @@ -66,26 +73,20 @@ class PledgeData private constructor( @JvmStatic fun builder() = Builder() - fun with(pledgeFlowContext: PledgeFlowContext, projectData: ProjectData, reward: Reward, addOns: List? = null, shippingRule: ShippingRule? = null) = - addOns?.let { addOns -> - shippingRule?.let { shippingRule -> - return@let builder() - .pledgeFlowContext(pledgeFlowContext) - .projectData(projectData) - .reward(reward) - .addOns(addOns) - .shippingRule(shippingRule) - .build() - } ?: builder() - .pledgeFlowContext(pledgeFlowContext) - .projectData(projectData) - .reward(reward) - .addOns(addOns) - .build() - } ?: builder() + fun with(pledgeFlowContext: PledgeFlowContext, projectData: ProjectData, reward: Reward, addOns: List? = null, shippingRule: ShippingRule? = null, bonusAmount: Double = 0.0) = + builder() .pledgeFlowContext(pledgeFlowContext) .projectData(projectData) .reward(reward) + .bonusAmount(bonusAmount) + .apply { + if (addOns != null) { + this.addOns(addOns) + } + if (shippingRule != null) { + this.shippingRule(shippingRule) + } + } .build() } } diff --git a/app/src/main/java/com/kickstarter/ui/data/PledgeFlowContext.kt b/app/src/main/java/com/kickstarter/ui/data/PledgeFlowContext.kt index 556ea5a8f7..76c3998d6c 100644 --- a/app/src/main/java/com/kickstarter/ui/data/PledgeFlowContext.kt +++ b/app/src/main/java/com/kickstarter/ui/data/PledgeFlowContext.kt @@ -4,6 +4,7 @@ enum class PledgeFlowContext(val trackingString: String) { CHANGE_REWARD("change_reward"), FIX_ERRORED_PLEDGE("fix_errored_pledge"), MANAGE_REWARD("manage_reward"), + LATE_PLEDGES("late_pledge"), NEW_PLEDGE("new_pledge"); companion object { @@ -12,6 +13,7 @@ enum class PledgeFlowContext(val trackingString: String) { PledgeReason.FIX_PLEDGE -> FIX_ERRORED_PLEDGE PledgeReason.PLEDGE -> NEW_PLEDGE PledgeReason.UPDATE_REWARD -> CHANGE_REWARD + PledgeReason.LATE_PLEDGE -> LATE_PLEDGES else -> MANAGE_REWARD } } diff --git a/app/src/main/java/com/kickstarter/ui/data/PledgeReason.kt b/app/src/main/java/com/kickstarter/ui/data/PledgeReason.kt index 986a17051b..09debd81b4 100644 --- a/app/src/main/java/com/kickstarter/ui/data/PledgeReason.kt +++ b/app/src/main/java/com/kickstarter/ui/data/PledgeReason.kt @@ -1,5 +1,5 @@ package com.kickstarter.ui.data enum class PledgeReason { - FIX_PLEDGE, PLEDGE, UPDATE_PAYMENT, UPDATE_PLEDGE, UPDATE_REWARD + FIX_PLEDGE, PLEDGE, UPDATE_PAYMENT, UPDATE_PLEDGE, UPDATE_REWARD, LATE_PLEDGE } diff --git a/app/src/main/java/com/kickstarter/ui/extensions/ActivityExt.kt b/app/src/main/java/com/kickstarter/ui/extensions/ActivityExt.kt index bfa376f763..aee1893190 100644 --- a/app/src/main/java/com/kickstarter/ui/extensions/ActivityExt.kt +++ b/app/src/main/java/com/kickstarter/ui/extensions/ActivityExt.kt @@ -44,6 +44,7 @@ import com.kickstarter.ui.activities.MessagesActivity import com.kickstarter.ui.data.PledgeData import com.kickstarter.ui.data.PledgeReason import com.kickstarter.ui.data.ProjectData +import com.kickstarter.ui.fragments.CrowdfundCheckoutFragment import com.kickstarter.ui.fragments.PledgeFragment import timber.log.Timber @@ -79,7 +80,10 @@ fun Activity.selectPledgeFragment( pledgeData: PledgeData, pledgeReason: PledgeReason, ): Fragment { - return PledgeFragment().withData(pledgeData, pledgeReason) + val fragment = if (pledgeReason == PledgeReason.FIX_PLEDGE) { + PledgeFragment() + } else CrowdfundCheckoutFragment() + return fragment.withData(pledgeData, pledgeReason) } fun Activity.showSnackbar(anchor: View, stringResId: Int) { diff --git a/app/src/main/java/com/kickstarter/ui/fragments/BackingAddOnsFragment.kt b/app/src/main/java/com/kickstarter/ui/fragments/BackingAddOnsFragment.kt index a82ad5d5fe..f7c9724d84 100644 --- a/app/src/main/java/com/kickstarter/ui/fragments/BackingAddOnsFragment.kt +++ b/app/src/main/java/com/kickstarter/ui/fragments/BackingAddOnsFragment.kt @@ -5,168 +5,101 @@ import android.util.Pair import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.appcompat.app.AlertDialog -import androidx.core.view.isGone +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.kickstarter.R import com.kickstarter.databinding.FragmentBackingAddonsBinding -import com.kickstarter.libs.KSString -import com.kickstarter.libs.utils.extensions.addToDisposable import com.kickstarter.libs.utils.extensions.getEnvironment import com.kickstarter.libs.utils.extensions.selectPledgeFragment -import com.kickstarter.models.Project -import com.kickstarter.models.Reward -import com.kickstarter.models.ShippingRule import com.kickstarter.ui.ArgumentsKey -import com.kickstarter.ui.adapters.BackingAddOnsAdapter -import com.kickstarter.ui.adapters.ShippingRulesAdapter +import com.kickstarter.ui.activities.compose.projectpage.AddOnsScreen +import com.kickstarter.ui.compose.designsystem.KSTheme +import com.kickstarter.ui.compose.designsystem.KSTheme.dimensions +import com.kickstarter.ui.compose.designsystem.KickstarterApp import com.kickstarter.ui.data.PledgeData import com.kickstarter.ui.data.PledgeReason -import com.kickstarter.ui.data.ProjectData -import com.kickstarter.ui.extensions.hideKeyboard -import com.kickstarter.ui.viewholders.BackingAddOnViewHolder -import com.kickstarter.viewmodels.BackingAddOnsFragmentViewModel -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.CompositeDisposable -import java.util.concurrent.TimeUnit +import com.kickstarter.ui.extensions.showErrorToast +import com.kickstarter.viewmodels.projectpage.AddOnsViewModel -class BackingAddOnsFragment : Fragment(), ShippingRulesAdapter.Delegate, BackingAddOnViewHolder.ViewListener { +class BackingAddOnsFragment : Fragment() { private var binding: FragmentBackingAddonsBinding? = null - private lateinit var viewModelFactory: BackingAddOnsFragmentViewModel.Factory - private val viewModel: BackingAddOnsFragmentViewModel.BackingAddOnsFragmentViewModel by viewModels { viewModelFactory } - - private var disposables = CompositeDisposable() + private lateinit var viewModelFactoryC: AddOnsViewModel.Factory + private val viewModelC: AddOnsViewModel by viewModels { + viewModelFactoryC + } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { super.onCreateView(inflater, container, savedInstanceState) - binding = FragmentBackingAddonsBinding.inflate(inflater, container, false) - return binding?.root - } - - private val backingAddonsAdapter = BackingAddOnsAdapter(this) - private lateinit var shippingRulesAdapter: ShippingRulesAdapter - private lateinit var errorDialog: AlertDialog - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - setUpShippingAdapter() - setupRecyclerView() - setupErrorDialog() - - val env = this.context?.getEnvironment()?.let { env -> - viewModelFactory = BackingAddOnsFragmentViewModel.Factory(env, bundle = arguments) - env - } - - this.viewModel.outputs.showPledgeFragment() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { showPledgeFragment(it.first, it.second) } - .addToDisposable(disposables) - - this.viewModel.outputs.addOnsList() - .throttleWithTimeout(50, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - populateAddOns(it) - } - .addToDisposable(disposables) - - this.viewModel.outputs.isEmptyState() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { showEmptyState(it) } - .addToDisposable(disposables) - this.viewModel.outputs.selectedShippingRule() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { binding?.fragmentBackingAddonsShippingRules?.setText(it.toString()) } - .addToDisposable(disposables) - - this.viewModel.outputs.showErrorDialog() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { showErrorDialog() } - .addToDisposable(disposables) - - this.viewModel.outputs.shippingRulesAndProject() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { displayShippingRules(it.first, it.second) } - .addToDisposable(disposables) - - this.viewModel.outputs.totalSelectedAddOns() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { total -> - binding?.fragmentBackingAddonsSectionFooterLayout?.backingAddonsFooterButton ?.text = selectProperString(total, requireNotNull(env?.ksString())) + binding = FragmentBackingAddonsBinding.inflate(inflater, container, false) + val view = binding?.root + binding?.composeView?.apply { + val env = this?.context?.getEnvironment()?.let { env -> + viewModelFactoryC = AddOnsViewModel.Factory(env, bundle = arguments) + // viewModelFactory = BackingAddOnsFragmentViewModel.Factory(env, bundle = arguments) + viewModelC.provideBundle(arguments) + env } - .addToDisposable(disposables) - this.viewModel.outputs.shippingSelectorIsGone() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - binding?.fragmentBackingAddonsShippingRules?.isGone = it - binding?.fragmentBackingAddonsCallOut?.isGone = it + viewModelC.provideErrorAction { message -> + showErrorToast(context, this, message ?: getString(R.string.general_error_something_wrong)) } - .addToDisposable(disposables) - this.viewModel.outputs.isEnabledCTAButton() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - binding?.fragmentBackingAddonsSectionFooterLayout?.backingAddonsFooterButton ?.isEnabled = it + // Dispose of the Composition when the view's LifecycleOwner + // is destroyed + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + KickstarterApp( + useDarkTheme = true + ) { + val addOnsUIState by viewModelC.addOnsUIState.collectAsState() + + val addOns = addOnsUIState.addOns + val totalCount = addOnsUIState.totalCount + val addOnsIsLoading = addOnsUIState.isLoading + val shippingRule = addOnsUIState.shippingRule + val totalPledgeAmount = addOnsUIState.totalPledgeAmount + + val project = viewModelC.getProject() + val selectedRw = viewModelC.getSelectedReward() + + KSTheme { + AddOnsScreen( + modifier = Modifier.padding(top = dimensions.paddingDoubleLarge), + environment = requireNotNull(env), + lazyColumnListState = rememberLazyListState(), + selectedReward = selectedRw, + addOns = addOns, + project = project, + onItemAddedOrRemoved = { quantity, rewardId -> + viewModelC.updateSelection(rewardId, quantity) + }, + isLoading = addOnsIsLoading, + currentShippingRule = shippingRule, + onContinueClicked = { + viewModelC.getPledgeDataAndReason()?.let { pDataAndReason -> + showPledgeFragment(pledgeData = pDataAndReason.first, pledgeReason = pDataAndReason.second) + } + }, + bonusAmountChanged = { bonusAmount -> + viewModelC.bonusAmountUpdated(bonusAmount) + }, + addOnCount = totalCount, + totalPledgeAmount = totalPledgeAmount, + totalBonusSupport = addOnsUIState.totalBonusAmount + ) + } + } } - .addToDisposable(disposables) - - binding?.fragmentBackingAddonsSectionFooterLayout?.backingAddonsFooterButton ?.setOnClickListener { - this.viewModel.inputs.continueButtonPressed() - } - } - - private fun selectProperString(totalSelected: Int, ksString: KSString): String { - return when { - totalSelected == 0 -> ksString.format(getString(R.string.Skip_add_ons), "", "") - totalSelected == 1 -> ksString.format(getString(R.string.Continue_with_quantity_count_add_ons_one), "quantity_count", totalSelected.toString()) - totalSelected > 1 -> ksString.format(getString(R.string.Continue_with_quantity_count_add_ons_many), "quantity_count", totalSelected.toString()) - else -> "" - } - } - - private fun showErrorDialog() { - if (!errorDialog.isShowing) { - errorDialog.show() - } - } - - private fun dismissErrorDialog() { - errorDialog.dismiss() - } - - private fun populateAddOns(projectDataAndAddOnList: Triple, ShippingRule>) { - val projectData = projectDataAndAddOnList.first - val selectedShippingRule = projectDataAndAddOnList.third - val list = projectDataAndAddOnList - .second - .map { - Triple(projectData, it, selectedShippingRule) - }.toList() - - backingAddonsAdapter.populateDataForAddOns(list) - } - - private fun showEmptyState(isEmptyState: Boolean) { - backingAddonsAdapter.showEmptyState(isEmptyState) - } - - private fun setupRecyclerView() { - binding?.fragmentSelectAddonsRecycler?.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) - binding?.fragmentSelectAddonsRecycler?.adapter = backingAddonsAdapter - } - - private fun setUpShippingAdapter() { - activity?.let { - shippingRulesAdapter = ShippingRulesAdapter(it, R.layout.item_shipping_rule, arrayListOf(), this) - binding?.fragmentBackingAddonsShippingRules?.setAdapter(shippingRulesAdapter) } + return view } private fun showPledgeFragment(pledgeData: PledgeData, pledgeReason: PledgeReason) { @@ -179,24 +112,6 @@ class BackingAddOnsFragment : Fragment(), ShippingRulesAdapter.Delegate, Backing .commit() } - private fun setupErrorDialog() { - context?.let { context -> - errorDialog = AlertDialog.Builder(context, R.style.AlertDialog) - .setCancelable(false) - .setTitle(getString(R.string.Something_went_wrong_please_try_again)) - .setPositiveButton(getString(R.string.Retry)) { _, _ -> - this.viewModel.inputs.retryButtonPressed() - } - .setNegativeButton(getString(R.string.general_navigation_buttons_close)) { _, _ -> dismissErrorDialog() } - .create() - } - } - - private fun displayShippingRules(shippingRules: List, project: Project) { - binding?.fragmentBackingAddonsShippingRules?.isEnabled = true - shippingRulesAdapter.populateShippingRules(shippingRules, project) - } - companion object { fun newInstance(pledgeDataAndReason: Pair): BackingAddOnsFragment { val fragment = BackingAddOnsFragment() @@ -207,20 +122,4 @@ class BackingAddOnsFragment : Fragment(), ShippingRulesAdapter.Delegate, Backing return fragment } } - - override fun ruleSelected(rule: ShippingRule) { - this.viewModel.inputs.shippingRuleSelected(rule) - activity?.hideKeyboard() - binding?.fragmentBackingAddonsShippingRules?.clearFocus() - } - - override fun quantityPerId(quantityPerId: Pair) { - this.viewModel.inputs.quantityPerId(quantityPerId) - } - - override fun onDestroyView() { - super.onDestroyView() - binding?.fragmentSelectAddonsRecycler?.adapter = null - disposables.clear() - } } diff --git a/app/src/main/java/com/kickstarter/ui/fragments/CrowdfundCheckoutFragment.kt b/app/src/main/java/com/kickstarter/ui/fragments/CrowdfundCheckoutFragment.kt new file mode 100644 index 0000000000..c962bda996 --- /dev/null +++ b/app/src/main/java/com/kickstarter/ui/fragments/CrowdfundCheckoutFragment.kt @@ -0,0 +1,223 @@ +package com.kickstarter.ui.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.kickstarter.R +import com.kickstarter.databinding.FragmentCrowdfundCheckoutBinding +import com.kickstarter.libs.utils.RewardUtils +import com.kickstarter.libs.utils.extensions.getEnvironment +import com.kickstarter.libs.utils.extensions.getPaymentSheetConfiguration +import com.kickstarter.models.Project +import com.kickstarter.models.Reward +import com.kickstarter.models.StoredCard +import com.kickstarter.ui.activities.compose.projectpage.CheckoutScreen +import com.kickstarter.ui.activities.compose.projectpage.getRewardListAndPrices +import com.kickstarter.ui.compose.designsystem.KSTheme +import com.kickstarter.ui.compose.designsystem.KickstarterApp +import com.kickstarter.ui.data.PledgeReason +import com.kickstarter.ui.extensions.showErrorToast +import com.kickstarter.ui.fragments.PledgeFragment.PledgeDelegate +import com.kickstarter.viewmodels.projectpage.CheckoutUIState +import com.kickstarter.viewmodels.projectpage.CrowdfundCheckoutViewModel +import com.kickstarter.viewmodels.projectpage.CrowdfundCheckoutViewModel.Factory +import com.stripe.android.paymentsheet.PaymentOptionCallback +import com.stripe.android.paymentsheet.PaymentSheet +import com.stripe.android.paymentsheet.PaymentSheetResult +import com.stripe.android.paymentsheet.PaymentSheetResultCallback +import timber.log.Timber + +class CrowdfundCheckoutFragment : Fragment() { + + private var binding: FragmentCrowdfundCheckoutBinding? = null + + private lateinit var viewModelFactory: Factory + private val viewModel: CrowdfundCheckoutViewModel by viewModels { + viewModelFactory + } + + private lateinit var flowController: PaymentSheet.FlowController + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + super.onCreateView(inflater, container, savedInstanceState) + binding = FragmentCrowdfundCheckoutBinding.inflate(inflater, container, false) + + val view = binding?.root + binding?.composeView?.apply { + val environment = this.context.getEnvironment()?.let { env -> + viewModelFactory = Factory(env, bundle = arguments) + viewModel.provideBundle(arguments) + env + } + + viewModel.provideErrorAction { message -> + activity?.runOnUiThread { + showErrorToast(context, this, message ?: getString(R.string.general_error_something_wrong)) + } + } + + // Dispose of the Composition when the view's LifecycleOwner is destroyed + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + // Compose world + setContent { + KickstarterApp( + useDarkTheme = true + ) { + + val checkoutStates = viewModel.crowdfundCheckoutUIState.collectAsStateWithLifecycle( + initialValue = CheckoutUIState() + ).value + + val rwList = checkoutStates.selectedRewards + val email = checkoutStates.userEmail + val storedCards = checkoutStates.storeCards + val isLoading = checkoutStates.isLoading + val shippingAmount = checkoutStates.shippingAmount + val totalAmount = checkoutStates.checkoutTotal + val shippingRule = checkoutStates.shippingRule + val bonus = checkoutStates.bonusAmount + + val pledgeData = viewModel.getPledgeData() + val pledgeReason = viewModel.getPledgeReason() ?: PledgeReason.PLEDGE + val project = pledgeData?.projectData()?.project() ?: Project.builder().build() + val selectedRw = pledgeData?.reward() ?: Reward.builder().build() + + val checkoutSuccess = viewModel.checkoutResultState.collectAsStateWithLifecycle().value + val id = checkoutSuccess.first?.id() ?: -1 + + val paymentSheetPresenter = viewModel.presentPaymentSheetStates.collectAsStateWithLifecycle().value + val setUpIntent = paymentSheetPresenter.setupClientId + + configurePaymentSheet(paymentSheetPresenter.setupClientId) + LaunchedEffect(key1 = setUpIntent) { + if (setUpIntent.isNotEmpty() && email.isNotEmpty()) { + flowControllerPresentPaymentOption(setUpIntent, email) + } + } + + LaunchedEffect(id) { + if (id > 0) { + if (pledgeReason == PledgeReason.PLEDGE) + (activity as PledgeDelegate?)?.pledgeSuccessfullyCreated(checkoutSuccess) + if (pledgeReason == PledgeReason.UPDATE_PAYMENT) + (activity as PledgeDelegate?)?.pledgePaymentSuccessfullyUpdated() + if (pledgeReason == PledgeReason.UPDATE_REWARD || pledgeReason == PledgeReason.UPDATE_PLEDGE) + (activity as PledgeDelegate?)?.pledgeSuccessfullyUpdated() + } + } + + KSTheme { + // TODO: update to display local pickup + // TODO: hide bonus support if 0 + CheckoutScreen( + rewardsList = getRewardListAndPrices(rwList, environment, project), + selectedRewardsAndAddOns = rwList, + environment = requireNotNull(environment), + shippingAmount = shippingAmount, + selectedReward = selectedRw, + currentShippingRule = shippingRule, + totalAmount = totalAmount, + totalBonusSupport = bonus, + storedCards = storedCards, + project = project, + email = email, + pledgeReason = pledgeReason, + rewardsHaveShippables = rwList.any { + RewardUtils.isShippable(it) + }, + onPledgeCtaClicked = { + viewModel.pledgeOrUpdatePledge() + }, + isLoading = isLoading, + newPaymentMethodClicked = { + viewModel.getSetupIntent() + }, + onDisclaimerItemClicked = {}, + onAccountabilityLinkClicked = {}, + onChangedPaymentMethod = { paymentMethodSelected -> + viewModel.userChangedPaymentMethodSelected(paymentMethodSelected) + } + ) + } + } + } + } + return view + } + + private fun flowControllerPresentPaymentOption(clientSecret: String, userEmail: String) { + context?.let { + flowController.configureWithSetupIntent( + setupIntentClientSecret = clientSecret, + configuration = it.getPaymentSheetConfiguration(userEmail), + callback = ::onConfigured + ) + } + } + + private fun onConfigured(success: Boolean, error: Throwable?) { + if (success) { + flowController.presentPaymentOptions() + } else { + binding?.composeView?.let { view -> + context?.let { + showErrorToast(it, view, error?.message ?: getString(R.string.general_error_something_wrong)) + } + } + } + this.viewModel.paymentSheetPresented(success) + } + + // TODO: explore this piece to be more generic/reusable between crowdfund/late pledges/pledge redemption, + // TODO: it does require specific VM callbacks + private fun configurePaymentSheet(setupClientId: String) { + + val paymentOptionCallback = PaymentOptionCallback { paymentOption -> + paymentOption?.let { + val storedCard = StoredCard.Builder( + lastFourDigits = paymentOption.label.takeLast(4), + resourceId = paymentOption.drawableResourceId, + clientSetupId = setupClientId + ).build() + this.viewModel.newlyAddedPaymentMethod(storedCard) + Timber.d(" ${this.javaClass.canonicalName} onPaymentOption with ${storedCard.lastFourDigits()} and ${storedCard.clientSetupId()}") + flowController.confirm() + } + } + + val onPaymentSheetResult = PaymentSheetResultCallback { paymentSheetResult -> + this.viewModel.paymentSheetResult(paymentSheetResult) + when (paymentSheetResult) { + is PaymentSheetResult.Canceled -> { + binding?.composeView?.let { view -> + context?.let { + showErrorToast(it, view, getString(R.string.general_error_oops)) + } + } + } + is PaymentSheetResult.Failed -> { + binding?.composeView?.let { view -> + context?.let { + val errorMessage = paymentSheetResult.error.localizedMessage ?: getString(R.string.general_error_something_wrong) + showErrorToast(it, view, errorMessage) + } + } + } + is PaymentSheetResult.Completed -> { + } + } + } + + flowController = PaymentSheet.FlowController.create( + fragment = this, + paymentOptionCallback = paymentOptionCallback, + paymentResultCallback = onPaymentSheetResult + ) + } +} diff --git a/app/src/main/java/com/kickstarter/ui/fragments/PledgeFragment.kt b/app/src/main/java/com/kickstarter/ui/fragments/PledgeFragment.kt index daa8adfc12..90decb19f7 100644 --- a/app/src/main/java/com/kickstarter/ui/fragments/PledgeFragment.kt +++ b/app/src/main/java/com/kickstarter/ui/fragments/PledgeFragment.kt @@ -5,11 +5,9 @@ import android.content.Intent import android.graphics.Rect import android.net.Uri import android.os.Bundle -import android.text.Editable import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.TextPaint -import android.text.TextWatcher import android.text.method.LinkMovementMethod import android.text.style.ClickableSpan import android.text.style.URLSpan @@ -53,8 +51,6 @@ import com.kickstarter.ui.data.CardState import com.kickstarter.ui.data.CheckoutData import com.kickstarter.ui.data.PledgeData import com.kickstarter.ui.extensions.hideKeyboard -import com.kickstarter.ui.extensions.onChange -import com.kickstarter.ui.extensions.setTextAndSelection import com.kickstarter.ui.extensions.showErrorToast import com.kickstarter.ui.itemdecorations.RewardCardItemDecoration import com.kickstarter.viewmodels.PledgeFragmentViewModel @@ -118,42 +114,12 @@ class PledgeFragment : setUpShippingAdapter() setupRewardRecyclerView() - binding?.pledgeSectionPledgeAmount?.pledgeAmount?.onChange { this.viewModel.inputs.pledgeInput(it) } - - binding?.pledgeSectionBonusSupport?.bonusAmount?.onChange { this.viewModel.inputs.bonusInput(it) } - flowController = PaymentSheet.FlowController.create( fragment = this, paymentOptionCallback = ::onPaymentOption, paymentResultCallback = ::onPaymentSheetResult ) - this.viewModel.outputs.additionalPledgeAmountIsGone() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - binding?.pledgeSectionPledgeAmount?.additionalPledgeAmountContainer?.isGone = it - } - .addToDisposable(disposables) - - this.viewModel.outputs.pledgeSectionIsGone() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - binding?.pledgeSectionPledgeAmount?.pledgeContainer?.isGone = it - } - .addToDisposable(disposables) - - this.viewModel.outputs.decreasePledgeButtonIsEnabled() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - binding?.pledgeSectionPledgeAmount?.decreasePledge?.isEnabled = it - } - .addToDisposable(disposables) - - this.viewModel.outputs.increasePledgeButtonIsEnabled() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { binding?.pledgeSectionPledgeAmount?.increasePledge?.isEnabled = it } - .addToDisposable(disposables) - this.viewModel.outputs.headerSectionIsGone() .observeOn(AndroidSchedulers.mainThread()) .subscribe { @@ -161,16 +127,6 @@ class PledgeFragment : } .addToDisposable(disposables) - this.viewModel.outputs.decreaseBonusButtonIsEnabled() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { binding?.pledgeSectionBonusSupport?.decreaseBonus ?.isEnabled = it } - .addToDisposable(disposables) - - this.viewModel.outputs.increaseBonusButtonIsEnabled() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { binding?.pledgeSectionBonusSupport?.increaseBonus?.isEnabled = it } - .addToDisposable(disposables) - this.viewModel.outputs.estimatedDelivery() .observeOn(AndroidSchedulers.mainThread()) .subscribe { @@ -222,73 +178,6 @@ class PledgeFragment : } .addToDisposable(disposables) - this.viewModel.outputs.pledgeAmount() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - binding?.pledgeSectionPledgeAmount?.pledgeAmount?.setTextAndSelection(it) - } - .addToDisposable(disposables) - - this.viewModel.outputs.bonusAmount() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - binding?.pledgeSectionBonusSupport?.bonusAmount?.setTextAndSelection(it) - binding?.pledgeSectionSummaryBonus?.bonusSummaryAmount?.text = it - } - .addToDisposable(disposables) - - this.viewModel.outputs.pledgeHint() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { binding?.pledgeSectionPledgeAmount?.pledgeAmount?.hint = it } - .addToDisposable(disposables) - - this.viewModel.outputs.bonusHint() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { binding?.pledgeSectionBonusSupport?.bonusAmount?.hint = it } - .addToDisposable(disposables) - - this.viewModel.outputs.pledgeMaximum() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - setPledgeMaximumText(it) - } - .addToDisposable(disposables) - - this.viewModel.outputs.pledgeMaximumIsGone() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - binding?.pledgeSectionPledgeAmount?.pledgeMaximum?.isInvisible = it - binding?.pledgeSectionBonusSupport?.bonusMaximum?.isInvisible = it - } - .addToDisposable(disposables) - - this.viewModel.outputs.pledgeMinimum() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { setPledgeMinimumText(it) } - .addToDisposable(disposables) - - this.viewModel.outputs.projectCurrencySymbol() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - setCurrencySymbols(it) - } - .addToDisposable(disposables) - - this.viewModel.outputs.pledgeTextColor() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - binding?.pledgeSectionPledgeAmount?.pledgeAmount ?.let { pledgeAmount -> - setTextColor(it, pledgeAmount) - } - binding?.pledgeSectionPledgeAmount?.pledgeSymbolStart ?.let { pledgeAmount -> - setTextColor(it, pledgeAmount) - } - binding?.pledgeSectionPledgeAmount?.pledgeSymbolEnd ?.let { pledgeAmount -> - setTextColor(it, pledgeAmount) - } - } - .addToDisposable(disposables) - this.viewModel.outputs.cardsAndProject() .observeOn(AndroidSchedulers.mainThread()) .subscribe { (binding?.pledgeSectionPayment?.cardsRecycler?.adapter as? RewardCardAdapter)?.takeCards(it.first, it.second) } @@ -310,7 +199,6 @@ class PledgeFragment : this.viewModel.outputs.selectedShippingRule() .observeOn(AndroidSchedulers.mainThread()) .subscribe { - binding?.pledgeSectionEditableShipping?.shippingRules?.setText(it.toString()) binding?.pledgeSectionShipping?.shippingRulesStatic ?.text = it.toString() } .addToDisposable(disposables) @@ -318,12 +206,6 @@ class PledgeFragment : this.viewModel.outputs.shippingAmount() .observeOn(AndroidSchedulers.mainThread()) .subscribe { - binding?.pledgeSectionEditableShipping?.shippingAmountLoadingView?.isGone = true - binding?.pledgeSectionEditableShipping?.shippingAmount?.let { shippingAmount -> - setPlusTextView( - shippingAmount, it - ) - } binding?.pledgeSectionShipping?. shippingAmountStatic?.let { shippingAmountStatic -> setPlusTextView( shippingAmountStatic, it @@ -356,13 +238,6 @@ class PledgeFragment : } .addToDisposable(disposables) - this.viewModel.outputs.shippingRulesSectionIsGone() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - binding?.pledgeSectionEditableShipping?.editableShippingCl?.isGone = it - } - .addToDisposable(disposables) - this.viewModel.outputs.shippingRuleStaticIsGone() .observeOn(AndroidSchedulers.mainThread()) .subscribe { @@ -537,21 +412,6 @@ class PledgeFragment : } .addToDisposable(disposables) - this.viewModel.outputs.isPledgeMinimumSubtitleGone() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - binding?.pledgeSectionPledgeAmount?.pledgeMinimum ?.isGone = it - } - .addToDisposable(disposables) - - this.viewModel.outputs.isBonusSupportSectionGone() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { - binding?.pledgeSectionBonusSupport?.bonusContainer?.isGone = it - binding?.pledgeSectionPledgeAmount?.pledgeContainer?.setPadding(0, resources.getDimension(R.dimen.grid_4).toInt(), 0, 0) - } - .addToDisposable(disposables) - this.viewModel.outputs.isNoReward() .observeOn(AndroidSchedulers.mainThread()) .subscribe { @@ -600,58 +460,9 @@ class PledgeFragment : } .addToDisposable(disposables) - binding?.pledgeSectionPledgeAmount?. pledgeAmount?.setOnTouchListener { _, _ -> - binding?.pledgeSectionPledgeAmount?. pledgeAmount?.post { - binding?.pledgeRoot?.let { pledgeRoot -> - binding?.pledgeSectionPledgeAmount?. pledgeAmountLabel?.let { pledgeAmountLabel -> - pledgeRoot.smoothScrollTo(0, relativeTop(pledgeAmountLabel, pledgeRoot)) - } - - binding?.pledgeSectionPledgeAmount?. pledgeAmount?.requestFocus() - } - } - false - } - - binding?.pledgeSectionBonusSupport?.bonusAmount ?.setOnTouchListener { _, _ -> - binding?.pledgeSectionBonusSupport?.bonusAmount ?.post { - binding?.pledgeRoot?.let { pledgeRoot -> - binding?.pledgeSectionBonusSupport?.bonusSupportLabel ?.let { - pledgeRoot.smoothScrollTo(0, relativeTop(it, pledgeRoot)) - } - binding?.pledgeSectionBonusSupport?.bonusAmount ?.requestFocus() - } - } - false - } - - binding?.pledgeSectionEditableShipping?.shippingRules?.setOnTouchListener { _, _ -> - binding?.pledgeSectionEditableShipping?.shippingRulesLabel?.post { - binding?.pledgeRoot?.let { pledgeRoot -> - binding?.pledgeSectionEditableShipping?.shippingRulesLabel?.let { shippingRulesLabel -> - pledgeRoot.smoothScrollTo(0, relativeTop(shippingRulesLabel, pledgeRoot)) - } - binding?.pledgeSectionEditableShipping?.shippingRules?.requestFocus() - binding?.pledgeSectionEditableShipping?.shippingRules?.showDropDown() - } - } - false - } - - binding?.pledgeSectionPledgeAmount?. decreasePledge ?.setOnClickListener { - this.viewModel.inputs.decreasePledgeButtonClicked() - } - - binding?.pledgeSectionPledgeAmount?. increasePledge ?.setOnClickListener { - this.viewModel.inputs.increasePledgeButtonClicked() - } - binding?.pledgeSectionHeaderRewardSummary?.pledgeHeaderContainer?.setOnClickListener { toggleAnimation(isExpanded) } - binding?.pledgeSectionBonusSupport?.decreaseBonus?.setOnClickListener { this.viewModel.inputs.decreaseBonusButtonClicked() } - - binding?.pledgeSectionBonusSupport?.increaseBonus?.setOnClickListener { this.viewModel.inputs.increaseBonusButtonClicked() } binding?.pledgeSectionFooter?.pledgeFooterPledgeButton?.setOnClickListener { this.viewModel.inputs.pledgeButtonClicked() @@ -826,7 +637,6 @@ class PledgeFragment : override fun ruleSelected(rule: ShippingRule) { this.viewModel.inputs.shippingRuleSelected(rule) activity?.hideKeyboard() - binding?.pledgeSectionEditableShipping?.shippingRules?.clearFocus() if (binding?.pledgeSectionFooter?.pledgeFooterPledgeButton?.isEnabled == false) { binding?.pledgeSectionFooter?.pledgeFooterPledgeButton?.isEnabled = true @@ -834,7 +644,6 @@ class PledgeFragment : } private fun displayShippingRules(shippingRules: List, project: Project) { - binding?.pledgeSectionEditableShipping?.shippingRules?.isEnabled = true adapter.populateShippingRules(shippingRules, project) } @@ -846,24 +655,6 @@ class PledgeFragment : return offsetViewBounds.top - parent.paddingTop } - private fun setCurrencySymbols(symbolAndStart: Pair) { - val symbol = symbolAndStart.first - val symbolAtStart = symbolAndStart.second - if (symbolAtStart) { - binding?.pledgeSectionPledgeAmount?. pledgeSymbolStart ?.text = symbol - binding?.pledgeSectionPledgeAmount?. pledgeSymbolEnd?.text = null - - binding?.pledgeSectionBonusSupport?.bonusSymbolStart?.text = symbol - binding?.pledgeSectionBonusSupport?.bonusSymbolEnd?.text = null - } else { - binding?.pledgeSectionPledgeAmount?. pledgeSymbolStart ?.text = null - binding?.pledgeSectionPledgeAmount?. pledgeSymbolEnd?.text = symbol - - binding?.pledgeSectionBonusSupport?.bonusSymbolStart?.text = null - binding?.pledgeSectionBonusSupport?.bonusSymbolEnd?.text = symbol - } - } - private fun setDeadlineWarning(totalAndDeadline: Pair) { val total = totalAndDeadline.first val deadline = totalAndDeadline.second @@ -881,15 +672,6 @@ class PledgeFragment : binding?.deadlineWarning?.text = spannableWarning } - private fun setPledgeMaximumText(maximumAmount: String) { - binding?.pledgeSectionPledgeAmount?. pledgeMaximum ?.text = ksString.format(getString(R.string.Enter_an_amount_less_than_max_pledge), "max_pledge", maximumAmount) - binding?.pledgeSectionBonusSupport?.bonusMaximum?.text = ksString.format(getString(R.string.Enter_an_amount_less_than_max_pledge), "max_pledge", maximumAmount) - } - - private fun setPledgeMinimumText(minimumAmount: String) { - binding?.pledgeSectionPledgeAmount?. pledgeMinimum ?.text = ksString.format(getString(R.string.The_minimum_pledge_is_min_pledge), "min_pledge", minimumAmount) - } - private fun setPlusTextView(textView: TextView, localizedAmount: CharSequence) { textView.contentDescription = ksString.format(getString(R.string.plus_shipping_cost), "shipping_cost", localizedAmount.toString()) textView.text = localizedAmount @@ -913,23 +695,7 @@ class PledgeFragment : private fun setUpShippingAdapter() { context?.let { adapter = ShippingRulesAdapter(it, R.layout.item_shipping_rule, arrayListOf(), this) - binding?.pledgeSectionEditableShipping?.shippingRules?.setAdapter(adapter) } - - binding?.pledgeSectionEditableShipping?.shippingRules?.addTextChangedListener(object : TextWatcher { - override fun afterTextChanged(s: Editable?) { - - if (s.toString().isNullOrBlank()) { - binding?.pledgeSectionFooter?.pledgeFooterPledgeButton?.isEnabled = false - } - } - - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { - } - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { - } - }) } private fun setupRewardRecyclerView() { diff --git a/app/src/main/java/com/kickstarter/ui/fragments/RewardsFragment.kt b/app/src/main/java/com/kickstarter/ui/fragments/RewardsFragment.kt index f16af9dd2b..b8ce816a57 100644 --- a/app/src/main/java/com/kickstarter/ui/fragments/RewardsFragment.kt +++ b/app/src/main/java/com/kickstarter/ui/fragments/RewardsFragment.kt @@ -6,32 +6,37 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.app.AlertDialog -import androidx.core.view.isGone +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.rxjava2.subscribeAsState +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels -import androidx.recyclerview.widget.LinearLayoutManager +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.kickstarter.R import com.kickstarter.databinding.FragmentRewardsBinding -import com.kickstarter.libs.utils.NumberUtils -import com.kickstarter.libs.utils.RewardDecoration -import com.kickstarter.libs.utils.ViewUtils +import com.kickstarter.libs.Environment import com.kickstarter.libs.utils.extensions.addToDisposable import com.kickstarter.libs.utils.extensions.getEnvironment import com.kickstarter.libs.utils.extensions.reduce import com.kickstarter.libs.utils.extensions.selectPledgeFragment -import com.kickstarter.models.Reward -import com.kickstarter.ui.adapters.RewardsAdapter +import com.kickstarter.ui.activities.compose.projectpage.RewardCarouselScreen +import com.kickstarter.ui.compose.designsystem.KSTheme +import com.kickstarter.ui.compose.designsystem.KickstarterApp import com.kickstarter.ui.data.PledgeData import com.kickstarter.ui.data.PledgeReason import com.kickstarter.ui.data.ProjectData import com.kickstarter.viewmodels.RewardsFragmentViewModel.Factory import com.kickstarter.viewmodels.RewardsFragmentViewModel.RewardsFragmentViewModel +import com.kickstarter.viewmodels.usecases.ShippingRulesState import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.CompositeDisposable -class RewardsFragment : Fragment(), RewardsAdapter.Delegate { +class RewardsFragment : Fragment() { - private var rewardsAdapter = RewardsAdapter(this) private lateinit var dialog: AlertDialog private var binding: FragmentRewardsBinding? = null @@ -40,39 +45,87 @@ class RewardsFragment : Fragment(), RewardsAdapter.Delegate { viewModelFactory } + private lateinit var environment: Environment private val disposables = CompositeDisposable() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + super.onCreateView(inflater, container, savedInstanceState) + + this.context?.getEnvironment()?.let { env -> + viewModelFactory = Factory(env) + environment = env + } + super.onCreateView(inflater, container, savedInstanceState) binding = FragmentRewardsBinding.inflate(inflater, container, false) return binding?.root } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - this.context?.getEnvironment()?.let { env -> - viewModelFactory = Factory(env) + @Composable + private fun ScrollToPosition( + scrollToPosition: State, + listState: LazyListState + ) { + LaunchedEffect(scrollToPosition) { + // Animate scroll to the scrollToPosition item + listState.animateScrollToItem(index = scrollToPosition.value) } + } - setupRecyclerView() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) createDialog() - this.viewModel.outputs.projectData() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { rewardsAdapter.populateRewards(it) } - .addToDisposable(disposables) - - this.viewModel.outputs.backedRewardPosition() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { scrollToReward(it) } - .addToDisposable(disposables) + binding?.composeView?.apply { + // Dispose of the Composition when the view's LifecycleOwner is destroyed + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + // Compose world + setContent { + KickstarterApp( + useDarkTheme = true + ) { + KSTheme { + + val projectData: State = viewModel.projectData().subscribeAsState(initial = ProjectData.builder().build()) + val backing = projectData.value.backing() ?: projectData.value.project().backing() + val project = projectData.value.project() + + val rules = viewModel.countrySelectorRules().collectAsStateWithLifecycle( + initialValue = ShippingRulesState() + ).value + + val rewards = rules.filteredRw + val listState = rememberLazyListState() + + RewardCarouselScreen( + lazyRowState = listState, + environment = requireNotNull(environment), + rewards = rewards, + project = project, + backing = backing, + onRewardSelected = { + viewModel.inputs.rewardClicked(it) + }, + countryList = rules.shippingRules, + onShippingRuleSelected = { + viewModel.inputs.selectedShippingRule(it) + }, + currentShippingRule = rules.selectedShippingRule, + isLoading = rules.loading + ) + + ScrollToPosition(viewModel.outputs.backedRewardPosition().subscribeAsState(initial = 0), listState) + } + } + } + } this.viewModel.outputs.showPledgeFragment() .observeOn(AndroidSchedulers.mainThread()) .subscribe { dialog.dismiss() - showPledgeFragment(it.first, it.second) + // showPledgeFragment(it.first, it.second) + showAddonsFragment(Pair(it.first, it.second)) } .addToDisposable(disposables) @@ -84,22 +137,14 @@ class RewardsFragment : Fragment(), RewardsAdapter.Delegate { } .addToDisposable(disposables) - this.viewModel.outputs.rewardsCount() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { setRewardsCount(it) } - .addToDisposable(disposables) - this.viewModel.outputs.showAlert() .observeOn(AndroidSchedulers.mainThread()) .subscribe { showAlert() } .addToDisposable(disposables) - - context?.apply { - binding?.rewardsCount?.isGone = ViewUtils.isLandscape(this) - } } + fun setState(state: Boolean?) { state?.let { viewModel.isExpanded(state) @@ -124,50 +169,15 @@ class RewardsFragment : Fragment(), RewardsAdapter.Delegate { dialog.show() } - private fun scrollToReward(position: Int) { - if (position != 0) { - val recyclerWidth = (binding?.rewardsRecycler?.width ?: 0) - val linearLayoutManager = binding?.rewardsRecycler?.layoutManager as LinearLayoutManager - val rewardWidth = resources.getDimensionPixelSize(R.dimen.item_reward_width) - val rewardMargin = resources.getDimensionPixelSize(R.dimen.reward_margin) - val center = (recyclerWidth - rewardWidth - rewardMargin) / 2 - linearLayoutManager.scrollToPositionWithOffset(position, center) - } - } - - private fun setRewardsCount(count: Int) { - val rewardsCountString = requireNotNull(this.viewModel.environment.ksString()).format( - "Rewards_count_rewards", count, - "rewards_count", NumberUtils.format(count) - ) - binding?.rewardsCount?.text = rewardsCountString - } - override fun onDetach() { disposables.clear() super.onDetach() - binding?.rewardsRecycler?.adapter = null - } - - override fun rewardClicked(reward: Reward) { - this.viewModel.inputs.rewardClicked(reward) } fun configureWith(projectData: ProjectData) { this.viewModel.inputs.configureWith(projectData) } - private fun addItemDecorator() { - val margin = resources.getDimension(R.dimen.reward_margin).toInt() - binding?.rewardsRecycler?.addItemDecoration(RewardDecoration(margin)) - } - - private fun setupRecyclerView() { - binding?.rewardsRecycler?.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) - binding?.rewardsRecycler?.adapter = rewardsAdapter - addItemDecorator() - } - private fun showPledgeFragment( pledgeData: PledgeData, pledgeReason: PledgeReason diff --git a/app/src/main/java/com/kickstarter/ui/views/compose/checkout/BonusSupport.kt b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/BonusSupport.kt new file mode 100644 index 0000000000..f03cc687b3 --- /dev/null +++ b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/BonusSupport.kt @@ -0,0 +1,207 @@ +package com.kickstarter.ui.views.compose.checkout + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.integerResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.kickstarter.R +import com.kickstarter.libs.Environment +import com.kickstarter.libs.utils.RewardUtils +import com.kickstarter.libs.utils.RewardViewUtils +import com.kickstarter.libs.utils.extensions.parseToDouble +import com.kickstarter.models.Reward +import com.kickstarter.ui.compose.designsystem.KSStepper +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 com.kickstarter.ui.compose.designsystem.shapes + +@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun BonusSupportContainerPreview() { + KSTheme { + Scaffold( + backgroundColor = KSTheme.colors.backgroundAccentGraySubtle + ) { padding -> + BonusSupportContainer( + modifier = Modifier + .padding(paddingValues = padding), + selectedReward = Reward.builder().build(), + initialAmount = 5.0, + maxAmount = 10.0, + minPledge = 5.0, + currencySymbolAtStart = "CAD", + currencySymbolAtEnd = "$", + totalAmount = 100.0, + totalBonusSupport = 5.0, + onBonusSupportPlusClicked = {}, + onBonusSupportMinusClicked = {}, + onBonusSupportInputted = {}, + environment = Environment.builder().build() + ) + } + } +} + +@Composable +fun BonusSupportContainer( + modifier: Modifier = Modifier, + selectedReward: Reward, + initialAmount: Double, + maxAmount: Double, + minPledge: Double, + currencySymbolAtStart: String?, + currencySymbolAtEnd: String?, + totalAmount: Double, + totalBonusSupport: Double, + onBonusSupportPlusClicked: (amount: Double) -> Unit, + onBonusSupportMinusClicked: (amount: Double) -> Unit, + onBonusSupportInputted: (amount: Double) -> Unit, + environment: Environment +) { + val bonusAmountMaxDigits = integerResource(R.integer.max_length) + val isNoReward = RewardUtils.isNoReward(selectedReward) + val displayedTotalBonusAmount = + if (isNoReward) totalBonusSupport + minPledge else totalBonusSupport + + Column { + if (isNoReward) { + Text( + text = stringResource(id = R.string.customize_your_reward_fpo), + style = typography.title3Bold, + color = colors.textPrimary + ) + + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) + } + + Text( + text = if (isNoReward) stringResource(id = R.string.Your_pledge_amount) + else stringResource(id = R.string.Bonus_support), + style = typography.subheadlineMedium, + color = colors.textPrimary + ) + + Spacer(modifier = Modifier.height(dimensions.paddingSmall)) + + if (!isNoReward && !selectedReward.hasAddons()) { + Text( + text = stringResource(id = R.string.A_little_extra_to_help), + style = typography.body2, + color = colors.textSecondary + ) + Spacer(modifier = Modifier.height(dimensions.paddingSmall)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + KSStepper( + onPlusClicked = { + onBonusSupportPlusClicked(totalBonusSupport + minPledge) + }, + isPlusEnabled = displayedTotalBonusAmount < maxAmount, + onMinusClicked = { + onBonusSupportMinusClicked(totalBonusSupport - minPledge) + }, + isMinusEnabled = initialAmount < displayedTotalBonusAmount, + enabledButtonBackgroundColor = colors.kds_white + ) + + Spacer(modifier = Modifier.weight(1f)) + + Text(text = "+", style = typography.calloutMedium, color = colors.textSecondary) + + Spacer(modifier = Modifier.width(dimensions.paddingMediumSmall)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .background( + color = colors.kds_white, + shape = shapes.small + ) + .padding( + start = dimensions.paddingXSmall, + top = dimensions.paddingXSmall, + bottom = dimensions.paddingXSmall, + end = dimensions.paddingXSmall + ), + + ) { + Text( + text = currencySymbolAtStart ?: "", + color = colors.textAccentGreen + ) + BasicTextField( + modifier = Modifier.width(IntrinsicSize.Min), + value = + if (displayedTotalBonusAmount % 1.0 == 0.0) displayedTotalBonusAmount.toInt().toString() + else displayedTotalBonusAmount.toString(), + onValueChange = { + if (it.length <= bonusAmountMaxDigits) onBonusSupportInputted(it.parseToDouble()) + }, + textStyle = typography.title1.copy(color = colors.textAccentGreen), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done + ), + cursorBrush = SolidColor(colors.iconSubtle) + ) + Text( + text = currencySymbolAtEnd ?: "", + color = colors.textAccentGreen + ) + } + } + + if (totalAmount > maxAmount) { + val maxInputString = RewardViewUtils.getMaxInputString( + context = LocalContext.current, + selectedReward = selectedReward, + maxPledgeAmount = maxAmount, + totalAmount = totalAmount, + totalBonusSupport = totalBonusSupport, + currencySymbolStartAndEnd = Pair(currencySymbolAtStart, currencySymbolAtEnd), + environment = environment + ) + + Text( + text = maxInputString, + textAlign = TextAlign.Right, + style = typography.footnoteMedium, + color = colors.textAccentRed, + modifier = Modifier + .fillMaxWidth() + .padding( + start = dimensions.paddingMedium, + end = dimensions.paddingMedium, + ) + ) + } + } +} 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 new file mode 100644 index 0000000000..2c020a504e --- /dev/null +++ b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/PledgeItemizedDetails.kt @@ -0,0 +1,242 @@ +package com.kickstarter.ui.views.compose.checkout + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.kickstarter.R +import com.kickstarter.libs.KSString +import com.kickstarter.ui.compose.designsystem.KSDividerLineGrey +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 + +@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ItemizedContainerForNoRewardPreview() { + KSTheme { + Scaffold( + backgroundColor = KSTheme.colors.backgroundAccentGraySubtle + ) { padding -> + ItemizedRewardListContainer( + modifier = Modifier.padding(paddingValues = padding), + totalAmount = "US$ 1", + totalAmountCurrencyConverted = "About CA$ 1.38", + initialBonusSupport = "US$ 0", + totalBonusSupport = "US$ 1", + shippingAmount = -1.0, + ) + } + } +} + +@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ItemizedRewardListContainerPreview() { + KSTheme { + Scaffold( + backgroundColor = KSTheme.colors.backgroundAccentGraySubtle + ) { padding -> + ItemizedRewardListContainer( + modifier = Modifier + .padding(paddingValues = padding), + ksString = null, + rewardsList = emptyList>(), + shippingAmount = 20.0, + shippingAmountString = "", + initialShippingLocation = "", + totalAmount = "50$", + totalAmountCurrencyConverted = "About CA\$ 1.38", + initialBonusSupport = "0", + totalBonusSupport = "0", + deliveryDateString = "", + rewardsHaveShippables = false, + disclaimerText = stringResource(id = R.string.If_the_project_reaches_its_funding_goal_you_will_be_charged_total_on_project_deadline) + ) + } + } +} + +@Composable +fun ItemizedRewardListContainer( + modifier: Modifier = Modifier, + ksString: KSString? = null, + rewardsList: List> = listOf(), + shippingAmount: Double, + shippingAmountString: String = "", + initialShippingLocation: String = "", + totalAmount: String, + totalAmountCurrencyConverted: String = "", + initialBonusSupport: String, + totalBonusSupport: String, + deliveryDateString: String = "", + rewardsHaveShippables: Boolean = false, + disclaimerText: String = "" +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = colors.backgroundSurfacePrimary) + .padding( + start = dimensions.paddingMedium, + end = dimensions.paddingMedium, + bottom = dimensions.paddingLarge, + top = dimensions.paddingMediumLarge + ) + ) { + Text( + text = stringResource(id = R.string.Your_pledge), + style = typography.headline, + color = colors.textPrimary + ) + + if (deliveryDateString.isNotEmpty()) { + Spacer(modifier = Modifier.height(dimensions.paddingXSmall)) + + Text( + text = deliveryDateString, + style = typography.caption1, + color = colors.textSecondary + ) + } + + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) + + KSDividerLineGrey() + + rewardsList.forEach { + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) + + Row { + Text( + text = it.first, + style = typography.subheadlineMedium, + color = colors.textSecondary + ) + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = it.second, + style = typography.subheadlineMedium, + color = colors.textSecondary + ) + } + + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) + + KSDividerLineGrey() + } + + if (shippingAmount > 0 && initialShippingLocation.isNotEmpty() && rewardsHaveShippables) { + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) + + Row { + Text( + text = ksString?.format( + stringResource(id = R.string.Shipping_to_country), + "country", + initialShippingLocation + ) ?: "Shipping: $initialShippingLocation", + style = typography.subheadlineMedium, + color = colors.textSecondary + ) + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = shippingAmountString, + style = typography.subheadlineMedium, + color = colors.textSecondary + ) + } + + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) + + KSDividerLineGrey() + } + + if (totalBonusSupport != initialBonusSupport) { + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) + + Row { + Text( + text = stringResource( + id = + if (rewardsList.isNotEmpty()) R.string.Bonus_support + else R.string.Pledge_without_a_reward + ), + style = typography.subheadlineMedium, + color = colors.textSecondary + ) + + Spacer(modifier = Modifier.weight(1f)) + + Text( + text = totalBonusSupport, + style = typography.subheadlineMedium, + color = colors.textSecondary + ) + } + + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) + + KSDividerLineGrey() + } + + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) + + Row { + Text( + text = stringResource(id = R.string.Pledge_amount), + style = typography.calloutMedium, + color = colors.textPrimary + ) + + Spacer(modifier = Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.End) { + Text( + text = totalAmount, + style = typography.subheadlineMedium, + color = colors.textPrimary + ) + + if (totalAmountCurrencyConverted.isNotEmpty()) { + Spacer(modifier = Modifier.height(dimensions.paddingXSmall)) + + Text( + text = totalAmountCurrencyConverted, + style = typography.footnote, + color = colors.textPrimary + ) + } + } + } + + if (disclaimerText.isNotEmpty()) { + Spacer(modifier = Modifier.height(dimensions.paddingMedium)) + Row { + Text( + text = disclaimerText, + style = typography.footnote, + color = colors.textPrimary + ) + } + } + } +} diff --git a/app/src/main/java/com/kickstarter/ui/views/compose/checkout/ShippingSelector.kt b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/ShippingSelector.kt new file mode 100644 index 0000000000..342fe426f7 --- /dev/null +++ b/app/src/main/java/com/kickstarter/ui/views/compose/checkout/ShippingSelector.kt @@ -0,0 +1,225 @@ +package com.kickstarter.ui.views.compose.checkout + +import android.content.res.Configuration +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.window.PopupProperties +import com.kickstarter.R +import com.kickstarter.mock.factories.ShippingRuleFactory +import com.kickstarter.models.ShippingRule +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 + +@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ShippingSelectorPreview() { + KSTheme { + Scaffold( + backgroundColor = KSTheme.colors.backgroundAccentGraySubtle + ) { padding -> + + val interactionSource = remember { + MutableInteractionSource() + } + + ShippingSelector( + modifier = Modifier + .padding(paddingValues = padding), + interactionSource = interactionSource, + currentShippingRule = ShippingRuleFactory.usShippingRule(), + countryList = listOf(ShippingRuleFactory.usShippingRule(), ShippingRuleFactory.germanyShippingRule()), + onShippingRuleSelected = {} + ) + } + } +} + +@Composable +fun ShippingSelector( + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource, + currentShippingRule: ShippingRule, + countryList: List, + onShippingRuleSelected: (ShippingRule) -> Unit, +) { + Column(modifier = modifier) { + // Spacer(modifier = Modifier.height(dimensions.paddingMediumLarge)) + + Text( + text = stringResource(id = R.string.Your_shipping_location), + style = typography.subheadlineMedium, + color = colors.textSecondary + ) + + Spacer(modifier = Modifier.height(KSTheme.dimensions.paddingSmall)) + + CountryInputWithDropdown( + interactionSource = interactionSource, + initialCountryInput = currentShippingRule.location()?.displayableName(), + countryList = countryList, + onShippingRuleSelected = onShippingRuleSelected + ) + // Spacer(modifier = Modifier.height(KSTheme.dimensions.paddingSmall)) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun CountryInputWithDropdown( + interactionSource: MutableInteractionSource, + initialCountryInput: String? = null, + countryList: List, + onShippingRuleSelected: (ShippingRule) -> Unit +) { + var countryListExpanded by remember { + mutableStateOf(false) + } + + var countryInput by remember(key1 = initialCountryInput) { + mutableStateOf(initialCountryInput ?: "") + } + + val focusManager = LocalFocusManager.current + + Column( + modifier = Modifier + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { countryListExpanded = false } + ), + ) { + Box(contentAlignment = Alignment.TopStart) { + BasicTextField( + modifier = Modifier + .background(color = colors.backgroundSurfacePrimary) + .fillMaxWidth(0.6f), + value = countryInput, + onValueChange = { + countryInput = it + countryListExpanded = true + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done + ), + textStyle = typography.subheadlineMedium.copy(color = colors.textAccentGreenBold), + singleLine = false + ) { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = countryInput, + innerTextField = innerTextField, + enabled = true, + singleLine = false, + visualTransformation = VisualTransformation.None, + interactionSource = interactionSource, + contentPadding = PaddingValues( + start = dimensions.paddingMedium, + top = dimensions.paddingSmall, + bottom = dimensions.paddingSmall, + end = dimensions.paddingMedium + ), + ) + } + + val shouldShowDropdown: Boolean = when { + countryListExpanded && countryInput.isNotEmpty() -> { + countryList.filter { + it.location()?.displayableName()?.lowercase() + ?.contains(countryInput.lowercase()) ?: false + }.isNotEmpty() + } + + else -> countryListExpanded + } + + DropdownMenu( + expanded = shouldShowDropdown, + onDismissRequest = { }, + modifier = Modifier + .width( + dimensions.countryInputWidth + ) + .heightIn(dimensions.none, dimensions.dropDownStandardWidth), + properties = PopupProperties(focusable = false) + ) { + if (countryInput.isNotEmpty()) { + countryList.filter { + it.location()?.displayableName()?.lowercase() + ?.contains(countryInput.lowercase()) ?: false + }.take(3).forEach { rule -> + DropdownMenuItem( + modifier = Modifier.background(color = colors.backgroundSurfacePrimary), + onClick = { + countryInput = + rule.location()?.displayableName() ?: "" + countryListExpanded = false + focusManager.clearFocus() + onShippingRuleSelected(rule) + } + ) { + Text( + text = rule.location()?.displayableName() ?: "", + style = typography.subheadlineMedium, + color = colors.textAccentGreenBold + ) + } + } + } else { + countryList.take(5).forEach { rule -> + DropdownMenuItem( + modifier = Modifier.background(color = colors.backgroundSurfacePrimary), + onClick = { + countryInput = + rule.location()?.displayableName() ?: "" + countryListExpanded = false + focusManager.clearFocus() + onShippingRuleSelected(rule) + } + ) { + Text( + text = rule.location()?.displayableName() ?: "", + style = typography.subheadlineMedium, + color = colors.textAccentGreenBold + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/kickstarter/viewmodels/BackingAddOnsFragmentViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/BackingAddOnsFragmentViewModel.kt deleted file mode 100644 index d31f741a58..0000000000 --- a/app/src/main/java/com/kickstarter/viewmodels/BackingAddOnsFragmentViewModel.kt +++ /dev/null @@ -1,661 +0,0 @@ -package com.kickstarter.viewmodels - -import android.os.Bundle -import android.util.Pair -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.kickstarter.libs.Environment -import com.kickstarter.libs.rx.transformers.Transformers.combineLatestPair -import com.kickstarter.libs.rx.transformers.Transformers.takeWhenV2 -import com.kickstarter.libs.utils.RewardUtils -import com.kickstarter.libs.utils.RewardUtils.isDigital -import com.kickstarter.libs.utils.RewardUtils.isLocalPickup -import com.kickstarter.libs.utils.RewardUtils.isShippable -import com.kickstarter.libs.utils.extensions.addToDisposable -import com.kickstarter.libs.utils.extensions.isNotNull -import com.kickstarter.mock.factories.LocationFactory -import com.kickstarter.mock.factories.ShippingRuleFactory -import com.kickstarter.models.Backing -import com.kickstarter.models.Location -import com.kickstarter.models.Project -import com.kickstarter.models.Reward -import com.kickstarter.models.ShippingRule -import com.kickstarter.ui.ArgumentsKey -import com.kickstarter.ui.data.PledgeData -import com.kickstarter.ui.data.PledgeReason -import com.kickstarter.ui.data.ProjectData -import io.reactivex.Observable -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.subjects.BehaviorSubject -import io.reactivex.subjects.PublishSubject -import java.util.Locale - -class BackingAddOnsFragmentViewModel { - - interface Inputs { - - /** Configure with the current [ProjectData] and [Reward]. - * @param projectData we get the Project for currency - */ - fun configureWith(pledgeDataAndReason: Pair) - - /** Call when user selects a shipping location. */ - fun shippingRuleSelected(shippingRule: ShippingRule) - - /** Emits when the CTA button has been pressed */ - fun continueButtonPressed() - - /** Emits the quantity per AddOn Id selected */ - fun quantityPerId(quantityPerId: Pair) - - /** Invoked when the retry button on the add-on Error alert dialog is pressed */ - fun retryButtonPressed() - } - - interface Outputs { - /** Emits a Pair containing the projectData and the pledgeReason. */ - fun showPledgeFragment(): Observable> - - /** Emits a Pair containing the projectData and the list for Add-ons associated to that project. */ - fun addOnsList(): Observable, ShippingRule>> - - /** Emits the current selected shipping rule. */ - fun selectedShippingRule(): Observable - - /** Emits a pair of list of shipping rules to be selected and the project. */ - fun shippingRulesAndProject(): Observable, Project>> - - /** Emits the total sum of addOns selected in each item of the addOns list. */ - fun totalSelectedAddOns(): Observable - - /** Emits whether or not the shipping selector is visible **/ - fun shippingSelectorIsGone(): Observable - - /** Emits whether or not the continue button should be enabled **/ - fun isEnabledCTAButton(): Observable - - /** Emits whether or not the empty state should be shown **/ - fun isEmptyState(): Observable - - /** Emits an alert dialog when add-ons request results in error **/ - fun showErrorDialog(): Observable - } - - class BackingAddOnsFragmentViewModel( - private val environment: Environment, - private val bundle: Bundle? = null - ) : ViewModel(), Outputs, Inputs { - val inputs = this - val outputs = this - - private val shippingRules = BehaviorSubject.create>() - private val addOnsFromGraph = BehaviorSubject.create>() - private var pledgeDataAndReason = BehaviorSubject.create>() - private val shippingRuleSelected = BehaviorSubject.create() - private val shippingRulesAndProject = PublishSubject.create, Project>>() - - private val projectAndReward: Observable> - private val retryButtonPressed = BehaviorSubject.create() - - private val pledgeFragmentData = PublishSubject.create>() - private val showPledgeFragment = PublishSubject.create>() - private val shippingSelectorIsGone = BehaviorSubject.create() - private val addOnsListFiltered = BehaviorSubject.create, ShippingRule>>() - private val isEmptyState = BehaviorSubject.create() - private val showErrorDialog = BehaviorSubject.create() - private val continueButtonPressed = BehaviorSubject.create() - private val isEnabledCTAButton = BehaviorSubject.create() - - // - Current addOns selection - private val totalSelectedAddOns = BehaviorSubject.createDefault(0) - private val quantityPerId = BehaviorSubject.create>() - private val currentSelection = BehaviorSubject.createDefault(mutableMapOf()) - - private fun arguments() = bundle?.let { Observable.just(it) } ?: Observable.empty() - - // - Environment Objects - private val apolloClient = requireNotNull(this.environment.apolloClientV2()) - private val currentConfig = requireNotNull(environment.currentConfigV2()) - private val analyticEvents = requireNotNull(environment.analytics()) - private val disposables = CompositeDisposable() - - init { - val pledgeData = arguments() - .map { - it.getParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA) as PledgeData? - } - .filter { it.isNotNull() } - .ofType(PledgeData::class.java) - - pledgeData - .take(1) - .subscribe(this.analyticEvents::trackAddOnsScreenViewed) - .addToDisposable(disposables) - - val pledgeReason = arguments() - .map { - it.getSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON) as PledgeReason - } - - val projectData = pledgeData - .map { it.projectData() } - - val project = projectData - .map { it.project() } - - val rewardPledge = pledgeData - .map { it.reward() } - - val backing = projectData - .filter { getBackingFromProjectData(it) != null } - .map { requireNotNull(getBackingFromProjectData(it)) } - - val backingReward = backing - .filter { it.reward() != null } - .map { requireNotNull(it.reward()) } - - val isSameReward = rewardPledge - .compose>(combineLatestPair(backingReward)) - .map { it.first.id() == it.second.id() } - - isSameReward - .filter { !it } - .subscribe { - this.currentSelection.value?.clear() - } - .addToDisposable(disposables) - - val filteredBackingReward = backingReward - .compose>(combineLatestPair(isSameReward)) - .filter { it.second } - .filter { it.isNotNull() } - .map { requireNotNull(it) } - .map { it.first } - - val reward = Observable.merge(rewardPledge, filteredBackingReward) - - projectAndReward = project - .compose>(combineLatestPair(reward)) - - // - If changing rewards do not emmit the backing information - val backingShippingRule = backing - .compose>(combineLatestPair(isSameReward)) - .filter { it.second } - .map { it.first } - .compose>>(combineLatestPair(shippingRules)) - .map { - it.second.first { rule -> - rule.location()?.id() == it.first.locationId() - } - } - .filter { it.isNotNull() } - .map { requireNotNull(it) } - - // - In case of digital Reward to follow the same flow as the rest of use cases use and empty shippingRule - reward - .filter { isDigital(it) || !isShippable(it) || isLocalPickup(it) } - .distinctUntilChanged() - .subscribe { - this.shippingSelectorIsGone.onNext(true) - } - .addToDisposable(disposables) - - val addOnsFromBacking = backing - .compose>(combineLatestPair(isSameReward)) - .filter { it.second } - .map { it.first } - .filter { it.addOns()?.toList().isNotNull() } - .map { it.addOns()?.toList() } - .distinctUntilChanged() - - val combinedList = addOnsFromBacking - .compose, List>>(combineLatestPair(addOnsFromGraph)) - .map { joinSelectedWithAvailableAddOns(it.first, it.second) } - .distinctUntilChanged() - - val addonsList = Observable.merge(addOnsFromGraph, combinedList) - .map { filterOutUnAvailableOrEndedExceptIfBacked(it) } - .distinctUntilChanged() - - shippingRules - .compose, Project>>(combineLatestPair(project)) - .subscribe { this.shippingRulesAndProject.onNext(it) } - .addToDisposable(disposables) - - val defaultShippingRule = shippingRules - .filter { it.isNotEmpty() } - .compose, Reward>>(combineLatestPair(reward)) - .filter { !isDigital(it.second) && isShippable(it.second) && !isLocalPickup(it.second) } - .switchMap { defaultShippingRule(it.first) } - - val shippingRule = getSelectedShippingRule(defaultShippingRule, isSameReward, backingShippingRule, reward) - - shippingRule - .distinctUntilChanged { rule1, rule2 -> - rule1.location()?.id() == rule2.location()?.id() && rule1.cost() == rule2.cost() - } - .subscribe { - this.shippingRuleSelected.onNext(it) - } - .addToDisposable(disposables) - - Observable - .combineLatest(this.retryButtonPressed.startWith(false), reward) { _, rw -> - return@combineLatest this.apolloClient - .getShippingRules(rw) - .doOnError { - this.showErrorDialog.onNext(true) - this.shippingSelectorIsGone.onNext(true) - } - .onErrorResumeNext(Observable.empty()) - } - .filter { it.isNotNull() } - .switchMap { it } - .map { it.shippingRules() } - .filter { it.isNotNull() } - .subscribe { - shippingRules.onNext(it.filterNotNull()) - } - .addToDisposable(disposables) - - val location = this.shippingRuleSelected - .map { it.location() } - .filter { it.isNotNull() } - .map { requireNotNull(it) } - - Observable - .combineLatest(this.retryButtonPressed.startWith(false), project, location, reward) { - _, pj, shipRuleLocation, reward -> - return@combineLatest projectSlugAndLocation(pj, reward, shipRuleLocation) - } - .filter { - it.first.isNotEmpty() - } - .switchMap { - this.apolloClient - .getProjectAddOns(it.first, it.second) - .doOnError { - this.showErrorDialog.onNext(true) - this.shippingSelectorIsGone.onNext(true) - } - .onErrorResumeNext(Observable.empty()) - } - .filter { it.isNotNull() } - .subscribe { addOnsFromGraph.onNext(it) } - .addToDisposable(disposables) - - val filteredAddOns = Observable.combineLatest(addonsList, projectData, this.shippingRuleSelected, reward) { - list, pData, rule, rw -> - return@combineLatest filterByLocation(list, pData, rule, rw) - } - .distinctUntilChanged() - - filteredAddOns - .distinctUntilChanged() - .subscribe { this.addOnsListFiltered.onNext(it) } - .addToDisposable(disposables) - - filteredAddOns - .map { it.second.isEmpty() } - .distinctUntilChanged() - .subscribe { this.isEmptyState.onNext(it) } - .addToDisposable(disposables) - - this.quantityPerId - .compose, Triple, ShippingRule>>>(combineLatestPair(this.addOnsListFiltered)) - .distinctUntilChanged() - .subscribe { - updateQuantityById(it.first) - calculateTotal(it.second.second) - } - .addToDisposable(disposables) - - // - .startWith(ShippingRuleFactory.emptyShippingRule()) because we need to trigger this validation every time the AddOns selection changes for digital rewards as well - val isButtonEnabled = Observable.combineLatest( - backingShippingRule.startWith(ShippingRuleFactory.emptyShippingRule()), - addOnsFromBacking, - this.shippingRuleSelected, - this.currentSelection.take(1), - this.quantityPerId - ) { backedRule, backedList, actualRule, currentSelection: MutableMap, _ -> - return@combineLatest isDifferentLocation( - backedRule, - actualRule - ) || isDifferentSelection(backedList, currentSelection) - } - .distinctUntilChanged() - - isButtonEnabled - .subscribe { - this.isEnabledCTAButton.onNext(it) - } - .addToDisposable(disposables) - - val updatedPledgeDataAndReason = getUpdatedPledgeData( - this.addOnsListFiltered, - pledgeData, - pledgeReason, - reward, - this.shippingRuleSelected, - this.currentSelection.take(1), - this.continueButtonPressed - ) - - updatedPledgeDataAndReason - .compose>(takeWhenV2(this.continueButtonPressed)) - .subscribe { - this.analyticEvents.trackAddOnsContinueCTA(it.first) - this.pledgeFragmentData.onNext(it) - } - .addToDisposable(disposables) - - this.pledgeFragmentData - .distinctUntilChanged() - .subscribe { - this.showPledgeFragment.onNext(it) - } - .addToDisposable(disposables) - } - - private fun projectSlugAndLocation( - pj: Project, - reward: Reward, - shipRuleLocation: Location - ): Pair { - val projectSlug = pj.slug() - - // - Location for Shippable rewards must not be empty - return if (isShippable(reward) && shipRuleLocation.id() > 0 && !projectSlug.isNullOrEmpty()) { - Pair(projectSlug, shipRuleLocation) - } else if (!isShippable(reward) && !projectSlug.isNullOrEmpty()) { - Pair(projectSlug, shipRuleLocation) - } else Pair("", LocationFactory.empty()) // - In case some combination fails return empty slug and location - } - - /** - * Observable containing the correct shippingRule to each case - */ - private fun getSelectedShippingRule( - defaultShippingRule: Observable, - isSameReward: Observable, - backingShippingRule: Observable, - reward: Observable - ): Observable { - return Observable.combineLatest( - defaultShippingRule.startWith(ShippingRuleFactory.emptyShippingRule()), - isSameReward.startWith(false), - backingShippingRule.startWith(ShippingRuleFactory.emptyShippingRule()), - reward - ) { defaultShipping, sameRw, backingRule, rw -> - return@combineLatest chooseShippingRule(defaultShipping, backingRule, sameRw, rw) - } - } - - /** - * The use cases for populating the shipping rule selector: - * - First pledge or choosing another reward, we load in the shipping selector the default shipping rule - * - Digital or not shippable we return empty shippingRule to unify flow for all rewards types - * - Choosing to update same reward use the backing shippingRule - */ - private fun chooseShippingRule(defaultShipping: ShippingRule, backingShippingRule: ShippingRule, sameReward: Boolean, rw: Reward): ShippingRule = - when { - isDigital(rw) || !isShippable(rw) || isLocalPickup(rw) -> ShippingRuleFactory.emptyShippingRule() - sameReward -> backingShippingRule - else -> defaultShipping - } - - /** - * Updates the pledgeData object if necessary. This observable should - * emit only once, when the user press the continue button. As we will update - * the selected quantity into the concrete items. - * - * @return updatedPledgeData depending on the selected shipping rule, - * if any addOn has been selected - */ - private fun getUpdatedPledgeData( - filteredList: Observable, ShippingRule>>, - pledgeData: Observable, - pledgeReason: Observable, - reward: Observable, - shippingRule: Observable, - currentSelection: Observable>, - continueButtonPressed: Observable - ): Observable> { - return Observable.combineLatest(filteredList, pledgeData, pledgeReason, reward, shippingRule, currentSelection, continueButtonPressed) { - listAddOns, pData, pReason, rw, shipRule, selection, _ -> - - val updatedList = updateQuantity(listAddOns.second, selection) - val selectedAddOns = getSelectedAddOns(updatedList) - - val updatedPledgeData = updatePledgeData(selectedAddOns, rw, pData, shipRule) - return@combineLatest Pair(updatedPledgeData, pReason) - } - } - - /** - * Updated list filtering out the addOns with quantity higher than 1 - * @return selected addOns - */ - private fun getSelectedAddOns(updatedList: List): List { - return updatedList.filter { addOn -> - addOn.quantity()?.let { it > 0 } ?: false - } - } - - /** - * Update the pledgeData according to: - * - The user has selected addOns, the reward is digital or shippable - * - * @param finalList - * @param rw - * @param pledgeData - * @param shippingRule - * - * @return pledgeData - */ - private fun updatePledgeData(finalList: List, rw: Reward, pledgeData: PledgeData, shippingRule: ShippingRule) = - if (finalList.isNotEmpty()) { - if (isShippable(rw) && !isDigital(rw) && !isLocalPickup(rw)) { - pledgeData.toBuilder() - .addOns(finalList) - .shippingRule(shippingRule) - .build() - } else pledgeData.toBuilder() - .addOns(finalList) - .build() - } else { - pledgeData.toBuilder() - .build() - } - - /** - * Update the items in the list with the current selected amount. - * - * This function should be called when the user hits the button - * either to continue or skip addOns. - * We update the amount at this point in order to avoid re-build the - * entire list every time the selection for some concrete addOns change, - * which leads to re-triggering all the subscriptions. - * - * @param addOnsList -> actual addOns list - * @param currentSelection -> current selection of addOns - */ - private fun updateQuantity(addOnsList: List, currentSelection: MutableMap): List = - addOnsList.map { addOn -> - if (currentSelection.containsKey(addOn.id())) { - return@map addOn.toBuilder().quantity(currentSelection[addOn.id()]).build() - } else return@map addOn - } - - /** - * In case selecting the same reward, if any of the addOns is unavailable or - * has an invalid time range but has been backed do NOT filter out that addOn - * and allow to modify the selection. - * - * In case selecting another reward or new pledge, filter out the unavailable/invalid time range ones - * - * @param combinedList -> combinedList of Graph addOns and backed ones - * @return List -> filtered list depending on availability and time range if new pledge - * @return List -> not filtered if the addOn item was previously backed - */ - private fun filterOutUnAvailableOrEndedExceptIfBacked(combinedList: List): List { - return combinedList.filter { addOn -> - addOn.quantity()?.let { it > 0 } ?: (addOn.isAvailable() && RewardUtils.isValidTimeRange(addOn)) - } - } - - /** - * Extract the ID:quantity from the original baked AddOns list - * in case the ID's of those addOns and the quantity are the same - * as the current selected ones the selection is the same as the - * backed one. - * - * @param backedList -> addOns list from backing object - * @param currentSelection -> map holding addOns selection, on first load if backed addOns - * will hold the id and amount by id. - * @return Boolean -> true in case different selection or new item selected false otherwise - */ - private fun isDifferentSelection(backedList: List, currentSelection: MutableMap): Boolean { - - val backedSelection: MutableMap = mutableMapOf() - backedList - .map { - backedSelection.put(it.id(), it.quantity() ?: 0) - } - - val isBackedItemList = currentSelection.map { item -> - if (backedSelection.containsKey(item.key)) backedSelection[item.key] == item.value - else false - } - - val isNewItemSelected = currentSelection.map { item -> - if (!backedSelection.containsKey(item.key)) item.value > 0 - else false - }.any { it } - - val sameSelection = isBackedItemList.filter { it }.size == backedSelection.size - - return !sameSelection || isNewItemSelected - } - - private fun isDifferentLocation(backedRule: ShippingRule, actualRule: ShippingRule) = - backedRule.location()?.id() != actualRule.location()?.id() - - private fun calculateTotal(list: List) = - this.currentSelection - .take(1) - .map { map: MutableMap -> - var total = 0 - list.map { total += map[it.id()] ?: 0 } - return@map total - } - .subscribe { - this.totalSelectedAddOns.onNext(it) - } - - private fun joinSelectedWithAvailableAddOns(backingList: List, graphList: List): List { - return graphList - .map { graphAddOn -> - modifyIfBacked(backingList, graphAddOn) - } - } - - /** - * If the addOn is previously backed, return the backedAddOn containing - * in the field quantity the amount of backed addOns. - * Modify it to hold the shippingRules from the graphAddOn, that information - * is not available in backing -> addOns graphQL schema. - */ - private fun modifyIfBacked(backingList: List, graphAddOn: Reward): Reward { - return backingList.firstOrNull { it.id() == graphAddOn.id() }?.let { - return@let it.toBuilder().shippingRules(graphAddOn.shippingRules()).build() - } ?: graphAddOn - } - - private fun getBackingFromProjectData(pData: ProjectData?) = pData?.project()?.backing() - ?: pData?.backing() - - private fun updateQuantityById(updated: Pair) = - this.currentSelection - .take(1) - .subscribe { selection -> - selection[updated.second] = updated.first - } - - private fun defaultShippingRule(shippingRules: List): Observable { - return this.currentConfig.observable() - .map { it.countryCode() } - .map { countryCode -> - shippingRules.firstOrNull { it.location()?.country() == countryCode } - ?: shippingRules.first() - } - } - - // - This will disappear when the query is ready in the backend [CT-649] - private fun filterByLocation(addOns: List, pData: ProjectData, rule: ShippingRule, rw: Reward): Triple, ShippingRule> { - val filteredAddOns = when (rw.shippingPreference()) { - Reward.ShippingPreference.UNRESTRICTED.name, - Reward.ShippingPreference.UNRESTRICTED.name.lowercase(Locale.getDefault()) -> { - addOns.filter { - it.shippingPreferenceType() == Reward.ShippingPreference.UNRESTRICTED || containsLocation(rule, it) || isDigital(it) - } - } - Reward.ShippingPreference.RESTRICTED.name, - Reward.ShippingPreference.RESTRICTED.name.lowercase(Locale.getDefault()) -> { - addOns.filter { containsLocation(rule, it) || isDigital(it) } - } - Reward.ShippingPreference.LOCAL.name, - Reward.ShippingPreference.LOCAL.name.lowercase(Locale.getDefault()) -> { - addOns.filter { it.localReceiptLocation() == rw.localReceiptLocation() || isDigital(it) } - } - else -> { - if (isDigital(rw)) - addOns.filter { isDigital(it) } - else emptyList() - } - } - - return Triple(pData, filteredAddOns, rule) - } - - private fun containsLocation(rule: ShippingRule, reward: Reward): Boolean { - val idLocations = reward - .shippingRules() - ?.map { - it.location()?.id() - } ?: emptyList() - - return idLocations.contains(rule.location()?.id()) - } - - // - Inputs - override fun configureWith(pledgeDataAndReason: Pair) = this.pledgeDataAndReason.onNext(pledgeDataAndReason) - override fun shippingRuleSelected(shippingRule: ShippingRule) = this.shippingRuleSelected.onNext(shippingRule) - override fun continueButtonPressed() = this.continueButtonPressed.onNext(Unit) - override fun quantityPerId(quantityPerId: Pair) = this.quantityPerId.onNext(quantityPerId) - override fun retryButtonPressed() = this.retryButtonPressed.onNext(true) - - // - Outputs - override fun showPledgeFragment(): Observable> = this.showPledgeFragment - override fun addOnsList(): Observable, ShippingRule>> = this.addOnsListFiltered - override fun selectedShippingRule(): Observable = this.shippingRuleSelected - override fun shippingRulesAndProject(): Observable, Project>> = this.shippingRulesAndProject - override fun totalSelectedAddOns(): Observable = this.totalSelectedAddOns - override fun shippingSelectorIsGone(): Observable = this.shippingSelectorIsGone - override fun isEnabledCTAButton(): Observable = this.isEnabledCTAButton - override fun isEmptyState(): Observable = this.isEmptyState - override fun showErrorDialog(): Observable = this.showErrorDialog - - override fun onCleared() { - disposables.clear() - super.onCleared() - } - } - - @Suppress("UNCHECKED_CAST") - class Factory(private val environment: Environment, private val bundle: Bundle? = null) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return BackingAddOnsFragmentViewModel(environment, bundle = bundle) as T - } - } -} diff --git a/app/src/main/java/com/kickstarter/viewmodels/PledgeFragmentViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/PledgeFragmentViewModel.kt index 258473b965..655f4620e3 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/PledgeFragmentViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/PledgeFragmentViewModel.kt @@ -101,9 +101,6 @@ interface PledgeFragmentViewModel { /** Call when the user updates the pledge amount. */ fun pledgeInput(amount: String) - /** Call when the user updates the bonus amount. */ - fun bonusInput(amount: String) - /** Call when user clicks the pledge button. */ fun pledgeButtonClicked() @@ -364,7 +361,6 @@ interface PledgeFragmentViewModel { private val stripeSetupResultUnsuccessful = BehaviorSubject.create() private val decreaseBonusButtonClicked = BehaviorSubject.create() private val increaseBonusButtonClicked = BehaviorSubject.create() - private val bonusInput = BehaviorSubject.create() private val onRiskMessageDismissed = BehaviorSubject.create() private val addedCard = BehaviorSubject.create>() @@ -496,6 +492,23 @@ interface PledgeFragmentViewModel { val project = projectData .map { it.project() } + pledgeData + .map { + it.bonusAmount() + } + .subscribe { + this.bonusAmount.onNext(it.toString()) + }.addToDisposable(disposables) + + pledgeData + .filter { it.shippingRule().isNotNull() && it.shippingRule()?.location().isNotNull() } + .map { + requireNotNull(it.shippingRule()) + } + .subscribe { + this.shippingRule.onNext(it) + }.addToDisposable(disposables) + // Shipping rules section val shippingRules = BehaviorSubject.create>() this.selectedReward @@ -597,15 +610,6 @@ interface PledgeFragmentViewModel { } .addToDisposable(disposables) - backing - .filter { it.bonusAmount().isNotNull() } - .map { it.bonusAmount() } - .filter { it > 0 } - .subscribe { - this.bonusInput.onNext(it.toString()) - } - .addToDisposable(disposables) - this.selectedReward .compose>(combineLatestPair(pledgeReason)) .filter { it.second == PledgeReason.PLEDGE || it.second == PledgeReason.UPDATE_REWARD } @@ -800,51 +804,6 @@ interface PledgeFragmentViewModel { .subscribe { this.pledgeAmount.onNext(it) } .addToDisposable(disposables) - // Bonus stepper action - val bonusMinimum = Observable.just(0.0) - val bonusStepAmount = Observable.just(1.0) - - val bonusInput = Observable.merge(bonusMinimum, this.bonusInput.map { it.parseToDouble() }) - - bonusMinimum - .map { NumberUtils.format(it.toInt()) } - .distinctUntilChanged() - .subscribe { this.bonusHint.onNext(it) } - .addToDisposable(disposables) - - bonusInput - .compose(takeWhenV2(this.increaseBonusButtonClicked)) - .compose>(combineLatestPair(bonusStepAmount)) - .map { it.first + it.second } - .map { it.toString() } - .subscribe { this.bonusInput.onNext(it) } - .addToDisposable(disposables) - - bonusInput - .compose(takeWhenV2(this.decreaseBonusButtonClicked)) - .compose>(combineLatestPair(bonusStepAmount)) - .map { it.first - it.second } - .map { it.toString() } - .subscribe { this.bonusInput.onNext(it) } - .addToDisposable(disposables) - - bonusInput - .compose>(combineLatestPair(bonusMinimum)) - .map { max(it.first, it.second) > it.second } - .distinctUntilChanged() - .subscribe { this.decreaseBonusButtonIsEnabled.onNext(it) } - .addToDisposable(disposables) - - bonusInput - .map { - val formatter = NumberFormat.getNumberInstance() - formatter.maximumFractionDigits = 2 - formatter.format(it) - } - .distinctUntilChanged() - .subscribe { this.bonusAmount.onNext(it) } - .addToDisposable(disposables) - Observable.merge(this.decreaseBonusButtonClicked, this.decreasePledgeButtonClicked, this.increaseBonusButtonClicked, this.increasePledgeButtonClicked) .distinctUntilChanged() .subscribe { @@ -1655,6 +1614,7 @@ interface PledgeFragmentViewModel { PledgeReason.UPDATE_REWARD, PledgeReason.PLEDGE, PledgeReason.UPDATE_PAYMENT, + PledgeReason.LATE_PLEDGE, PledgeReason.FIX_PLEDGE -> hasCards PledgeReason.UPDATE_PLEDGE -> changedValues && hasCards } @@ -1760,6 +1720,7 @@ interface PledgeFragmentViewModel { PledgeReason.PLEDGE -> if (rw.hasAddons()) shippingCostForAddOns(listRw, rule) + rule.cost() else rule.cost() PledgeReason.FIX_PLEDGE, PledgeReason.UPDATE_PAYMENT, + PledgeReason.LATE_PLEDGE, PledgeReason.UPDATE_PLEDGE -> bShippingAmount?.toDouble() ?: rule.cost() } } @@ -1917,8 +1878,6 @@ interface PledgeFragmentViewModel { override fun stripeSetupResultUnsuccessful(exception: Exception) = this.stripeSetupResultUnsuccessful.onNext(exception) - override fun bonusInput(amount: String) = this.bonusInput.onNext(amount) - override fun paymentSheetResult(paymentResult: PaymentSheetResult) = this.paymentSheetResult.onNext( paymentResult ) diff --git a/app/src/main/java/com/kickstarter/viewmodels/RewardsFragmentViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/RewardsFragmentViewModel.kt index cc3541fa9c..d351752669 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/RewardsFragmentViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/RewardsFragmentViewModel.kt @@ -4,6 +4,7 @@ import android.util.Pair import androidx.annotation.VisibleForTesting import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope import com.kickstarter.libs.Environment import com.kickstarter.libs.rx.transformers.Transformers import com.kickstarter.libs.rx.transformers.Transformers.combineLatestPair @@ -17,15 +18,24 @@ import com.kickstarter.mock.factories.RewardFactory import com.kickstarter.models.Backing import com.kickstarter.models.Project import com.kickstarter.models.Reward +import com.kickstarter.models.ShippingRule import com.kickstarter.ui.data.PledgeData import com.kickstarter.ui.data.PledgeFlowContext import com.kickstarter.ui.data.PledgeReason import com.kickstarter.ui.data.ProjectData +import com.kickstarter.viewmodels.usecases.GetShippingRulesUseCase import com.kickstarter.viewmodels.usecases.SendThirdPartyEventUseCaseV2 +import com.kickstarter.viewmodels.usecases.ShippingRulesState import io.reactivex.Observable import io.reactivex.disposables.CompositeDisposable import io.reactivex.subjects.BehaviorSubject import io.reactivex.subjects.PublishSubject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.launch import java.util.Locale class RewardsFragmentViewModel { @@ -40,6 +50,8 @@ class RewardsFragmentViewModel { fun alertButtonPressed() fun isExpanded(state: Boolean?) + + fun selectedShippingRule(shippingRule: ShippingRule) } interface Outputs { @@ -49,9 +61,6 @@ class RewardsFragmentViewModel { /** Emits the current [ProjectData]. */ fun projectData(): Observable - /** Emits the count of the current project's rewards. */ - fun rewardsCount(): Observable - /** Emits when we should show the [com.kickstarter.ui.fragments.PledgeFragment]. */ fun showPledgeFragment(): Observable> @@ -62,7 +71,7 @@ class RewardsFragmentViewModel { fun showAlert(): Observable> } - class RewardsFragmentViewModel(val environment: Environment) : ViewModel(), Inputs, Outputs { + class RewardsFragmentViewModel(val environment: Environment, private var shippingRulesUseCase: GetShippingRulesUseCase? = null) : ViewModel(), Inputs, Outputs { private val isExpanded = PublishSubject.create() private val projectDataInput = BehaviorSubject.create() @@ -71,17 +80,18 @@ class RewardsFragmentViewModel { private val backedRewardPosition = PublishSubject.create() private val projectData = BehaviorSubject.create() - private val rewardsCount = BehaviorSubject.create() private val pledgeData = PublishSubject.create>() private val showPledgeFragment = PublishSubject.create>() private val showAddOnsFragment = PublishSubject.create>() private val showAlert = PublishSubject.create>() + private var selectedShippingRule: ShippingRule? = null private val sharedPreferences = requireNotNull(environment.sharedPreferences()) private val ffClient = requireNotNull(environment.featureFlagClient()) private val apolloClient = requireNotNull(environment.apolloClientV2()) private val currentUser = requireNotNull(environment.currentUserV2()) private val analyticEvents = requireNotNull(environment.analytics()) + private val configObservable = requireNotNull(environment.currentConfigV2()?.observable()) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) val onThirdPartyEventSent = BehaviorSubject.create() @@ -91,14 +101,6 @@ class RewardsFragmentViewModel { private val disposables = CompositeDisposable() - /*** - * setState method brought from BaseFragment.java - */ - fun setState(state: Boolean?) { - state?.let { - this.isExpanded.onNext(it) - } - } init { this.isExpanded @@ -114,7 +116,9 @@ class RewardsFragmentViewModel { this.projectDataInput .filter { sortAndFilterRewards(it).isNotNull() } .map { sortAndFilterRewards(it) } - .subscribe { this.projectData.onNext(it) } + .subscribe { + this.projectData.onNext(it) + } .addToDisposable(disposables) val project = this.projectData @@ -162,7 +166,11 @@ class RewardsFragmentViewModel { if (!rewardPair.second) { return@combineLatest Unit } else { - return@combineLatest pledgeDataAndPledgeReason(projectData, rewardPair.first) + return@combineLatest pledgeDataAndPledgeReason( + projectData, + rewardPair.first, + selectedShippingRule + ) } } .filter { it.isNotNull() && it is Pair<*, *> && it.first is PledgeData && it.second is PledgeReason } @@ -190,11 +198,15 @@ class RewardsFragmentViewModel { if (!rewardPair.second) { return@combineLatest Unit } else { - return@combineLatest Pair(pledgeDataAndPledgeReason(projectData, rewardPair.first), backedReward) + return@combineLatest Pair(pledgeDataAndPledgeReason(projectData, rewardPair.first, selectedShippingRule), backedReward) } } - .filter { it.isNotNull() && it is Pair<*, *> && it.first is Pair<*, *> && it.second is Reward } // todo extract to a function - .map { requireNotNull(it as Pair, Reward>) } + .filter { + it.isNotNull() && it is Pair<*, *> && it.first is Pair<*, *> && it.second is Reward + } // todo extract to a function + .map { + requireNotNull(it as Pair, Reward>) + } .subscribe { val pledgeAndData = it.first val newRw = it.first.first.reward() @@ -224,11 +236,6 @@ class RewardsFragmentViewModel { } .addToDisposable(disposables) - project - .map { it.rewards()?.size ?: 0 } - .subscribe { this.rewardsCount.onNext(it) } - .addToDisposable(disposables) - this.showAlert .compose>(takeWhenV2(alertButtonPressed)) .subscribe { @@ -244,6 +251,27 @@ class RewardsFragmentViewModel { this.showPledgeFragment.onNext(it) } .addToDisposable(disposables) + + Observable.combineLatest(configObservable, project) { config, project -> + if (shippingRulesUseCase == null) { + shippingRulesUseCase = GetShippingRulesUseCase( + apolloClient, + project, + config, + viewModelScope, + Dispatchers.IO + ) + } + shippingRulesUseCase?.let { useCaseState -> + useCaseState.invoke() + useCaseState.getScope().launch(useCaseState.getDispatcher()) { + shippingRulesUseCase?.shippingRulesState?.collectLatest { + selectedShippingRule = it.selectedShippingRule + } + } + } + return@combineLatest Observable.empty() + }.subscribe().addToDisposable(disposables) } private fun sortAndFilterRewards(pData: ProjectData): ProjectData { @@ -279,9 +307,13 @@ class RewardsFragmentViewModel { } } - private fun pledgeDataAndPledgeReason(projectData: ProjectData, reward: Reward): Pair { + private fun pledgeDataAndPledgeReason( + projectData: ProjectData, + reward: Reward, + selectedShippingRule: ShippingRule? + ): Pair { val pledgeReason = if (projectData.project().isBacking()) PledgeReason.UPDATE_REWARD else PledgeReason.PLEDGE - val pledgeData = PledgeData.with(PledgeFlowContext.forPledgeReason(pledgeReason), projectData, reward) + val pledgeData = PledgeData.with(PledgeFlowContext.forPledgeReason(pledgeReason), projectData, reward, shippingRule = selectedShippingRule) return Pair(pledgeData, pledgeReason) } @@ -310,6 +342,10 @@ class RewardsFragmentViewModel { this.rewardClicked.onNext(Pair(reward, true)) } + override fun selectedShippingRule(shippingRule: ShippingRule) { + this.shippingRulesUseCase?.filterBySelectedRule(shippingRule) + } + override fun onCleared() { disposables.clear() super.onCleared() @@ -321,18 +357,23 @@ class RewardsFragmentViewModel { override fun projectData(): Observable = this.projectData - override fun rewardsCount(): Observable = this.rewardsCount - override fun showPledgeFragment(): Observable> = this.showPledgeFragment override fun showAddOnsFragment(): Observable> = this.showAddOnsFragment override fun showAlert(): Observable> = this.showAlert + + fun countrySelectorRules(): Flow { + val state = shippingRulesUseCase?.let { useCase -> + useCase.shippingRulesState + } ?: emptyFlow() + return state + } } - class Factory(private val environment: Environment) : ViewModelProvider.Factory { + class Factory(private val environment: Environment, private var shippingRulesUseCase: GetShippingRulesUseCase? = null) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return RewardsFragmentViewModel(environment) as T + return RewardsFragmentViewModel(environment, shippingRulesUseCase) as T } } } diff --git a/app/src/main/java/com/kickstarter/viewmodels/projectpage/AddOnsViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/projectpage/AddOnsViewModel.kt index 925ac7be3f..9b87bb565d 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/projectpage/AddOnsViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/AddOnsViewModel.kt @@ -1,16 +1,27 @@ package com.kickstarter.viewmodels.projectpage +import android.os.Bundle import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.kickstarter.libs.Environment import com.kickstarter.libs.utils.RewardUtils -import com.kickstarter.mock.factories.ShippingRuleFactory -import com.kickstarter.models.Location +import com.kickstarter.libs.utils.extensions.isNotNull +import com.kickstarter.libs.utils.extensions.pledgeAmountTotalPlusBonus +import com.kickstarter.mock.factories.LocationFactory +import com.kickstarter.mock.factories.RewardFactory +import com.kickstarter.models.Backing +import com.kickstarter.models.Project import com.kickstarter.models.Reward import com.kickstarter.models.ShippingRule +import com.kickstarter.ui.ArgumentsKey +import com.kickstarter.ui.data.PledgeData +import com.kickstarter.ui.data.PledgeFlowContext +import com.kickstarter.ui.data.PledgeReason import com.kickstarter.ui.data.ProjectData -import io.reactivex.Observable +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -25,29 +36,37 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.rx2.asFlow data class AddOnsUIState( - var currentShippingRule: ShippingRule = ShippingRule.builder().build(), - var shippingSelectorIsGone: Boolean = false, - var currentAddOnsSelection: MutableMap = mutableMapOf(), - val addOns: List = listOf(), - val shippingRules: List = listOf(), - val isLoading: Boolean = false + val addOns: List = emptyList(), + val totalCount: Int = 0, + val isLoading: Boolean = false, + val shippingRule: ShippingRule = ShippingRule.builder().build(), + val totalPledgeAmount: Double = 0.0, + val totalBonusAmount: Double = 0.0 ) -class AddOnsViewModel(val environment: Environment) : ViewModel() { - private val currentConfig = requireNotNull(environment.currentConfigV2()) +class AddOnsViewModel(val environment: Environment, bundle: Bundle? = null) : ViewModel() { private val apolloClient = requireNotNull(environment.apolloClientV2()) - private val mutableAddOnsUIState = MutableStateFlow(AddOnsUIState()) - private var addOns: List = listOf() private var currentUserReward: Reward = Reward.builder().build() - private var defaultShippingRule: ShippingRule = ShippingRule.builder().build() - private var currentShippingRule: ShippingRule = ShippingRule.builder().build() - private var shippingSelectorIsGone: Boolean = false - private var currentAddOnsSelections: MutableMap = mutableMapOf() - private var shippingRules: List = listOf() - private lateinit var projectData: ProjectData + private var pledgeData: PledgeData? = null + private var projectData: ProjectData? = null + private var project: Project = Project.builder().build() + private var shippingRule: ShippingRule = ShippingRule.builder().build() + private var pledgeflowcontext: PledgeFlowContext = PledgeFlowContext.NEW_PLEDGE + private var bonusAmount: Double = 0.0 + private var pReason: PledgeReason = PledgeReason.PLEDGE + private var backing: Backing? = null + + private var addOns: List = listOf() private var errorAction: (message: String?) -> Unit = {} + private val currentSelection = mutableMapOf() + + private var backedAddOns = emptyList() + private var scope: CoroutineScope = viewModelScope + private var dispatcher: CoroutineDispatcher = Dispatchers.IO + + private val mutableAddOnsUIState = MutableStateFlow(AddOnsUIState()) val addOnsUIState: StateFlow get() = mutableAddOnsUIState .asStateFlow() @@ -61,160 +80,226 @@ class AddOnsViewModel(val environment: Environment) : ViewModel() { this.errorAction = errorAction } - fun provideProjectData(projectData: ProjectData) { - this.projectData = projectData + /** + * By default run in + * scope: viewModelScope + * dispatcher: Dispatchers.IO + */ + fun provideScopeAndDispatcher(scope: CoroutineScope, dispatcher: CoroutineDispatcher) { + this.scope = scope + this.dispatcher = dispatcher + } - viewModelScope.launch { - projectData.project().rewards()?.let { rewards -> - if (rewards.isNotEmpty()) { - val reward = rewards.firstOrNull { theOne -> - !theOne.isAddOn() && theOne.isAvailable() && RewardUtils.isShippable(theOne) - } - reward?.let { - apolloClient.getShippingRules(reward = reward) - .asFlow() - .collect { shippingRulesEnvelope -> - shippingRules = shippingRulesEnvelope.shippingRules() - recalculateShippingRule() - } + /** + * Used in Crowdfund checkout + */ + fun provideBundle(bundle: Bundle?) { + bundle?.let { + pledgeData = it.getParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA) as PledgeData? + + // Send analytic event for crowdfund checkout + this.sendEvent() + + pReason = it.getSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON) as PledgeReason + + pledgeData?.projectData()?.let { + projectData = it + } + + // New pledge, selected reward + pledgeData?.reward()?.let { rw -> + currentUserReward = rw + bonusAmount = RewardUtils.minPledgeAmount(rw, project) + } + + projectData?.project()?.let { + project = it + } + + pledgeData?.shippingRule()?.let { + shippingRule = it + } + + pledgeData?.pledgeFlowContext()?.let { pFContext -> + pledgeflowcontext = pFContext + } + + backing = pledgeData?.projectData()?.backing() ?: project.backing() + + if (pReason == PledgeReason.UPDATE_REWARD && backing?.reward()?.id() != currentUserReward.id()) { + // Do nothing, user is selecting a different reward/addOns ... + } else { + // User is selecting a same reward with AddOns, might just adding/deleting addOns, bonus support ... + backing?.let { b -> + // - backed a reward no reward + if (b.reward() == null && b.amount().isNotNull()) { + currentUserReward = + RewardFactory.noReward().toBuilder().pledgeAmount(b.amount()).build() + bonusAmount = b.amount() + } else { + backedAddOns = b.addOns() ?: emptyList() + currentUserReward = b.reward() ?: currentUserReward + bonusAmount = b.bonusAmount() } } } + + getAddOns(shippingRule) } } - private fun getAddOns(noShippingRule: Boolean) { - viewModelScope.launch { - apolloClient - .getProjectAddOns( - slug = projectData.project().slug() ?: "", - locationId = currentShippingRule.location() ?: defaultShippingRule.location() ?: Location.builder().build() - ).asFlow() - .onStart { - emitCurrentState(isLoading = true) - }.map { addOns -> - if (!addOns.isNullOrEmpty()) { - if (noShippingRule) { - this@AddOnsViewModel.addOns = addOns.filter { !RewardUtils.isShippable(it) } - } else { - this@AddOnsViewModel.addOns = addOns - } - } - }.onCompletion { - emitCurrentState() - }.catch { - errorAction.invoke(null) - }.collect() + /** + * Used in late pledges + */ + fun provideProjectData(projectData: ProjectData) { + this.projectData = projectData + val isLatePledge = projectData.project().postCampaignPledgingEnabled() == true && projectData.project().isInPostCampaignPledgingPhase() == true + val flowContext = if (isLatePledge) PledgeFlowContext.forPledgeReason(PledgeReason.LATE_PLEDGE) else PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) + this.pledgeflowcontext = flowContext + this.pledgeData = PledgeData.with( + projectData = projectData, + pledgeFlowContext = flowContext, + reward = currentUserReward + ) + this.projectData?.project()?.let { + project = it } } - private fun getDefaultShippingRule(shippingRules: List): Observable { - return this.currentConfig.observable() - .map { it.countryCode() } - .map { countryCode -> - if (shippingRules.isNotEmpty()) { - shippingRules.firstOrNull { it.location()?.country() == countryCode } - ?: shippingRules.first() - } else { - ShippingRule.builder().build() - } - } + /** + * Used in late pledges + */ + fun provideSelectedShippingRule(shippingRule: ShippingRule) { + if (this.shippingRule != shippingRule) { + this.shippingRule = shippingRule + getAddOns(selectedShippingRule = shippingRule) + } } - private fun getSelectedShippingRule( - defaultShippingRule: ShippingRule, - reward: Reward - ): ShippingRule { - return chooseShippingRule(defaultShippingRule, reward) + private fun getAddOns(selectedShippingRule: ShippingRule) { + // - Do not execute call unless reward has addOns + if (currentUserReward.hasAddons()) { + scope.launch(dispatcher) { + apolloClient + .getProjectAddOns( + slug = project.slug() ?: "", + locationId = selectedShippingRule.location() ?: LocationFactory.empty() + ) + .asFlow() + .onStart { + emitCurrentState(isLoading = true) + } + .map { addOns -> + if (!addOns.isNullOrEmpty()) { + this@AddOnsViewModel.addOns = getUpdatedList(addOns, backedAddOns) + } + }.onCompletion { + emitCurrentState(isLoading = false) + }.catch { + errorAction.invoke(null) + }.collect() + } + } else { + scope.launch { + emitCurrentState(isLoading = false) + } + } } - private fun chooseShippingRule(defaultShipping: ShippingRule, rw: Reward): ShippingRule = - when { - RewardUtils.isDigital(rw) || !RewardUtils.isShippable(rw) || RewardUtils.isLocalPickup( - rw - ) -> ShippingRuleFactory.emptyShippingRule() - else -> defaultShipping + /** + * List of available addOns, updated for those backed addOns with the Backed information + * such as quantity backed. + */ + private fun getUpdatedList(addOns: List, backedAddOns: List): List { + val holder = mutableMapOf() + // First Store all addOns, Key should be the addOns ID + addOns.map { + holder[it.id()] = it } - // UI events - - fun userRewardSelection(reward: Reward) { - // A new reward has been selected, so clear out any previous addons selection - this.currentAddOnsSelections = mutableMapOf() - shippingSelectorIsGone = - RewardUtils.isDigital(reward) || !RewardUtils.isShippable(reward) || RewardUtils.isLocalPickup(reward) + // Take the backed AddOns, update with the backed AddOn information which will contain the backed quantity + backedAddOns.map { + holder[it.id()] = it + currentSelection[it.id()] = it.quantity() ?: 0 + } - currentUserReward = reward + return holder.values.toList() + } - recalculateShippingRule() + // - User has selected a different reward clear previous states + fun userRewardSelection(reward: Reward) { + if (reward != currentUserReward) { + currentUserReward = reward + currentSelection.clear() + bonusAmount = 0.0 - viewModelScope.launch { - emitCurrentState() + scope.launch { + emitCurrentState() + } } } - fun onShippingLocationChanged(shippingRule: ShippingRule) { - currentShippingRule = shippingRule - // A new location has been selected, so clear out any previous addons selection - this.currentAddOnsSelections = mutableMapOf() + private suspend fun emitCurrentState(isLoading: Boolean = false) { + mutableAddOnsUIState.emit( + AddOnsUIState( + addOns = addOns, + isLoading = isLoading, + totalCount = currentSelection.values.sum(), + shippingRule = shippingRule, + totalPledgeAmount = calculateTotalPledgeAmount(), + totalBonusAmount = bonusAmount + ) + ) + } - viewModelScope.launch { - emitCurrentState() + /** + * Callback to update the selected addOns quantity + */ + fun updateSelection(rewardId: Long, quantity: Int) { + scope.launch { + currentSelection[rewardId] = quantity + emitCurrentState(isLoading = false) } - - getAddOns(noShippingRule = shippingSelectorIsGone) } - fun onAddOnsAddedOrRemoved(currentAddOnsSelections: MutableMap) { - this.currentAddOnsSelections = currentAddOnsSelections - viewModelScope.launch { - emitCurrentState() + fun getPledgeDataAndReason(): Pair? { + val selectedAddOns = mutableListOf() + addOns.forEach { + val amount = currentSelection[it.id()] + if (amount != null) { + selectedAddOns.add(it.toBuilder().quantity(amount).build()) + } } - } - private fun recalculateShippingRule() { - viewModelScope.launch { - getDefaultShippingRule(shippingRules) - .asFlow() - .catch { - errorAction.invoke(null) - } - .collect { default -> - defaultShippingRule = default - currentShippingRule = defaultShippingRule - - val newShippingRule = getSelectedShippingRule(defaultShippingRule, currentUserReward) - val oldShippingRule = currentShippingRule - if (newShippingRule.location()?.id() != oldShippingRule.location()?.id() && newShippingRule.cost() != oldShippingRule.cost()) { - currentShippingRule = newShippingRule - } - emitCurrentState() - } + return projectData?.let { + Pair( + PledgeData.with(pledgeflowcontext, it, currentUserReward, selectedAddOns.toList(), shippingRule, bonusAmount = bonusAmount), + pReason + ) } + } - viewModelScope.launch { - emitCurrentState() + fun getProject() = this.project + fun getSelectedReward() = this.currentUserReward + fun sendEvent() = this.pledgeData?.let { + environment.analytics()?.trackAddOnsScreenViewed(it) + } + + fun bonusAmountUpdated(bAmount: Double) { + scope.launch { + bonusAmount = bAmount + emitCurrentState(isLoading = false) } - getAddOns(noShippingRule = shippingSelectorIsGone) } - private suspend fun emitCurrentState(isLoading: Boolean = false) { - mutableAddOnsUIState.emit( - AddOnsUIState( - currentShippingRule = currentShippingRule, - shippingSelectorIsGone = shippingSelectorIsGone, - currentAddOnsSelection = currentAddOnsSelections, - addOns = addOns, - shippingRules = shippingRules, - isLoading = isLoading - ) - ) + private fun calculateTotalPledgeAmount(): Double { + return getPledgeDataAndReason()?.first?.pledgeAmountTotalPlusBonus() ?: 0.0 } - class Factory(private val environment: Environment) : + class Factory(private val environment: Environment, private val bundle: Bundle? = null) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return AddOnsViewModel(environment) as T + return AddOnsViewModel(environment, bundle) as T } } } diff --git a/app/src/main/java/com/kickstarter/viewmodels/projectpage/CheckoutFlowViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/projectpage/CheckoutFlowViewModel.kt index 176a725cf1..ee2d55dd9a 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/projectpage/CheckoutFlowViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/CheckoutFlowViewModel.kt @@ -48,8 +48,8 @@ class CheckoutFlowViewModel(val environment: Environment) : ViewModel() { when (currentPage) { // From Checkout Screen 3 -> { - // To Confirm Details - mutableFlowUIState.emit(FlowUIState(currentPage = 2, expanded = true)) + // To AddOns + mutableFlowUIState.emit(FlowUIState(currentPage = 1, expanded = true)) } // From Confirm Details Screen @@ -85,26 +85,23 @@ class CheckoutFlowViewModel(val environment: Environment) : ViewModel() { } } - fun onConfirmDetailsContinueClicked(logInCallback: () -> Unit) { + fun onContinueClicked(logInCallback: () -> Unit, continueCallback: () -> Unit) { viewModelScope.launch { currentUser.isLoggedIn .asFlow() .collect { userLoggedIn -> // - Show checkout page - if (userLoggedIn) mutableFlowUIState.emit(FlowUIState(currentPage = 4, expanded = true)) + if (userLoggedIn) { + mutableFlowUIState.emit(FlowUIState(currentPage = 4, expanded = true)) + // - In case after login some action is required + continueCallback() + } // - Trigger LoginFlow callback else logInCallback() } } } - fun onAddOnsContinueClicked() { - viewModelScope.launch { - // Go to confirm page - mutableFlowUIState.emit(FlowUIState(currentPage = 2, expanded = true)) - } - } - fun onProjectSuccess() { viewModelScope.launch { // Open Flow @@ -112,6 +109,9 @@ class CheckoutFlowViewModel(val environment: Environment) : ViewModel() { } } + fun onContinueClicked(continueCallback: () -> Unit) { + } + class Factory(private val environment: Environment) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { diff --git a/app/src/main/java/com/kickstarter/viewmodels/projectpage/ConfirmDetailsViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/projectpage/ConfirmDetailsViewModel.kt deleted file mode 100644 index 76f652cbd0..0000000000 --- a/app/src/main/java/com/kickstarter/viewmodels/projectpage/ConfirmDetailsViewModel.kt +++ /dev/null @@ -1,331 +0,0 @@ -package com.kickstarter.viewmodels.projectpage - -import android.util.Pair -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.kickstarter.libs.Environment -import com.kickstarter.libs.models.Country -import com.kickstarter.libs.utils.RewardUtils -import com.kickstarter.models.CheckoutPayment -import com.kickstarter.models.Reward -import com.kickstarter.models.ShippingRule -import com.kickstarter.services.mutations.CreateCheckoutData -import com.kickstarter.ui.data.PledgeData -import com.kickstarter.ui.data.PledgeFlowContext -import com.kickstarter.ui.data.PledgeReason -import com.kickstarter.ui.data.ProjectData -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.rx2.asFlow - -data class ConfirmDetailsUIState( - val rewardsAndAddOns: List = listOf(), - val initialBonusSupportAmount: Double = 0.0, - val finalBonusSupportAmount: Double = 0.0, - val shippingAmount: Double = 0.0, - val totalAmount: Double = 0.0, - val minStepAmount: Double = 0.0, - val maxPledgeAmount: Double = 0.0, - val isLoading: Boolean = false -) - -class ConfirmDetailsViewModel(val environment: Environment) : ViewModel() { - - private val apolloClient = requireNotNull(environment.apolloClientV2()) - - private lateinit var projectData: ProjectData - private lateinit var userSelectedReward: Reward - private var rewardAndAddOns: List = listOf() - private var pledgeReason: PledgeReason? = null - private lateinit var selectedShippingRule: ShippingRule - private var initialBonusSupport = 0.0 - private var addedBonusSupport = 0.0 - private var shippingAmount: Double = 0.0 - private var totalAmount: Double = 0.0 - private var minStepAmount: Double = 0.0 - private var maxPledgeAmount: Double = 0.0 - private var errorAction: (message: String?) -> Unit = {} - - private val mutableConfirmDetailsUIState = MutableStateFlow(ConfirmDetailsUIState()) - val confirmDetailsUIState: StateFlow - get() = mutableConfirmDetailsUIState - .asStateFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(), - initialValue = ConfirmDetailsUIState() - ) - - private val mutableCheckoutPayment = - MutableStateFlow(CheckoutPayment(id = 0L, paymentUrl = null, backing = null)) - val checkoutPayment: StateFlow - get() = mutableCheckoutPayment - .asStateFlow() - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(), - initialValue = CheckoutPayment(id = 0L, paymentUrl = null, null) - ) - - fun provideProjectData(projectData: ProjectData) { - this.projectData = projectData - viewModelScope.launch { - val country = Country.findByCurrencyCode(projectData.project().currency()) - country?.let { - minStepAmount = it.minPledge.toDouble() - maxPledgeAmount = it.maxPledge.toDouble() - } - } - } - - fun onUserSelectedReward(reward: Reward) { - this.userSelectedReward = reward - if (RewardUtils.isNoReward(reward)) { - rewardAndAddOns = listOf() - initialBonusSupport = minStepAmount - } else { - rewardAndAddOns = listOf(userSelectedReward) - initialBonusSupport = 0.0 - } - if (::projectData.isInitialized) { - pledgeReason = pledgeDataAndPledgeReason(projectData, reward).second - } - addedBonusSupport = 0.0 - - updateShippingAmount() - - totalAmount = calculateTotal() - - viewModelScope.launch { - emitCurrentState() - } - } - - fun onUserUpdatedAddOns(addOns: Map) { - val rewardsAndAddOns = mutableListOf() - if (::userSelectedReward.isInitialized && !RewardUtils.isNoReward(userSelectedReward)) { - rewardsAndAddOns.add(userSelectedReward) - } - - addOns.forEach { rewardAndQuantity -> - if (rewardAndQuantity.value > 0) { - rewardsAndAddOns.add( - rewardAndQuantity.key.toBuilder().quantity(rewardAndQuantity.value).build() - ) - } - } - - rewardAndAddOns = rewardsAndAddOns - - updateShippingAmount() - - totalAmount = calculateTotal() - - viewModelScope.launch { - emitCurrentState() - } - } - - fun provideErrorAction(errorAction: (message: String?) -> Unit) { - this.errorAction = errorAction - } - - private fun calculateTotal(): Double { - var total = 0.0 - total += getRewardsTotalAmount(rewardAndAddOns) - total += RewardUtils.getFinalBonusSupportAmount(addedBonusSupport, initialBonusSupport) - if (::userSelectedReward.isInitialized) { - total += - if (RewardUtils.isNoReward(userSelectedReward)) 0.0 - else if (RewardUtils.isShippable(userSelectedReward)) shippingAmount - else 0.0 - } - return total - } - - private fun updateShippingAmount() { - if (::selectedShippingRule.isInitialized) { - shippingAmount = getShippingAmount( - rule = selectedShippingRule, - reason = pledgeReason, - bShippingAmount = null, - rewards = rewardAndAddOns - ) - } else shippingAmount = 0.0 - } - - /** - * Calculate the shipping amount in case of shippable reward and reward + AddOns - */ - private fun getShippingAmount( - rule: ShippingRule, - reason: PledgeReason? = null, - bShippingAmount: Float? = null, - rewards: List - ): Double { - return when (reason) { - PledgeReason.UPDATE_REWARD, - PledgeReason.PLEDGE -> if (rewards.any { it.isAddOn() }) shippingCostForAddOns( - rewards, - rule - ) + rule.cost() else rule.cost() - - else -> bShippingAmount?.toDouble() ?: rule.cost() - } - } - - private fun shippingCostForAddOns(listRw: List, selectedRule: ShippingRule): Double { - var shippingCost = 0.0 - listRw.filter { - it.isAddOn() - }.map { rw -> - rw.shippingRules()?.filter { rule -> - rule.location()?.id() == selectedRule.location()?.id() - }?.map { rule -> - shippingCost += rule.cost() * (rw.quantity() ?: 1) - } - } - - return shippingCost - } - - private fun pledgeDataAndPledgeReason( - projectData: ProjectData, - reward: Reward - ): Pair { - val pledgeReason = - if (projectData.project().isBacking()) PledgeReason.UPDATE_REWARD - else PledgeReason.PLEDGE - val pledgeData = - PledgeData.with(PledgeFlowContext.forPledgeReason(pledgeReason), projectData, reward) - return Pair(pledgeData, pledgeReason) - } - - private fun getRewardsTotalAmount(rewards: List): Double { - var total = 0.0 - rewards.forEach { reward -> - reward.quantity()?.let { quantity -> - total += (reward.minimum() * quantity) - } ?: run { - total += reward.minimum() - } - } - return total - } - - fun incrementBonusSupport() { - addedBonusSupport += minStepAmount - totalAmount = calculateTotal() - viewModelScope.launch { - emitCurrentState() - } - } - - fun decrementBonusSupport() { - if ((addedBonusSupport + initialBonusSupport) - minStepAmount >= initialBonusSupport) { - addedBonusSupport -= minStepAmount - totalAmount = calculateTotal() - viewModelScope.launch { - emitCurrentState() - } - } - } - - fun inputBonusSupport(input: Double) { - addedBonusSupport = input - totalAmount = calculateTotal() - viewModelScope.launch { - emitCurrentState() - } - } - - fun onContinueClicked(defaultAction: () -> Unit) { - if (projectData.project().postCampaignPledgingEnabled() == true && projectData.project() - .isInPostCampaignPledgingPhase() == true - ) { - viewModelScope.launch { - emitCurrentState(isLoading = true) - apolloClient.createCheckout( - CreateCheckoutData( - project = projectData.project(), - amount = totalAmount.toString(), - locationId = if (::selectedShippingRule.isInitialized) selectedShippingRule.location() - ?.id()?.toString() else null, - rewardsIds = fullIdListForQuantities(rewardAndAddOns), - refTag = projectData.refTagFromIntent() - ) - ) - .asFlow() - .map { checkoutPayment -> - mutableCheckoutPayment.emit(checkoutPayment) - } - .catch { - errorAction.invoke(null) - } - .onCompletion { - emitCurrentState() - } - .collect() - } - } else { - defaultAction.invoke() - } - } - - private fun fullIdListForQuantities(flattenedList: List): List { - val mutableList = mutableListOf() - - flattenedList.map { - if (!it.isAddOn()) mutableList.add(it) - else { - val q = it.quantity() ?: 1 - for (i in 1..q) { - mutableList.add(it) - } - } - } - - return mutableList.toList() - } - - fun provideCurrentShippingRule(shippingRule: ShippingRule) { - selectedShippingRule = shippingRule - updateShippingAmount() - totalAmount = calculateTotal() - - viewModelScope.launch { - emitCurrentState() - } - } - - private suspend fun emitCurrentState(isLoading: Boolean = false) { - mutableConfirmDetailsUIState.emit( - ConfirmDetailsUIState( - rewardsAndAddOns = rewardAndAddOns, - initialBonusSupportAmount = initialBonusSupport, - finalBonusSupportAmount = RewardUtils.getFinalBonusSupportAmount(addedBonusSupport, initialBonusSupport), - shippingAmount = shippingAmount, - totalAmount = totalAmount, - minStepAmount = minStepAmount, - maxPledgeAmount = maxPledgeAmount, - isLoading = isLoading - ) - ) - } - - class Factory(private val environment: Environment) : - ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return ConfirmDetailsViewModel(environment) as T - } - } -} diff --git a/app/src/main/java/com/kickstarter/viewmodels/projectpage/CrowdfundCheckoutViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/projectpage/CrowdfundCheckoutViewModel.kt new file mode 100644 index 0000000000..1d1f9ef6e2 --- /dev/null +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/CrowdfundCheckoutViewModel.kt @@ -0,0 +1,526 @@ +package com.kickstarter.viewmodels.projectpage + +import android.os.Bundle +import android.util.Pair +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.kickstarter.libs.Environment +import com.kickstarter.libs.RefTag +import com.kickstarter.libs.utils.RefTagUtils +import com.kickstarter.libs.utils.RewardUtils +import com.kickstarter.libs.utils.ThirdPartyEventValues +import com.kickstarter.libs.utils.extensions.checkoutTotalAmount +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.Location +import com.kickstarter.models.Project +import com.kickstarter.models.Reward +import com.kickstarter.models.ShippingRule +import com.kickstarter.models.StoredCard +import com.kickstarter.models.User +import com.kickstarter.models.extensions.getBackingData +import com.kickstarter.models.extensions.isFromPaymentSheet +import com.kickstarter.services.mutations.getUpdateBackingData +import com.kickstarter.ui.ArgumentsKey +import com.kickstarter.ui.data.CheckoutData +import com.kickstarter.ui.data.PledgeData +import com.kickstarter.ui.data.PledgeFlowContext +import com.kickstarter.ui.data.PledgeReason +import com.kickstarter.viewmodels.usecases.SendThirdPartyEventUseCaseV2 +import com.stripe.android.paymentsheet.PaymentSheetResult +import io.reactivex.Observable +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx2.asFlow +import type.CreditCardPaymentType + +data class CheckoutUIState( + val storeCards: List = listOf(), + val userEmail: String = "", + val isLoading: Boolean = false, + val selectedRewards: List = emptyList(), + val shippingAmount: Double = 0.0, + val checkoutTotal: Double = 0.0, + val isPledgeButtonEnabled: Boolean = true, + val selectedPaymentMethod: StoredCard = StoredCard.builder().build(), + val bonusAmount: Double = 0.0, + val shippingRule: ShippingRule? = null +) + +data class PaymentSheetPresenterState(val setupClientId: String = "") +class CrowdfundCheckoutViewModel(val environment: Environment, bundle: Bundle? = null) : ViewModel() { + val analytics = requireNotNull(environment.analytics()) + val apolloClient = requireNotNull(environment.apolloClientV2()) + val currentUser = requireNotNull(environment.currentUserV2()?.loggedInUser()?.asFlow()) + val cookieManager = requireNotNull(environment.cookieManager()) + val sharedPreferences = requireNotNull(environment.sharedPreferences()) + val ffClient = requireNotNull(environment.featureFlagClient()) + + private var pledgeData: PledgeData? = null + private var checkoutData: CheckoutData? = null // TOD potentially needs to change with user card input + private var pledgeReason: PledgeReason? = null + private var storedCards = emptyList() + private var project = Project.builder().build() + private var backing: Backing? = null + private var user: User? = null + private var selectedRewards = emptyList() + private var selectedPaymentMethod: StoredCard = StoredCard.builder().build() + private var shippingRule: ShippingRule? = null + private var refTag: RefTag? = null + private var shippingAmount = 0.0 + private var totalAmount = 0.0 + private var bonusAmount = 0.0 + private var thirdPartyEventSent = Pair(false, "") + + private var errorAction: (message: String?) -> Unit = {} + + private var scope: CoroutineScope = viewModelScope + private var dispatcher: CoroutineDispatcher = Dispatchers.IO + + // - UI screen states + private var _crowdfundCheckoutUIState = MutableStateFlow(CheckoutUIState()) + val crowdfundCheckoutUIState: StateFlow + get() = _crowdfundCheckoutUIState + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = CheckoutUIState(isLoading = false) + ) + + // - CreateBacking/UpdateBacking Result States + private var _checkoutResultState = MutableStateFlow>(Pair(null, null)) + val checkoutResultState: StateFlow> + get() = _checkoutResultState + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = Pair(null, null) + ) + + // - PaymentSheet related states + private var _presentPaymentSheet = MutableStateFlow(PaymentSheetPresenterState()) + val presentPaymentSheetStates + get() = _presentPaymentSheet + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = PaymentSheetPresenterState() + ) + + /** + * By default run in + * scope: viewModelScope + * dispatcher: Dispatchers.IO + */ + fun provideScopeAndDispatcher(scope: CoroutineScope, dispatcher: CoroutineDispatcher) { + this.scope = scope + this.dispatcher = dispatcher + } + + /** + * PledgeData information that is given to the VM via + * constructor on the bundle object. + */ + fun getPledgeData() = this.pledgeData + + /** + * PledgeReason information that is given to the VM via + * constructor on the bundle object. + */ + fun getPledgeReason() = this.pledgeReason + + fun provideBundle(arguments: Bundle?) { + val pData = arguments?.getParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA) as PledgeData? + pledgeReason = arguments?.getSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON) as PledgeReason? + val flowContext = pledgeReason?.let { PledgeFlowContext.forPledgeReason(it) } + + if (pData != null) { + pledgeData = pData + project = pData.projectData().project() + backing = project.backing() + refTag = RefTagUtils.storedCookieRefTagForProject( + project, + cookieManager, + sharedPreferences + ) + + when (flowContext) { + PledgeFlowContext.NEW_PLEDGE, + PledgeFlowContext.CHANGE_REWARD -> getPledgeInfoFrom(pData) + PledgeFlowContext.MANAGE_REWARD -> { + backing?.let { getPledgeInfoFrom(it) } + } + + else -> { + errorAction.invoke(null) + } + } + + collectUserInformation() + sendPageViewedEvent() + } + } + + private fun getPledgeInfoFrom(backing: Backing) { + // TODO: explore make re-usable into a separate Utils/extension extracting function all information from backing code + val list = mutableListOf() + backing.reward()?.let { + list.add(it) + } + backing.addOns()?.let { + list.addAll(it) + } + backing.location()?.let { + shippingRule = ShippingRule.builder() + .location(it) + .build() + } + + if (backing.location() == null && backing.locationName() != null && backing.locationId() != null) { + val location = Location.builder() + .name(backing.locationName()) + .displayableName(backing.locationName()) + .id(backing.locationId()) + .build() + shippingRule = ShippingRule.builder() + .location(location) + .build() + } + + selectedRewards = list.toList() + + shippingAmount = (backing.shippingAmount() ?: 0.0).toDouble() + + bonusAmount = (backing.bonusAmount() ?: 0.0).toDouble() + totalAmount = (backing.amount() ?: 0.0).toDouble() + + // - User was backing reward no reward + if (backing.reward() == null) { + bonusAmount = 0.0 + } + + checkoutData = CheckoutData.builder() + .amount(totalAmount) + .paymentType(CreditCardPaymentType.CREDIT_CARD) + .bonusAmount(bonusAmount) + .shippingAmount(shippingAmount) + .build() + } + + private fun getPledgeInfoFrom(pData: PledgeData) { + selectedRewards = pData.rewardsAndAddOnsList() + if (selectedRewards.isNotEmpty()) { + val isNoReward = RewardUtils.isNoReward(selectedRewards.first()) + pledgeData = pData + refTag = RefTagUtils.storedCookieRefTagForProject( + project, + cookieManager, + sharedPreferences + ) + shippingRule = pData.shippingRule() + + if (!isNoReward) { + shippingAmount = pData.shippingCostIfShipping() + bonusAmount = pData.bonusAmount() + totalAmount = pData.checkoutTotalAmount() + } + + if (isNoReward) { + totalAmount = selectedRewards.first().pledgeAmount() + pData.bonusAmount() + bonusAmount = 0.0 + } + + checkoutData = CheckoutData.builder() + .amount(pData.pledgeAmountTotal()) + .paymentType(CreditCardPaymentType.CREDIT_CARD) + .bonusAmount(bonusAmount) + .shippingAmount(pData.shippingCostIfShipping()) + .build() + } + } + + fun provideErrorAction(errorAction: (message: String?) -> Unit) { + this.errorAction = errorAction + } + + // TODO: can potentially be extracted to an UserUseCase + private fun collectUserInformation() { + scope.launch(dispatcher) { + emitCurrentState(isLoading = true) + currentUser.combine(apolloClient.userPrivacy().asFlow()) { cUser, privacy -> + cUser.toBuilder() + .email(privacy.email) + .name(privacy.name) + .build() + }.catch { + errorAction.invoke(it.message) + emitCurrentState(isLoading = false) + }.combine(apolloClient.getStoredCards().asFlow()) { updatedUser, cards -> + user = updatedUser + storedCards = cards + }.catch { + errorAction.invoke(it.message) + emitCurrentState(isLoading = false) + }.collectLatest { + emitCurrentState(isLoading = false) + } + } + } + + private fun sendPageViewedEvent() { + if (checkoutData != null && pledgeData != null) { + if (pledgeData?.pledgeFlowContext() == PledgeFlowContext.NEW_PLEDGE) + analytics.trackCheckoutScreenViewed(requireNotNull(checkoutData), requireNotNull(pledgeData)) + else analytics.trackUpdatePledgePageViewed(requireNotNull(checkoutData), requireNotNull(pledgeData)) + } + } + + private suspend fun emitCurrentState(isLoading: Boolean = false) { + _crowdfundCheckoutUIState.emit( + CheckoutUIState( + storeCards = storedCards.toList(), + userEmail = user?.email() ?: "", + isLoading = isLoading, + selectedRewards = selectedRewards, + shippingAmount = shippingAmount, + checkoutTotal = totalAmount, + isPledgeButtonEnabled = !isLoading, + selectedPaymentMethod = selectedPaymentMethod, + bonusAmount = bonusAmount, + shippingRule = shippingRule + ) + ) + } + + /** + * Should be called from PaymentSheet `PaymentOptionCallback` + * it will provide on @param paymentMethodSelected enough information + * to update the UI of available payment methods. + * + * The payment method will not be saved on the backend user profile + * until a successful pledge/update pledge is successfully performed + */ + fun newlyAddedPaymentMethod(paymentMethodSelected: StoredCard?) { + paymentMethodSelected?.let { + selectedPaymentMethod = it + + // - Update the list of available payment methods with the newly added one + if (paymentMethodSelected.isFromPaymentSheet()) { + val updatedCards = mutableListOf(paymentMethodSelected) + updatedCards.addAll(storedCards) + storedCards = updatedCards + } + + scope.launch { + emitCurrentState(isLoading = true) + } + } + } + + fun userChangedPaymentMethodSelected(paymentMethodSelected: StoredCard?) { + paymentMethodSelected?.let { + selectedPaymentMethod = it + } + + // - Send event on background thread + scope.launch(dispatcher) { + SendThirdPartyEventUseCaseV2(sharedPreferences, ffClient) + .sendThirdPartyEvent( + project = Observable.just(project), + currentUser = requireNotNull(environment.currentUserV2()), + apolloClient = apolloClient, + draftPledge = Pair(pledgeData?.pledgeAmountTotal(), shippingAmount), + checkoutAndPledgeData = Observable.just(Pair(checkoutData, pledgeData)), + eventName = ThirdPartyEventValues.EventName.ADD_PAYMENT_INFO + ).asFlow().collect { + thirdPartyEventSent = it + } + } + } + + fun isThirdPartyEventSent(): Pair = this.thirdPartyEventSent + + /** + * Called when user hits pledge button + */ + fun pledgeOrUpdatePledge() { + scope.launch(dispatcher) { + when (pledgeReason) { + PledgeReason.PLEDGE -> createBacking() + PledgeReason.UPDATE_PLEDGE, + PledgeReason.UPDATE_REWARD, + PledgeReason.UPDATE_PAYMENT -> updateBacking() + else -> { + errorAction.invoke(null) + } + } + } + } + + private suspend fun createBacking() { + if (checkoutData != null && pledgeData != null) { + analytics.trackPledgeSubmitCTA(requireNotNull(checkoutData), requireNotNull(pledgeData)) + } + + val shouldNotSendId = pledgeData?.reward()?.let { + RewardUtils.isDigital(it) || RewardUtils.isNoReward(it) || RewardUtils.isLocalPickup(it) + } ?: true + + val locationID = pledgeData?.shippingRule()?.location()?.id()?.toString() + val backingData = selectedPaymentMethod.getBackingData( + proj = project, + amount = pledgeData?.checkoutTotalAmount().toString(), + locationId = if (shouldNotSendId) null else locationID, + rewards = RewardUtils.extendAddOns(pledgeData?.rewardsAndAddOnsList() ?: emptyList()), + cookieRefTag = refTag + ) + + this.apolloClient.createBacking(backingData).asFlow() + .onStart { + emitCurrentState(isLoading = true) + }.catch { + errorAction.invoke(it.message) + emitCurrentState(isLoading = false) + } + .collectLatest { + checkoutData = checkoutData?.toBuilder()?.id(it.id())?.build() + _checkoutResultState.emit(Pair(checkoutData, pledgeData)) + emitCurrentState(isLoading = false) + } + } + + private suspend fun updateBacking() { + project.backing()?.let { backing -> + val backingData = when (pledgeReason) { + PledgeReason.UPDATE_PAYMENT -> { + val locationId = backing.locationId() ?: 0 + val rwl = mutableListOf() + backing.reward()?.let { + rwl.add(it) + } + backing.addOns()?.let { + rwl.addAll(it) + } + + getUpdateBackingData( + backing, + null, + locationId.toString(), + rwl, + selectedPaymentMethod + ) + } + PledgeReason.UPDATE_REWARD -> { + val isShippable = pledgeData?.reward()?.let { RewardUtils.isShippable(it) } ?: false + val locationIdOrNull = + if (isShippable) pledgeData?.shippingRule()?.location()?.id().toString() + else null + val isNoRw = pledgeData?.reward()?.let { RewardUtils.isNoReward(it) } ?: false + val rwListOrEmpty = if (isNoRw) emptyList() + else pledgeData?.rewardsAndAddOnsList() ?: emptyList() + + getUpdateBackingData( + backing, + pledgeData?.checkoutTotalAmount().toString(), + locationId = locationIdOrNull, + rwListOrEmpty, + selectedPaymentMethod + ) + } + PledgeReason.FIX_PLEDGE, // Managed on PledgeFragment/ViewModel + PledgeReason.PLEDGE, // Error + PledgeReason.UPDATE_PLEDGE, // Error + PledgeReason.LATE_PLEDGE, // Error + null -> { null } + } + + backingData?.let { + apolloClient.updateBacking(it).asFlow() + .onStart { + emitCurrentState(isLoading = true) + }.catch { + errorAction.invoke(it.message) + emitCurrentState(isLoading = false) + }.collectLatest { + checkoutData = checkoutData?.toBuilder()?.id(it.id())?.build() + _checkoutResultState.emit(Pair(checkoutData, pledgeData)) + emitCurrentState(isLoading = false) + } + } + } + } + + /** + * PaymentSheet has been presented to the user, stop loading until + * a new payment method is received. Will cover as well the case of + * an user dismissing PaymentSheet without adding a payment method + */ + fun paymentSheetPresented(state: Boolean) { + scope.launch { + emitCurrentState(isLoading = !state) + } + } + + /** + * Required to present the Stripe PaymentSheet to the user + */ + fun getSetupIntent() { + scope.launch(dispatcher) { + apolloClient.createSetupIntent(project).asFlow() + .onStart { emitCurrentState(isLoading = true) } + .catch { + emitCurrentState(isLoading = false) + errorAction.invoke(it.message) + } + .collectLatest { + _presentPaymentSheet.emit(PaymentSheetPresenterState(it)) + } + } + } + + /** + * If @param = PaymentSheetResult.Failed or PaymentSheetResult.Canceled + * reload remove the payment methods added via payment sheet and keep only those + * obtained via `apolloClient.getStoredCards()`. PaymentSheetResult.Canceled will be produce + * by a failed/abandoned 3DS challenge + * + * If @PaymentSheetResult.Completed stop loading state + */ + fun paymentSheetResult(paymentSheetResult: PaymentSheetResult) { + when (paymentSheetResult) { + PaymentSheetResult.Canceled, + is PaymentSheetResult.Failed -> { + scope.launch { + val updatedList = storedCards.filter { !it.isFromPaymentSheet() } + storedCards = updatedList + emitCurrentState(isLoading = false) + } + } + PaymentSheetResult.Completed -> { + scope.launch { + emitCurrentState(isLoading = false) + } + } + } + } + + class Factory(private val environment: Environment, private val bundle: Bundle? = null) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return CrowdfundCheckoutViewModel(environment, bundle) as T + } + } +} diff --git a/app/src/main/java/com/kickstarter/viewmodels/projectpage/LatePledgeCheckoutViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/projectpage/LatePledgeCheckoutViewModel.kt index 67a32bc7d0..0409c72986 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/projectpage/LatePledgeCheckoutViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/LatePledgeCheckoutViewModel.kt @@ -4,18 +4,24 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.kickstarter.libs.Environment +import com.kickstarter.libs.utils.RewardUtils +import com.kickstarter.libs.utils.extensions.checkoutTotalAmount import com.kickstarter.libs.utils.extensions.isNotNull +import com.kickstarter.libs.utils.extensions.locationId +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.Checkout +import com.kickstarter.models.CheckoutPayment import com.kickstarter.models.CreatePaymentIntentInput import com.kickstarter.models.Project import com.kickstarter.models.Reward -import com.kickstarter.models.ShippingRule import com.kickstarter.models.StoredCard +import com.kickstarter.services.mutations.CreateCheckoutData import com.kickstarter.services.mutations.SavePaymentMethodData import com.kickstarter.ui.data.CheckoutData import com.kickstarter.ui.data.PledgeData -import com.kickstarter.ui.data.ProjectData import com.stripe.android.Stripe import com.stripe.android.confirmPaymentIntent import com.stripe.android.model.ConfirmPaymentIntentParams @@ -40,11 +46,17 @@ import type.CreditCardPaymentType data class LatePledgeCheckoutUIState( val storeCards: List = listOf(), val userEmail: String = "", - val isLoading: Boolean = false + val isLoading: Boolean = false, + val selectedRewards: List = emptyList(), + val shippingAmount: Double = 0.0, + val checkoutTotal: Double = 0.0, + val isPledgeButtonEnabled: Boolean = true ) class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { + private var pledgeData: PledgeData? = null + private var checkoutData: CheckoutData? = null private val apolloClient = requireNotNull(environment.apolloClientV2()) private val analytics = requireNotNull(environment.analytics()) @@ -52,6 +64,8 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { private var userEmail: String = "" private var checkoutId: String? = null private var backing: Backing? = null + private val selectedRewards = mutableListOf() + private var buttonEnabled = true private var stripe: Stripe = requireNotNull(environment.stripe()) @@ -81,7 +95,18 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(), - initialValue = LatePledgeCheckoutUIState(isLoading = true) + initialValue = LatePledgeCheckoutUIState(isLoading = false) + ) + + private val mutableCheckoutPayment = + MutableStateFlow(CheckoutPayment(id = 0L, paymentUrl = null, backing = null)) + val checkoutPayment: StateFlow + get() = mutableCheckoutPayment + .asStateFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = CheckoutPayment(id = 0L, paymentUrl = null, null) ) private var mutableClientSecretForNewPaymentMethod = MutableSharedFlow() @@ -115,6 +140,9 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { } } + fun getCheckoutData() = checkoutData + fun getPledgeData() = pledgeData + fun provideCheckoutIdAndBacking(checkoutId: Long, backing: Backing) { this.checkoutId = checkoutId.toString() this.backing = backing @@ -174,8 +202,11 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { }.collect() } - fun onPledgeButtonClicked(selectedCard: StoredCard?, project: Project, totalAmount: Double) { - createPaymentIntentForCheckout(selectedCard, project, totalAmount) + fun onPledgeButtonClicked(selectedCard: StoredCard?) { + this.pledgeData?.let { + val project = it.projectData().project() + createPaymentIntentForCheckout(selectedCard, project, it.checkoutTotalAmount()) + } } fun provideErrorAction(errorAction: (message: String?) -> Unit) { @@ -337,59 +368,41 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { .build() } - private fun createPledgeData(projectData: ProjectData, addOns: List, shippingRule: ShippingRule, reward: Reward): PledgeData { - return PledgeData.builder() - .projectData(projectData) - .addOns(addOns) - .shippingRule(shippingRule) - .reward(reward) - .build() - } - private suspend fun emitCurrentState(isLoading: Boolean = false) { mutableLatePledgeCheckoutUIState.emit( LatePledgeCheckoutUIState( storeCards = storedCards, userEmail = userEmail, - isLoading = isLoading + isLoading = isLoading, + selectedRewards = selectedRewards.toList(), + shippingAmount = this.pledgeData?.shippingCostIfShipping() ?: 0.0, + checkoutTotal = this.pledgeData?.checkoutTotalAmount() ?: 0.0, + isPledgeButtonEnabled = buttonEnabled, ) ) } - fun sendPageViewedEvent( - projectData: ProjectData, - addOns: List, - currentUserShippingRule: ShippingRule, - shippingAmount: Double, - totalAmount: Double, - totalBonusSupportAmount: Double - ) { - this.selectedReward?.let { - val pledgeData = createPledgeData(projectData, addOns, currentUserShippingRule, it) - val checkOutData = - createCheckoutData(shippingAmount, totalAmount, totalBonusSupportAmount) - this@LatePledgeCheckoutViewModel.analytics.trackCheckoutScreenViewed( - checkoutData = checkOutData, - pledgeData = pledgeData - ) + fun sendPageViewedEvent() { + this.pledgeData?.let { pData -> + this.checkoutData?.let { cData -> + this.selectedReward?.let { + this@LatePledgeCheckoutViewModel.analytics.trackCheckoutScreenViewed( + checkoutData = cData, + pledgeData = pData + ) + } + } } } - fun sendSubmitCTAEvent( - projectData: ProjectData, - addOns: List, - currentUserShippingRule: ShippingRule, - shippingAmount: Double, - totalAmount: Double, - totalBonusSupportAmount: Double - ) { - this.selectedReward?.let { - val pledgeData = createPledgeData(projectData, addOns, currentUserShippingRule, it) - val checkOutData = - createCheckoutData(shippingAmount, totalAmount, totalBonusSupportAmount) - this@LatePledgeCheckoutViewModel.analytics.trackLatePledgeSubmitCTA( - checkoutData = checkOutData, - pledgeData = pledgeData - ) + + fun sendSubmitCTAEvent() { + this.pledgeData?.let { pData -> + this.checkoutData?.let { cData -> + this@LatePledgeCheckoutViewModel.analytics.trackLatePledgeSubmitCTA( + checkoutData = cData, + pledgeData = pData + ) + } } } @@ -403,6 +416,63 @@ class LatePledgeCheckoutViewModel(val environment: Environment) : ViewModel() { } } + private fun createCheckout() { + this.pledgeData?.let { pData -> + val locationId = if (!RewardUtils.isNoReward(pData.reward())) pData.locationId() else null + val rewards = if (!RewardUtils.isNoReward(pData.reward())) pData.rewardsAndAddOnsList() else emptyList() + val totalPledge = pData.checkoutTotalAmount() + + this.pledgeData?.projectData()?.let { projectData -> + if (projectData.project() + .postCampaignPledgingEnabled() == true && projectData.project() + .isInPostCampaignPledgingPhase() == true + ) { + viewModelScope.launch { + apolloClient.createCheckout( + CreateCheckoutData( + project = projectData.project(), + amount = totalPledge.toString(), + locationId = locationId?.toString(), + rewardsIds = rewards, + refTag = projectData.refTagFromIntent() + ) + ) + .asFlow() + .map { checkoutPayment -> + buttonEnabled = true + mutableCheckoutPayment.emit(checkoutPayment) + } + .catch { + buttonEnabled = false + errorAction.invoke(it.message) + } + .onCompletion { + emitCurrentState(isLoading = false) + } + .collect() + } + } else { + errorAction.invoke(null) + } + } + } + } + + fun providePledgeData(pledgeData: PledgeData) { + this.pledgeData = pledgeData + this.checkoutData = createCheckoutData(pledgeData.shippingCostIfShipping(), pledgeData.pledgeAmountTotal(), pledgeData.bonusAmount()) + viewModelScope.launch { + selectedRewards.clear() + pledgeData.addOns()?.let { addOns -> + selectedRewards.add(pledgeData.reward()) + selectedRewards.addAll(addOns) + } + + emitCurrentState(isLoading = true) + createCheckout() + } + } + class Factory(private val environment: Environment) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { diff --git a/app/src/main/java/com/kickstarter/viewmodels/projectpage/ProjectPageViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/projectpage/ProjectPageViewModel.kt index 85aa926399..0c58cc05b4 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/projectpage/ProjectPageViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/ProjectPageViewModel.kt @@ -128,9 +128,6 @@ interface ProjectPageViewModel { /** Call when the update payment option is clicked. */ fun updatePaymentClicked() - /** Call when the update pledge option is clicked. */ - fun updatePledgeClicked() - /** Call when the updates button is clicked. */ fun updatesTextViewClicked() @@ -309,7 +306,6 @@ interface ProjectPageViewModel { private val reloadProjectContainerClicked = PublishSubject.create() private val shareButtonClicked = PublishSubject.create() private val updatePaymentClicked = PublishSubject.create() - private val updatePledgeClicked = PublishSubject.create() private val updatesTextViewClicked = PublishSubject.create() private val viewRewardsClicked = PublishSubject.create() private val onVideoPlayButtonClicked = PublishSubject.create() @@ -870,12 +866,6 @@ interface ProjectPageViewModel { this.analyticEvents.trackChangePaymentMethod(it.first) }.addToDisposable(disposables) - projectDataAndBackedReward - .compose(takeWhenV2, Unit>(this.updatePledgeClicked)) - .map { Pair(pledgeData(it.second, it.first, PledgeFlowContext.MANAGE_REWARD), PledgeReason.UPDATE_PLEDGE) } - .subscribe { this.updatePledgeData.onNext(it) } - .addToDisposable(disposables) - this.viewRewardsClicked .subscribe { this.revealRewardsFragment.onNext(it) } .addToDisposable(disposables) @@ -1184,10 +1174,6 @@ interface ProjectPageViewModel { this.updatePaymentClicked.onNext(Unit) } - override fun updatePledgeClicked() { - this.updatePledgeClicked.onNext(Unit) - } - override fun updatesTextViewClicked() { this.updatesTextViewClicked.onNext(Unit) } diff --git a/app/src/main/java/com/kickstarter/viewmodels/projectpage/RewardsSelectionViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/projectpage/RewardsSelectionViewModel.kt index 70808c228f..11856b7735 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/projectpage/RewardsSelectionViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/RewardsSelectionViewModel.kt @@ -4,15 +4,19 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.kickstarter.libs.Environment -import com.kickstarter.libs.utils.RewardUtils import com.kickstarter.libs.utils.extensions.isBacked import com.kickstarter.mock.factories.RewardFactory +import com.kickstarter.mock.factories.ShippingRuleFactory import com.kickstarter.models.Backing import com.kickstarter.models.Project import com.kickstarter.models.Reward +import com.kickstarter.models.ShippingRule import com.kickstarter.ui.data.PledgeData import com.kickstarter.ui.data.PledgeFlowContext import com.kickstarter.ui.data.ProjectData +import com.kickstarter.viewmodels.usecases.GetShippingRulesUseCase +import com.kickstarter.viewmodels.usecases.ShippingRulesState +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -20,24 +24,28 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.rx2.asFlow data class RewardSelectionUIState( - val rewardList: List = listOf(), val selectedReward: Reward = Reward.builder().build(), val initialRewardIndex: Int = 0, val project: ProjectData = ProjectData.builder().build(), ) -class RewardsSelectionViewModel(environment: Environment) : ViewModel() { +class RewardsSelectionViewModel(private val environment: Environment, private var shippingRulesUseCase: GetShippingRulesUseCase? = null) : ViewModel() { private val analytics = requireNotNull(environment.analytics()) + private val apolloClient = requireNotNull(environment.apolloClientV2()) + private lateinit var currentProjectData: ProjectData private var previousUserBacking: Backing? = null private var previouslyBackedReward: Reward? = null private var indexOfBackedReward = 0 private var newUserReward: Reward = Reward.builder().build() + private var selectedShippingRule: ShippingRule = ShippingRuleFactory.emptyShippingRule() private val mutableRewardSelectionUIState = MutableStateFlow(RewardSelectionUIState()) val rewardSelectionUIState: StateFlow @@ -49,6 +57,16 @@ class RewardsSelectionViewModel(environment: Environment) : ViewModel() { initialValue = RewardSelectionUIState(), ) + private val mutableShippingUIState = MutableStateFlow(ShippingRulesState()) + val shippingUIState: StateFlow + get() = mutableShippingUIState + .asStateFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = ShippingRulesState(), + ) + private val mutableFlowUIRequest = MutableSharedFlow() val flowUIRequest: SharedFlow get() = mutableFlowUIRequest @@ -59,8 +77,23 @@ class RewardsSelectionViewModel(environment: Environment) : ViewModel() { previousUserBacking = projectData.backing() previouslyBackedReward = getReward(previousUserBacking) indexOfBackedReward = indexOfBackedReward(project = projectData.project()) + viewModelScope.launch { emitCurrentState() + environment.currentConfigV2()?.observable()?.asFlow()?.collectLatest { + if (shippingRulesUseCase == null) { + shippingRulesUseCase = GetShippingRulesUseCase( + apolloClient, + projectData.project(), + it, + viewModelScope, + Dispatchers.IO + ) + } + shippingRulesUseCase?.invoke() + + emitShippingUIState() + } } } @@ -72,12 +105,8 @@ class RewardsSelectionViewModel(environment: Environment) : ViewModel() { emitCurrentState() analytics.trackSelectRewardCTA(pledgeData) - if (newUserReward.hasAddons()) // Show add-ons - mutableFlowUIRequest.emit(FlowUIState(currentPage = 1, expanded = true)) - else - // Show confirm page - mutableFlowUIRequest.emit(FlowUIState(currentPage = 2, expanded = true)) + mutableFlowUIRequest.emit(FlowUIState(currentPage = 1, expanded = true)) } } @@ -107,22 +136,39 @@ class RewardsSelectionViewModel(environment: Environment) : ViewModel() { } } + private suspend fun emitShippingUIState() { + // - collect useCase flow and update shippingUIState + shippingRulesUseCase?.shippingRulesState?.collectLatest { shippingUseCase -> + selectedShippingRule = shippingUseCase.selectedShippingRule + mutableShippingUIState.emit(shippingUseCase) + } + } + private suspend fun emitCurrentState() { - val filteredRewards = currentProjectData.project().rewards()?.filter { RewardUtils.isNoReward(it) || it.isAvailable() } ?: listOf() mutableRewardSelectionUIState.emit( RewardSelectionUIState( - rewardList = filteredRewards, initialRewardIndex = indexOfBackedReward, project = currentProjectData, - selectedReward = newUserReward + selectedReward = newUserReward, ) ) } - class Factory(private val environment: Environment) : + /** + * The user has change the shipping location on the UI + * @param shippingRule is the new selected location + */ + fun selectedShippingRule(shippingRule: ShippingRule) { + viewModelScope.launch { + shippingRulesUseCase?.filterBySelectedRule(shippingRule) + emitShippingUIState() + } + } + + class Factory(private val environment: Environment, private var shippingRulesUseCase: GetShippingRulesUseCase? = null) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return RewardsSelectionViewModel(environment = environment) as T + return RewardsSelectionViewModel(environment = environment, shippingRulesUseCase) as T } } } diff --git a/app/src/main/java/com/kickstarter/viewmodels/usecases/GetShippingRulesUseCase.kt b/app/src/main/java/com/kickstarter/viewmodels/usecases/GetShippingRulesUseCase.kt new file mode 100644 index 0000000000..bf237e0ded --- /dev/null +++ b/app/src/main/java/com/kickstarter/viewmodels/usecases/GetShippingRulesUseCase.kt @@ -0,0 +1,257 @@ +package com.kickstarter.viewmodels.usecases + +import com.kickstarter.libs.Config +import com.kickstarter.libs.utils.RewardUtils +import com.kickstarter.libs.utils.extensions.getDefaultLocationFrom +import com.kickstarter.libs.utils.extensions.isNotNull +import com.kickstarter.models.Location +import com.kickstarter.models.Project +import com.kickstarter.models.Reward +import com.kickstarter.models.ShippingRule +import com.kickstarter.services.ApolloClientTypeV2 +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx2.asFlow + +data class ShippingRulesState( + val shippingRules: List = emptyList(), + val loading: Boolean = false, + val error: String? = null, + val selectedShippingRule: ShippingRule = ShippingRule.builder().build(), + val filteredRw: List = emptyList() +) + +/** + * Will provide ShippingRulesState where: + * `shippingRules` is the list of available shipping rules for a given project + * `selectedShippingRule` will be the initial default shipping rule for a given configuration + * `error/loading` states for internal networking calls + * + * Should be provided with: + * @param scope + * @param dispatcher + * + * As the UseCase is lifecycle agnostic and is scoped to the class that uses it. + */ +class GetShippingRulesUseCase( + private val apolloClient: ApolloClientTypeV2, + private val project: Project, + private val config: Config?, + private val scope: CoroutineScope, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) { + + private val filteredRewards = mutableListOf() + private var defaultShippingRule = ShippingRule.builder().build() + private var rewardsByShippingType: List + private val allAvailableRulesForProject = mutableMapOf() + private val projectRewards = project.rewards()?.filter { RewardUtils.isNoReward(it) || it.isAvailable() } ?: listOf() + + init { + + // To avoid duplicates insert reward.id as key + val rewardsToQuery = mutableMapOf() + + // Get first reward with unrestricted shipping preference, when quering `getShippingRules` will return ALL available locations, no need to query more rewards locations + project.rewards()?.filter { RewardUtils.shipsWorldwide(reward = it) }?.firstOrNull()?.let { + rewardsToQuery.put(it.id(), it) + } + + // In case there is no unrestricted preference need to get restricted and local rewards, to query their specific locations + if (rewardsToQuery.isEmpty()) { + project.rewards()?.filter { + RewardUtils.shipsToRestrictedLocations(reward = it) + }?.forEach { + rewardsToQuery[it.id()] = it + } + } + + this.rewardsByShippingType = rewardsToQuery.values.toList() + } + + // - Do not expose mutable states + private val _mutableShippingRules = + MutableStateFlow(ShippingRulesState()) + + /** + * Exposes result of this use case + */ + val shippingRulesState: Flow + get() = _mutableShippingRules + + // - IO dispatcher for network operations to avoid blocking main thread + operator fun invoke() { + scope.launch(dispatcher) { + val avShipMap = allAvailableRulesForProject + emitCurrentState(isLoading = true) + + if (rewardsByShippingType.isNotEmpty()) { + rewardsByShippingType.forEachIndexed { index, reward -> + + if (RewardUtils.shipsToRestrictedLocations(reward)) { + reward.shippingRules()?.map { + avShipMap.put( + requireNotNull( + it.location()?.id() + ), + it + ) + } + } + if (RewardUtils.shipsWorldwide(reward)) { + getGlobalShippingRulesForReward(reward, avShipMap) + } + + // - Filter rewards once all shipping rules have been collected + if (index == rewardsByShippingType.size - 1) { + defaultShippingRule = getDefaultShippingRule( + avShipMap, + project + ) + filterRewardsByLocation(avShipMap, defaultShippingRule, projectRewards) + } + } + } else { + // - All rewards are digital, all rewards must be available + filteredRewards.clear() + filteredRewards.addAll(projectRewards) + emitCurrentState(isLoading = false) + } + } + } + + fun getScope() = this.scope + fun getDispatcher() = this.dispatcher + + fun filterBySelectedRule(shippingRule: ShippingRule) { + scope.launch(dispatcher) { + defaultShippingRule = shippingRule + emitCurrentState(isLoading = true) + delay(500) // Added delay due to the filtering happening too fast for the user to perceive the loading state + filterRewardsByLocation(allAvailableRulesForProject, shippingRule, projectRewards) + } + } + + private suspend fun emitCurrentState(isLoading: Boolean, errorMessage: String? = null) { + _mutableShippingRules.emit( + ShippingRulesState( + shippingRules = allAvailableRulesForProject.values.toList(), + loading = isLoading, + selectedShippingRule = defaultShippingRule, + error = errorMessage, + filteredRw = filteredRewards + ) + ) + } + + private suspend fun getGlobalShippingRulesForReward( + reward: Reward, + shippingRules: MutableMap + ) { + apolloClient.getShippingRules(reward) + .asFlow() + .map { rulesEnvelope -> + rulesEnvelope.shippingRules()?.map { rule -> + rule?.let { + shippingRules.put( + requireNotNull( + it.location()?.id() + ), + it + ) + } + } + } + .catch { throwable -> + emitCurrentState(isLoading = false, errorMessage = throwable?.message) + }.collect() + } + + /** + * Check if the given @param rule is available in the list + * of @param allAvailableShippingRules for this project. + * + * In case it is available, return only those rewards able to ship to + * the selected rule + */ + private suspend fun filterRewardsByLocation( + allAvailableShippingRules: MutableMap, + rule: ShippingRule, + rewards: List + ) { + filteredRewards.clear() + val locationId = rule.location()?.id() ?: 0 + val isIsValidRule = allAvailableShippingRules[locationId] + + rewards.map { rw -> + if (RewardUtils.shipsWorldwide(rw)) { + filteredRewards.add(rw) + } + + if (RewardUtils.isNoReward(rw)) { + filteredRewards.add(rw) + } + + if (RewardUtils.isLocalPickup(rw)) { + filteredRewards.add(rw) + } + + if (RewardUtils.isDigital(rw)) { + filteredRewards.add(rw) + } + + // - If shipping is restricted, make sure the reward is able to ship to selected rule + if (RewardUtils.shipsToRestrictedLocations(rw)) { + if (isIsValidRule != null) { + rw.shippingRules()?.map { + if (it.location()?.id() == locationId) { + filteredRewards.add(rw) + } + } + } + } + } + + emitCurrentState(isLoading = false) + } + + /** + * In case the project is backing, return the backed shippingRule + * otherwise return the config default shippingRule + */ + private fun getDefaultShippingRule( + shippingRules: MutableMap, + project: Project + ): ShippingRule = + if (project.isBacking() && project.backing()?.location().isNotNull()) ShippingRule.builder() + .apply { + val backing = project.backing() + val locationId = project.backing()?.locationId() ?: 0L + this.id(locationId) + val reward = backing?.reward()?.let { + if (RewardUtils.shipsToRestrictedLocations(it)) { + val rule = backing?.reward()?.shippingRules() + ?.first { it.location()?.id() == locationId } + this.location(rule?.location()) + this.id(rule?.id()) + this.cost(rule?.cost() ?: 0.0) + } + if (RewardUtils.shipsWorldwide(it)) { + val locationName = project.backing()?.locationName() ?: "" + this.location(Location.Builder().id(locationId).name(locationName).displayableName(locationName).build()) + this.cost(it.shippingRules()?.first()?.cost() ?: 0.0) + } + } + } + .build() + else config?.getDefaultLocationFrom(shippingRules.values.toList()) ?: ShippingRule.builder() + .build() +} diff --git a/app/src/main/java/com/kickstarter/viewmodels/usecases/SendThirdPartyEventUseCaseV2.kt b/app/src/main/java/com/kickstarter/viewmodels/usecases/SendThirdPartyEventUseCaseV2.kt index 81b3ef1451..7d2db9fb69 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/usecases/SendThirdPartyEventUseCaseV2.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/usecases/SendThirdPartyEventUseCaseV2.kt @@ -46,7 +46,9 @@ class SendThirdPartyEventUseCaseV2( ): Observable> { return project - .filter { it.sendThirdPartyEvents() ?: false && canSendEventFlag } + .filter { + it.sendThirdPartyEvents() ?: false && canSendEventFlag + } .withLatestFrom(currentUser.observable()) { proj, user -> Pair(proj, user.getValue()) } diff --git a/app/src/main/res/layout/fragment_backing_addons.xml b/app/src/main/res/layout/fragment_backing_addons.xml index 0ab420085e..91f37886d9 100644 --- a/app/src/main/res/layout/fragment_backing_addons.xml +++ b/app/src/main/res/layout/fragment_backing_addons.xml @@ -8,95 +8,9 @@ android:background="@color/kds_support_100" android:orientation="vertical"> - - - - - - - - - - - - - - - - - - - + android:layout_height="wrap_content"> + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_crowdfund_checkout.xml b/app/src/main/res/layout/fragment_crowdfund_checkout.xml new file mode 100644 index 0000000000..6a340c0f83 --- /dev/null +++ b/app/src/main/res/layout/fragment_crowdfund_checkout.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/layout/fragment_pledge.xml b/app/src/main/res/layout/fragment_pledge.xml index 17b1d46460..5ec8c4dfe0 100644 --- a/app/src/main/res/layout/fragment_pledge.xml +++ b/app/src/main/res/layout/fragment_pledge.xml @@ -49,18 +49,6 @@ android:id="@+id/pledge_section_shipping" layout="@layout/fragment_pledge_section_shipping" /> - - - - - - diff --git a/app/src/main/res/layout/fragment_rewards.xml b/app/src/main/res/layout/fragment_rewards.xml index ed51ec2166..6a340c0f83 100644 --- a/app/src/main/res/layout/fragment_rewards.xml +++ b/app/src/main/res/layout/fragment_rewards.xml @@ -7,23 +7,10 @@ android:orientation="vertical" android:paddingTop="?android:attr/actionBarSize"> - - - - - + + diff --git a/app/src/main/res/menu/manage_pledge_live.xml b/app/src/main/res/menu/manage_pledge_live.xml index 4410f99604..13c8e38cec 100644 --- a/app/src/main/res/menu/manage_pledge_live.xml +++ b/app/src/main/res/menu/manage_pledge_live.xml @@ -1,9 +1,6 @@ - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00144e51db..ca4a48741d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -101,4 +101,8 @@ Authentication success. Please pull to refresh. Authentication failed. Please try again. + + Estimated Shipping + "This is meant to give you an idea of what shipping might cost. Once the creator is ready to fulfill your reward, you’ll return to pay shipping and applicable taxes." + "Customize your reward" diff --git a/app/src/test/java/com/kickstarter/libs/SegmentTest.kt b/app/src/test/java/com/kickstarter/libs/SegmentTest.kt index 0d45aab144..d847db3a85 100644 --- a/app/src/test/java/com/kickstarter/libs/SegmentTest.kt +++ b/app/src/test/java/com/kickstarter/libs/SegmentTest.kt @@ -1640,7 +1640,7 @@ class SegmentTest : KSRobolectricTestCase() { assertEquals(10.0, expectedProperties["checkout_reward_minimum"]) assertEquals(10.0, expectedProperties["checkout_reward_minimum_usd"]) assertEquals(true, expectedProperties["checkout_reward_shipping_enabled"]) - assertEquals("unrestricted", expectedProperties["checkout_reward_shipping_preference"]) + assertEquals("UNRESTRICTED", expectedProperties["checkout_reward_shipping_preference"]) assertEquals(6, expectedProperties["checkout_add_ons_count_total"]) assertEquals(2, expectedProperties["checkout_add_ons_count_unique"]) assertEquals(110.71, expectedProperties["checkout_add_ons_minimum_usd"]) diff --git a/app/src/test/java/com/kickstarter/libs/utils/RewardUtilsTest.kt b/app/src/test/java/com/kickstarter/libs/utils/RewardUtilsTest.kt index 8a4138e9f3..ca1cd06cd1 100644 --- a/app/src/test/java/com/kickstarter/libs/utils/RewardUtilsTest.kt +++ b/app/src/test/java/com/kickstarter/libs/utils/RewardUtilsTest.kt @@ -23,6 +23,8 @@ import com.kickstarter.libs.utils.RewardUtils.isTimeLimitedEnd import com.kickstarter.libs.utils.RewardUtils.isTimeLimitedStart import com.kickstarter.libs.utils.RewardUtils.isValidTimeRange import com.kickstarter.libs.utils.RewardUtils.shippingSummary +import com.kickstarter.libs.utils.RewardUtils.shipsToRestrictedLocations +import com.kickstarter.libs.utils.RewardUtils.shipsWorldwide import com.kickstarter.libs.utils.RewardUtils.timeInSecondsUntilDeadline import com.kickstarter.mock.factories.LocationFactory import com.kickstarter.mock.factories.ProjectFactory @@ -475,6 +477,20 @@ class RewardUtilsTest : KSRobolectricTestCase() { assertEquals(5.0, RewardUtils.getFinalBonusSupportAmount(5.0, 1.0)) } + @Test + fun `test is a reward ships globally`() { + val reward = RewardFactory.rewardWithShipping() + assertTrue(shipsWorldwide(reward)) + assertFalse(shipsToRestrictedLocations(reward)) + } + + @Test + fun `test is a reward does not ships globally`() { + val reward = RewardFactory.rewardRestrictedShipping() + assertTrue(shipsToRestrictedLocations(reward)) + assertFalse(shipsWorldwide(reward)) + } + companion object { private const val DAYS_TO_GO = "days to go" private const val HOURS_TO_GO = "hours to go" diff --git a/app/src/test/java/com/kickstarter/libs/utils/extensions/ConfigExtensionTest.kt b/app/src/test/java/com/kickstarter/libs/utils/extensions/ConfigExtensionTest.kt index 9acde5ab93..a71b7dc554 100644 --- a/app/src/test/java/com/kickstarter/libs/utils/extensions/ConfigExtensionTest.kt +++ b/app/src/test/java/com/kickstarter/libs/utils/extensions/ConfigExtensionTest.kt @@ -3,6 +3,7 @@ package com.kickstarter.libs.utils.extensions import com.kickstarter.KSRobolectricTestCase import com.kickstarter.R import com.kickstarter.mock.factories.ConfigFactory +import com.kickstarter.mock.factories.ShippingRulesEnvelopeFactory import org.json.JSONArray import org.junit.Test import java.util.Collections @@ -110,6 +111,24 @@ class ConfigExtensionTest : KSRobolectricTestCase() { ) } + @Test + fun `test getDefaultLocation when given a list of ShippingRules and config country code location is within shipping rules`() { + val config = ConfigFactory.configForCA() + val shippingRules = ShippingRulesEnvelopeFactory.shippingRules() + val defaultLocation = config.getDefaultLocationFrom(shippingRules.shippingRules()) + + assertEquals(defaultLocation.location()?.country(), "CA") + } + + @Test + fun `test getDefaultLocation when given a list of ShippingRules and config country code location is not within shipping rules default is US`() { + val config = ConfigFactory.configForITUser() + val shippingRules = ShippingRulesEnvelopeFactory.shippingRules() + val defaultLocation = config.getDefaultLocationFrom(shippingRules.shippingRules()) + + assertEquals(defaultLocation.location()?.country(), "US") + } + @Test fun setUserFeatureFlagsPrefWithFeatureFlag_whenGivenTrueFeatureFlag_shouldReturnTrueEnabledFeatureFlag() { val configEnableFeatureFlag = ConfigFactory.configWithFeaturesEnabled( diff --git a/app/src/test/java/com/kickstarter/libs/utils/extensions/FragmentExtTest.kt b/app/src/test/java/com/kickstarter/libs/utils/extensions/FragmentExtTest.kt index 190ff97e9b..2a83c1a366 100644 --- a/app/src/test/java/com/kickstarter/libs/utils/extensions/FragmentExtTest.kt +++ b/app/src/test/java/com/kickstarter/libs/utils/extensions/FragmentExtTest.kt @@ -2,6 +2,7 @@ package com.kickstarter.libs.utils.extensions import androidx.fragment.app.Fragment import com.kickstarter.KSRobolectricTestCase +import com.kickstarter.mock.factories.BackingFactory import com.kickstarter.mock.factories.ProjectDataFactory import com.kickstarter.mock.factories.ProjectFactory import com.kickstarter.models.Reward @@ -9,6 +10,7 @@ import com.kickstarter.ui.ArgumentsKey import com.kickstarter.ui.data.PledgeData import com.kickstarter.ui.data.PledgeFlowContext import com.kickstarter.ui.data.PledgeReason +import com.kickstarter.ui.fragments.CrowdfundCheckoutFragment import com.kickstarter.ui.fragments.PledgeFragment import org.junit.Test @@ -45,14 +47,14 @@ class FragmentExtTest : KSRobolectricTestCase() { } @Test - fun testPledgeFragmentInstance() { + fun testPledgeFragmentInstance_ForNewPledge() { val project = ProjectFactory.project() val projectData = ProjectDataFactory.project(project) val reward = Reward.builder().build() val addOns = listOf(reward) val pledgeData = PledgeData.builder() - .pledgeFlowContext(PledgeFlowContext.MANAGE_REWARD) + .pledgeFlowContext(PledgeFlowContext.NEW_PLEDGE) .projectData(projectData) .reward(reward) .addOns(addOns) @@ -60,7 +62,7 @@ class FragmentExtTest : KSRobolectricTestCase() { val fragment = Fragment().selectPledgeFragment(pledgeData, PledgeReason.PLEDGE) - assertTrue(fragment is PledgeFragment) + assertTrue(fragment is CrowdfundCheckoutFragment) val arg1 = fragment.arguments?.get(ArgumentsKey.PLEDGE_PLEDGE_DATA) as? PledgeData val arg2 = fragment.arguments?.get(ArgumentsKey.PLEDGE_PLEDGE_REASON) @@ -68,4 +70,31 @@ class FragmentExtTest : KSRobolectricTestCase() { assertEquals(arg1, pledgeData) assertEquals(arg2, PledgeReason.PLEDGE) } + + @Test + fun testPledgeFragmentInstance_ForFixPledge() { + val project = ProjectFactory.project() + val backing = BackingFactory.backing(project) + val updatedProj = project.toBuilder().backing(backing).isBacking(true).build() + val projectData = ProjectDataFactory.project(updatedProj) + val reward = Reward.builder().build() + val addOns = listOf(reward) + + val pledgeData = PledgeData.builder() + .pledgeFlowContext(PledgeFlowContext.FIX_ERRORED_PLEDGE) + .projectData(projectData) + .reward(reward) + .addOns(addOns) + .build() + + val fragment = Fragment().selectPledgeFragment(pledgeData, PledgeReason.FIX_PLEDGE) + + assertTrue(fragment is PledgeFragment) + + val arg1 = fragment.arguments?.get(ArgumentsKey.PLEDGE_PLEDGE_DATA) as? PledgeData + val arg2 = fragment.arguments?.get(ArgumentsKey.PLEDGE_PLEDGE_REASON) + + assertEquals(arg1, pledgeData) + assertEquals(arg2, PledgeReason.FIX_PLEDGE) + } } diff --git a/app/src/test/java/com/kickstarter/libs/utils/extensions/PledgeDataExtTest.kt b/app/src/test/java/com/kickstarter/libs/utils/extensions/PledgeDataExtTest.kt index 102f251821..d9050f643d 100644 --- a/app/src/test/java/com/kickstarter/libs/utils/extensions/PledgeDataExtTest.kt +++ b/app/src/test/java/com/kickstarter/libs/utils/extensions/PledgeDataExtTest.kt @@ -3,6 +3,7 @@ package com.kickstarter.libs.utils.extensions import com.kickstarter.mock.factories.ProjectDataFactory.project import com.kickstarter.mock.factories.ProjectFactory import com.kickstarter.mock.factories.RewardFactory +import com.kickstarter.mock.factories.ShippingRuleFactory import com.kickstarter.models.Reward import com.kickstarter.ui.data.PledgeData.Companion.with import com.kickstarter.ui.data.PledgeFlowContext @@ -10,6 +11,162 @@ import junit.framework.TestCase class PledgeDataExtTest : TestCase() { + fun `test checkoutTotalAmount for reward shipping with AddOns and bonus support on late pledges`() { + val project = ProjectFactory.project() + val shippingRule = ShippingRuleFactory.canadaShippingRule() + val rw = RewardFactory.rewardWithShipping().toBuilder().latePledgeAmount(8.0).pledgeAmount(2.0).build() + + val addOn1 = RewardFactory.addOn().toBuilder() + .id(1L) + .quantity(3) + .latePledgeAmount(5.0) + .pledgeAmount(3.0) + .shippingRules(listOf(shippingRule)) + .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name) + .build() + val addOn2 = RewardFactory.addOn().toBuilder() + .id(2L) + .quantity(2) + .latePledgeAmount(6.0) + .pledgeAmount(2.0) + .shippingRules(listOf(shippingRule)) + .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name) + .build() + val addOns = listOf(addOn1, addOn2) + + val pledgeData1 = with( + PledgeFlowContext.LATE_PLEDGES, + project(project), rw, addOns, bonusAmount = 3.0, shippingRule = shippingRule + ) + + assertEquals(pledgeData1.checkoutTotalAmount(), 98.0) + } + + fun `test checkoutTotalAmount for reward Not Shipping with AddOns and bonus support on crowdfund`() { + val project = ProjectFactory.project() + val shippingRule = ShippingRuleFactory.canadaShippingRule() + val rw = RewardFactory.digitalReward().toBuilder().latePledgeAmount(8.0).pledgeAmount(2.0).build() + val addOn1 = RewardFactory.addOn().toBuilder().id(1L).quantity(3).latePledgeAmount(5.0).pledgeAmount(3.0).build() + val addOn2 = RewardFactory.addOn().toBuilder().id(2L).quantity(2).latePledgeAmount(6.0).pledgeAmount(2.0).build() + val addOns = listOf(addOn1, addOn2) + + val pledgeData1 = with( + PledgeFlowContext.NEW_PLEDGE, + project(project), rw, addOns, bonusAmount = 4.0, shippingRule = shippingRule + ) + + assertEquals(pledgeData1.checkoutTotalAmount(), 19.0) + } + + fun `test checkoutTotalAmount for reward shipping with AddOns and bonus support on crowdfund`() { + val project = ProjectFactory.project() + val shippingRule = ShippingRuleFactory.canadaShippingRule() + val rw = RewardFactory.rewardWithShipping().toBuilder().latePledgeAmount(8.0).pledgeAmount(2.0).build() + val addOn1 = RewardFactory.addOn().toBuilder().id(1L).quantity(3).latePledgeAmount(5.0).pledgeAmount(3.0).build() + val addOn2 = RewardFactory.addOn().toBuilder().id(2L).quantity(2).latePledgeAmount(6.0).pledgeAmount(2.0).build() + val addOns = listOf(addOn1, addOn2) + + val pledgeData1 = with( + PledgeFlowContext.NEW_PLEDGE, + project(project), rw, addOns, bonusAmount = 4.0, shippingRule = shippingRule + ) + + assertEquals(pledgeData1.checkoutTotalAmount(), 29.0) + } + + fun `test when the selected reward has shipping test shippingCostIfShipping`() { + val rw = RewardFactory.rewardWithShipping() + val project = ProjectFactory.project() + val shippingRule = ShippingRuleFactory.canadaShippingRule() + + val pledgeData = with( + PledgeFlowContext.NEW_PLEDGE, + project(project), rw, shippingRule = shippingRule + ) + + assertEquals(pledgeData.shippingCostIfShipping(), shippingRule.cost()) + } + + fun `test when digital reward test shippingCostIfShipping is 0`() { + val digital = RewardFactory.digitalReward() + val project = ProjectFactory.project() + val shippingRule = ShippingRuleFactory.canadaShippingRule() + + val pledgeData2 = with( + PledgeFlowContext.NEW_PLEDGE, + project(project), digital, shippingRule = shippingRule + ) + assertEquals(pledgeData2.shippingCostIfShipping(), 0.0) + } + + fun `test pledgeTotalAmount with AddOns on late pledges`() { + val project = ProjectFactory.project() + val rw = RewardFactory.reward().toBuilder().latePledgeAmount(8.0).pledgeAmount(2.0).build() + val addOn1 = RewardFactory.addOn().toBuilder().id(1L).quantity(3).latePledgeAmount(5.0).pledgeAmount(3.0).build() + val addOn2 = RewardFactory.addOn().toBuilder().id(2L).quantity(2).latePledgeAmount(6.0).pledgeAmount(2.0).build() + val addOns = listOf(addOn1, addOn2) + + val pledgeData = with( + PledgeFlowContext.LATE_PLEDGES, + project(project), rw, addOns + ) + assertEquals(pledgeData.pledgeAmountTotal(), 35.0) + } + + fun `test pledgeTotalAmountPlusBonus with AddOns on late pledges`() { + val project = ProjectFactory.project() + val rw = RewardFactory.reward().toBuilder().latePledgeAmount(8.0).pledgeAmount(2.0).build() + val addOn1 = RewardFactory.addOn().toBuilder().id(1L).quantity(3).latePledgeAmount(5.0).pledgeAmount(3.0).build() + val addOn2 = RewardFactory.addOn().toBuilder().id(2L).quantity(2).latePledgeAmount(6.0).pledgeAmount(2.0).build() + val addOns = listOf(addOn1, addOn2) + + val pledgeData1 = with( + PledgeFlowContext.LATE_PLEDGES, + project(project), rw, addOns, bonusAmount = 0.0 + ) + assertEquals(pledgeData1.pledgeAmountTotalPlusBonus(), 35.0) + + val pledgeData2 = with( + PledgeFlowContext.LATE_PLEDGES, + project(project), rw, addOns, bonusAmount = 7.0 + ) + assertEquals(pledgeData2.pledgeAmountTotalPlusBonus(), 42.0) + } + + fun `test pledgeTotalAmountPlusBonus with AddOns on crowdfund`() { + val project = ProjectFactory.project() + val rw = RewardFactory.reward().toBuilder().latePledgeAmount(8.0).pledgeAmount(2.0).build() + val addOn1 = RewardFactory.addOn().toBuilder().id(1L).quantity(3).latePledgeAmount(5.0).pledgeAmount(3.0).build() + val addOn2 = RewardFactory.addOn().toBuilder().id(2L).quantity(2).latePledgeAmount(6.0).pledgeAmount(2.0).build() + val addOns = listOf(addOn1, addOn2) + + val pledgeData1 = with( + PledgeFlowContext.NEW_PLEDGE, + project(project), rw, addOns, bonusAmount = 0.0 + ) + assertEquals(pledgeData1.pledgeAmountTotalPlusBonus(), 15.0) + + val pledgeData2 = with( + PledgeFlowContext.NEW_PLEDGE, + project(project), rw, addOns, bonusAmount = 7.0 + ) + assertEquals(pledgeData2.pledgeAmountTotalPlusBonus(), 22.0) + } + + fun `test pledgeTotalAmount with AddOns on crowdfund`() { + val project = ProjectFactory.project() + val rw = RewardFactory.reward().toBuilder().latePledgeAmount(8.0).pledgeAmount(2.0).build() + val addOn1 = RewardFactory.addOn().toBuilder().id(1L).quantity(3).latePledgeAmount(5.0).pledgeAmount(3.0).build() + val addOn2 = RewardFactory.addOn().toBuilder().id(2L).quantity(2).latePledgeAmount(6.0).pledgeAmount(2.0).build() + val addOns = listOf(addOn1, addOn2) + + val pledgeData = with( + PledgeFlowContext.NEW_PLEDGE, + project(project), rw, addOns + ) + assertEquals(pledgeData.pledgeAmountTotal(), 15.0) + } + fun testAddOnsCountTotalEmpty() { val project = ProjectFactory.project() val rw = RewardFactory.reward() diff --git a/app/src/test/java/com/kickstarter/viewmodels/AddOnsViewModelTest.kt b/app/src/test/java/com/kickstarter/viewmodels/AddOnsViewModelTest.kt index 8855bb4305..e91fe349c0 100644 --- a/app/src/test/java/com/kickstarter/viewmodels/AddOnsViewModelTest.kt +++ b/app/src/test/java/com/kickstarter/viewmodels/AddOnsViewModelTest.kt @@ -1,20 +1,33 @@ package com.kickstarter.viewmodels +import android.os.Bundle import com.kickstarter.KSRobolectricTestCase +import com.kickstarter.libs.Environment +import com.kickstarter.libs.utils.EventName +import com.kickstarter.libs.utils.extensions.pledgeAmountTotal +import com.kickstarter.mock.factories.BackingFactory +import com.kickstarter.mock.factories.ProjectFactory import com.kickstarter.mock.factories.RewardFactory import com.kickstarter.mock.factories.ShippingRuleFactory +import com.kickstarter.mock.services.MockApolloClientV2 import com.kickstarter.models.Backing +import com.kickstarter.models.Location import com.kickstarter.models.Project import com.kickstarter.models.Reward +import com.kickstarter.ui.ArgumentsKey +import com.kickstarter.ui.data.PledgeData +import com.kickstarter.ui.data.PledgeFlowContext +import com.kickstarter.ui.data.PledgeReason import com.kickstarter.ui.data.ProjectData import com.kickstarter.viewmodels.projectpage.AddOnsUIState import com.kickstarter.viewmodels.projectpage.AddOnsViewModel +import io.reactivex.Observable import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.Before import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) @@ -22,17 +35,15 @@ class AddOnsViewModelTest : KSRobolectricTestCase() { private lateinit var viewModel: AddOnsViewModel - private fun createViewModel() { - val env = environment().toBuilder().build() + private fun createViewModel(environment: Environment) { viewModel = - AddOnsViewModel.Factory(env).create(AddOnsViewModel::class.java) + AddOnsViewModel.Factory(environment).create(AddOnsViewModel::class.java) } - @Before - fun setup() { - createViewModel() + fun setup(environment: Environment = environment()) { + createViewModel(environment) - val testRewards = (0..5).map { Reward.builder().title("$it").id(it.toLong()).build() } + val testRewards = (0..5).map { Reward.builder().hasAddons(true).title("$it").id(it.toLong()).build() } val testBacking = Backing.builder().reward(testRewards[2]).rewardId(testRewards[2].id()).build() val testProject = Project.builder().rewards(testRewards).backing(testBacking).build() @@ -41,104 +52,297 @@ class AddOnsViewModelTest : KSRobolectricTestCase() { viewModel.provideProjectData(testProjectData) } - // Tests for UI events @Test - fun `test_hide_location_selection_on_reward_not_shippable`() = runTest { - val reward = RewardFactory - .rewardHasAddOns() - .toBuilder() - .shippingType(Reward.SHIPPING_TYPE_NO_SHIPPING) + fun `analytic event sent for crowdfund checkout, should be sent when calling provideBundle()`() { + val addOnReward = RewardFactory.addOn().toBuilder().id(1L).build() + val aDifferentAddOnReward = RewardFactory.addOnSingle().toBuilder().id(2L).build() + val addOnsList = listOf(addOnReward, aDifferentAddOnReward) + + val apolloClient = object : MockApolloClientV2() { + override fun getProjectAddOns( + slug: String, + locationId: Location + ): Observable> { + return Observable.just(addOnsList) + } + } + + val env = environment().toBuilder() + .apolloClientV2(apolloClient) .build() - val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.addOnsUIState.toList(uiState) + val backedAddOnq = aDifferentAddOnReward.toBuilder().quantity(4).build() + val shippingRule = ShippingRuleFactory.canadaShippingRule() + val backedReward = RewardFactory.reward().toBuilder().hasAddons(true).build() + val backing = BackingFactory.backing(reward = backedReward).toBuilder().addOns(listOf(backedAddOnq)).build() + val testProject = ProjectFactory.project().toBuilder().rewards(listOf(backedReward)).backing(backing).build() + val testProjectData = ProjectData.builder().project(testProject).build() + + createViewModel(env) + + val bundle = Bundle() + bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(PledgeFlowContext.CHANGE_REWARD, testProjectData, backedReward, shippingRule = shippingRule)) + bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.UPDATE_PLEDGE) + viewModel.provideBundle(bundle) + + this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) + } + + @Test + fun `analytic event sent for late pledges, should be sent wen calling sendEvent()`() { + val addOnReward = RewardFactory.addOn() + val aDifferentAddOnReward = RewardFactory.addOnSingle() + val addOnsList = listOf(addOnReward, aDifferentAddOnReward) + + val apolloClient = object : MockApolloClientV2() { + override fun getProjectAddOns( + slug: String, + locationId: Location + ): Observable> { + return Observable.just(addOnsList) + } } - viewModel.userRewardSelection(reward) + val env = environment().toBuilder() + .apolloClientV2(apolloClient) + .build() + + setup(env) - assertEquals( - uiState.last().shippingSelectorIsGone, - true - ) + viewModel.sendEvent() + this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) } @Test - fun `test_show_location_selection_on_reward_is_shippable`() = runTest { - val reward = RewardFactory - .rewardHasAddOns() - .toBuilder() - .shippingType(Reward.SHIPPING_TYPE_MULTIPLE_LOCATIONS) + fun `test_on_addons_added_or_removed`() = runTest { + val addOnReward = RewardFactory.addOn() + val aDifferentAddOnReward = RewardFactory.addOnSingle() + val addOnsList = listOf(addOnReward, aDifferentAddOnReward) + + val apolloClient = object : MockApolloClientV2() { + override fun getProjectAddOns( + slug: String, + locationId: Location + ): Observable> { + return Observable.just(addOnsList) + } + } + + val env = environment().toBuilder() + .apolloClientV2(apolloClient) .build() + setup(env) + val rw = RewardFactory.reward().toBuilder().hasAddons(true).build() + viewModel.userRewardSelection(rw) + viewModel.provideSelectedShippingRule(ShippingRuleFactory.canadaShippingRule()) + val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) viewModel.addOnsUIState.toList(uiState) } - viewModel.userRewardSelection(reward) - + // - Initial state addOns freshly loaded, assertEquals( uiState.last(), AddOnsUIState( - shippingSelectorIsGone = false + addOns = addOnsList, + totalCount = 0, + isLoading = false, + shippingRule = ShippingRuleFactory.canadaShippingRule(), + totalBonusAmount = 0.0, + totalPledgeAmount = rw.pledgeAmount() ) ) - } - @Test - fun `test_on_shipping_location_changed`() = runTest { - val newShippingRule = ShippingRuleFactory.germanyShippingRule() - - val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.addOnsUIState.toList(uiState) - } - - viewModel.onShippingLocationChanged(newShippingRule) + // Increment addOnReward to quantity 3 + viewModel.updateSelection(addOnsList.first().id(), 3) + val total = viewModel.getPledgeDataAndReason()?.first?.pledgeAmountTotal()?.toDouble() ?: 0.0 assertEquals( uiState.last(), AddOnsUIState( - currentShippingRule = newShippingRule, - shippingSelectorIsGone = false + addOns = addOnsList, + totalCount = 3, + isLoading = false, + shippingRule = ShippingRuleFactory.canadaShippingRule(), + totalPledgeAmount = total ) ) } @Test - fun `test_on_addons_added_or_removed`() = runTest { + fun `test add bonus Support without selecting addOns`() = runTest { val addOnReward = RewardFactory.addOn() val aDifferentAddOnReward = RewardFactory.addOnSingle() - val currentAddOnsSelections = mutableMapOf( - addOnReward to 2, - aDifferentAddOnReward to 1 - ) + val addOnsList = listOf(addOnReward, aDifferentAddOnReward) + + val apolloClient = object : MockApolloClientV2() { + override fun getProjectAddOns( + slug: String, + locationId: Location + ): Observable> { + return Observable.just(addOnsList) + } + } + + val env = environment().toBuilder() + .apolloClientV2(apolloClient) + .build() + + setup(env) + val rw = RewardFactory.reward().toBuilder().hasAddons(true).pledgeAmount(55.0).build() + viewModel.userRewardSelection(rw) + viewModel.provideSelectedShippingRule(ShippingRuleFactory.canadaShippingRule()) val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) viewModel.addOnsUIState.toList(uiState) } - viewModel.onAddOnsAddedOrRemoved(currentAddOnsSelections) - + // - Initial state addOns freshly loaded, assertEquals( uiState.last(), AddOnsUIState( - currentAddOnsSelection = currentAddOnsSelections + addOns = addOnsList, + totalCount = 0, + isLoading = false, + shippingRule = ShippingRuleFactory.canadaShippingRule(), + totalBonusAmount = 0.0, + totalPledgeAmount = rw.pledgeAmount() ) ) - // Decrement addOnReward to quantity 1 - currentAddOnsSelections[addOnReward] = 1 - - viewModel.onAddOnsAddedOrRemoved(currentAddOnsSelections) + // - bonus Amount has been added + viewModel.bonusAmountUpdated(3.0) + // - No addOns selected, but increase bonus amount, assertEquals( uiState.last(), AddOnsUIState( - currentAddOnsSelection = currentAddOnsSelections + addOns = addOnsList, + totalCount = 0, + isLoading = false, + shippingRule = ShippingRuleFactory.canadaShippingRule(), + totalBonusAmount = 3.0, + totalPledgeAmount = rw.pledgeAmount() + 3.0 ) ) } + + @Test + fun `test backed addOns total amount on start`() = runTest { + + val addOnReward = RewardFactory.addOn().toBuilder().id(1L).build() + val aDifferentAddOnReward = RewardFactory.addOnSingle().toBuilder().id(2L).build() + val addOnsList = listOf(addOnReward, aDifferentAddOnReward) + + val apolloClient = object : MockApolloClientV2() { + override fun getProjectAddOns( + slug: String, + locationId: Location + ): Observable> { + return Observable.just(addOnsList) + } + } + + val env = environment().toBuilder() + .apolloClientV2(apolloClient) + .build() + + val backedAddOnq = aDifferentAddOnReward.toBuilder().quantity(4).build() + val shippingRule = ShippingRuleFactory.canadaShippingRule() + val backedReward = RewardFactory.reward().toBuilder().hasAddons(true).build() + val backing = BackingFactory.backing(reward = backedReward).toBuilder().addOns(listOf(backedAddOnq)).build() + val testProject = ProjectFactory.project().toBuilder().rewards(listOf(backedReward)).backing(backing).build() + val testProjectData = ProjectData.builder().project(testProject).build() + + createViewModel(env) + + val bundle = Bundle() + bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(PledgeFlowContext.CHANGE_REWARD, testProjectData, backedReward, shippingRule = shippingRule)) + bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.UPDATE_PLEDGE) + + val uiState = mutableListOf() + val dispatcher = UnconfinedTestDispatcher(testScheduler) + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.provideBundle(bundle) + viewModel.addOnsUIState.toList(uiState) + } + + advanceUntilIdle() + val pledgeDataAndReason = viewModel.getPledgeDataAndReason() + + // - Initial state queried for addOns and updated with backed information + assertEquals(uiState.last().addOns.size, 2) + assertEquals(uiState.last().addOns.last(), backedAddOnq) + assertEquals(uiState.last().addOns.first(), addOnReward) + assertEquals(uiState.last().totalCount, 4) + assertEquals(pledgeDataAndReason?.first?.shippingRule(), shippingRule) + assertEquals(pledgeDataAndReason?.first?.addOns()?.size, 1) // - only the backed AddOn + assertEquals(pledgeDataAndReason?.first?.addOns()?.first()?.id(), backedAddOnq.id()) // - only the backed AddOn + assertEquals(pledgeDataAndReason?.first?.addOns()?.first()?.quantity(), backedAddOnq.quantity()) // - only the backed AddOn + } + + @Test + fun `test backed addOns total amount when the amount has been updated`() = runTest { + + val addOnReward = RewardFactory.addOn().toBuilder().id(1L).build() + val aDifferentAddOnReward = RewardFactory.addOnSingle().toBuilder().id(2L).build() + val addOnsList = listOf(addOnReward, aDifferentAddOnReward) + + val apolloClient = object : MockApolloClientV2() { + override fun getProjectAddOns( + slug: String, + locationId: Location + ): Observable> { + return Observable.just(addOnsList) + } + } + + val env = environment().toBuilder() + .apolloClientV2(apolloClient) + .build() + + val backedAddOnq = aDifferentAddOnReward.toBuilder().quantity(4).build() + val shippingRule = ShippingRuleFactory.canadaShippingRule() + val backedReward = RewardFactory.reward().toBuilder().hasAddons(true).build() + val backing = BackingFactory.backing(reward = backedReward).toBuilder().addOns(listOf(backedAddOnq)).build() + val testProject = ProjectFactory.project().toBuilder().rewards(listOf(backedReward)).backing(backing).build() + val testProjectData = ProjectData.builder().project(testProject).build() + + createViewModel(env) + + val bundle = Bundle() + bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(PledgeFlowContext.CHANGE_REWARD, testProjectData, backedReward, shippingRule = shippingRule)) + bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.UPDATE_PLEDGE) + + val uiState = mutableListOf() + val dispatcher = UnconfinedTestDispatcher(testScheduler) + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.provideBundle(bundle) + + // - Increment addOnReward to quantity 3, the addOn that was not backed + viewModel.updateSelection(addOnsList.first().id(), 3) + viewModel.addOnsUIState.toList(uiState) + } + + advanceUntilIdle() + val pledgeDataAndReason = viewModel.getPledgeDataAndReason() + + assertEquals(uiState.last().totalCount, 7) + + assertEquals(pledgeDataAndReason?.first?.addOns()?.size, 2) + assertEquals(pledgeDataAndReason?.first?.addOns()?.first()?.id(), addOnReward.id()) + assertEquals(pledgeDataAndReason?.first?.addOns()?.first()?.quantity(), 3) + assertEquals(pledgeDataAndReason?.first?.addOns()?.last(), backedAddOnq) + } } diff --git a/app/src/test/java/com/kickstarter/viewmodels/BackingAddOnsFragmentViewModelTest.kt b/app/src/test/java/com/kickstarter/viewmodels/BackingAddOnsFragmentViewModelTest.kt deleted file mode 100644 index 73d90d55a5..0000000000 --- a/app/src/test/java/com/kickstarter/viewmodels/BackingAddOnsFragmentViewModelTest.kt +++ /dev/null @@ -1,1160 +0,0 @@ -package com.kickstarter.viewmodels - -import android.os.Bundle -import android.util.Pair -import androidx.annotation.NonNull -import com.kickstarter.KSRobolectricTestCase -import com.kickstarter.libs.Environment -import com.kickstarter.libs.utils.EventName -import com.kickstarter.libs.utils.extensions.addToDisposable -import com.kickstarter.mock.MockCurrentConfigV2 -import com.kickstarter.mock.factories.ApiExceptionFactory -import com.kickstarter.mock.factories.BackingFactory -import com.kickstarter.mock.factories.ConfigFactory -import com.kickstarter.mock.factories.ProjectDataFactory -import com.kickstarter.mock.factories.ProjectFactory -import com.kickstarter.mock.factories.RewardFactory -import com.kickstarter.mock.factories.ShippingRuleFactory -import com.kickstarter.mock.factories.ShippingRulesEnvelopeFactory -import com.kickstarter.mock.factories.UserFactory -import com.kickstarter.mock.services.MockApolloClientV2 -import com.kickstarter.models.Location -import com.kickstarter.models.Reward -import com.kickstarter.models.ShippingRule -import com.kickstarter.services.apiresponses.ShippingRulesEnvelope -import com.kickstarter.ui.ArgumentsKey -import com.kickstarter.ui.data.PledgeData -import com.kickstarter.ui.data.PledgeFlowContext -import com.kickstarter.ui.data.PledgeReason -import com.kickstarter.ui.data.ProjectData -import com.kickstarter.viewmodels.BackingAddOnsFragmentViewModel.BackingAddOnsFragmentViewModel -import io.reactivex.Observable -import io.reactivex.disposables.CompositeDisposable -import io.reactivex.subscribers.TestSubscriber -import junit.framework.TestCase -import org.junit.After -import org.junit.Test - -class BackingAddOnsFragmentViewModelTest : KSRobolectricTestCase() { - private lateinit var vm: BackingAddOnsFragmentViewModel - private val shippingSelectorIsGone = TestSubscriber.create() - private val addOnsList = TestSubscriber.create, ShippingRule>>() - private val showPledgeFragment = TestSubscriber.create>() - private val isEnabledButton = TestSubscriber.create() - private val totalSelectedAddOns = TestSubscriber.create() - private val isEmptyState = TestSubscriber.create() - private val showErrorDialog = TestSubscriber.create() - private val selectedShippingRule = TestSubscriber.create() - private val disposables = CompositeDisposable() - - @After - fun cleanUp() { - disposables.clear() - } - private fun setUpEnvironment(@NonNull environment: Environment, bundle: Bundle? = null) { - this.vm = BackingAddOnsFragmentViewModel(environment, bundle) - this.vm.outputs.addOnsList().subscribe { this.addOnsList.onNext(it) }.addToDisposable(disposables) - this.vm.outputs.shippingSelectorIsGone().subscribe { this.shippingSelectorIsGone.onNext(it) }.addToDisposable(disposables) - this.vm.outputs.showPledgeFragment().subscribe { this.showPledgeFragment.onNext(it) }.addToDisposable(disposables) - this.vm.outputs.isEnabledCTAButton().subscribe { this.isEnabledButton.onNext(it) }.addToDisposable(disposables) - this.vm.outputs.totalSelectedAddOns().subscribe { this.totalSelectedAddOns.onNext(it) }.addToDisposable(disposables) - this.vm.outputs.isEmptyState().subscribe { this.isEmptyState.onNext(it) }.addToDisposable(disposables) - this.vm.outputs.showErrorDialog().subscribe { this.showErrorDialog.onNext(it) }.addToDisposable(disposables) - this.vm.outputs.selectedShippingRule().subscribe { this.selectedShippingRule.onNext(it) }.addToDisposable(disposables) - } - - @Test - fun emptyAddOnsListForReward() { - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory - .rewardHasAddOns() - .toBuilder() - .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - - setUpEnvironment(buildEnvironmentWith(emptyList(), ShippingRulesEnvelopeFactory.emptyShippingRules(), currentConfig), bundle) - - this.addOnsList.assertValue(Triple(projectData, emptyList(), ShippingRuleFactory.emptyShippingRule())) - this.isEmptyState.assertValue(true) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun addOnsForUnrestrictedSameShippingRules() { - val shippingRule = ShippingRulesEnvelopeFactory.shippingRules() - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .build() - val listAddons = listOf(addOn, addOn, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) // - Reward from V1 use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - - setUpEnvironment(buildEnvironmentWith(listAddons, shippingRule, currentConfig), bundle) - this.addOnsList.assertValue(Triple(projectData, listAddons, shippingRule.shippingRules().first())) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun addOnsForRestrictedSameShippingRules() { - val shippingRule = ShippingRulesEnvelopeFactory.shippingRules() - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .build() - val listAddons = listOf(addOn, addOn, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) // - Reward from V1 use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - setUpEnvironment(buildEnvironmentWith(listAddons, shippingRule, currentConfig), bundle) - - this.addOnsList.assertValue(Triple(projectData, listAddons, shippingRule.shippingRules().first())) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun addOnsForRestricted_whenNoMatchingShippingRulesInReward() { - val shippingRuleAddOn = ShippingRuleFactory.germanyShippingRule() - val shippingRuleRw = ShippingRuleFactory.usShippingRule() - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(listOf(shippingRuleAddOn, shippingRuleAddOn, shippingRuleAddOn)) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) - .shippingType(Reward.SHIPPING_TYPE_ANYWHERE) - .build() - val listAddons = listOf(addOn, addOn, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingRules(listOf(shippingRuleRw)) - .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) // - Reward from V1 check this field - .shippingType(Reward.SHIPPING_TYPE_MULTIPLE_LOCATIONS) // - Reward from V1 to check is digital use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelope.builder().shippingRules(listOf(shippingRuleRw)).build(), currentConfig), bundle) - - this.addOnsList.assertValue(Triple(projectData, emptyList(), shippingRuleRw)) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun addOnsForRestrictedOneMatchingShippingRules() { - val shippingRuleAddOn = ShippingRuleFactory.germanyShippingRule() - val shippingRuleRw = ShippingRuleFactory.usShippingRule() - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(listOf(shippingRuleAddOn, shippingRuleRw)) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .build() - val listAddons = listOf(addOn, addOn, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) - .shippingRules(listOf(shippingRuleRw)) - .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) // - Reward from V1 use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelope.builder().shippingRules(listOf(shippingRuleRw)).build(), currentConfig), bundle) - - this.addOnsList.assertValue(Triple(projectData, listAddons, shippingRuleRw)) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun addOnsForRestrictedFilterOutNoMatching() { - val shippingRuleAddOn = ShippingRuleFactory.germanyShippingRule() - val shippingRuleRw = ShippingRuleFactory.usShippingRule() - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(listOf(shippingRuleRw)) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .build() - val addOn2 = RewardFactory.rewardHasAddOns().toBuilder() - .id(11) - .shippingRules(listOf(shippingRuleAddOn)) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .build() - val listAddons = listOf(addOn, addOn2, addOn, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelope.builder().shippingRules(listOf(shippingRuleRw)).build(), currentConfig)) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) - .shippingRules(listOf(shippingRuleRw)) - .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) // - Reward from V1 use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelope.builder().shippingRules(listOf(shippingRuleRw)).build(), currentConfig), bundle) - - this.vm.outputs.addOnsList().subscribe { - assertEquals(it.second.size, 1) - val filteredAddOn = it.second.first() - assertEquals(filteredAddOn, addOn2) - } - .addToDisposable(disposables) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun addOnsForRestricted_NoDigitalAddOn_ChangeSelectedShippingRule() { - val shippingRuleRw = ShippingRuleFactory.usShippingRule() - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(listOf(shippingRuleRw)) - .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) // - Reward from GraphQL use this field - .shippingType(Reward.SHIPPING_TYPE_SINGLE_LOCATION) - .build() - val listAddons = listOf(addOn, addOn, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) - .shippingRules(listOf(shippingRuleRw)) - .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) // - Reward from V1 use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelope.builder().shippingRules(listOf(shippingRuleRw)).build(), currentConfig), bundle) - - this.addOnsList.assertValue(Triple(projectData, listAddons, shippingRuleRw)) - - val shippingRuleAddOn = ShippingRuleFactory.germanyShippingRule() - this.vm.inputs.shippingRuleSelected(shippingRuleAddOn) - - this.addOnsList.assertValues(Triple(projectData, listAddons, shippingRuleRw), Triple(projectData, emptyList(), shippingRuleAddOn)) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun testDigitalAddOns_whenDigitalReward() { - - // DIGITAL AddOns - val addOn = RewardFactory.addOn().toBuilder() - .shippingPreferenceType(Reward.ShippingPreference.NONE) // - Reward from GraphQL use this field - .shippingType(Reward.SHIPPING_TYPE_NO_SHIPPING) // - // - Reward from V1 use this field - .build() - val listAddons = listOf(addOn, addOn, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - // - Digital Reward - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.NOSHIPPING.name.toLowerCase()) - .shippingPreferenceType(Reward.ShippingPreference.NONE) // - Reward from GraphQL use this field - .shippingType(Reward.SHIPPING_TYPE_NO_SHIPPING) // - Reward from V1 use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelopeFactory.emptyShippingRules(), currentConfig), bundle) - - this.addOnsList.assertValue(Triple(projectData, listAddons, ShippingRuleFactory.emptyShippingRule())) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun testDigitalAddOns_whenLocalReceiptReward() { - - // DIGITAL AddOns - val addOn = RewardFactory.addOn().toBuilder() - .shippingPreferenceType(Reward.ShippingPreference.NONE) // - Reward from GraphQL use this field - .shippingType(Reward.SHIPPING_TYPE_NO_SHIPPING) // - // - Reward from V1 use this field - .build() - val listAddons = listOf(addOn, addOn, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - // - LocalReceipt Reward - val rw = RewardFactory.localReceiptLocation().toBuilder() - .hasAddons(true) - .shippingType(Reward.ShippingPreference.LOCAL.name.toLowerCase()) - .shippingPreferenceType(Reward.ShippingPreference.LOCAL) // - Reward from GraphQL use this field - .shippingType(Reward.SHIPPING_TYPE_LOCAL_PICKUP) // - Reward from V1 use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelopeFactory.emptyShippingRules(), currentConfig), bundle) - - this.shippingSelectorIsGone.assertValues(true) - this.addOnsList.assertValue(Triple(projectData, listAddons, ShippingRuleFactory.emptyShippingRule())) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun testDigitalAddOnsAndLocalReceipt_whenLocalReceiptReward() { - - // - LocalReceipt Reward - val rw = RewardFactory.localReceiptLocation().toBuilder() - .hasAddons(true) - .build() - - // DIGITAL AddOn - val addOn = RewardFactory.addOn().toBuilder() - .shippingPreferenceType(Reward.ShippingPreference.NONE) // - Reward from GraphQL use this field - .shippingType(Reward.SHIPPING_TYPE_NO_SHIPPING) // - // - Reward from V1 use this field - .build() - - val localReceipAddOn = RewardFactory.localReceiptLocation().toBuilder() - .isAddOn(true) - .isAvailable(true) - .build() - - val listAddons = listOf(addOn, addOn, addOn, localReceipAddOn, localReceipAddOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelopeFactory.emptyShippingRules(), currentConfig)) - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelopeFactory.emptyShippingRules(), currentConfig), bundle) - - this.shippingSelectorIsGone.assertValues(true) - this.addOnsList.assertValue(Triple(projectData, listAddons, ShippingRuleFactory.emptyShippingRule())) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun testFilterOutShippableAddOns_whenLocalReceiptReward() { - - // - LocalReceipt Reward - val rw = RewardFactory.localReceiptLocation().toBuilder() - .hasAddons(true) - .build() - - // DIGITAL AddOn - val digitalAddOn = RewardFactory.addOn().toBuilder() - .shippingPreferenceType(Reward.ShippingPreference.NONE) // - Reward from GraphQL use this field - .shippingType(Reward.SHIPPING_TYPE_NO_SHIPPING) // - // - Reward from V1 use this field - .build() - - val localReceipAddOn = RewardFactory.localReceiptLocation().toBuilder() - .isAddOn(true) - .isAvailable(true) - .build() - - val shippableAddOn = RewardFactory.addOn().toBuilder() - .shippingRules(listOf(ShippingRuleFactory.usShippingRule(), ShippingRuleFactory.germanyShippingRule())) - .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) - .shippingType(Reward.SHIPPING_TYPE_ANYWHERE) - .build() - - val listAddons = listOf(shippableAddOn, digitalAddOn, shippableAddOn, localReceipAddOn, shippableAddOn) - val outputTestList = listOf(digitalAddOn, localReceipAddOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelopeFactory.emptyShippingRules(), currentConfig)) - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelopeFactory.emptyShippingRules(), currentConfig), bundle) - - this.shippingSelectorIsGone.assertValues(true) - this.addOnsList.assertValue(Triple(projectData, outputTestList, ShippingRuleFactory.emptyShippingRule())) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun testShippingSelectorGone_WhenNoAddOns_Shippable() { - val shippingRuleRw = ShippingRuleFactory.usShippingRule() - val addOn = RewardFactory.addOn().toBuilder() - .shippingType(Reward.SHIPPING_TYPE_NO_SHIPPING) - .shippingPreferenceType(Reward.ShippingPreference.NOSHIPPING) // - Reward from GraphQL use this field - .build() - val listAddons = listOf(addOn, addOn, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelope.builder().shippingRules(listOf(shippingRuleRw)).build(), currentConfig)) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.SHIPPING_TYPE_NO_SHIPPING) - .shippingRules(listOf(shippingRuleRw)) - .shippingPreferenceType(Reward.ShippingPreference.NONE) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.NOSHIPPING.name.toLowerCase()) // - Reward from V1 use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelopeFactory.emptyShippingRules(), currentConfig), bundle) - - this.shippingSelectorIsGone.assertValues(true) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun testShippingSelectorGoneWhenBaseRewardIsNotShippable() { - val shippingRuleRw = ShippingRuleFactory.usShippingRule() - val addOn = RewardFactory.addOn().toBuilder() - .build() - val listAddons = listOf(addOn, addOn, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelope.builder().shippingRules(listOf(shippingRuleRw)).build(), currentConfig)) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.SHIPPING_TYPE_NO_SHIPPING) - .shippingRules(listOf(shippingRuleRw)) - .shippingPreferenceType(Reward.ShippingPreference.NONE) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.NOSHIPPING.name.toLowerCase()) // - Reward from V1 use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelopeFactory.emptyShippingRules(), currentConfig), bundle) - - this.shippingSelectorIsGone.assertValues(true) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun continueButtonPressedNoAddOnsSelected() { - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory - .rewardHasAddOns() - .toBuilder() - .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - val pledgeData = PledgeData.with(pledgeReason, projectData, rw) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, pledgeData) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - setUpEnvironment(buildEnvironmentWith(emptyList(), ShippingRulesEnvelopeFactory.emptyShippingRules(), currentConfig), bundle) - - val quantityPerId = Pair(0, rw.id()) - this.vm.inputs.quantityPerId(quantityPerId) - this.vm.inputs.continueButtonPressed() - - this.addOnsList.assertValue(Triple(projectData, emptyList(), ShippingRuleFactory.emptyShippingRule())) - this.vm.outputs.showPledgeFragment() - .subscribe { - assertEquals(it.first, pledgeData) - assertEquals(it.second, pledgeReason) - } - .addToDisposable(disposables) - - this.segmentTrack.assertValues(EventName.PAGE_VIEWED.eventName, EventName.CTA_CLICKED.eventName) - } - - @Test - fun continueButtonPressedAddOnsFewAddOnsSelected() { - val shippingRule = ShippingRulesEnvelopeFactory.shippingRules() - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .build() - val addOn2 = addOn.toBuilder().id(8).build() - val addOn3 = addOn.toBuilder().id(99).build() - val listAddons = listOf(addOn, addOn2, addOn3) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) // - Reward from V1 use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - val pledgeData = PledgeData.with(pledgeReason, projectData, rw) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - setUpEnvironment(buildEnvironmentWith(listAddons, shippingRule, currentConfig), bundle) - - this.addOnsList.assertValue(Triple(projectData, listAddons, shippingRule.shippingRules().first())) - - val quantityPerIdAddOn1 = Pair(7, addOn.id()) - val quantityPerIdAddOn2 = Pair(2, addOn2.id()) - val quantityPerIdAddOn3 = Pair(5, addOn3.id()) - - this.vm.inputs.quantityPerId(quantityPerIdAddOn1) - this.vm.inputs.quantityPerId(quantityPerIdAddOn2) - this.vm.inputs.quantityPerId(quantityPerIdAddOn3) - - // - Comparison purposes the quantity of the add-ons has been updated in the previous vm.input.quantityPerId - val listAddonsToCheck = listOf( - addOn.toBuilder().quantity(7).build(), - addOn2.toBuilder().quantity(2).build(), - addOn3.toBuilder().quantity(5).build() - ) - - this.addOnsList.assertValues(Triple(projectData, listAddons, shippingRule.shippingRules().first())) - this.totalSelectedAddOns.assertValues(0, 7, 9, 14) - - this.vm.inputs.continueButtonPressed() - // - value only when updating pledge - this.isEnabledButton.assertNoValues() - - this.vm.outputs.showPledgeFragment() - .subscribe { - assertEquals(it.first, pledgeData) - assertEquals(it.second, pledgeReason) - - val selectedAddOnsList = pledgeData.addOns() - assertEquals(selectedAddOnsList, listAddonsToCheck) - } - .addToDisposable(disposables) - - this.segmentTrack.assertValues(EventName.PAGE_VIEWED.eventName, EventName.CTA_CLICKED.eventName) - } - - @Test - fun givenBackedAddOns_whenUpdatingRewardReason_DisabledButton() { - val shippingRule = ShippingRulesEnvelopeFactory.shippingRules() - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .build() - val addOn2 = addOn.toBuilder().id(8).build() - val addOn3 = addOn.toBuilder().id(99).build() - val listAddons = listOf(addOn, addOn2, addOn3) - val listAddonsBacked = listOf(addOn2.toBuilder().quantity(2).build(), addOn3.toBuilder().quantity(1).build()) - val combinedList = listOf(addOn, addOn2.toBuilder().quantity(2).build(), addOn3.toBuilder().quantity(1).build()) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) // - Reward from V1 use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - - // -Build the backing with location and list of AddOns - val backing = BackingFactory.backing(project, UserFactory.user(), rw) - .toBuilder() - .locationId(ShippingRuleFactory.usShippingRule().location()?.id()) - .location(ShippingRuleFactory.usShippingRule().location()) - .addOns(listAddonsBacked) - .build() - val backedProject = project.toBuilder() - .backing(backing) - .build() - - val projectData = ProjectDataFactory.project(backedProject, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.UPDATE_REWARD) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.UPDATE_REWARD) - - setUpEnvironment(buildEnvironmentWith(listAddons, shippingRule, currentConfig), bundle) - // - input from ViewHolder when building the item with the backed info - this.vm.inputs.quantityPerId(Pair(2, addOn2.id())) - this.vm.inputs.quantityPerId(Pair(1, addOn3.id())) - - this.isEnabledButton.assertValues(true, false) - this.addOnsList.assertValue(Triple(projectData, combinedList, shippingRule.shippingRules().first())) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun givenBackedAddOns_whenUpdatingRewardIncreaseQuantity_EnabledButtonAndResultList() { - val shippingRule = ShippingRulesEnvelopeFactory.shippingRules() - - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .build() - val addOn2 = addOn.toBuilder().id(8).build() - val addOn3 = addOn.toBuilder().id(99).build() - - val listAddons = listOf(addOn, addOn2, addOn3) - val listAddonsBacked = listOf(addOn2.toBuilder().quantity(2).build(), addOn3.toBuilder().quantity(1).build()) - val combinedList = listOf(addOn, addOn2.toBuilder().quantity(2).build(), addOn3.toBuilder().quantity(1).build()) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) // - Reward from V1 use this field. - .shippingType(Reward.SHIPPING_TYPE_ANYWHERE) // - Reward from V1 use this field to check if is Digital - .build() - val project = ProjectFactory.project() - - // -Build the backing with location and list of AddOns - val backing = BackingFactory.backing(project, UserFactory.user(), rw) - .toBuilder() - .locationId(ShippingRuleFactory.usShippingRule().location()?.id()) - .location(ShippingRuleFactory.usShippingRule().location()) - .addOns(listAddonsBacked) - .build() - val backedProject = project.toBuilder() - .rewards(listOf(rw)) - .backing(backing) - .build() - - val projectData = ProjectDataFactory.project(backedProject, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.UPDATE_REWARD) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.UPDATE_REWARD) - - setUpEnvironment(buildEnvironmentWith(listAddons, shippingRule, currentConfig), bundle) - this.vm.inputs.quantityPerId(Pair(7, addOn3.id())) - this.vm.inputs.quantityPerId(Pair(2, addOn2.id())) - - this.isEnabledButton.assertValues(true) - this.addOnsList.assertValues(Triple(projectData, combinedList, shippingRule.shippingRules().first())) - - // - Always 0 first time, them summatory of all addOns quantity every time the list gets updated - this.totalSelectedAddOns.assertValues(0, 7, 9) - - this.vm.inputs.continueButtonPressed() - this.vm.outputs.showPledgeFragment() - .subscribe { - val updateList = listOf(addOn, addOn2.toBuilder().quantity(2).build(), addOn3.toBuilder().quantity(7).build()) - TestCase.assertEquals(it.first.addOns(), updateList) - } - .addToDisposable(disposables) - - this.segmentTrack.assertValues(EventName.PAGE_VIEWED.eventName, EventName.CTA_CLICKED.eventName) - } - - @Test - fun givenBackedAddOns_whenUpdatingRewardUnrestrictedChooseAnotherRewardDigital_AddOnsListNotBacked() { - val shippingRule = ShippingRulesEnvelopeFactory.shippingRules() - - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .build() - val addOn2 = addOn.toBuilder().id(8).build() - val addOn3 = addOn.toBuilder().id(99).build() - - val listAddons = listOf(addOn, addOn2, addOn3) - val listAddonsBacked = listOf(addOn2.toBuilder().quantity(2).build(), addOn3.toBuilder().quantity(1).build()) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val backedRw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) // - Reward from V1 use this field. - .shippingType(Reward.SHIPPING_TYPE_ANYWHERE) // - Reward from V1 use this field to check if is Digital - .build() - - // - Digital Reward - val newRw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.NOSHIPPING.name.toLowerCase()) - .shippingPreferenceType(Reward.ShippingPreference.NONE) // - Reward from GraphQL use this field - .shippingType(Reward.SHIPPING_TYPE_NO_SHIPPING) // - Reward from V1 use this field - .shippingRules(emptyList()) - .build() - - val project = ProjectFactory.project() - - // -Build the backing with location and list of AddOns - val backing = BackingFactory.backing(project, UserFactory.user(), backedRw) - .toBuilder() - .locationId(ShippingRuleFactory.usShippingRule().location()?.id()) - .location(ShippingRuleFactory.usShippingRule().location()) - .addOns(listAddonsBacked) - .build() - - val backedProject = project.toBuilder() - .rewards(listOf(backedRw)) - .backing(backing) - .build() - - val projectData = ProjectDataFactory.project(backedProject, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.UPDATE_REWARD) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, newRw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.UPDATE_REWARD) - - setUpEnvironment(buildEnvironmentWith(listAddons, shippingRule, currentConfig), bundle) - - this.shippingSelectorIsGone.assertValue(true) - this.selectedShippingRule.assertValue(ShippingRuleFactory.emptyShippingRule()) - this.addOnsList.assertValues(Triple(projectData, listAddons, ShippingRuleFactory.emptyShippingRule())) - - // - Always 0 first time, them summatory of all addOns quantity every time the list gets updated - this.totalSelectedAddOns.assertValues(0) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun givenBackedAddOns_whenUpdatingRewardDigitalChooseAnotherRewardLimited_AddOnsListNotBacked() { - val shippingRule = ShippingRulesEnvelopeFactory.shippingRules() - - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .build() - val addOn2 = addOn.toBuilder().id(8).build() - val addOn3 = addOn.toBuilder().id(99).build() - - val listAddons = listOf(addOn, addOn2, addOn3) - val listAddonsBacked = listOf(addOn2.toBuilder().quantity(2).build(), addOn3.toBuilder().quantity(1).build()) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val newRw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) // - Reward from V1 use this field. - .shippingType(Reward.SHIPPING_TYPE_MULTIPLE_LOCATIONS) // - Reward from V1 use this field to check if is Digital - .build() - - // - Digital Reward - val backedRw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.NOSHIPPING.name.toLowerCase()) - .shippingPreferenceType(Reward.ShippingPreference.NONE) // - Reward from GraphQL use this field - .shippingType(Reward.SHIPPING_TYPE_NO_SHIPPING) // - Reward from V1 use this field - .build() - val project = ProjectFactory.project() - - // -Build the backing with location and list of AddOns - val backing = BackingFactory.backing(project, UserFactory.user(), backedRw) - .toBuilder() - .locationId(ShippingRuleFactory.usShippingRule().location()?.id()) - .location(ShippingRuleFactory.usShippingRule().location()) - .addOns(listAddonsBacked) - .build() - - val backedProject = project.toBuilder() - .rewards(listOf(backedRw)) - .backing(backing) - .build() - - val projectData = ProjectDataFactory.project(backedProject, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.UPDATE_REWARD) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, newRw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.UPDATE_REWARD) - - setUpEnvironment(buildEnvironmentWith(listAddons, shippingRule, currentConfig), bundle) - - this.shippingSelectorIsGone.assertNoValues() - this.selectedShippingRule.assertValues(shippingRule.shippingRules().first()) - this.addOnsList.assertValues(Triple(projectData, listAddons, shippingRule.shippingRules().first())) - - // - Always 0 first time, them summatory of all addOns quantity every time the list gets updated - this.totalSelectedAddOns.assertValues(0) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun givenBackedAddOns_whenUpdatingSameReward_ChangeShippingRule() { - val shippingRule = ShippingRulesEnvelopeFactory.shippingRules() - - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .build() - val addOn2 = addOn.toBuilder().id(8).build() - val addOn3 = addOn.toBuilder().id(99).build() - - val listAddons = listOf(addOn, addOn2, addOn3) - val listAddonsBacked = listOf(addOn2.toBuilder().quantity(2).build(), addOn3.toBuilder().quantity(1).build()) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - // - Backed Reward - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) // - Reward from V1 use this field. - .shippingType(Reward.SHIPPING_TYPE_MULTIPLE_LOCATIONS) // - Reward from V1 use this field to check if is Digital - .build() - - val project = ProjectFactory.project() - - // -Build the backing with location and list of AddOns - val backing = BackingFactory.backing(project, UserFactory.user(), rw) - .toBuilder() - .locationId(ShippingRuleFactory.usShippingRule().location()?.id()) - .location(ShippingRuleFactory.usShippingRule().location()) - .addOns(listAddonsBacked) - .build() - - val backedProject = project.toBuilder() - .rewards(listOf(rw)) - .backing(backing) - .build() - - val projectData = ProjectDataFactory.project(backedProject, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.UPDATE_REWARD) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.UPDATE_REWARD) - - setUpEnvironment(buildEnvironmentWith(listAddons, shippingRule, currentConfig), bundle) - - this.shippingSelectorIsGone.assertNoValues() - this.selectedShippingRule.assertValues(shippingRule.shippingRules().first()) - - // - Change shippingRule - this.vm.inputs.shippingRuleSelected(ShippingRuleFactory.mexicoShippingRule()) - - // - Test asserts - this.selectedShippingRule.assertValues( - shippingRule.shippingRules().first(), - ShippingRuleFactory.mexicoShippingRule() - ) - - this.vm.inputs.continueButtonPressed() - - this.vm.outputs.showPledgeFragment().subscribe { - val shippingRuleSendToPledge = it.first.shippingRule() - TestCase.assertEquals(shippingRuleSendToPledge, ShippingRuleFactory.mexicoShippingRule()) - } - .addToDisposable(disposables) - - this.segmentTrack.assertValues(EventName.PAGE_VIEWED.eventName, EventName.CTA_CLICKED.eventName) - } - - @Test - fun emptyState_whenNoAddOnsForShippingRule_shouldShowEmptyViewState() { - val shippingRuleAddOn = ShippingRuleFactory.germanyShippingRule() - val shippingRuleRw = ShippingRuleFactory.usShippingRule() - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(listOf(shippingRuleAddOn, shippingRuleAddOn, shippingRuleAddOn)) - .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) - .shippingType(Reward.SHIPPING_TYPE_SINGLE_LOCATION) - .build() - val listAddons = listOf(addOn, addOn, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) - .shippingRules(listOf(shippingRuleRw)) - .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) - .shippingPreference(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelope.builder().shippingRules(listOf(shippingRuleRw)).build(), currentConfig), bundle) - - this.addOnsList.assertValue(Triple(projectData, emptyList(), shippingRuleRw)) - this.isEmptyState.assertValue(true) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun emptyState_whenMatchingShippingRule_shouldNotShowEmptyState() { - val shippingRuleAddOn = ShippingRuleFactory.usShippingRule() - val shippingRuleRw = ShippingRuleFactory.usShippingRule() - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(listOf(shippingRuleAddOn, shippingRuleAddOn, shippingRuleAddOn)) - .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) - .build() - val listAddons = listOf(addOn, addOn, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) - .shippingRules(listOf(shippingRuleRw)) - .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) - .shippingPreference(Reward.ShippingPreference.RESTRICTED.name.toLowerCase()) - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - setUpEnvironment(buildEnvironmentWith(listAddons, ShippingRulesEnvelope.builder().shippingRules(listOf(shippingRuleRw)).build(), currentConfig), bundle) - - this.addOnsList.assertValue(Triple(projectData, listAddons, shippingRuleRw)) - this.isEmptyState.assertValue(false) - - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun errorState_whenErrorReturned_shouldShowErrorAlertDialogAndHideShippingSelector() { - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - setUpEnvironment(buildEnvironmentWithError(currentConfig), bundle) - - // Two values -> two failed network calls - this.showErrorDialog.assertValue(true) - this.shippingSelectorIsGone.assertValues(true) - } - - fun addOnsList_whenUnavailable_FilteredOut() { - val shippingRule = ShippingRulesEnvelopeFactory.shippingRules() - val addOn = RewardFactory.addOn().toBuilder() - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .build() - - val addOns2 = addOn.toBuilder().isAvailable(false).build() - val listAddons = listOf(addOn, addOns2, addOn) - - val config = ConfigFactory.configForUSUser() - val currentConfig = MockCurrentConfigV2() - currentConfig.config(config) - - val rw = RewardFactory.rewardHasAddOns().toBuilder() - .shippingType(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) - .shippingRules(shippingRule.shippingRules()) - .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) // - Reward from GraphQL use this field - .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name.toLowerCase()) // - Reward from V1 use this field - .build() - - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() - val projectData = ProjectDataFactory.project(project, null, null) - val pledgeReason = PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE) - - val bundle = Bundle() - bundle.putParcelable(ArgumentsKey.PLEDGE_PLEDGE_DATA, PledgeData.with(pledgeReason, projectData, rw)) - bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) - setUpEnvironment(buildEnvironmentWith(listAddons, shippingRule, currentConfig)) - - val filteredList = listOf(addOn, addOn) - this.addOnsList.assertValue(Triple(projectData, filteredList, shippingRule.shippingRules().first())) - } - - private fun buildEnvironmentWithError(currentConfig: MockCurrentConfigV2): Environment { - - return environment() - .toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun getProjectAddOns(slug: String, location: Location): Observable> { - return Observable.error(ApiExceptionFactory.badRequestException()) - } - override fun getShippingRules(reward: Reward): Observable { - return Observable.error(ApiExceptionFactory.badRequestException()) - } - }) - .currentConfig2(currentConfig) - .build() - } - - private fun buildEnvironmentWith(addOns: List, shippingRule: ShippingRulesEnvelope, currentConfig: MockCurrentConfigV2): Environment { - return environment() - .toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun getProjectAddOns(slug: String, location: Location): Observable> { - return Observable.just(addOns) - } - - override fun getShippingRules(reward: Reward): Observable { - return Observable.just(shippingRule) - } - }) - .currentConfig2(currentConfig) - .build() - } -} diff --git a/app/src/test/java/com/kickstarter/viewmodels/usecases/CheckoutFlowViewModelTest.kt b/app/src/test/java/com/kickstarter/viewmodels/CheckoutFlowViewModelTest.kt similarity index 71% rename from app/src/test/java/com/kickstarter/viewmodels/usecases/CheckoutFlowViewModelTest.kt rename to app/src/test/java/com/kickstarter/viewmodels/CheckoutFlowViewModelTest.kt index ed65ab57ef..36f14a8afc 100644 --- a/app/src/test/java/com/kickstarter/viewmodels/usecases/CheckoutFlowViewModelTest.kt +++ b/app/src/test/java/com/kickstarter/viewmodels/CheckoutFlowViewModelTest.kt @@ -1,4 +1,4 @@ -package com.kickstarter.viewmodels.usecases +package com.kickstarter.viewmodels import com.kickstarter.KSRobolectricTestCase import com.kickstarter.libs.Environment @@ -23,33 +23,39 @@ class CheckoutFlowViewModelTest : KSRobolectricTestCase() { @OptIn(ExperimentalCoroutinesApi::class) @Test fun testLaunchLogInCallBack_whenNoUser_loggedIn() = runTest { - var callbackCalled = 0 + var logInCallback = 0 + var continueCallback = 0 // - No user present on environment setUpEnvironment(environment()) - // - Call onConfirmDetailsContinueClicked with a VM loaded with Environment without user - vm.onConfirmDetailsContinueClicked { callbackCalled++ } - - // - Make sure the callback provided is called when no user present, `onConfirmDetailsContinueClicked` will produce states ONLY if user present - assertTrue(callbackCalled == 1) + assertTrue(logInCallback == 0) + assertTrue(continueCallback == 0) val state = mutableListOf() backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + // - Call onConfirmDetailsContinueClicked with a VM loaded with Environment without user + vm.onContinueClicked({ logInCallback++ }, { continueCallback++ }) + vm.flowUIState.toList(state) } // - make sure empty FlowUISate has been produced, `onConfirmDetailsContinueClicked` will produce states ONLY if user present assertEquals(state, listOf(FlowUIState())) - assertNotSame(state, listOf(FlowUIState(currentPage = 4, expanded = true))) + assertNotSame(state, listOf(FlowUIState(currentPage = 2, expanded = true))) assertNotSame(state, listOf(FlowUIState(currentPage = 3, expanded = true))) assert(state.size == 1) + + // - Make sure the callback provided is called when no user present, `onConfirmDetailsContinueClicked` will produce states ONLY if user present + assertTrue(logInCallback == 1) + assertTrue(continueCallback == 0) } @OptIn(ExperimentalCoroutinesApi::class) @Test fun testProduceNextPageState_whenUser_LoggedIn() = runTest { - var callbackCalled = 0 + var loginInCallback = 0 + var continueCallback = 0 // - Environment with user present val environment = environment() @@ -57,21 +63,25 @@ class CheckoutFlowViewModelTest : KSRobolectricTestCase() { .currentUserV2(MockCurrentUserV2(UserFactory.user())) .build() - setUpEnvironment(environment) - - // - Call onConfirmDetailsContinueClicked with a VM loaded with Environment containing an user - vm.onConfirmDetailsContinueClicked { callbackCalled++ } - // - Make sure the callback is not called - assertTrue(callbackCalled == 0) + assertTrue(loginInCallback == 0) + assertTrue(continueCallback == 0) val state = mutableListOf() backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + + setUpEnvironment(environment) + + // - Call VM loaded with Environment containing an user, so continueCallback it's executed + vm.onContinueClicked({ loginInCallback++ }, continueCallback = { continueCallback++ }) + vm.flowUIState.toList(state) } // - make sure next page FlowUIState has been generated, not just the initial empty state assertEquals(state, listOf(FlowUIState(), FlowUIState(currentPage = 4, expanded = true))) assert(state.size == 2) + assertTrue(loginInCallback == 0) + assertTrue(continueCallback == 1) } } diff --git a/app/src/test/java/com/kickstarter/viewmodels/ConfirmDetailsViewModelTest.kt b/app/src/test/java/com/kickstarter/viewmodels/ConfirmDetailsViewModelTest.kt deleted file mode 100644 index c55cbe1b84..0000000000 --- a/app/src/test/java/com/kickstarter/viewmodels/ConfirmDetailsViewModelTest.kt +++ /dev/null @@ -1,384 +0,0 @@ -package com.kickstarter.viewmodels - -import com.kickstarter.KSRobolectricTestCase -import com.kickstarter.mock.factories.ProjectDataFactory -import com.kickstarter.mock.factories.ProjectFactory -import com.kickstarter.mock.factories.RewardFactory -import com.kickstarter.mock.factories.ShippingRuleFactory -import com.kickstarter.mock.services.MockApolloClientV2 -import com.kickstarter.models.CheckoutPayment -import com.kickstarter.services.mutations.CreateCheckoutData -import com.kickstarter.viewmodels.projectpage.ConfirmDetailsUIState -import com.kickstarter.viewmodels.projectpage.ConfirmDetailsViewModel -import io.reactivex.Observable -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import org.junit.Before -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class ConfirmDetailsViewModelTest : KSRobolectricTestCase() { - - private lateinit var viewModel: ConfirmDetailsViewModel - - @Before - fun setUpWithEnvironment() { - viewModel = ConfirmDetailsViewModel.Factory(environment = environment()) - .create(ConfirmDetailsViewModel::class.java) - } - - @Test - fun `test_when_project_provided_then_min_and_max_step_is_updated`() = runTest { - val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.confirmDetailsUIState.toList(uiState) - } - - // US Project, min amount $1, max amount $10,000 - val projectData1 = ProjectDataFactory.project(ProjectFactory.project()).toBuilder().build() - viewModel.provideProjectData(projectData1) - viewModel.onUserSelectedReward(RewardFactory.noReward()) - - assertEquals(uiState.last().minStepAmount, 1.0) - assertEquals(uiState.last().maxPledgeAmount, 10000.0) - - // MX Project, min amount $10, max amount $200,000 - val projectData2 = - ProjectDataFactory.project(ProjectFactory.mxProject()).toBuilder().build() - viewModel.provideProjectData(projectData2) - viewModel.onUserSelectedReward(RewardFactory.noReward()) - - assertEquals(uiState.last().minStepAmount, 10.0) - assertEquals(uiState.last().maxPledgeAmount, 200000.0) - } - - @Test - fun `test_when_no_reward_selected_then_ui_state_is_correct`() = runTest { - val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.confirmDetailsUIState.toList(uiState) - } - - val projectData1 = ProjectDataFactory.project(ProjectFactory.project()).toBuilder().build() - viewModel.provideProjectData(projectData1) - viewModel.onUserSelectedReward(RewardFactory.noReward()) - - assertEquals( - uiState.last(), - ConfirmDetailsUIState( - rewardsAndAddOns = listOf(), - initialBonusSupportAmount = 1.0, - finalBonusSupportAmount = 1.0, - shippingAmount = 0.0, - totalAmount = 1.0, - minStepAmount = 1.0, - maxPledgeAmount = 10000.0, - isLoading = false - ) - ) - } - - @Test - fun `test_when_reward_selected_then_ui_state_is_correct`() = runTest { - val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.confirmDetailsUIState.toList(uiState) - } - - val reward = RewardFactory.reward() - val projectData1 = ProjectDataFactory.project(ProjectFactory.project()).toBuilder().build() - - viewModel.provideProjectData(projectData1) - viewModel.onUserSelectedReward(reward) - - assertEquals( - uiState.last(), - ConfirmDetailsUIState( - rewardsAndAddOns = listOf(reward), - initialBonusSupportAmount = 0.0, - finalBonusSupportAmount = 0.0, - shippingAmount = 0.0, - totalAmount = 20.0, - minStepAmount = 1.0, - maxPledgeAmount = 10000.0, - isLoading = false - ) - ) - } - - @Test - fun `test_when_reward_and_add_ons_selected_then_ui_state_is_correct`() = runTest { - val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.confirmDetailsUIState.toList(uiState) - } - - val reward = RewardFactory.reward() - val addOn = RewardFactory.addOn() - val addOnQuantity_1 = addOn.toBuilder().quantity(1).build() - val addOnQuantity_2 = addOn.toBuilder().quantity(2).build() - val projectData1 = ProjectDataFactory.project(ProjectFactory.project()).toBuilder().build() - - viewModel.provideProjectData(projectData1) - viewModel.onUserSelectedReward(reward) - viewModel.onUserUpdatedAddOns(mapOf(Pair(addOn, 1))) - - assertEquals( - uiState.last(), - ConfirmDetailsUIState( - rewardsAndAddOns = listOf(reward, addOnQuantity_1), - initialBonusSupportAmount = 0.0, - finalBonusSupportAmount = 0.0, - shippingAmount = 0.0, - totalAmount = 40.0, - minStepAmount = 1.0, - maxPledgeAmount = 10000.0, - isLoading = false - ) - ) - - viewModel.onUserUpdatedAddOns(mapOf(Pair(addOn, 2))) - - assertEquals( - uiState.last(), - ConfirmDetailsUIState( - rewardsAndAddOns = listOf(reward, addOnQuantity_2), - initialBonusSupportAmount = 0.0, - finalBonusSupportAmount = 0.0, - shippingAmount = 0.0, - totalAmount = 60.0, - minStepAmount = 1.0, - maxPledgeAmount = 10000.0, - isLoading = false - ) - ) - } - - @Test - fun `test_when_bonus_amount_changes_then_total_is_updated`() = runTest { - val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.confirmDetailsUIState.toList(uiState) - } - - val reward = RewardFactory.reward() - val addOn = RewardFactory.addOn() - val addOnQuantity_1 = addOn.toBuilder().quantity(1).build() - val projectData1 = ProjectDataFactory.project(ProjectFactory.project()).toBuilder().build() - - viewModel.provideProjectData(projectData1) - viewModel.onUserSelectedReward(reward) - viewModel.onUserUpdatedAddOns(mapOf(Pair(addOn, 1))) - - viewModel.incrementBonusSupport() - - assertEquals( - uiState.last(), - ConfirmDetailsUIState( - rewardsAndAddOns = listOf(reward, addOnQuantity_1), - initialBonusSupportAmount = 0.0, - finalBonusSupportAmount = 1.0, - shippingAmount = 0.0, - totalAmount = 41.0, - minStepAmount = 1.0, - maxPledgeAmount = 10000.0, - isLoading = false - ) - ) - - viewModel.incrementBonusSupport() - - assertEquals( - uiState.last(), - ConfirmDetailsUIState( - rewardsAndAddOns = listOf(reward, addOnQuantity_1), - initialBonusSupportAmount = 0.0, - finalBonusSupportAmount = 2.0, - shippingAmount = 0.0, - totalAmount = 42.0, - minStepAmount = 1.0, - maxPledgeAmount = 10000.0, - isLoading = false - ) - ) - - viewModel.decrementBonusSupport() - - assertEquals( - uiState.last(), - ConfirmDetailsUIState( - rewardsAndAddOns = listOf(reward, addOnQuantity_1), - initialBonusSupportAmount = 0.0, - finalBonusSupportAmount = 1.0, - shippingAmount = 0.0, - totalAmount = 41.0, - minStepAmount = 1.0, - maxPledgeAmount = 10000.0, - isLoading = false - ) - ) - - viewModel.decrementBonusSupport() - - assertEquals( - uiState.last(), - ConfirmDetailsUIState( - rewardsAndAddOns = listOf(reward, addOnQuantity_1), - initialBonusSupportAmount = 0.0, - finalBonusSupportAmount = 0.0, - shippingAmount = 0.0, - totalAmount = 40.0, - minStepAmount = 1.0, - maxPledgeAmount = 10000.0, - isLoading = false - ) - ) - } - - @Test - fun `test_when_shipping_rule_changes_then_total_and_shipping_amount_is_updated`() = runTest { - val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.confirmDetailsUIState.toList(uiState) - } - - val reward = RewardFactory.rewardWithShipping() - val addOn = RewardFactory.addOn() - val addOnQuantity_1 = addOn.toBuilder().quantity(1).build() - val projectData1 = ProjectDataFactory.project(ProjectFactory.project()).toBuilder().build() - val shippingRule1 = ShippingRuleFactory.usShippingRule() - val shippingRule2 = ShippingRuleFactory.germanyShippingRule() - - viewModel.provideProjectData(projectData1) - viewModel.onUserSelectedReward(reward) - viewModel.onUserUpdatedAddOns(mapOf(Pair(addOn, 1))) - viewModel.provideCurrentShippingRule(shippingRule1) - - assertEquals( - uiState.last(), - ConfirmDetailsUIState( - rewardsAndAddOns = listOf(reward, addOnQuantity_1), - initialBonusSupportAmount = 0.0, - finalBonusSupportAmount = 0.0, - shippingAmount = 30.0, - totalAmount = 70.0, - minStepAmount = 1.0, - maxPledgeAmount = 10000.0, - isLoading = false - ) - ) - - viewModel.provideCurrentShippingRule(shippingRule2) - - assertEquals( - uiState.last(), - ConfirmDetailsUIState( - rewardsAndAddOns = listOf(reward, addOnQuantity_1), - initialBonusSupportAmount = 0.0, - finalBonusSupportAmount = 0.0, - shippingAmount = 40.0, - totalAmount = 80.0, - minStepAmount = 1.0, - maxPledgeAmount = 10000.0, - isLoading = false - ) - ) - } - - @Test - fun `test_when_continue_clicked_when_late_pledge_disabled_then_default_action_is_called`() = - runTest { - val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.confirmDetailsUIState.toList(uiState) - } - - val projectData1 = ProjectDataFactory.project( - ProjectFactory - .project() - .toBuilder() - .isInPostCampaignPledgingPhase(false) - .build() - ).toBuilder().build() - - var defaultActionCalled = false - - viewModel.provideProjectData(projectData1) - - val defaultAction: () -> Unit = { - defaultActionCalled = true - } - - viewModel.onContinueClicked(defaultAction) - - assertTrue(defaultActionCalled) - } - - @Test - fun `test_when_continue_clicked_when_late_pledge_enabled_then_create_checkout_is_called`() = - runTest { - var createCheckoutCalled = false - viewModel = ConfirmDetailsViewModel.Factory( - environment = environment().toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun createCheckout(createCheckoutData: CreateCheckoutData): Observable { - createCheckoutCalled = true - return Observable.empty() - } - }).build() - ).create(ConfirmDetailsViewModel::class.java) - val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.confirmDetailsUIState.toList(uiState) - } - - val projectData1 = ProjectDataFactory.project( - ProjectFactory - .project() - .toBuilder() - .isInPostCampaignPledgingPhase(true) - .postCampaignPledgingEnabled(true) - .build() - ).toBuilder().build() - viewModel.provideProjectData(projectData1) - viewModel.onContinueClicked {} - - assertTrue(createCheckoutCalled) - } - - @Test - fun `test_when_continue_clicked_when_late_pledge_enabled_and_errors_then_error_action_occurs`() = - runTest { - var errorMessage: String? = "error" - viewModel = ConfirmDetailsViewModel.Factory( - environment = environment().toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun createCheckout(createCheckoutData: CreateCheckoutData): Observable { - return Observable.error(Throwable("This is an error")) - } - }).build() - ).create(ConfirmDetailsViewModel::class.java) - val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.confirmDetailsUIState.toList(uiState) - } - - val projectData1 = ProjectDataFactory.project( - ProjectFactory - .project() - .toBuilder() - .isInPostCampaignPledgingPhase(true) - .postCampaignPledgingEnabled(true) - .build() - ).toBuilder().build() - viewModel.provideProjectData(projectData1) - viewModel.provideErrorAction { errorMessage = it } - viewModel.onContinueClicked {} - - // Default message is null - assertNull(errorMessage) - } -} diff --git a/app/src/test/java/com/kickstarter/viewmodels/CrowdfundCheckoutViewModelTest.kt b/app/src/test/java/com/kickstarter/viewmodels/CrowdfundCheckoutViewModelTest.kt new file mode 100644 index 0000000000..b1a48df451 --- /dev/null +++ b/app/src/test/java/com/kickstarter/viewmodels/CrowdfundCheckoutViewModelTest.kt @@ -0,0 +1,884 @@ +package com.kickstarter.viewmodels + +import android.content.SharedPreferences +import android.os.Bundle +import android.util.Pair +import com.kickstarter.KSRobolectricTestCase +import com.kickstarter.libs.Environment +import com.kickstarter.libs.MockCurrentUserV2 +import com.kickstarter.libs.featureflag.FlagKey +import com.kickstarter.libs.utils.EventName +import com.kickstarter.libs.utils.extensions.checkoutTotalAmount +import com.kickstarter.libs.utils.extensions.pledgeAmountTotal +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.PaymentSourceFactory +import com.kickstarter.mock.factories.ProjectDataFactory +import com.kickstarter.mock.factories.ProjectFactory +import com.kickstarter.mock.factories.RewardFactory +import com.kickstarter.mock.factories.ShippingRuleFactory +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.Checkout +import com.kickstarter.models.Project +import com.kickstarter.models.Reward +import com.kickstarter.models.StoredCard +import com.kickstarter.models.UserPrivacy +import com.kickstarter.services.mutations.CreateBackingData +import com.kickstarter.services.mutations.UpdateBackingData +import com.kickstarter.ui.ArgumentsKey +import com.kickstarter.ui.SharedPreferenceKey +import com.kickstarter.ui.data.CheckoutData +import com.kickstarter.ui.data.PledgeData +import com.kickstarter.ui.data.PledgeFlowContext +import com.kickstarter.ui.data.PledgeReason +import com.kickstarter.viewmodels.projectpage.CheckoutUIState +import com.kickstarter.viewmodels.projectpage.CrowdfundCheckoutViewModel +import com.kickstarter.viewmodels.projectpage.PaymentSheetPresenterState +import com.kickstarter.viewmodels.usecases.TPEventInputData +import com.stripe.android.paymentsheet.PaymentSheetResult +import io.reactivex.Observable +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito +import type.CreditCardPaymentType + +@OptIn(ExperimentalCoroutinesApi::class) +class CrowdfundCheckoutViewModelTest : KSRobolectricTestCase() { + private lateinit var viewModel: CrowdfundCheckoutViewModel + + private fun setUpEnvironment(environment: Environment, bundle: Bundle? = null) { + viewModel = CrowdfundCheckoutViewModel.Factory(environment, bundle).create( + CrowdfundCheckoutViewModel::class.java + ) + } + + @Test + fun `test new pledge with rw with shipping + addOns + bonus support, selecting a saved payment method initial state`() = 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.userChangedPaymentMethodSelected(cards.first()) + + viewModel.crowdfundCheckoutUIState.toList(uiState) + } + advanceUntilIdle() + + assertEquals(uiState.size, 3) + + 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.first().id()) + assertEquals(uiState.last().storeCards, cards) + assertEquals(uiState.last().userEmail, "hola@ksr.com") + assertEquals(uiState.last().selectedRewards, pledgeData.rewardsAndAddOnsList()) + + assertEquals(errorActionCount, 0) + + segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) + } + + @Test + fun `test user hits pledges button with rw + addOns + bonus support with shipping`() = runTest { + // - The test reward with shipping + 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() + + // - AddOns shipping same as the reward + 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) + + // - Network mocks + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { + return Observable.just(cards) + } + + override fun createBacking(createBackingData: CreateBackingData): Observable { + val id = 33L + val backing = Checkout.Backing.builder() + .clientSecret("boop") + .requiresAction(false) + .build() + return Observable.just( + Checkout.builder() + .id(id) + .backing(backing) + .build() + ) + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val checkoutState = mutableListOf>() + val dispatcher = UnconfinedTestDispatcher(testScheduler) + + viewModel.provideBundle(bundle) + viewModel.userChangedPaymentMethodSelected(cards.first()) + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.pledgeOrUpdatePledge() + + viewModel.checkoutResultState.toList(checkoutState) + } + advanceUntilIdle() + + // - Checkout Data + assertEquals(checkoutState.last().first.id(), 33L) + assertEquals(checkoutState.last().first.bonusAmount(), 3.0) + assertEquals(checkoutState.last().first.shippingAmount(), pledgeData.shippingCostIfShipping()) + assertEquals(checkoutState.last().first.amount(), pledgeData.pledgeAmountTotal()) + assertEquals(checkoutState.last().first.paymentType(), CreditCardPaymentType.CREDIT_CARD) + + // - PledgeData + assertEquals(checkoutState.last().second, pledgeData) + segmentTrack.assertValues(EventName.PAGE_VIEWED.eventName, EventName.CTA_CLICKED.eventName) + } + + @Test + fun `test sendThirdPartyEvent when a payment method selected`() = runTest { + + var sharedPreferences: SharedPreferences = Mockito.mock(SharedPreferences::class.java) + Mockito.`when`(sharedPreferences.getBoolean(SharedPreferenceKey.CONSENT_MANAGEMENT_PREFERENCE, false)).thenReturn(true) + + val user = UserFactory.user() + val currentUser = MockCurrentUserV2(initialUser = user) + // - Network mocks + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun triggerThirdPartyEvent(eventInput: TPEventInputData): Observable> { + return Observable.just(Pair(true, "cosa")) + } + }) + .featureFlagClient(object : MockFeatureFlagClient() { + override fun getBoolean(FlagKey: FlagKey): Boolean { + return true + } + }) + .sharedPreferences(sharedPreferences) + .currentUserV2(currentUser) + .build() + + val project = ProjectFactory.project().toBuilder().sendThirdPartyEvents(true).build() + val bundle = Bundle() + val pledgeData = PledgeData.with( + PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE), + ProjectDataFactory.project(project), + RewardFactory.reward() + ) + + setUpEnvironment(environment) + bundle.putParcelable( + ArgumentsKey.PLEDGE_PLEDGE_DATA, + pledgeData + ) + bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.PLEDGE) + viewModel.provideBundle(bundle) + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.userChangedPaymentMethodSelected(StoredCardFactory.visa()) + } + advanceUntilIdle() + + assertTrue(viewModel.isThirdPartyEventSent().first) + assertEquals(viewModel.isThirdPartyEventSent().second, "cosa") + } + + @Test + fun `test update payment method with rw with shipping + addOns + bonus support backed, selecting a different payment method`() = runTest { + // - The test reward with shipping + val shippingRules = ShippingRulesEnvelopeFactory.shippingRules().shippingRules() + val reward = RewardFactory.rewardWithShipping().toBuilder() + .shippingRules(shippingRules = shippingRules) + .build() + + val addOns1 = RewardFactory.rewardWithShipping() + .toBuilder() + .isAddOn(true) + .shippingRules(shippingRules) + .build() + + val cards = listOf(StoredCardFactory.visa(), StoredCardFactory.discoverCard(), StoredCardFactory.fromPaymentSheetCard()) + + val backing = BackingFactory.backing(reward) + .toBuilder() + .addOns(listOf(addOns1)) + .location(shippingRules.first().location()) + .locationId(shippingRules.first().location()?.id()) + .bonusAmount(5.0) + .amount(44.0) + .shippingAmount(33f) + .paymentSource(PaymentSourceFactory.visa()) + .build() + + val project = ProjectFactory.project().toBuilder() + .backing(backing) + .isBacking(true) + .rewards(listOf(reward)) + .build() + + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + + val projectData = ProjectDataFactory.project(project) + + val bundle = Bundle() + + val pledgeData = PledgeData.with( + PledgeFlowContext.forPledgeReason(PledgeReason.UPDATE_PAYMENT), + projectData, + reward + ) + + bundle.putParcelable( + ArgumentsKey.PLEDGE_PLEDGE_DATA, + pledgeData + ) + bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.UPDATE_PAYMENT) + + lateinit var data: UpdateBackingData + // - Network mocks + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { + return Observable.just(cards) + } + + override fun updateBacking(updateBackingData: UpdateBackingData): Observable { + data = updateBackingData + val checkout = Checkout.builder().id(77L).backing(Checkout.Backing.builder().requiresAction(false).clientSecret("client").build()).build() + return Observable.just(checkout) + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val uiState = mutableListOf() + val checkout = mutableListOf>() + + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.provideBundle(bundle) + viewModel.userChangedPaymentMethodSelected(cards.last()) + viewModel.pledgeOrUpdatePledge() + viewModel.crowdfundCheckoutUIState.toList(uiState) + } + advanceUntilIdle() + + backgroundScope.launch(dispatcher) { + viewModel.checkoutResultState.toList(checkout) + } + + // - Assert initial state of the screen before any user interaction + assertEquals(uiState.size, 4) + + // - Amounts and selection should be the obtained from backing + assertEquals(uiState.last().shippingAmount, 33.0) + assertEquals(uiState.last().checkoutTotal, 44.0) + assertTrue(uiState.last().isPledgeButtonEnabled) + assertFalse(uiState.last().isLoading) + assertEquals(uiState.last().bonusAmount, 5.0) + assertEquals(uiState.last().shippingRule?.location(), backing.location()) + assertEquals(uiState.last().selectedPaymentMethod.id(), cards.last().id()) + assertEquals(uiState.last().selectedRewards.last(), addOns1) + assertEquals(uiState.last().selectedRewards.first(), reward) + + segmentTrack.assertValues(EventName.PAGE_VIEWED.eventName) + + assertEquals(checkout.size, 2) + assertEquals(checkout.last().first.id(), 77L) + assertEquals(data.amount, null) // When updating payment method UpdateBacking mutation expects null as amount + } + + @Test + fun `test change reward from(rw with shipping + addOns + bonus support) to a reward no reward`() = runTest { + val shippingRules = ShippingRulesEnvelopeFactory.shippingRules().shippingRules() + val rewardBacked = RewardFactory.rewardWithShipping().toBuilder() + .shippingRules(shippingRules = shippingRules) + .build() + + val addOns1Backed = RewardFactory.rewardWithShipping() + .toBuilder() + .isAddOn(true) + .shippingRules(shippingRules) + .build() + + val cards = listOf(StoredCardFactory.visa(), StoredCardFactory.discoverCard(), StoredCardFactory.fromPaymentSheetCard()) + + val backing = BackingFactory.backing(rewardBacked) + .toBuilder() + .addOns(listOf(addOns1Backed)) + .location(shippingRules.first().location()) + .locationId(shippingRules.first().location()?.id()) + .bonusAmount(5.0) + .amount(44.0) + .shippingAmount(33f) + .paymentSource(PaymentSourceFactory.visa()) + .build() + + val project = ProjectFactory.project().toBuilder() + .backing(backing) + .isBacking(true) + .rewards(listOf(rewardBacked)) + .build() + + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + + val projectData = ProjectDataFactory.project(project) + + val bundle = Bundle() + + // On pledge data add the newly selected NO-Reward, plus Reason = UPDATE_REWARD + val pledgeData = PledgeData.with( + PledgeFlowContext.forPledgeReason(PledgeReason.UPDATE_REWARD), + projectData, + RewardFactory.noReward() + ) + + bundle.putParcelable( + ArgumentsKey.PLEDGE_PLEDGE_DATA, + pledgeData + ) + bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.UPDATE_REWARD) + + lateinit var data: UpdateBackingData + 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") + ) + } + + override fun updateBacking(updateBackingData: UpdateBackingData): Observable { + data = updateBackingData + val checkout = Checkout.builder().id(999L).backing(Checkout.Backing.builder().requiresAction(false).clientSecret("client").build()).build() + return Observable.just(checkout) + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val uiState = mutableListOf() + val checkout = mutableListOf>() + + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.provideBundle(bundle) + viewModel.userChangedPaymentMethodSelected(cards.first()) + viewModel.pledgeOrUpdatePledge() + + viewModel.crowdfundCheckoutUIState.toList(uiState) + } + advanceUntilIdle() + + backgroundScope.launch(dispatcher) { + viewModel.checkoutResultState.toList(checkout) + } + + assertEquals(uiState.size, 4) + + assertEquals(uiState.last().shippingAmount, 0.0) + assertEquals(uiState.last().checkoutTotal, RewardFactory.noReward().pledgeAmount()) // TODO: REVIEW + assertEquals(uiState.last().bonusAmount, 0.0) + assertEquals(uiState.last().shippingRule, pledgeData.shippingRule()) + assertEquals(uiState.last().selectedPaymentMethod.id(), cards.first().id()) + assertEquals(uiState.last().storeCards, cards) + assertEquals(uiState.last().userEmail, "hola@ksr.com") + assertEquals(uiState.last().selectedRewards, listOf(RewardFactory.noReward())) + + segmentTrack.assertValues(EventName.PAGE_VIEWED.eventName) + + assertEquals(checkout.size, 2) + assertEquals(checkout.last().first.id(), 999L) + assertEquals(checkout.last().first.amount(), RewardFactory.noReward().pledgeAmount()) + assertEquals(checkout.last().first.shippingAmount(), 0.0) + assertEquals(checkout.last().first.bonusAmount(), 0.0) + + assertEquals(data.rewardsIds, emptyList()) // when calling updateBacking make with reward no reward make sure no rewardID's sent + + segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) + } + + @Test + fun `test change reward from(rw with shipping + addOns + bonus support) to another reward + bonus`() = runTest { + val shippingRules = ShippingRulesEnvelopeFactory.shippingRules().shippingRules() + val rewardBacked = RewardFactory.rewardWithShipping().toBuilder() + .shippingRules(shippingRules = shippingRules) + .build() + + val addOns1Backed = RewardFactory.rewardWithShipping() + .toBuilder() + .isAddOn(true) + .shippingRules(shippingRules) + .build() + + val cards = listOf(StoredCardFactory.visa(), StoredCardFactory.discoverCard(), StoredCardFactory.fromPaymentSheetCard()) + + val backing = BackingFactory.backing(rewardBacked) + .toBuilder() + .addOns(listOf(addOns1Backed)) + .location(shippingRules.first().location()) + .locationId(shippingRules.first().location()?.id()) + .bonusAmount(5.0) + .amount(44.0) + .shippingAmount(33f) + .paymentSource(PaymentSourceFactory.visa()) + .build() + + val project = ProjectFactory.project().toBuilder() + .backing(backing) + .isBacking(true) + .rewards(listOf(rewardBacked)) + .build() + + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + + val projectData = ProjectDataFactory.project(project) + + val bundle = Bundle() + + val secondReward = RewardFactory.rewardWithShipping() + + // On pledge data add the newly selected secondReward, bonus, plus Reason = UPDATE_REWARD + val pledgeData = PledgeData.with( + PledgeFlowContext.forPledgeReason(PledgeReason.UPDATE_REWARD), + projectData, + secondReward, + bonusAmount = 7.0 + ) + + bundle.putParcelable( + ArgumentsKey.PLEDGE_PLEDGE_DATA, + pledgeData + ) + bundle.putSerializable(ArgumentsKey.PLEDGE_PLEDGE_REASON, PledgeReason.UPDATE_REWARD) + + lateinit var data: UpdateBackingData + 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") + ) + } + + override fun updateBacking(updateBackingData: UpdateBackingData): Observable { + data = updateBackingData + val checkout = Checkout.builder().id(22L).backing(Checkout.Backing.builder().requiresAction(false).clientSecret("client").build()).build() + return Observable.just(checkout) + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val uiState = mutableListOf() + val checkout = mutableListOf>() + + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.provideBundle(bundle) + viewModel.userChangedPaymentMethodSelected(cards.first()) + viewModel.pledgeOrUpdatePledge() + + viewModel.crowdfundCheckoutUIState.toList(uiState) + } + advanceUntilIdle() + + backgroundScope.launch(dispatcher) { + viewModel.checkoutResultState.toList(checkout) + } + + assertEquals(uiState.size, 4) + + assertEquals(uiState.last().shippingAmount, 0.0) + assertEquals(uiState.last().checkoutTotal, secondReward.pledgeAmount() + 7.0) + assertEquals(uiState.last().checkoutTotal, pledgeData.checkoutTotalAmount()) + assertEquals(uiState.last().bonusAmount, 7.0) + assertEquals(uiState.last().shippingRule, pledgeData.shippingRule()) + assertEquals(uiState.last().selectedPaymentMethod.id(), cards.first().id()) + assertEquals(uiState.last().storeCards, cards) + assertEquals(uiState.last().userEmail, "hola@ksr.com") + assertEquals(uiState.last().selectedRewards, listOf(secondReward)) + + assertEquals(checkout.size, 2) + assertEquals(checkout.last().first.id(), 22L) + assertEquals(checkout.last().first.shippingAmount(), 0.0) + assertEquals(checkout.last().first.bonusAmount(), 7.0) + + assertEquals(data.rewardsIds?.size, 1) + assertEquals(data.rewardsIds?.first(), secondReward) + assertEquals(data.amount, (secondReward.pledgeAmount() + 7.0).toString()) + + segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) + } + + @Test + fun `test adding new paymentMethod throw Stripe's paymentSheet`() = runTest { + + val reward = RewardFactory.reward() + 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, + ) + + 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") + ) + } + + override fun createSetupIntent(project: Project?): Observable { + return Observable.just("SetupIntent") + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val paymentSheetState = mutableListOf() + val uiState = mutableListOf() + + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.provideBundle(bundle) + viewModel.getSetupIntent() + viewModel.presentPaymentSheetStates.toList(paymentSheetState) + } + advanceUntilIdle() + + assertEquals(paymentSheetState.size, 2) + assertEquals(paymentSheetState.last().setupClientId, "SetupIntent") + + backgroundScope.launch(dispatcher) { + viewModel.paymentSheetPresented(true) + viewModel.newlyAddedPaymentMethod(StoredCardFactory.fromPaymentSheetCard()) + viewModel.paymentSheetResult(PaymentSheetResult.Completed) + viewModel.crowdfundCheckoutUIState.toList(uiState) + } + + assertEquals(uiState.last().storeCards.first(), StoredCardFactory.fromPaymentSheetCard()) + } + + @Test + fun `test adding new paymentMethod throw Stripe's paymentSheet, but result canceled or failed (3DS challenge failed)`() = runTest { + + val reward = RewardFactory.reward() + val project = ProjectFactory.project().toBuilder() + .rewards(listOf(reward)) + .build() + val cards = listOf(StoredCardFactory.visa(), StoredCardFactory.discoverCard()) + + 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, + ) + + 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") + ) + } + + override fun createSetupIntent(project: Project?): Observable { + return Observable.just("SetupIntent") + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val paymentSheetState = mutableListOf() + val uiState = mutableListOf() + + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.provideBundle(bundle) + viewModel.getSetupIntent() + viewModel.presentPaymentSheetStates.toList(paymentSheetState) + } + advanceUntilIdle() + + assertEquals(paymentSheetState.size, 2) + assertEquals(paymentSheetState.last().setupClientId, "SetupIntent") + + backgroundScope.launch(dispatcher) { + viewModel.paymentSheetPresented(true) + viewModel.newlyAddedPaymentMethod(StoredCardFactory.fromPaymentSheetCard()) + viewModel.crowdfundCheckoutUIState.toList(uiState) + } + advanceUntilIdle() + + backgroundScope.launch(dispatcher) { + viewModel.paymentSheetResult(PaymentSheetResult.Failed(Throwable())) + } + + assertEquals(uiState.last().storeCards, cards) + } + + @Test + fun `test some error occurred`() = runTest { + val projectData = ProjectDataFactory.project(ProjectFactory.project()) + val reward = RewardFactory.reward() + + val bundle = Bundle() + + val pledgeData = PledgeData.with( + PledgeFlowContext.forPledgeReason(PledgeReason.PLEDGE), + projectData, + reward, + ) + + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + + 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.error(Throwable("Oh no, no more chocolate!")) + } + + override fun userPrivacy(): Observable { + return Observable.error(Throwable("Oh no, we are out of coffee!")) + } + + override fun createBacking(createBackingData: CreateBackingData): Observable { + return Observable.error(Throwable("Oh no, the team rocket!")) + } + + override fun createSetupIntent(project: Project?): Observable { + return Observable.error(Throwable("Oh no, Godzilla!")) + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val uiState = mutableListOf() + + val errorList = mutableListOf() + backgroundScope.launch(dispatcher) { + viewModel.provideScopeAndDispatcher(this, dispatcher) + viewModel.provideErrorAction { message -> + message?.let { errorList.add(it) } + } + viewModel.provideBundle(bundle) + viewModel.crowdfundCheckoutUIState.toList(uiState) + } + advanceUntilIdle() + backgroundScope.launch(dispatcher) { + viewModel.pledgeOrUpdatePledge() + } + advanceUntilIdle() + backgroundScope.launch(dispatcher) { + viewModel.getSetupIntent() + } + + assertEquals(errorList.size, 4) + assertEquals(errorList.last(), "Oh no, Godzilla!") + assertEquals(errorList.first(), "Oh no, we are out of coffee!") + assertEquals(errorList[1], "Oh no, no more chocolate!") + assertEquals(errorList[2], "Oh no, the team rocket!") + } +} diff --git a/app/src/test/java/com/kickstarter/viewmodels/LatePledgeCheckoutViewModelTest.kt b/app/src/test/java/com/kickstarter/viewmodels/LatePledgeCheckoutViewModelTest.kt index 7a1b3a3db6..6571de617b 100644 --- a/app/src/test/java/com/kickstarter/viewmodels/LatePledgeCheckoutViewModelTest.kt +++ b/app/src/test/java/com/kickstarter/viewmodels/LatePledgeCheckoutViewModelTest.kt @@ -1,9 +1,11 @@ package com.kickstarter.viewmodels +import android.util.Pair import com.kickstarter.KSRobolectricTestCase import com.kickstarter.libs.Environment import com.kickstarter.libs.MockCurrentUserV2 import com.kickstarter.libs.utils.EventName +import com.kickstarter.mock.factories.BackingFactory import com.kickstarter.mock.factories.ProjectDataFactory import com.kickstarter.mock.factories.ProjectFactory import com.kickstarter.mock.factories.RewardFactory @@ -11,14 +13,23 @@ import com.kickstarter.mock.factories.ShippingRuleFactory import com.kickstarter.mock.factories.StoredCardFactory import com.kickstarter.mock.factories.UserFactory import com.kickstarter.mock.services.MockApolloClientV2 +import com.kickstarter.models.Backing +import com.kickstarter.models.CheckoutPayment +import com.kickstarter.models.CreatePaymentIntentInput +import com.kickstarter.models.PaymentValidationResponse +import com.kickstarter.models.Project import com.kickstarter.models.StoredCard import com.kickstarter.models.UserPrivacy +import com.kickstarter.services.mutations.CreateCheckoutData +import com.kickstarter.ui.data.PledgeData +import com.kickstarter.ui.data.PledgeFlowContext import com.kickstarter.viewmodels.projectpage.LatePledgeCheckoutUIState import com.kickstarter.viewmodels.projectpage.LatePledgeCheckoutViewModel import io.reactivex.Observable import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test @@ -31,56 +42,499 @@ class LatePledgeCheckoutViewModelTest : KSRobolectricTestCase() { } @Test - fun `test send PageViewed event`() { + fun `test_when_loading_called_then_state_shows_loading`() = runTest { setUpEnvironment(environment()) - val rw = RewardFactory.rewardWithShipping() - val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() + val state = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.latePledgeCheckoutUIState.toList(state) + } + + viewModel.loading() + assertEquals( + state.last(), + LatePledgeCheckoutUIState( + isLoading = true + ) + ) + } + + @Test + fun `test_when_user_logged_in_then_email_is_provided`() = runTest { + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { + return Observable.empty() + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val state = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.latePledgeCheckoutUIState.toList(state) + } + + assertEquals( + state.last(), + LatePledgeCheckoutUIState( + userEmail = "some@email.com" + ) + ) + } + + @Test + fun `test_when_user_logged_in_then_cards_are_fetched`() = runTest { + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + val cardList = listOf(StoredCardFactory.visa()) + + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { + return Observable.just(cardList) + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val state = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.latePledgeCheckoutUIState.toList(state) + } + + assertEquals( + state.last(), + LatePledgeCheckoutUIState( + storeCards = cardList, + userEmail = "some@email.com" + ) + ) + } + + @Test + fun `test_when_user_clicks_add_new_card_then_setup_intent_is_called`() = runTest { + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun createSetupIntent(project: Project?): Observable { + return Observable.just("thisIsAClientSecret") + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val state = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.clientSecretForNewPaymentMethod.toList(state) + } + + viewModel.onAddNewCardClicked(Project.builder().build()) + + assertEquals( + state.last(), + "thisIsAClientSecret" + ) + } + + @Test + fun `test_when_new_card_added_then_payment_methods_are_refreshed`() = runTest { + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + var cardList = mutableListOf(StoredCardFactory.visa()) + + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { + return Observable.just(cardList) + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val state = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.latePledgeCheckoutUIState.toList(state) + } + + // Before List changes + assertEquals( + state.last(), + LatePledgeCheckoutUIState( + storeCards = cardList, + userEmail = "some@email.com" + ) + ) + + cardList = mutableListOf(StoredCardFactory.visa(), StoredCardFactory.discoverCard()) + + viewModel.onNewCardSuccessfullyAdded() + + // After list is updated + assertEquals( + state.last(), + LatePledgeCheckoutUIState( + storeCards = cardList, + userEmail = "some@email.com" + ) + ) + } + + @Test + fun `test_when_new_card_adding_fails_then_state_emits`() = runTest { + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + val cardList = mutableListOf(StoredCardFactory.visa()) + + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { + return Observable.just(cardList) + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + val state = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.latePledgeCheckoutUIState.toList(state) + } + + viewModel.onNewCardFailed() + + assertEquals( + state.last(), + LatePledgeCheckoutUIState( + storeCards = cardList, + userEmail = "some@email.com" + ) + ) + + assertEquals(state.size, 2) + } + + @Test + fun `test when pledge_clicked_and_checkout_id_ and backingID not_provided then_error_action_is_called`() = + runTest { + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + val cardList = mutableListOf(StoredCardFactory.visa()) + + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { + return Observable.just(cardList) + } + + override fun createCheckout(createCheckoutData: CreateCheckoutData): Observable { + return Observable.just(CheckoutPayment(100L, backing = Backing.builder().id(101L).build(), paymentUrl = "")) + } + override fun createPaymentIntent(createPaymentIntentInput: CreatePaymentIntentInput): Observable { + return Observable.just("paymentIntent") + } + + override fun validateCheckout( + checkoutId: String, + paymentIntentClientSecret: String, + paymentSourceId: String + ): Observable { + return Observable.just( + PaymentValidationResponse( + isValid = true, + messages = listOf() + ) + ) + } + + override fun completeOnSessionCheckout( + checkoutId: String, + paymentIntentClientSecret: String, + paymentSourceId: String?, + paymentSourceReusable: Boolean + ): Observable> { + return Observable.just(Pair("Success", false)) + } + }) + .currentUserV2(currentUserV2) + .build() + + val rw = RewardFactory.rewardWithShipping().toBuilder().latePledgeAmount(34.0).build() + val project = ProjectFactory.project().toBuilder() + .isInPostCampaignPledgingPhase(true) + .postCampaignPledgingEnabled(true) + .isBacking(false) + .rewards(listOf(rw)).build() + + val addOns = listOf(rw, rw, rw) + val rule = ShippingRuleFactory.germanyShippingRule().toBuilder().cost(3.0).build() + val bonusAmount = 5.0 + + val projectData = ProjectDataFactory.project(project = project) + val pledgeData = PledgeData.with(PledgeFlowContext.LATE_PLEDGES, projectData, rw, addOns = addOns, bonusAmount = bonusAmount, shippingRule = rule) + + var errorActionCount = 0 + val state = mutableListOf() + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + setUpEnvironment(environment) + + viewModel.provideErrorAction { + errorActionCount++ + } + + viewModel.providePledgeData(pledgeData) + + viewModel.onPledgeButtonClicked(cardList.first()) + + viewModel.latePledgeCheckoutUIState.toList(state) + } + advanceUntilIdle() + + assertEquals(state.last().storeCards, cardList) + assertEquals(state.last().userEmail, "some@email.com") + + assertEquals(errorActionCount, 1) + } + + @Test + fun `test_when_pledge_clicked_and_checkout_id_provided_then_checkout_continues`() = runTest { + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + val cardList = mutableListOf(StoredCardFactory.visa()) + + var paymentIntentCalled = 0 + var validateCheckoutCalled = 0 + + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { + return Observable.just(cardList) + } + + override fun createCheckout(createCheckoutData: CreateCheckoutData): Observable { + return Observable.just(CheckoutPayment(100L, backing = Backing.builder().id(101L).build(), paymentUrl = "")) + } + override fun createPaymentIntent(createPaymentIntentInput: CreatePaymentIntentInput): Observable { + paymentIntentCalled++ + return Observable.just("paymentIntent") + } + + override fun validateCheckout( + checkoutId: String, + paymentIntentClientSecret: String, + paymentSourceId: String + ): Observable { + validateCheckoutCalled++ + return Observable.just( + PaymentValidationResponse( + isValid = true, + messages = listOf() + ) + ) + } + + override fun completeOnSessionCheckout( + checkoutId: String, + paymentIntentClientSecret: String, + paymentSourceId: String?, + paymentSourceReusable: Boolean + ): Observable> { + return Observable.just(Pair("Success", false)) + } + }) + .currentUserV2(currentUserV2) + .build() + + val rw = RewardFactory.rewardWithShipping().toBuilder().latePledgeAmount(34.0).build() + val project = ProjectFactory.project().toBuilder() + .isInPostCampaignPledgingPhase(true) + .postCampaignPledgingEnabled(true) + .isBacking(false) + .rewards(listOf(rw)).build() + val addOns = listOf(rw, rw, rw) - val rule = ShippingRuleFactory.germanyShippingRule() - val shipAmount = 3.0 - val totalAmount = 300.0 + val rule = ShippingRuleFactory.germanyShippingRule().toBuilder().cost(3.0).build() val bonusAmount = 5.0 val projectData = ProjectDataFactory.project(project = project) + val pledgeData = PledgeData.with(PledgeFlowContext.LATE_PLEDGES, projectData, rw, addOns = addOns, bonusAmount = bonusAmount, shippingRule = rule) - viewModel.userRewardSelection(rw) - viewModel.sendPageViewedEvent( - projectData, - addOns, - rule, - shipAmount, - totalAmount, - bonusAmount - ) + var errorActionCount = 0 + val state = mutableListOf() + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + setUpEnvironment(environment) + + viewModel.provideErrorAction { + errorActionCount++ + } + + viewModel.providePledgeData(pledgeData) + viewModel.provideCheckoutIdAndBacking(100L, Backing.builder().id(101L).build()) + + viewModel.onPledgeButtonClicked(cardList.first()) + + viewModel.latePledgeCheckoutUIState.toList(state) + } + advanceUntilIdle() + + assertEquals(state.last().storeCards, cardList) + assertEquals(state.last().userEmail, "some@email.com") + + // Stripe will give an error since this is mock data + assertEquals(errorActionCount, 1) + assertEquals(validateCheckoutCalled, 1) + assertEquals(paymentIntentCalled, 1) + } + + @Test + fun `test_when_complete3DSCheckout_called_with_no_values_then_errors`() = runTest { + val user = UserFactory.user() + val currentUserV2 = MockCurrentUserV2(initialUser = user) + + var completeOnSessionCheckoutCalled = 0 - this.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) + val environment = environment().toBuilder() + .apolloClientV2(object : MockApolloClientV2() { + override fun completeOnSessionCheckout( + checkoutId: String, + paymentIntentClientSecret: String, + paymentSourceId: String?, + paymentSourceReusable: Boolean + ): Observable> { + completeOnSessionCheckoutCalled++ + return Observable.just(Pair("Success", false)) + } + }) + .currentUserV2(currentUserV2) + .build() + + setUpEnvironment(environment) + + var errorActionCount = 0 + + viewModel.provideErrorAction { + errorActionCount++ + } + + viewModel.completeOnSessionCheckoutFor3DS() + + assertEquals(errorActionCount, 1) + assertEquals(completeOnSessionCheckoutCalled, 0) } @Test - fun `test send CTAClicked event`() { + fun `test send PageViewed event`() = runTest { setUpEnvironment(environment()) - val rw = RewardFactory.rewardWithShipping() + val rw = RewardFactory.rewardWithShipping().toBuilder().latePledgeAmount(34.0).build() val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() val addOns = listOf(rw, rw, rw) - val rule = ShippingRuleFactory.germanyShippingRule() - val shipAmount = 3.0 - val totalAmount = 300.0 + val rule = ShippingRuleFactory.germanyShippingRule().toBuilder().cost(3.0).build() val bonusAmount = 5.0 + val discover = StoredCardFactory.discoverCard() + val visa = StoredCardFactory.visa() + val cardsList = listOf(visa, discover) + + val currentUser = MockCurrentUserV2(UserFactory.user()) + setUpEnvironment( + environment() + .toBuilder() + .currentUserV2(currentUser) // - mock the user + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { // - mock the stored cards + return Observable.just(cardsList) + } + + override fun userPrivacy(): Observable { // - mock the user email and name + return Observable.just( + UserPrivacy("Hola holita", "hola@gmail.com", true, true, true, true, "MXN") + ) + } + + override fun createCheckout(createCheckoutData: CreateCheckoutData): Observable { + return Observable.just(CheckoutPayment(id = 3L, backing = BackingFactory.backing(rw), paymentUrl = "some url")) + } + }).build() + ) + + val state = mutableListOf() val projectData = ProjectDataFactory.project(project = project) - viewModel.userRewardSelection(rw) - viewModel.sendSubmitCTAEvent( - projectData, - addOns, - rule, - shipAmount, - totalAmount, - bonusAmount + val pledgeData = PledgeData.with(PledgeFlowContext.LATE_PLEDGES, projectData, rw, addOns = addOns, bonusAmount = bonusAmount, shippingRule = rule) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + + viewModel.providePledgeData(pledgeData) + viewModel.userRewardSelection(rw) + viewModel.sendPageViewedEvent() + + segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) + viewModel.latePledgeCheckoutUIState.toList(state) + } + } + + @Test + fun `test send CTAClicked event`() = runTest { + setUpEnvironment(environment()) + + val rw = RewardFactory.rewardWithShipping().toBuilder().latePledgeAmount(34.0).build() + val project = ProjectFactory.project().toBuilder().rewards(listOf(rw)).build() + val addOns = listOf(rw, rw, rw) + val rule = ShippingRuleFactory.germanyShippingRule().toBuilder().cost(3.0).build() + val bonusAmount = 5.0 + + val discover = StoredCardFactory.discoverCard() + val visa = StoredCardFactory.visa() + val cardsList = listOf(visa, discover) + + val currentUser = MockCurrentUserV2(UserFactory.user()) + setUpEnvironment( + environment() + .toBuilder() + .currentUserV2(currentUser) // - mock the user + .apolloClientV2(object : MockApolloClientV2() { + override fun getStoredCards(): Observable> { // - mock the stored cards + return Observable.just(cardsList) + } + + override fun userPrivacy(): Observable { // - mock the user email and name + return Observable.just( + UserPrivacy("Hola holita", "hola@gmail.com", true, true, true, true, "MXN") + ) + } + + override fun createCheckout(createCheckoutData: CreateCheckoutData): Observable { + return Observable.just(CheckoutPayment(id = 3L, backing = BackingFactory.backing(rw), paymentUrl = "some url")) + } + }).build() ) - this.segmentTrack.assertValue(EventName.CTA_CLICKED.eventName) + val state = mutableListOf() + val projectData = ProjectDataFactory.project(project = project) + val pledgeData = PledgeData.with(PledgeFlowContext.LATE_PLEDGES, projectData, rw, addOns = addOns, bonusAmount = bonusAmount, shippingRule = rule) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + + viewModel.providePledgeData(pledgeData) + viewModel.sendSubmitCTAEvent() + + segmentTrack.assertValue(EventName.CTA_CLICKED.eventName) + viewModel.latePledgeCheckoutUIState.toList(state) + } } @Test @@ -145,7 +599,7 @@ class LatePledgeCheckoutViewModelTest : KSRobolectricTestCase() { viewModel.latePledgeCheckoutUIState.toList(state) } - assertEquals(state.size, 2) + assertEquals(state.size, 1) assertEquals(state.last().userEmail, "") assertEquals(state.last().storeCards, emptyList()) } diff --git a/app/src/test/java/com/kickstarter/viewmodels/LatePledgeCheckoutViewModelTests.kt b/app/src/test/java/com/kickstarter/viewmodels/LatePledgeCheckoutViewModelTests.kt deleted file mode 100644 index b8b0a2fa47..0000000000 --- a/app/src/test/java/com/kickstarter/viewmodels/LatePledgeCheckoutViewModelTests.kt +++ /dev/null @@ -1,443 +0,0 @@ -package com.kickstarter.viewmodels - -import android.util.Pair -import com.kickstarter.KSRobolectricTestCase -import com.kickstarter.libs.Environment -import com.kickstarter.libs.MockCurrentUserV2 -import com.kickstarter.libs.utils.EventName -import com.kickstarter.mock.factories.ProjectDataFactory -import com.kickstarter.mock.factories.ProjectFactory -import com.kickstarter.mock.factories.RewardFactory -import com.kickstarter.mock.factories.ShippingRuleFactory -import com.kickstarter.mock.factories.StoredCardFactory -import com.kickstarter.mock.factories.UserFactory -import com.kickstarter.mock.services.MockApolloClientV2 -import com.kickstarter.models.Backing -import com.kickstarter.models.CreatePaymentIntentInput -import com.kickstarter.models.PaymentValidationResponse -import com.kickstarter.models.Project -import com.kickstarter.models.StoredCard -import com.kickstarter.viewmodels.projectpage.LatePledgeCheckoutUIState -import com.kickstarter.viewmodels.projectpage.LatePledgeCheckoutViewModel -import io.reactivex.Observable -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class LatePledgeCheckoutViewModelTests : KSRobolectricTestCase() { - - private lateinit var viewModel: LatePledgeCheckoutViewModel - - private fun setUpWithEnvironment(environment: Environment) { - viewModel = - LatePledgeCheckoutViewModel.Factory(environment) - .create(LatePledgeCheckoutViewModel::class.java) - } - - @Test - fun `test_when_loading_called_then_state_shows_loading`() = runTest { - setUpWithEnvironment(environment()) - - val state = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.latePledgeCheckoutUIState.toList(state) - } - - viewModel.loading() - assertEquals( - state.last(), - LatePledgeCheckoutUIState( - isLoading = true - ) - ) - } - - @Test - fun `test_when_user_logged_in_then_email_is_provided`() = runTest { - val user = UserFactory.user() - val currentUserV2 = MockCurrentUserV2(initialUser = user) - - val environment = environment().toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun getStoredCards(): Observable> { - return Observable.empty() - } - }) - .currentUserV2(currentUserV2) - .build() - - setUpWithEnvironment(environment) - - val state = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.latePledgeCheckoutUIState.toList(state) - } - - assertEquals( - state.last(), - LatePledgeCheckoutUIState( - userEmail = "some@email.com" - ) - ) - } - - @Test - fun `test_when_user_logged_in_then_cards_are_fetched`() = runTest { - val user = UserFactory.user() - val currentUserV2 = MockCurrentUserV2(initialUser = user) - val cardList = listOf(StoredCardFactory.visa()) - - val environment = environment().toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun getStoredCards(): Observable> { - return Observable.just(cardList) - } - }) - .currentUserV2(currentUserV2) - .build() - - setUpWithEnvironment(environment) - - val state = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.latePledgeCheckoutUIState.toList(state) - } - - assertEquals( - state.last(), - LatePledgeCheckoutUIState( - storeCards = cardList, - userEmail = "some@email.com" - ) - ) - } - - @Test - fun `test_when_user_clicks_add_new_card_then_setup_intent_is_called`() = runTest { - val user = UserFactory.user() - val currentUserV2 = MockCurrentUserV2(initialUser = user) - - val environment = environment().toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun createSetupIntent(project: Project?): Observable { - return Observable.just("thisIsAClientSecret") - } - }) - .currentUserV2(currentUserV2) - .build() - - setUpWithEnvironment(environment) - - val state = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.clientSecretForNewPaymentMethod.toList(state) - } - - viewModel.onAddNewCardClicked(Project.builder().build()) - - assertEquals( - state.last(), - "thisIsAClientSecret" - ) - } - - @Test - fun `test_when_new_card_added_then_payment_methods_are_refreshed`() = runTest { - val user = UserFactory.user() - val currentUserV2 = MockCurrentUserV2(initialUser = user) - var cardList = mutableListOf(StoredCardFactory.visa()) - - val environment = environment().toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun getStoredCards(): Observable> { - return Observable.just(cardList) - } - }) - .currentUserV2(currentUserV2) - .build() - - setUpWithEnvironment(environment) - - val state = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.latePledgeCheckoutUIState.toList(state) - } - - // Before List changes - assertEquals( - state.last(), - LatePledgeCheckoutUIState( - storeCards = cardList, - userEmail = "some@email.com" - ) - ) - - cardList = mutableListOf(StoredCardFactory.visa(), StoredCardFactory.discoverCard()) - - viewModel.onNewCardSuccessfullyAdded() - - // After list is updated - assertEquals( - state.last(), - LatePledgeCheckoutUIState( - storeCards = cardList, - userEmail = "some@email.com" - ) - ) - } - - @Test - fun `test_when_new_card_adding_fails_then_state_emits`() = runTest { - val user = UserFactory.user() - val currentUserV2 = MockCurrentUserV2(initialUser = user) - val cardList = mutableListOf(StoredCardFactory.visa()) - - val environment = environment().toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun getStoredCards(): Observable> { - return Observable.just(cardList) - } - }) - .currentUserV2(currentUserV2) - .build() - - setUpWithEnvironment(environment) - - val state = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.latePledgeCheckoutUIState.toList(state) - } - - viewModel.onNewCardFailed() - - assertEquals( - state.last(), - LatePledgeCheckoutUIState( - storeCards = cardList, - userEmail = "some@email.com" - ) - ) - - assertEquals(state.size, 2) - } - - @Test - fun `test_when_pledge_clicked_and_checkout_id_not_provided_then_error_action_is_called`() = - runTest { - val user = UserFactory.user() - val currentUserV2 = MockCurrentUserV2(initialUser = user) - val cardList = mutableListOf(StoredCardFactory.visa()) - - val environment = environment().toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun getStoredCards(): Observable> { - return Observable.just(cardList) - } - }) - .currentUserV2(currentUserV2) - .build() - - setUpWithEnvironment(environment) - - val state = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.latePledgeCheckoutUIState.toList(state) - } - - var errorActionCount = 0 - - viewModel.provideErrorAction { - errorActionCount++ - } - - viewModel.onPledgeButtonClicked(cardList.first(), ProjectFactory.project(), 100.0) - - assertEquals( - state.last(), - LatePledgeCheckoutUIState( - storeCards = cardList, - userEmail = "some@email.com" - ) - ) - - assertEquals(errorActionCount, 1) - } - - @Test - fun `test_when_pledge_clicked_and_checkout_id_provided_then_checkout_continues`() = runTest { - val user = UserFactory.user() - val currentUserV2 = MockCurrentUserV2(initialUser = user) - val cardList = mutableListOf(StoredCardFactory.visa()) - - var paymentIntentCalled = 0 - var validateCheckoutCalled = 0 - - val environment = environment().toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun getStoredCards(): Observable> { - return Observable.just(cardList) - } - - override fun createPaymentIntent(createPaymentIntentInput: CreatePaymentIntentInput): Observable { - paymentIntentCalled++ - return Observable.just("paymentIntent") - } - - override fun validateCheckout( - checkoutId: String, - paymentIntentClientSecret: String, - paymentSourceId: String - ): Observable { - validateCheckoutCalled++ - return Observable.just( - PaymentValidationResponse( - isValid = true, - messages = listOf() - ) - ) - } - - override fun completeOnSessionCheckout( - checkoutId: String, - paymentIntentClientSecret: String, - paymentSourceId: String?, - paymentSourceReusable: Boolean - ): Observable> { - return Observable.just(Pair("Success", false)) - } - }) - .currentUserV2(currentUserV2) - .build() - - setUpWithEnvironment(environment) - - val state = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.latePledgeCheckoutUIState.toList(state) - } - - var errorActionCount = 0 - - viewModel.provideErrorAction { - errorActionCount++ - } - - viewModel.provideCheckoutIdAndBacking(100L, Backing.builder().id(101L).build()) - - viewModel.onPledgeButtonClicked(cardList.first(), ProjectFactory.project(), 100.0) - - assertEquals( - state.last(), - LatePledgeCheckoutUIState( - storeCards = cardList, - userEmail = "some@email.com" - ) - ) - - // Stripe will give an error since this is mock data - assertEquals(errorActionCount, 1) - assertEquals(validateCheckoutCalled, 1) - assertEquals(paymentIntentCalled, 1) - } - - @Test - fun `test_when_complete3DSCheckout_called_with_no_values_then_errors`() = runTest { - val user = UserFactory.user() - val currentUserV2 = MockCurrentUserV2(initialUser = user) - - var completeOnSessionCheckoutCalled = 0 - - val environment = environment().toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun completeOnSessionCheckout( - checkoutId: String, - paymentIntentClientSecret: String, - paymentSourceId: String?, - paymentSourceReusable: Boolean - ): Observable> { - completeOnSessionCheckoutCalled++ - return Observable.just(Pair("Success", false)) - } - }) - .currentUserV2(currentUserV2) - .build() - - setUpWithEnvironment(environment) - - var errorActionCount = 0 - - viewModel.provideErrorAction { - errorActionCount++ - } - - viewModel.completeOnSessionCheckoutFor3DS() - - assertEquals(errorActionCount, 1) - assertEquals(completeOnSessionCheckoutCalled, 0) - } - - @Test - fun `test_when_no_reward_selected_then_no_analytics_sent`() = runTest { - setUpWithEnvironment(environment()) - - viewModel.sendPageViewedEvent( - ProjectDataFactory.project(ProjectFactory.project()), - listOf(), - ShippingRuleFactory.usShippingRule(), - 100.0, - 1000.0, - 100.0 - ) - - this@LatePledgeCheckoutViewModelTests.segmentTrack.assertNoValues() - - viewModel.sendSubmitCTAEvent( - ProjectDataFactory.project(ProjectFactory.project()), - listOf(), - ShippingRuleFactory.usShippingRule(), - 100.0, - 1000.0, - 100.0 - ) - - this@LatePledgeCheckoutViewModelTests.segmentTrack.assertNoValues() - } - - @Test - fun `test_when_reward_selected_then_page_analytics_sent`() = runTest { - setUpWithEnvironment(environment()) - - viewModel.userRewardSelection(RewardFactory.reward()) - - viewModel.sendPageViewedEvent( - ProjectDataFactory.project(ProjectFactory.project()), - listOf(), - ShippingRuleFactory.usShippingRule(), - 100.0, - 1000.0, - 100.0 - ) - - this@LatePledgeCheckoutViewModelTests.segmentTrack.assertValue(EventName.PAGE_VIEWED.eventName) - } - - @Test - fun `test_when_reward_selected_then_cta_analytics_sent`() = runTest { - setUpWithEnvironment(environment()) - - viewModel.userRewardSelection(RewardFactory.reward()) - - viewModel.sendSubmitCTAEvent( - ProjectDataFactory.project(ProjectFactory.project()), - listOf(), - ShippingRuleFactory.usShippingRule(), - 100.0, - 1000.0, - 100.0 - ) - - this@LatePledgeCheckoutViewModelTests.segmentTrack.assertValue(EventName.CTA_CLICKED.eventName) - } -} diff --git a/app/src/test/java/com/kickstarter/viewmodels/PledgeFragmentViewModelTest.kt b/app/src/test/java/com/kickstarter/viewmodels/PledgeFragmentViewModelTest.kt index ce7896ee45..c9c8b32000 100644 --- a/app/src/test/java/com/kickstarter/viewmodels/PledgeFragmentViewModelTest.kt +++ b/app/src/test/java/com/kickstarter/viewmodels/PledgeFragmentViewModelTest.kt @@ -1013,7 +1013,7 @@ class PledgeFragmentViewModelTest : KSRobolectricTestCase() { this.pledgeSummaryIsGone.assertValue(true) this.headerSectionIsGone.assertValue(true) this.shippingRulesSectionIsGone.assertValue(true) - this.selectedShippingRule.assertNoValues() + this.selectedShippingRule.assertValueCount(1) this.shippingSummaryIsGone.assertValues(true) this.totalDividerIsGone.assertValue(true) this.pledgeButtonIsEnabled.assertValue(true) @@ -1058,7 +1058,7 @@ class PledgeFragmentViewModelTest : KSRobolectricTestCase() { this.pledgeSummaryIsGone.assertValue(false) this.headerSectionIsGone.assertValue(true) this.shippingRulesSectionIsGone.assertValue(true) - this.selectedShippingRule.assertNoValues() + this.selectedShippingRule.assertValueCount(1) this.shippingSummaryIsGone.assertValues(true) this.totalDividerIsGone.assertValue(true) this.pledgeButtonIsEnabled.assertValue(true) @@ -1171,7 +1171,7 @@ class PledgeFragmentViewModelTest : KSRobolectricTestCase() { this.bonusSectionIsGone.assertNoValues() this.pledgeSummaryIsGone.assertValue(true) this.shippingRulesSectionIsGone.assertValue(true) - this.selectedShippingRule.assertNoValues() + this.selectedShippingRule.assertValueCount(1) this.shippingSummaryIsGone.assertValues(true) this.bonusSummaryIsGone.assertValues(true) this.totalDividerIsGone.assertValue(true) @@ -1304,26 +1304,6 @@ class PledgeFragmentViewModelTest : KSRobolectricTestCase() { this.totalAmount.assertValue(expectedCurrency(environment, backedProject, 40.0)) } - @Test - fun testPledgeSummaryAmount_whenFixingPaymentMethod_whenRewardAddOnShippingBonusAmount() { - val testData = setUpBackedRewardWithAddOnsAndShippingAndBonusAmountTestData() - val backedProject = testData.project - val reward = testData.reward - - val environment = environment() - .toBuilder() - .currentUserV2(MockCurrentUserV2(UserFactory.user())) - .apolloClientV2(object : MockApolloClientV2() { - override fun getShippingRules(reward: Reward): Observable { - return Observable.just(ShippingRulesEnvelopeFactory.shippingRules()) - } - }) - .build() - setUpEnvironment(environment, reward, backedProject, PledgeReason.FIX_PLEDGE) - - this.totalAmount.assertValue(expectedCurrency(environment, backedProject, 42.0)) - } - @Test fun testTotalAmount_whenUpdatingPledge() { val testData = setUpBackedShippableRewardTestData() @@ -1435,29 +1415,29 @@ class PledgeFragmentViewModelTest : KSRobolectricTestCase() { this.estimatedDeliveryInfoIsGone.assertValue(true) } - @Test - fun testShowMaxPledge_USProject_USDPref() { - val environment = environment() - .toBuilder() - .apolloClientV2(object : MockApolloClientV2() { - override fun getStoredCards(): Observable> { - return Observable.just(listOf(StoredCardFactory.visa())) - } - override fun getShippingRules(reward: Reward): Observable { - return Observable.just(ShippingRulesEnvelopeFactory.shippingRules()) - } - }) - .currentUserV2(MockCurrentUserV2(UserFactory.user())) - .build() - val project = ProjectFactory.project() - val rw = RewardFactory.reward() - setUpEnvironment(environment, rw, project) - - this.vm.inputs.bonusInput("999999") - - this.pledgeMaximumIsGone.assertValues(true, false) - this.pledgeMaximum.assertValues("$9,980") // 10.000 - 20 : MAXUSD - REWARD.minimum - } +// @Test TODO: Test for bonus input compose component +// fun testShowMaxPledge_USProject_USDPref() { +// val environment = environment() +// .toBuilder() +// .apolloClientV2(object : MockApolloClientV2() { +// override fun getStoredCards(): Observable> { +// return Observable.just(listOf(StoredCardFactory.visa())) +// } +// override fun getShippingRules(reward: Reward): Observable { +// return Observable.just(ShippingRulesEnvelopeFactory.shippingRules()) +// } +// }) +// .currentUserV2(MockCurrentUserV2(UserFactory.user())) +// .build() +// val project = ProjectFactory.project() +// val rw = RewardFactory.reward() +// setUpEnvironment(environment, rw, project) +// +// this.vm.inputs.bonusInput("999999") +// +// this.pledgeMaximumIsGone.assertValues(true, false) +// this.pledgeMaximum.assertValues("$9,950") // 10.000 - 20 : MAXUSD - REWARD.minimum +// } @Test fun testUpdatingPledgeAmount_WithShippingChange_USProject_USDPref() { @@ -1624,17 +1604,17 @@ class PledgeFragmentViewModelTest : KSRobolectricTestCase() { // this.increasePledgeButtonIsEnabled.assertValuesAndClear(false) } - @Test - fun testPledgeStepping_maxReward_MXProject() { - val environment = environment() - val mxProject = ProjectFactory.mxProject() - setUpEnvironment(environment, RewardFactory.maxReward(Country.MX), mxProject) - - this.additionalPledgeAmountIsGone.assertValue(true) - this.additionalPledgeAmount.assertValue(expectedCurrency(environment, mxProject, 0.0)) - this.decreaseBonusButtonIsEnabled.assertValue(false) - this.increasePledgeButtonIsEnabled.assertValue(false) - } +// @Test TODO: Test form compose bonus amount component +// fun testPledgeStepping_maxReward_MXProject() { +// val environment = environment() +// val mxProject = ProjectFactory.mxProject() +// setUpEnvironment(environment, RewardFactory.maxReward(Country.MX), mxProject) +// +// this.additionalPledgeAmountIsGone.assertValue(true) +// this.additionalPledgeAmount.assertValue(expectedCurrency(environment, mxProject, 0.0)) +// this.decreaseBonusButtonIsEnabled.assertValue(false) +// this.increasePledgeButtonIsEnabled.assertValue(false) +// } @Test fun testRefTagIsSent() { @@ -1840,7 +1820,7 @@ class PledgeFragmentViewModelTest : KSRobolectricTestCase() { val environment = environment() setUpEnvironment(environment, reward, backedProject, PledgeReason.UPDATE_PAYMENT) - this.shippingRule.assertNoValues() + this.shippingRule.assertValueCount(1) } @Test @@ -1942,7 +1922,7 @@ class PledgeFragmentViewModelTest : KSRobolectricTestCase() { setUpEnvironment(environment, project = project) this.shippingRuleAndProject.assertNoValues() - this.totalAmount.assertNoValues() + this.totalAmount.assertValueCount(1) } @Test @@ -3176,15 +3156,14 @@ class PledgeFragmentViewModelTest : KSRobolectricTestCase() { this.vm.inputs.shippingRuleSelected(germanyShippingRule) this.selectedShippingRule.assertValues(backingShippingRule, germanyShippingRule) - this.pledgeButtonIsEnabled.assertValues(false, true) + this.pledgeButtonIsEnabled.assertValues(false) this.vm.inputs.shippingRuleSelected(backingShippingRule) this.selectedShippingRule.assertValues(backingShippingRule, germanyShippingRule, backingShippingRule) - this.pledgeButtonIsEnabled.assertValues(false, true, false) + this.pledgeButtonIsEnabled.assertValues(false) - this.vm.inputs.bonusInput("500") this.vm.inputs.increaseBonusButtonClicked() - this.pledgeButtonIsEnabled.assertValues(false, true, false, true) + this.pledgeButtonIsEnabled.assertValues(false, true) } @Test @@ -3246,38 +3225,6 @@ class PledgeFragmentViewModelTest : KSRobolectricTestCase() { this.headerSectionIsGone.assertValues(true) } - @Test // TODO: Review - fun testBonusAmountIncreases_whenPlusButtonIsClicked() { - val reward = RewardFactory.reward() - val backing = BackingFactory.backing() - .toBuilder() - .amount(50.0) - .reward(reward) - .rewardId(reward.id()) - .build() - - val backedProject = ProjectFactory.backedProject() - .toBuilder() - .backing(backing) - .build() - - val environment = environment() - .toBuilder() - .currentUserV2(MockCurrentUserV2(UserFactory.user())) - .apolloClientV2(object : MockApolloClientV2() { - override fun getShippingRules(reward: Reward): Observable { - return Observable.just(ShippingRulesEnvelopeFactory.shippingRules()) - } - }) - .build() - - setUpEnvironment(environment, reward, backedProject, PledgeReason.PLEDGE) - - this.vm.inputs.increaseBonusButtonClicked() - - this.bonusAmount.assertValues("0", "1") - } - @Test fun testTotalAmountUpdates_whenBonusIsAdded() { val testData = setUpBackedShippableRewardTestData() @@ -3293,31 +3240,8 @@ class PledgeFragmentViewModelTest : KSRobolectricTestCase() { .build() setUpEnvironment(environment, testData.reward, testData.project, PledgeReason.UPDATE_PLEDGE) - this.vm.inputs.bonusInput("20") - this.vm.inputs.increaseBonusButtonClicked() - this.totalAmount.assertValues("$50", "$70", "$71") - this.bonusAmount.assertValues("0", "20", "21") - } - - @Test - fun testBonusMinimumIsZero_andMinusButtonIsDisabled() { - val reward = RewardFactory.reward() - val backing = BackingFactory.backing() - .toBuilder() - .amount(30.0) - .reward(reward) - .rewardId(reward.id()) - .build() - - val backedProject = ProjectFactory.project() - .toBuilder() - .backing(backing) - .build() - - setUpEnvironment(environment(), reward, backedProject) - - this.bonusAmount.assertValue("0") - this.decreaseBonusButtonIsEnabled.assertValue(false) + this.totalAmount.assertValues("$50") + this.bonusAmount.assertValues("0.0") } @Test @@ -3508,18 +3432,8 @@ class PledgeFragmentViewModelTest : KSRobolectricTestCase() { val environment = environment() setUpEnvironment(environment, reward, project, addOns = listAddOns) - this.vm.inputs.shippingRuleSelected(shipRule) - this.vm.inputs.bonusInput("123") - this.vm.inputs.increaseBonusButtonClicked() - this.vm.inputs.increaseBonusButtonClicked() - this.vm.inputs.increaseBonusButtonClicked() - this.totalAmount.assertValues( - "$414", - "$537", - "$538", - "$539", - "$540" + "$414" ) } diff --git a/app/src/test/java/com/kickstarter/viewmodels/RewardsFragmentViewModelTest.kt b/app/src/test/java/com/kickstarter/viewmodels/RewardsFragmentViewModelTest.kt index 2329daf9f8..013a24a45b 100644 --- a/app/src/test/java/com/kickstarter/viewmodels/RewardsFragmentViewModelTest.kt +++ b/app/src/test/java/com/kickstarter/viewmodels/RewardsFragmentViewModelTest.kt @@ -9,15 +9,20 @@ import com.kickstarter.libs.MockCurrentUserV2 import com.kickstarter.libs.featureflag.FlagKey import com.kickstarter.libs.utils.EventName import com.kickstarter.libs.utils.extensions.addToDisposable +import com.kickstarter.mock.MockCurrentConfigV2 import com.kickstarter.mock.MockFeatureFlagClient import com.kickstarter.mock.factories.BackingFactory +import com.kickstarter.mock.factories.ConfigFactory import com.kickstarter.mock.factories.ProjectDataFactory import com.kickstarter.mock.factories.ProjectFactory import com.kickstarter.mock.factories.RewardFactory +import com.kickstarter.mock.factories.ShippingRuleFactory +import com.kickstarter.mock.factories.ShippingRulesEnvelopeFactory import com.kickstarter.mock.factories.UserFactory import com.kickstarter.mock.services.MockApolloClientV2 import com.kickstarter.models.Project import com.kickstarter.models.Reward +import com.kickstarter.services.apiresponses.ShippingRulesEnvelope import com.kickstarter.ui.SharedPreferenceKey import com.kickstarter.ui.data.PledgeData import com.kickstarter.ui.data.PledgeFlowContext @@ -25,10 +30,17 @@ import com.kickstarter.ui.data.PledgeReason import com.kickstarter.ui.data.ProjectData import com.kickstarter.viewmodels.RewardsFragmentViewModel.Factory import com.kickstarter.viewmodels.RewardsFragmentViewModel.RewardsFragmentViewModel +import com.kickstarter.viewmodels.usecases.GetShippingRulesUseCase +import com.kickstarter.viewmodels.usecases.ShippingRulesState import com.kickstarter.viewmodels.usecases.TPEventInputData import io.reactivex.Observable import io.reactivex.disposables.CompositeDisposable import io.reactivex.subscribers.TestSubscriber +import kotlinx.coroutines.flow.toList +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.After import org.junit.Test @@ -39,17 +51,18 @@ class RewardsFragmentViewModelTest : KSRobolectricTestCase() { private lateinit var vm: RewardsFragmentViewModel private val backedRewardPosition = TestSubscriber.create() private val projectData = TestSubscriber.create() - private val rewardsCount = TestSubscriber.create() private val showPledgeFragment = TestSubscriber>() private val showAddOnsFragment = TestSubscriber>() private val showAlert = TestSubscriber>() private val disposables = CompositeDisposable() - private fun setUpEnvironment(@NonNull environment: Environment) { - this.vm = Factory(environment).create(RewardsFragmentViewModel::class.java) + private fun setUpEnvironment( + @NonNull environment: Environment, + useCase: GetShippingRulesUseCase? = null + ) { + this.vm = Factory(environment, useCase).create(RewardsFragmentViewModel::class.java) this.vm.outputs.backedRewardPosition().subscribe { this.backedRewardPosition.onNext(it) }.addToDisposable(disposables) this.vm.outputs.projectData().subscribe { this.projectData.onNext(it) }.addToDisposable(disposables) - this.vm.outputs.rewardsCount().subscribe { this.rewardsCount.onNext(it) }.addToDisposable(disposables) this.vm.outputs.showPledgeFragment().subscribe { this.showPledgeFragment.onNext(it) }.addToDisposable(disposables) this.vm.outputs.showAddOnsFragment().subscribe { this.showAddOnsFragment.onNext(it) }.addToDisposable(disposables) this.vm.outputs.showAlert().subscribe { this.showAlert.onNext(it) }.addToDisposable(disposables) @@ -197,16 +210,12 @@ class RewardsFragmentViewModelTest : KSRobolectricTestCase() { this.vm.inputs.rewardClicked(reward) this.showPledgeFragment.assertNoValues() - this.showAddOnsFragment.assertValue( - Pair( - PledgeData.builder() - .pledgeFlowContext(PledgeFlowContext.CHANGE_REWARD) - .reward(reward) - .projectData(ProjectDataFactory.project(backedProject)) - .build(), - PledgeReason.UPDATE_REWARD - ) - ) + this.vm.outputs.showAddOnsFragment().subscribe { + assertEquals(it.first.reward(), reward) + assertEquals(it.first.projectData(), ProjectDataFactory.project(backedProject)) + assertEquals(it.second, PledgeReason.UPDATE_REWARD) + }.addToDisposable(disposables) + this.showAlert.assertNoValues() } @@ -257,27 +266,6 @@ class RewardsFragmentViewModelTest : KSRobolectricTestCase() { this.showPledgeFragment.assertNoValues() } - @Test - fun testShowPledgeFragment_whenManagingPledge() { - val project = ProjectFactory.backedProject() - setUpEnvironment(environment()) - - this.vm.inputs.configureWith(ProjectDataFactory.project(project)) - - val reward = RewardFactory.reward() - this.vm.inputs.rewardClicked(reward) - this.showPledgeFragment.assertValue( - Pair( - PledgeData.builder() - .pledgeFlowContext(PledgeFlowContext.CHANGE_REWARD) - .reward(reward) - .projectData(ProjectDataFactory.project(project)) - .build(), - PledgeReason.UPDATE_REWARD - ) - ) - } - @Test fun testFilterOutRewards_whenRewardNotStarted_filtersOutReward() { val rwNotLimitedStart = RewardFactory.reward() @@ -298,7 +286,6 @@ class RewardsFragmentViewModelTest : KSRobolectricTestCase() { // - We check that the viewModel has filtered out the rewards not started yet this.projectData.assertValue(modifiedPData) - this.rewardsCount.assertValue(2) } @Test @@ -331,19 +318,173 @@ class RewardsFragmentViewModelTest : KSRobolectricTestCase() { // - We check that the viewModel has filtered out the rewards not started yet this.projectData.assertValue(modifiedPData) - this.rewardsCount.assertValue(5) } @Test - fun testRewardsCount() { - val project = ProjectFactory.project() + fun `test countrySelectorRules state contains appropriate ShippingRules when reward shipping worldwide and default location Canada`() = runTest { + + val unlimitedReward = RewardFactory.rewardWithShipping() + + val rewards = listOf( + unlimitedReward + ) + val project = ProjectFactory.project().toBuilder().rewards(rewards).build() + + val config = ConfigFactory.configForCA() + val currentConfig = MockCurrentConfigV2() + currentConfig.config(config) + + val testShippingRulesList = ShippingRulesEnvelopeFactory.shippingRules() + val apolloClient = object : MockApolloClientV2() { + override fun getShippingRules(reward: Reward): Observable { + return Observable.just(testShippingRulesList) + } + } + + val user = UserFactory.user() + val env = environment() + .toBuilder() + .currentUserV2(MockCurrentUserV2(user)) + .apolloClientV2(apolloClient) + .currentConfig2(currentConfig) + .build() + + val state = mutableListOf() + val dispatcher = UnconfinedTestDispatcher(testScheduler) + backgroundScope.launch(dispatcher) { + val useCase = GetShippingRulesUseCase(apolloClient, project, config, this, dispatcher) + setUpEnvironment(env, useCase) + + vm.inputs.configureWith(ProjectDataFactory.project(project)) + vm.countrySelectorRules().toList(state) + } + + advanceUntilIdle() // wait until all state emissions completed + + assertEquals(state.size, 3) + assertEquals(state[0], ShippingRulesState()) // Initialization + assertEquals(state[1], ShippingRulesState(loading = true)) // starts loading + assertEquals( + state[2], + ShippingRulesState( + loading = false, + selectedShippingRule = ShippingRuleFactory.canadaShippingRule(), + shippingRules = testShippingRulesList.shippingRules() + ) + ) // completed requests + } + + @Test + fun `test call vm-selectedShippingRule() with location US, pledgeData-shipping should be US `() = runTest { + + val unlimitedReward = RewardFactory.rewardWithShipping() + + val rewards = listOf( + unlimitedReward + ) + val project = ProjectFactory.project().toBuilder().rewards(rewards).build() + + val config = ConfigFactory.configForCA() + val currentConfig = MockCurrentConfigV2() + currentConfig.config(config) + + val testShippingRulesList = ShippingRulesEnvelopeFactory.shippingRules() + val apolloClient = object : MockApolloClientV2() { + override fun getShippingRules(reward: Reward): Observable { + return Observable.just(testShippingRulesList) + } + } + + val user = UserFactory.user() + val env = environment() .toBuilder() - .rewards(listOf(RewardFactory.noReward(), RewardFactory.reward())) + .currentUserV2(MockCurrentUserV2(user)) + .apolloClientV2(apolloClient) + .currentConfig2(currentConfig) .build() + + val state = mutableListOf() + val dispatcher = UnconfinedTestDispatcher(testScheduler) + backgroundScope.launch(dispatcher) { + val useCase = GetShippingRulesUseCase(apolloClient, project, config, this, dispatcher) + setUpEnvironment(env, useCase) + + vm.inputs.configureWith(ProjectDataFactory.project(project)) + vm.countrySelectorRules().toList(state) + } + + advanceUntilIdle() // wait until all state emissions completed + + assertEquals(state.size, 3) + assertEquals(state[0], ShippingRulesState()) // Initialization + assertEquals(state[1], ShippingRulesState(loading = true)) // starts loading + assertEquals( + state[2], + ShippingRulesState( + loading = false, + selectedShippingRule = ShippingRuleFactory.canadaShippingRule(), + shippingRules = testShippingRulesList.shippingRules() + ) + ) // completed requests + + val usShippingRule = testShippingRulesList.shippingRules().first() + backgroundScope.launch(dispatcher) { + vm.inputs.configureWith(ProjectDataFactory.project(project)) + vm.inputs.selectedShippingRule(usShippingRule) + } + + vm.showAddOnsFragment().subscribe { + assertEquals(it.first.shippingRule()?.location(), usShippingRule.location()) + }.addToDisposable(disposables) + } + + @Test + fun `test DefaultShipping Rule is sent to PledgeFragment`() { + val project = ProjectFactory.backedProject() + val reward = RewardFactory.reward() + val selectedShippingRule = ShippingRuleFactory.usShippingRule() + setUpEnvironment(environment()) + vm.inputs.configureWith(ProjectDataFactory.project(project)) + vm.inputs.selectedShippingRule(selectedShippingRule) + vm.inputs.rewardClicked(reward) + + vm.outputs.showPledgeFragment().subscribe { + assertEquals(it.second, PledgeFlowContext.CHANGE_REWARD) + assertEquals(it.first.reward(), reward) + assertEquals(it.first.projectData(), ProjectDataFactory.project(project)) + assertEquals(it.first.shippingRule(), selectedShippingRule) + }.addToDisposable(disposables) + this.showAddOnsFragment.assertNoValues() + } - this.vm.inputs.configureWith(ProjectDataFactory.project(project)) + @Test + fun `test DefaultShipping Rule is sent to AddOnsFragment`() { + val reward = RewardFactory.rewardWithShipping().toBuilder().hasAddons(true).build() + val backedProject = ProjectFactory.backedProject() + .toBuilder() + .backing( + BackingFactory.backing() + .toBuilder() + .reward(reward) + .rewardId(reward.id()) + .build() + ) + .rewards(listOf(RewardFactory.noReward(), reward)) + .build() + val selectedShippingRule = ShippingRuleFactory.usShippingRule() + + setUpEnvironment(environment()) - this.rewardsCount.assertValue(2) + this.vm.inputs.configureWith(ProjectDataFactory.project(backedProject)) + this.vm.inputs.selectedShippingRule(selectedShippingRule) + this.vm.inputs.rewardClicked(reward) + + this.showPledgeFragment.assertNoValues() + this.vm.showAddOnsFragment().subscribe { + assertEquals(it.first.shippingRule(), selectedShippingRule) + assertEquals(it.first.reward(), reward) + }.addToDisposable(disposables) + this.showAlert.assertNoValues() } } diff --git a/app/src/test/java/com/kickstarter/viewmodels/RewardsSelectionViewModelTest.kt b/app/src/test/java/com/kickstarter/viewmodels/RewardsSelectionViewModelTest.kt index 8a123fa475..3448d4cca6 100644 --- a/app/src/test/java/com/kickstarter/viewmodels/RewardsSelectionViewModelTest.kt +++ b/app/src/test/java/com/kickstarter/viewmodels/RewardsSelectionViewModelTest.kt @@ -2,20 +2,35 @@ package com.kickstarter.viewmodels import com.kickstarter.KSRobolectricTestCase import com.kickstarter.libs.Environment +import com.kickstarter.libs.MockCurrentUserV2 import com.kickstarter.libs.utils.EventName +import com.kickstarter.mock.MockCurrentConfigV2 +import com.kickstarter.mock.factories.BackingFactory +import com.kickstarter.mock.factories.ConfigFactory import com.kickstarter.mock.factories.ProjectDataFactory import com.kickstarter.mock.factories.ProjectFactory +import com.kickstarter.mock.factories.RewardFactory +import com.kickstarter.mock.factories.ShippingRuleFactory +import com.kickstarter.mock.factories.ShippingRulesEnvelopeFactory +import com.kickstarter.mock.factories.UserFactory +import com.kickstarter.mock.services.MockApolloClientV2 import com.kickstarter.models.Backing import com.kickstarter.models.Project import com.kickstarter.models.Reward +import com.kickstarter.services.apiresponses.ShippingRulesEnvelope import com.kickstarter.ui.data.ProjectData import com.kickstarter.viewmodels.projectpage.FlowUIState import com.kickstarter.viewmodels.projectpage.RewardSelectionUIState import com.kickstarter.viewmodels.projectpage.RewardsSelectionViewModel +import com.kickstarter.viewmodels.usecases.GetShippingRulesUseCase +import com.kickstarter.viewmodels.usecases.ShippingRulesState +import io.reactivex.Observable import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test @@ -23,9 +38,9 @@ class RewardsSelectionViewModelTest : KSRobolectricTestCase() { private lateinit var viewModel: RewardsSelectionViewModel - private fun createViewModel(environment: Environment = environment()) { + private fun createViewModel(environment: Environment = environment(), useCase: GetShippingRulesUseCase? = null) { viewModel = - RewardsSelectionViewModel.Factory(environment).create(RewardsSelectionViewModel::class.java) + RewardsSelectionViewModel.Factory(environment, useCase).create(RewardsSelectionViewModel::class.java) } @OptIn(ExperimentalCoroutinesApi::class) @@ -51,7 +66,6 @@ class RewardsSelectionViewModelTest : KSRobolectricTestCase() { assertEquals( state.last(), RewardSelectionUIState( - rewardList = testRewards, initialRewardIndex = 2, project = testProjectData, ) @@ -85,7 +99,6 @@ class RewardsSelectionViewModelTest : KSRobolectricTestCase() { assertEquals( uiState.last(), RewardSelectionUIState( - rewardList = testRewards, initialRewardIndex = 0, project = testProjectData, ) @@ -130,7 +143,6 @@ class RewardsSelectionViewModelTest : KSRobolectricTestCase() { assertEquals( uiState.last(), RewardSelectionUIState( - rewardList = testRewards, initialRewardIndex = 0, project = testProjectData, ) @@ -142,7 +154,7 @@ class RewardsSelectionViewModelTest : KSRobolectricTestCase() { assert(flowState.size == 1) assertEquals( flowState.last(), - FlowUIState(currentPage = 2, expanded = true) + FlowUIState(currentPage = 1, expanded = true) ) this@RewardsSelectionViewModelTest.segmentTrack.assertValue(EventName.CTA_CLICKED.eventName) @@ -179,7 +191,6 @@ class RewardsSelectionViewModelTest : KSRobolectricTestCase() { assertEquals( uiState.last(), RewardSelectionUIState( - rewardList = testRewards, initialRewardIndex = 2, project = testProjectData, ) @@ -228,7 +239,6 @@ class RewardsSelectionViewModelTest : KSRobolectricTestCase() { assertEquals( uiState.last(), RewardSelectionUIState( - rewardList = testRewards, initialRewardIndex = 3, project = testProjectData, ) @@ -277,7 +287,6 @@ class RewardsSelectionViewModelTest : KSRobolectricTestCase() { assertEquals( uiState.last(), RewardSelectionUIState( - rewardList = testRewards, initialRewardIndex = 3, project = testProjectData, ) @@ -289,49 +298,106 @@ class RewardsSelectionViewModelTest : KSRobolectricTestCase() { assert(flowState.size == 1) assertEquals( flowState.last(), - FlowUIState(currentPage = 2, expanded = true) + FlowUIState(currentPage = 1, expanded = true) ) this@RewardsSelectionViewModelTest.segmentTrack.assertValue(EventName.CTA_CLICKED.eventName) } @Test - fun `Test rewards list when given a list of rewards that contains unavailable rewards will produce a list of rewards with only rewards available`() = runTest { - createViewModel() - val testRewards = (0..8).map { - if (it % 2 == 0) - Reward.builder().title("$it").id(it.toLong()).isAvailable(true).hasAddons(it != 2).shippingType("$it") + fun `Test rewards list filtered when given a Germany location and a Project with unavailable rewards and mixed types of shipping`() = runTest { + val testShippingRulesList = ShippingRulesEnvelopeFactory.shippingRules() + val testRewards: List = (0..8).map { + if (it == 5) + Reward.builder().title("$it").id(it.toLong()).isAvailable(true).hasAddons(it != 2) + .pledgeAmount(3.0) + .shippingPreference(Reward.ShippingPreference.RESTRICTED.name) + .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) + .shippingRules((listOf(ShippingRuleFactory.mexicoShippingRule()))) + .build() + else if (it == 3) + Reward.builder().title("$it").id(it.toLong()).isAvailable(true).hasAddons(it != 2) + .pledgeAmount(3.0) + .shippingPreference(Reward.ShippingPreference.RESTRICTED.name) + .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) + .shippingRules((listOf(ShippingRuleFactory.germanyShippingRule()))) + .build() + else if (it == 0) + RewardFactory.noReward() + else if (it % 2 == 0) + Reward.builder().title("$it").id(it.toLong()).isAvailable(true).hasAddons(it != 2) + .pledgeAmount(3.0) + .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name) + .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) .build() else - Reward.builder().title("$it").id(it.toLong()).isAvailable(false).hasAddons(it != 2).shippingType("$it") + Reward.builder().title("$it").id(it.toLong()).isAvailable(false).hasAddons(it != 2) + .pledgeAmount(3.0) + .shippingPreference(Reward.ShippingPreference.RESTRICTED.name) + .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) + .shippingRules((listOf(ShippingRuleFactory.mexicoShippingRule()))) .build() } val testProject = Project.builder().rewards(testRewards).build() val testProjectData = ProjectData.builder().project(testProject).build() - viewModel.provideProjectData(testProjectData) + val config = ConfigFactory.configForCA() + val currentConfig = MockCurrentConfigV2() + currentConfig.config(config) + + val user = UserFactory.canadianUser() + val env = environment() + .toBuilder() + .currentConfig2(currentConfig) + .currentUserV2(MockCurrentUserV2(user)) + .build() + + val apolloClient = object : MockApolloClientV2() { + override fun getShippingRules(reward: Reward): Observable { + return Observable.just(testShippingRulesList) + } + } - val uiState = mutableListOf() - backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { - viewModel.rewardSelectionUIState.toList(uiState) + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val shippingUiState = mutableListOf() + + backgroundScope.launch(dispatcher) { + val useCase = GetShippingRulesUseCase(apolloClient, testProject, config, this, dispatcher) + createViewModel(env, useCase) + viewModel.provideProjectData(testProjectData) + + viewModel.shippingUIState.toList(shippingUiState) } + advanceUntilIdle() // wait until all state emissions completed + viewModel.selectedShippingRule(ShippingRuleFactory.germanyShippingRule()) + advanceTimeBy(600) // account for de delay within GetShippingRulesUseCase.filterBySelectedRule + + // - Available rewards should be those available AND able to ship to germany + val filteredRewards = mutableListOf( + testRewards[0], + testRewards[2], + testRewards[3], + testRewards[4], + testRewards[6], + testRewards[8] + ) - val filteredRewards = testRewards.filter { it.isAvailable() } - assertEquals(uiState.size, 2) + assertEquals(shippingUiState.size, 5) + assertEquals(shippingUiState[3].loading, true) + assertEquals(shippingUiState[4].loading, false) // - make sure the uiState output reward list is filtered + assertEquals(shippingUiState.last().filteredRw.size, filteredRewards.size) + + val obtained = shippingUiState.last().filteredRw assertEquals( - uiState.last(), - RewardSelectionUIState( - rewardList = filteredRewards, - initialRewardIndex = 0, - project = testProjectData, - ) + obtained, + filteredRewards ) - // - make sure the uiState output reward list is not the same as the provided reward list - assertNotSame(uiState.last().rewardList, testRewards.size) + assertEquals(obtained.first(), RewardFactory.noReward()) + assertEquals(obtained[2].shippingRules()?.first(), ShippingRuleFactory.germanyShippingRule()) } @Test @@ -356,4 +422,127 @@ class RewardsSelectionViewModelTest : KSRobolectricTestCase() { viewModel.sendEvent(expanded = false, currentPage = 0, projectData) this@RewardsSelectionViewModelTest.segmentTrack.assertNoValues() } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `Default Location when Backing Project is backed location, and list of shipping rules for "restricted" is all places available for all restricted rewards without duplicated`() = runTest { + val testShippingRulesList = ShippingRulesEnvelopeFactory.shippingRules().shippingRules() + + val rw1 = RewardFactory + .reward() + .toBuilder() + .id(1) + .shippingPreference(Reward.ShippingPreference.RESTRICTED.name) + .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) + .shippingRules((listOf(testShippingRulesList.first()))) + .build() + + val rw2 = RewardFactory + .reward() + .toBuilder() + .id(2) + .shippingPreference(Reward.ShippingPreference.RESTRICTED.name) + .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) + .shippingRules((listOf(testShippingRulesList.first()))) + .build() + + val rw3 = RewardFactory + .reward() + .toBuilder() + .id(3) + .shippingPreference(Reward.ShippingPreference.RESTRICTED.name) + .shippingPreferenceType(Reward.ShippingPreference.RESTRICTED) + .shippingRules(listOf(testShippingRulesList[2])) + .build() + + val user = UserFactory.user() + val backing = BackingFactory.backing(rw1).toBuilder() + .location(testShippingRulesList.first().location()) + .locationId(testShippingRulesList.first().location()?.id()) + .locationName(testShippingRulesList.first().location()?.displayableName()) + .build() + + val project = ProjectFactory.project().toBuilder() + .rewards(listOf(rw1, rw2, rw3)) + .backing(backing) + .isBacking(true) + .build() + + val projectData = ProjectDataFactory.project(project, null, null) + + val config = ConfigFactory.configForCA() + val currentConfig = MockCurrentConfigV2() + currentConfig.config(config) + + val env = environment() + .toBuilder() + .currentConfig2(currentConfig) + .currentUserV2(MockCurrentUserV2(user)) + .build() + + val apolloClient = requireNotNull(env.apolloClientV2()) + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val shippingUiState = mutableListOf() + backgroundScope.launch(dispatcher) { + val useCase = GetShippingRulesUseCase(apolloClient, project, config, this, dispatcher) + createViewModel(env, useCase) + viewModel.provideProjectData(projectData) + viewModel.shippingUIState.toList(shippingUiState) + } + + advanceUntilIdle() // wait until all state emissions completed + + assertEquals(shippingUiState.size, 2) + assertEquals(shippingUiState.last().selectedShippingRule.location()?.id(), testShippingRulesList.first().location()?.id()) + assertNotSame(shippingUiState.last().shippingRules, testShippingRulesList) + assertEquals(shippingUiState.last().shippingRules.size, 2) // the 3 available shipping rules + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `config is from Canada and available rules are global so Default Shipping is Canada, and list of shipping Rules provided matches all available reward global shipping`() = runTest { + val rw = RewardFactory + .reward() + .toBuilder() + .shippingPreference(Reward.ShippingPreference.UNRESTRICTED.name) + .shippingPreferenceType(Reward.ShippingPreference.UNRESTRICTED) + .build() + val user = UserFactory.user() + val project = ProjectFactory.project().toBuilder().rewards(listOf(rw, rw, rw)).build() + val projectData = ProjectDataFactory.project(project, null, null) + + val testShippingRulesList = ShippingRulesEnvelopeFactory.shippingRules() + val apolloClient = object : MockApolloClientV2() { + override fun getShippingRules(reward: Reward): Observable { + return Observable.just(testShippingRulesList) + } + } + + val config = ConfigFactory.configForCA() + val currentConfig = MockCurrentConfigV2() + currentConfig.config(config) + + val env = environment() + .toBuilder() + .currentConfig2(currentConfig) + .apolloClientV2(apolloClient) + .currentUserV2(MockCurrentUserV2(user)) + .build() + + val dispatcher = UnconfinedTestDispatcher(testScheduler) + val shippingUiState = mutableListOf() + backgroundScope.launch(dispatcher) { + val useCase = GetShippingRulesUseCase(apolloClient, project, config, this, dispatcher) + createViewModel(env, useCase) + viewModel.provideProjectData(projectData) + viewModel.shippingUIState.toList(shippingUiState) + } + + advanceUntilIdle() // wait until all state emissions completed + + assertEquals(shippingUiState.size, 3) + assertEquals(shippingUiState.last().selectedShippingRule.location()?.name(), "Canada") + assertEquals(shippingUiState.last().shippingRules, testShippingRulesList.shippingRules()) + } }