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 40acc65ab5..adabd6b6eb 100644 --- a/app/src/main/java/com/kickstarter/libs/utils/RewardUtils.kt +++ b/app/src/main/java/com/kickstarter/libs/utils/RewardUtils.kt @@ -232,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/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/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/ui/activities/compose/projectpage/CheckoutScreen.kt b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/CheckoutScreen.kt index 07d53edbc7..1fc1fe1c25 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 @@ -117,7 +117,8 @@ fun CheckoutScreenPreview() { onPledgeCtaClicked = { }, newPaymentMethodClicked = { }, onDisclaimerItemClicked = {}, - onAccountabilityLinkClicked = {} + onAccountabilityLinkClicked = {}, + onChangedPaymentMethod = {} ) } } @@ -134,14 +135,15 @@ fun CheckoutScreen( 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( @@ -153,6 +155,7 @@ fun CheckoutScreen( val onOptionSelected: (StoredCard?) -> Unit = { selectedOption.value = it + onChangedPaymentMethod.invoke(it) } // - After adding new payment method, selected card should be updated to the newly added @@ -285,7 +288,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( 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 68fae63c74..f7c9724d84 100644 --- a/app/src/main/java/com/kickstarter/ui/fragments/BackingAddOnsFragment.kt +++ b/app/src/main/java/com/kickstarter/ui/fragments/BackingAddOnsFragment.kt @@ -24,6 +24,7 @@ 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.extensions.showErrorToast import com.kickstarter.viewmodels.projectpage.AddOnsViewModel class BackingAddOnsFragment : Fragment() { @@ -46,6 +47,11 @@ class BackingAddOnsFragment : Fragment() { viewModelC.provideBundle(arguments) env } + + viewModelC.provideErrorAction { message -> + 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) 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..437263425d --- /dev/null +++ b/app/src/main/java/com/kickstarter/ui/fragments/CrowdfundCheckoutFragment.kt @@ -0,0 +1,130 @@ +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.models.Project +import com.kickstarter.models.Reward +import com.kickstarter.ui.activities.compose.projectpage.CheckoutScreen +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 + +class CrowdfundCheckoutFragment : Fragment() { + + private var binding: FragmentCrowdfundCheckoutBinding? = null + + private lateinit var viewModelFactory: Factory + private val viewModel: CrowdfundCheckoutViewModel by viewModels { + viewModelFactory + } + + 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 + + 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 = rwList.map { Pair(it.title() ?: "", it.pledgeAmount().toString()) }, + 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.pledge() + }, + isLoading = isLoading, + newPaymentMethodClicked = {}, + onDisclaimerItemClicked = {}, + onAccountabilityLinkClicked = {}, + onChangedPaymentMethod = { paymentMethodSelected -> + viewModel.userChangedPaymentMethodSelected(paymentMethodSelected) + } + ) + } + } + } + } + return view + } +} 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 b8ce816a57..6f727d0dba 100644 --- a/app/src/main/java/com/kickstarter/ui/fragments/RewardsFragment.kt +++ b/app/src/main/java/com/kickstarter/ui/fragments/RewardsFragment.kt @@ -23,6 +23,7 @@ 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.mock.factories.ShippingRuleFactory import com.kickstarter.ui.activities.compose.projectpage.RewardCarouselScreen import com.kickstarter.ui.compose.designsystem.KSTheme import com.kickstarter.ui.compose.designsystem.KickstarterApp @@ -94,6 +95,10 @@ class RewardsFragment : Fragment() { initialValue = ShippingRulesState() ).value + if (rules.selectedShippingRule != ShippingRuleFactory.emptyShippingRule()) { + viewModel.setInitialShippingRule(rules.selectedShippingRule) + } + val rewards = rules.filteredRw val listState = rememberLazyListState() diff --git a/app/src/main/java/com/kickstarter/viewmodels/RewardsFragmentViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/RewardsFragmentViewModel.kt index 3c05d13873..26a61458de 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/RewardsFragmentViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/RewardsFragmentViewModel.kt @@ -337,6 +337,10 @@ class RewardsFragmentViewModel { this.selectedShippingRule = shippingRule } + fun setInitialShippingRule(rule: ShippingRule) { + this.selectedShippingRule = rule + } + override fun onCleared() { disposables.clear() super.onCleared() 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..436e2fdc06 --- /dev/null +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/CrowdfundCheckoutViewModel.kt @@ -0,0 +1,403 @@ +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.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.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 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 +) + +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 isPledgeButtonEnabled = false + 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 + + 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) + ) + + /** + * 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() + + checkoutData = CheckoutData.builder() + .amount(totalAmount) + .paymentType(CreditCardPaymentType.CREDIT_CARD) + .bonusAmount(bonusAmount) + .shippingAmount(shippingAmount) + .build() + } + + private fun getPledgeInfoFrom(pData: PledgeData) { + selectedRewards = pData.rewardsAndAddOnsList() + pledgeData = pData + refTag = RefTagUtils.storedCookieRefTagForProject( + project, + cookieManager, + sharedPreferences + ) + + shippingRule = pData.shippingRule() + shippingAmount = pData.shippingCostIfShipping() + bonusAmount = pData.bonusAmount() + totalAmount = pData.checkoutTotalAmount() + + 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() + }.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 + ) + ) + } + + 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 + + fun pledge() { + 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 backingData = selectedPaymentMethod.getBackingData( + proj = project, + amount = pledgeData?.checkoutTotalAmount().toString(), + locationId = pledgeData?.shippingRule()?.location()?.id()?.toString(), + rewards = pledgeData?.rewardsAndAddOnsList() ?: emptyList(), + cookieRefTag = refTag + ) + + this.apolloClient.createBacking(backingData).asFlow() + .onStart { + isPledgeButtonEnabled = false + emitCurrentState(isLoading = true) + }.catch { + errorAction.invoke(it.message) + isPledgeButtonEnabled = true + 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 -> { + getUpdateBackingData( + backing, + pledgeData?.checkoutTotalAmount().toString(), + pledgeData?.shippingRule()?.location()?.id().toString(), + pledgeData?.rewardsAndAddOnsList() ?: emptyList(), + 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 { + isPledgeButtonEnabled = false + emitCurrentState(isLoading = true) + }.catch { + errorAction.invoke(it.message) + isPledgeButtonEnabled = true + emitCurrentState(isLoading = false) + }.collectLatest { + checkoutData = checkoutData?.toBuilder()?.id(it.id())?.build() + _checkoutResultState.emit(Pair(checkoutData, pledgeData)) + 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/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/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) + } }