diff --git a/.circleci/config.yml b/.circleci/config.yml index c718456cf5..9fc5dfac00 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,7 +5,7 @@ base_job: &base_job executor: name: android/android-machine resource-class: xlarge - tag: default + tag: default #https://circleci.com/developer/images/image/cimg/android working_directory: "~/project" environment: TERM: dumb diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 146f75f77b..97c56470e1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -187,7 +187,7 @@ android:screenOrientation="portrait" android:exported="true" android:configChanges="screenSize|orientation" - android:windowSoftInputMode="adjustResize" + android:windowSoftInputMode="adjustPan" tools:ignore="LockedOrientationActivity" > diff --git a/app/src/main/graphql/fragments.graphql b/app/src/main/graphql/fragments.graphql index a076661c00..28a249524e 100644 --- a/app/src/main/graphql/fragments.graphql +++ b/app/src/main/graphql/fragments.graphql @@ -389,6 +389,7 @@ fragment payment on CreditCard { expirationDate type state + stripeCardId } fragment freeformPost on FreeformPost { diff --git a/app/src/main/graphql/payments.graphql b/app/src/main/graphql/payments.graphql index 548c447701..98f1209bc5 100644 --- a/app/src/main/graphql/payments.graphql +++ b/app/src/main/graphql/payments.graphql @@ -8,6 +8,7 @@ query UserPayments { state paymentType type + stripeCardId } } } @@ -28,6 +29,7 @@ mutation SavePaymentMethod($paymentType: PaymentTypes, $stripeToken: String, $st state paymentType type + stripeCardId } } } 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 7bb3735fff..4e4a0c37af 100644 --- a/app/src/main/java/com/kickstarter/libs/utils/RewardUtils.kt +++ b/app/src/main/java/com/kickstarter/libs/utils/RewardUtils.kt @@ -19,7 +19,8 @@ object RewardUtils { */ fun hasBackers(reward: Reward) = reward.backersCount().isNonZero() - fun isAvailable(project: Project, reward: Reward) = project.isLive && !isLimitReached(reward) && !isExpired(reward) + fun isAvailable(project: Project, reward: Reward) = + (project.isLive || (project.isInPostCampaignPledgingPhase() ?: false && project.postCampaignPledgingEnabled() ?: false)) && !isLimitReached(reward) && !isExpired(reward) /** * Returns `true` if the reward has expired. diff --git a/app/src/main/java/com/kickstarter/libs/utils/extensions/ProjectExt.kt b/app/src/main/java/com/kickstarter/libs/utils/extensions/ProjectExt.kt index 64bb0aec2f..d7258491ae 100644 --- a/app/src/main/java/com/kickstarter/libs/utils/extensions/ProjectExt.kt +++ b/app/src/main/java/com/kickstarter/libs/utils/extensions/ProjectExt.kt @@ -52,6 +52,8 @@ fun Project.updateProjectWith(config: Config, user: User?): Project { .build() } +fun Project.showLatePledgeFlow() = this.isInPostCampaignPledgingPhase() ?: false && this.postCampaignPledgingEnabled() ?: false + /** * Checks if the given card type is listed in the available card types * diff --git a/app/src/main/java/com/kickstarter/models/StoredCard.kt b/app/src/main/java/com/kickstarter/models/StoredCard.kt index fade768df4..81e90f29fc 100644 --- a/app/src/main/java/com/kickstarter/models/StoredCard.kt +++ b/app/src/main/java/com/kickstarter/models/StoredCard.kt @@ -13,7 +13,8 @@ class StoredCard private constructor( private val lastFourDigits: String?, private val type: CreditCardTypes?, private val resourceId: Int?, - private val clientSetupId: String? + private val clientSetupId: String?, + private val stripeCardId: String? ) : Parcelable { fun id() = this.id fun expiration() = this.expiration @@ -21,6 +22,7 @@ class StoredCard private constructor( fun type() = this.type fun resourceId() = this.resourceId fun clientSetupId() = this.clientSetupId + fun stripeCardId() = this.stripeCardId @Parcelize data class Builder( @@ -29,7 +31,8 @@ class StoredCard private constructor( private var expiration: Date? = null, private var type: CreditCardTypes? = CreditCardTypes.`$UNKNOWN`, private var resourceId: Int? = null, - private var clientSetupId: String? = null + private var clientSetupId: String? = null, + private var stripeCardId: String? = null ) : Parcelable { fun id(id: String?) = apply { this.id = id } fun lastFourDigits(lastFourDigits: String?) = apply { this.lastFourDigits = lastFourDigits } @@ -37,13 +40,15 @@ class StoredCard private constructor( fun type(type: CreditCardTypes?) = apply { this.type = type } fun resourceId(resourceId: Int?) = apply { this.resourceId = resourceId } fun clientSetupId(clientSetupId: String?) = apply { this.clientSetupId = clientSetupId } + fun stripeCardId(stripeCardId: String?) = apply { this.stripeCardId = stripeCardId } fun build() = StoredCard( id = id, lastFourDigits = lastFourDigits, expiration = expiration, type = type, resourceId = resourceId, - clientSetupId = clientSetupId + clientSetupId = clientSetupId, + stripeCardId = stripeCardId ) } diff --git a/app/src/main/java/com/kickstarter/models/extensions/StoredCardExt.kt b/app/src/main/java/com/kickstarter/models/extensions/StoredCardExt.kt index 16474ba76f..6e7a51466a 100644 --- a/app/src/main/java/com/kickstarter/models/extensions/StoredCardExt.kt +++ b/app/src/main/java/com/kickstarter/models/extensions/StoredCardExt.kt @@ -39,7 +39,8 @@ fun StoredCard.getBackingData( setupIntentClientSecret = this.clientSetupId(), locationId = locationId, rewardsIds = rewards, - refTag = if (cookieRefTag?.tag()?.isNotEmpty() == true) cookieRefTag else null + refTag = if (cookieRefTag?.tag()?.isNotEmpty() == true) cookieRefTag else null, + stripeCardId = this.stripeCardId() ) } else { CreateBackingData( @@ -48,7 +49,8 @@ fun StoredCard.getBackingData( paymentSourceId = this.id(), locationId = locationId, rewardsIds = rewards, - refTag = if (cookieRefTag?.tag()?.isNotEmpty() == true) cookieRefTag else null + refTag = if (cookieRefTag?.tag()?.isNotEmpty() == true) cookieRefTag else null, + stripeCardId = this.stripeCardId() ) } } diff --git a/app/src/main/java/com/kickstarter/services/KSApolloClientV2.kt b/app/src/main/java/com/kickstarter/services/KSApolloClientV2.kt index 0c7ac17d03..db5e707e55 100644 --- a/app/src/main/java/com/kickstarter/services/KSApolloClientV2.kt +++ b/app/src/main/java/com/kickstarter/services/KSApolloClientV2.kt @@ -321,6 +321,7 @@ class KSApolloClientV2(val service: ApolloClient, val gson: Gson) : ApolloClient .id(cardData.id()) .lastFourDigits(cardData.lastFour()) .type(it.type()) + .stripeCardId(it.stripeCardId()) .build() cardsList.add(card) } diff --git a/app/src/main/java/com/kickstarter/services/mutations/CreateBackingData.kt b/app/src/main/java/com/kickstarter/services/mutations/CreateBackingData.kt index 57e09982be..98b5ee757f 100644 --- a/app/src/main/java/com/kickstarter/services/mutations/CreateBackingData.kt +++ b/app/src/main/java/com/kickstarter/services/mutations/CreateBackingData.kt @@ -4,4 +4,4 @@ import com.kickstarter.libs.RefTag import com.kickstarter.models.Project import com.kickstarter.models.Reward -data class CreateBackingData(val project: Project, val amount: String, val paymentSourceId: String? = null, val setupIntentClientSecret: String? = null, val locationId: String?, val reward: Reward? = null, val rewardsIds: List? = null, val refTag: RefTag?) +data class CreateBackingData(val project: Project, val amount: String, val paymentSourceId: String? = null, val setupIntentClientSecret: String? = null, val locationId: String?, val reward: Reward? = null, val rewardsIds: List? = null, val refTag: RefTag?, val stripeCardId: String? = null) 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 dcfba4d72c..f080e0c75f 100644 --- a/app/src/main/java/com/kickstarter/ui/activities/ProjectPageActivity.kt +++ b/app/src/main/java/com/kickstarter/ui/activities/ProjectPageActivity.kt @@ -4,6 +4,7 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.AnimatorSet import android.animation.ObjectAnimator +import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.content.res.Configuration @@ -22,8 +23,19 @@ import androidx.annotation.MenuRes import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat +import androidx.core.view.isGone import androidx.fragment.app.FragmentManager +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aghajari.zoomhelper.ZoomHelper import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator @@ -36,15 +48,20 @@ import com.kickstarter.libs.Either import com.kickstarter.libs.KSString import com.kickstarter.libs.MessagePreviousScreenType import com.kickstarter.libs.ProjectPagerTabs +import com.kickstarter.libs.featureflag.FlagKey import com.kickstarter.libs.rx.transformers.Transformers import com.kickstarter.libs.utils.ApplicationUtils import com.kickstarter.libs.utils.ViewUtils import com.kickstarter.libs.utils.extensions.addToDisposable import com.kickstarter.libs.utils.extensions.getEnvironment +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.ui.IntentKey +import com.kickstarter.ui.activities.compose.projectpage.ProjectPledgeButtonAndFragmentContainer import com.kickstarter.ui.adapters.ProjectPagerAdapter +import com.kickstarter.ui.compose.designsystem.KickstarterApp import com.kickstarter.ui.data.ActivityResult.Companion.create import com.kickstarter.ui.data.CheckoutData import com.kickstarter.ui.data.LoginReason @@ -63,12 +80,17 @@ import com.kickstarter.ui.fragments.BackingFragment import com.kickstarter.ui.fragments.CancelPledgeFragment 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.PagerTabConfig import com.kickstarter.viewmodels.projectpage.ProjectPageViewModel +import com.kickstarter.viewmodels.projectpage.RewardsSelectionViewModel import com.stripe.android.view.CardInputWidget import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.CompositeDisposable import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.launch class ProjectPageActivity : AppCompatActivity(), @@ -80,6 +102,18 @@ class ProjectPageActivity : private lateinit var viewModelFactory: ProjectPageViewModel.Factory private val viewModel: ProjectPageViewModel.ProjectPageViewModel by viewModels { viewModelFactory } + private lateinit var checkoutViewModelFactory: CheckoutFlowViewModel.Factory + private val checkoutFlowViewModel: CheckoutFlowViewModel by viewModels { checkoutViewModelFactory } + + private val 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 addOnsViewModelFactory: AddOnsViewModel.Factory + private val addOnsViewModel: AddOnsViewModel by viewModels { addOnsViewModelFactory } + private val projectShareLabelString = R.string.project_accessibility_button_share_label private val projectShareCopyString = R.string.project_share_twitter_message private val projectStarConfirmationString = R.string.project_star_confirmation @@ -114,8 +148,12 @@ class ProjectPageActivity : val environment = this.getEnvironment()?.let { env -> viewModelFactory = ProjectPageViewModel.Factory(env) + checkoutViewModelFactory = CheckoutFlowViewModel.Factory(env) + confirmDetailsViewModelFactory = ConfirmDetailsViewModel.Factory(env) + addOnsViewModelFactory = AddOnsViewModel.Factory(env) env } + this.ksString = requireNotNull(environment?.ksString()) viewModel.configureWith(intent) @@ -149,12 +187,31 @@ class ProjectPageActivity : } } + this.viewModel.outputs.showLatePledgeFlow() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { showLatePledgeFlow -> + if (showLatePledgeFlow) { + binding.pledgeContainerLayout.pledgeContainerRoot.isGone = true + latePledgesSetUp(binding.pledgeContainerCompose) + } else { + binding.pledgeContainerCompose.isGone = true + binding.pledgeContainerLayout.pledgeContainerRoot.isGone = false + } + }.addToDisposable(disposables) + this.viewModel.outputs.projectData() .observeOn(AndroidSchedulers.mainThread()) .subscribe { // - Every time the ProjectData gets updated // - the fragments on the viewPager are updated as well (binding.projectPager.adapter as? ProjectPagerAdapter)?.updatedWithProjectData(it) + val fFLatePledge = environment?.featureFlagClient()?.getBoolean(FlagKey.ANDROID_POST_CAMPAIGN_PLEDGES) ?: false + + if (fFLatePledge && it.project().showLatePledgeFlow()) { + rewardsSelectionViewModel.provideProjectData(it) + addOnsViewModel.provideProjectData(it) + confirmDetailsViewModel.provideProjectData(it) + } }.addToDisposable(disposables) this.viewModel.outputs.updateTabs() @@ -403,6 +460,149 @@ class ProjectPageActivity : } } + @OptIn(ExperimentalFoundationApi::class) + private fun latePledgesSetUp(composeView: ComposeView) { + composeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + KickstarterApp { + val flowUIState by checkoutFlowViewModel.flowUIState.collectAsStateWithLifecycle() + + val expanded = flowUIState.expanded + val currentPage = flowUIState.currentPage + + val rewardSelectionUIState by rewardsSelectionViewModel.rewardSelectionUIState.collectAsStateWithLifecycle() + + val projectData = rewardSelectionUIState.project + val indexOfBackedReward = rewardSelectionUIState.initialRewardIndex + val rewardsList = rewardSelectionUIState.rewardList + val showRewardCarouselAlertDialog = rewardSelectionUIState.showAlertDialog + + LaunchedEffect(Unit) { + rewardsSelectionViewModel.flowUIRequest.collect { + checkoutFlowViewModel.changePage(it) + } + } + + 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 + + LaunchedEffect(Unit) { + addOnsViewModel.flowUIRequest.collect { + checkoutFlowViewModel.changePage(it) + } + } + + 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.totalBonusSupportAmount + val currentShippingRule = confirmUiState.currentShippingRule + val maxPledgeAmount = confirmUiState.maxPledgeAmount + val minStepAmount = confirmUiState.minStepAmount + + val checkoutPayment by confirmDetailsViewModel.checkoutPayment.collectAsStateWithLifecycle() + + LaunchedEffect(checkoutPayment.id) { + if (checkoutPayment.id != 0L) checkoutFlowViewModel.onConfirmDetailsContinueClicked() + } + + val pagerState = rememberPagerState(initialPage = 0, pageCount = { 4 }) + + this@ProjectPageActivity.onBackPressedDispatcher.addCallback { + if (expanded) checkoutFlowViewModel.onBackPressed(pagerState.currentPage) + else finishWithAnimation() + } + + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(currentPage) { + coroutineScope.launch { + pagerState.animateScrollToPage( + page = currentPage, + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ) + ) + } + } + + var selectedReward: Reward? = null + + ProjectPledgeButtonAndFragmentContainer( + expanded = expanded, + onContinueClicked = { checkoutFlowViewModel.onBackThisProjectClicked() }, + onBackClicked = { + checkoutFlowViewModel.onBackPressed(pagerState.currentPage) + }, + pagerState = pagerState, + onAddOnsContinueClicked = { + addOnsViewModel.onAddOnsContinueClicked() + }, + currentShippingRule = currentShippingRule ?: currentUserShippingRule, + shippingSelectorIsGone = shippingSelectorIsGone, + shippingRules = shippingRules, + environment = getEnvironment(), + initialRewardCarouselPosition = indexOfBackedReward, + rewardsList = rewardsList, + showRewardCarouselDialog = showRewardCarouselAlertDialog, + onRewardAlertDialogNegativeClicked = { + rewardsSelectionViewModel.onRewardCarouselAlertClicked(wasPositive = false) + }, + onRewardAlertDialogPositiveClicked = { + rewardsSelectionViewModel.onRewardCarouselAlertClicked(wasPositive = true) + }, + addOns = addOns, + project = projectData.project(), + onRewardSelected = { reward -> + selectedReward = reward + checkoutFlowViewModel.userRewardSelection(reward) + addOnsViewModel.userRewardSelection(reward) + rewardsSelectionViewModel.onUserRewardSelection(reward) + confirmDetailsViewModel.onUserSelectedReward(reward) + }, + onAddOnAddedOrRemoved = { updateAddOnRewardCount -> + selectedAddOnsMap[updateAddOnRewardCount.keys.first()] = + updateAddOnRewardCount[updateAddOnRewardCount.keys.first()] ?: 0 + addOnsViewModel.onAddOnsAddedOrRemoved(selectedAddOnsMap) + + confirmDetailsViewModel.onUserUpdatedAddOns(selectedAddOnsMap) + }, + selectedReward = selectedReward, + totalAmount = totalAmount, + selectedRewardAndAddOnList = rewardsAndAddOns, + initialBonusSupportAmount = initialBonusAmount, + totalBonusSupportAmount = totalBonusSupportAmount, + maxPledgeAmount = maxPledgeAmount, + minStepAmount = minStepAmount, + onShippingRuleSelected = { shippingRule -> + addOnsViewModel.onShippingLocationChanged(shippingRule) + confirmDetailsViewModel.onShippingRuleSelected(shippingRule) + }, + shippingAmount = shippingAmount, + onConfirmDetailsContinueClicked = { + confirmDetailsViewModel.onContinueClicked { + checkoutFlowViewModel.onConfirmDetailsContinueClicked() + } + }, + onBonusSupportMinusClicked = { confirmDetailsViewModel.decrementBonusSupport() }, + onBonusSupportPlusClicked = { confirmDetailsViewModel.incrementBonusSupport() }, + selectedAddOnsMap = selectedAddOnsMap + ) + } + } + } + } + /** * Give a List of configurations will iterate over it and apply * the configuration required. @@ -549,8 +749,10 @@ class ProjectPageActivity : return supportFragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) } + @SuppressLint("DiscouragedApi", "InternalInsetResource") private fun expandPledgeSheet(expandAndAnimate: Pair) { var statusBarHeight = 0 + // TODO: Replace with window insets compat val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android") if (resourceId > 0) { statusBarHeight = resources.getDimensionPixelSize(resourceId) 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 b22aeaf95d..7b36c07028 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,5 +1,6 @@ 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 @@ -17,13 +18,14 @@ 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.saveable.rememberSaveable -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.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 @@ -49,8 +51,9 @@ private fun AddOnsContainerPreview() { limit = 10, buttonEnabled = true, buttonText = "Add", - onItemAddedOrRemoved = { count -> - } + environment = Environment.builder().build(), + onItemAddedOrRemoved = { count -> }, + itemAddOnCount = 1 ) } } @@ -66,10 +69,10 @@ fun AddOnsContainer( limit: Int = -1, buttonEnabled: Boolean, buttonText: String, - onItemAddedOrRemoved: (count: Int) -> Unit + environment: Environment, + onItemAddedOrRemoved: (count: Int) -> Unit, + itemAddOnCount: Int ) { - var addOnCount by rememberSaveable { mutableStateOf(0) } - Card( modifier = Modifier.fillMaxWidth(), backgroundColor = colors.kds_white, @@ -88,7 +91,7 @@ fun AddOnsContainer( if (!shippingAmount.isNullOrEmpty()) { Text( - text = " + $shippingAmount", + text = getShippingString(LocalContext.current, environment.ksString(), shippingAmount) ?: "", style = typography.callout, color = colors.textAccentGreen ) @@ -133,6 +136,10 @@ fun AddOnsContainer( ) Text( + modifier = Modifier.padding( + top = dimensions.paddingXSmall, + bottom = dimensions.paddingXSmall + ), text = itemDescription, style = typography.body2, color = colors.textPrimary @@ -153,17 +160,17 @@ fun AddOnsContainer( Spacer(Modifier.height(dimensions.paddingLarge)) - when (addOnCount) { + when (itemAddOnCount) { 0 -> { KSPrimaryBlackButton( onClickAction = { - addOnCount++ - onItemAddedOrRemoved(addOnCount) + onItemAddedOrRemoved(itemAddOnCount + 1) }, text = buttonText, isEnabled = buttonEnabled ) } + else -> { Row( modifier = Modifier.fillMaxWidth(), @@ -172,13 +179,11 @@ fun AddOnsContainer( ) { KSStepper( onPlusClicked = { - addOnCount++ - onItemAddedOrRemoved(addOnCount) + onItemAddedOrRemoved(itemAddOnCount + 1) }, - isPlusEnabled = addOnCount < limit, + isPlusEnabled = itemAddOnCount < limit, onMinusClicked = { - addOnCount-- - onItemAddedOrRemoved(addOnCount) + onItemAddedOrRemoved(itemAddOnCount - 1) }, isMinusEnabled = true ) @@ -198,7 +203,7 @@ fun AddOnsContainer( ) ) { Text( - text = "$addOnCount", + text = "$itemAddOnCount", style = typography.callout, color = colors.textPrimary ) @@ -209,3 +214,17 @@ 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 2d933aea3f..31142663b7 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 @@ -1,17 +1,17 @@ package com.kickstarter.ui.activities.compose.projectpage import android.content.res.Configuration -import androidx.compose.animation.AnimatedVisibility 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.Row +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight 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 @@ -19,26 +19,34 @@ 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.Card +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.TextField 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.graphics.Color +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.libs.Environment +import com.kickstarter.libs.KSCurrency +import com.kickstarter.libs.utils.RewardUtils +import com.kickstarter.models.Location import com.kickstarter.models.Project import com.kickstarter.models.Reward import com.kickstarter.models.ShippingRule @@ -47,7 +55,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.compose.designsystem.shapes +import java.math.RoundingMode @Composable @Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) @@ -61,8 +69,23 @@ private fun AddOnsScreenPreview() { modifier = Modifier.padding(padding), environment = Environment.Builder().build(), lazyColumnListState = rememberLazyListState(), - countryList = listOf(), + 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 { Reward.builder() .title("Item Number $it") @@ -79,6 +102,7 @@ private fun AddOnsScreenPreview() { .currentCurrency("USD") .build(), onItemAddedOrRemoved = {}, + selectedAddOnsMap = mutableMapOf(), onContinueClicked = {} ) } @@ -90,21 +114,20 @@ fun AddOnsScreen( modifier: Modifier, environment: Environment, lazyColumnListState: LazyListState, - initialCountryInput: String? = null, + shippingSelectorIsGone: Boolean, + currentShippingRule: ShippingRule, countryList: List, onShippingRuleSelected: (ShippingRule) -> Unit, rewardItems: List, project: Project, onItemAddedOrRemoved: (Map) -> Unit, + selectedAddOnsMap: Map, onContinueClicked: () -> Unit ) { val interactionSource = remember { MutableInteractionSource() } - var addOnCount by remember { - mutableStateOf(0) - } - val rewardSelections: MutableMap = mutableMapOf() + val addOnCount = getAddOnCount(selectedAddOnsMap) Scaffold( modifier = modifier, @@ -173,22 +196,24 @@ fun AddOnsScreen( color = colors.textPrimary ) - Spacer(modifier = Modifier.height(dimensions.paddingMediumLarge)) + if (!shippingSelectorIsGone) { + Spacer(modifier = Modifier.height(dimensions.paddingMediumLarge)) - Text( - text = stringResource(id = R.string.Your_shipping_location), - style = typography.subheadlineMedium, - color = colors.textSecondary - ) + Text( + text = stringResource(id = R.string.Your_shipping_location), + style = typography.subheadlineMedium, + color = colors.textSecondary + ) - Spacer(modifier = Modifier.height(dimensions.paddingSmall)) + Spacer(modifier = Modifier.height(dimensions.paddingSmall)) - CountryInputWithDropdown( - interactionSource = interactionSource, - initialCountryInput = initialCountryInput, - countryList = countryList, - onShippingRuleSelected = onShippingRuleSelected - ) + CountryInputWithDropdown( + interactionSource = interactionSource, + initialCountryInput = currentShippingRule.location()?.displayableName(), + countryList = countryList, + onShippingRuleSelected = onShippingRuleSelected + ) + } } items( @@ -199,24 +224,49 @@ fun AddOnsScreen( AddOnsContainer( title = reward.title() ?: "", amount = environment.ksCurrency()?.format( - reward.convertedMinimum(), + reward.minimum(), project, true, ) ?: "", - shippingAmount = "", // todo in implementation + conversionAmount = environment.ksString()?.format( + stringResource(R.string.About_reward_amount), + "reward_amount", + environment.ksCurrency()?.format( + reward.convertedMinimum(), + project, + true, + RoundingMode.HALF_UP, + true + ) + ), + shippingAmount = environment.ksCurrency()?.let { + getShippingCost( + reward = reward, + ksCurrency = it, + shippingRules = reward.shippingRules(), + selectedShippingRule = currentShippingRule, + project = project + ) + }, description = reward.description() ?: "", buttonEnabled = reward.isAvailable(), buttonText = stringResource(id = R.string.Add), limit = reward.limit() ?: -1, onItemAddedOrRemoved = { count -> + val rewardSelections = mutableMapOf() rewardSelections[reward] = count - var totalRewardsCount = 0 - rewardSelections.forEach { - totalRewardsCount += it.value - } - addOnCount = totalRewardsCount + onItemAddedOrRemoved(rewardSelections) - } + }, + environment = environment, + includesList = reward.addOnsItems()?.map { + environment.ksString()?.format( + "rewards_info_item_quantity_title", it.quantity(), + "quantity", it.quantity().toString(), + "title", it.item().name() + ) ?: "" + } ?: listOf(), + itemAddOnCount = selectedAddOnsMap[reward] ?: 0 ) } @@ -227,22 +277,37 @@ fun AddOnsScreen( } } -@Composable -fun CountryListItems( - item: ShippingRule, - title: String, - onSelect: (ShippingRule) -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onSelect(item) } - .padding(dimensions.paddingXSmall) - ) { - Text(text = title) +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, @@ -258,6 +323,8 @@ fun CountryInputWithDropdown( mutableStateOf(initialCountryInput ?: "United States") } + val focusManager = LocalFocusManager.current + Column( modifier = Modifier .clickable( @@ -266,65 +333,98 @@ fun CountryInputWithDropdown( onClick = { countryListExpanded = false } ), ) { - TextField( - modifier = Modifier - .height(dimensions.minButtonHeight) - .width(dimensions.countryInputWidth), - value = countryInput, - onValueChange = { - countryInput = it - countryListExpanded = true - }, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Text, - imeAction = ImeAction.Done - ), - shape = shapes.medium, - colors = TextFieldDefaults.textFieldColors( - backgroundColor = colors.kds_white, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent - ), - textStyle = typography.subheadlineMedium.copy(color = colors.textAccentGreenBold), - ) + 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 + ), + ) + } - AnimatedVisibility(visible = countryListExpanded) { - Card(shape = shapes.medium) { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .background(color = colors.kds_white) - ) { - if (countryInput.isNotEmpty()) { - items( - items = countryList.filter { - it.location()?.displayableName()?.lowercase() - ?.contains(countryInput.lowercase()) ?: false + 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) } ) { - CountryListItems( - item = it, - title = it.location()?.displayableName() ?: "", - onSelect = { country -> - countryInput = - country.location()?.displayableName() ?: "" - countryListExpanded = false - onShippingRuleSelected(country) - } + Text( + text = rule.location()?.displayableName() ?: "", + style = typography.subheadlineMedium, + color = colors.textAccentGreenBold ) } - } else { - items(countryList) { - CountryListItems( - item = it, - title = it.location()?.displayableName() ?: "", - onSelect = { country -> - countryInput = - country.location()?.displayableName() ?: "" - countryListExpanded = false - onShippingRuleSelected(country) - } + } + } 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/ui/activities/compose/projectpage/CheckoutScreen.kt b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/CheckoutScreen.kt index dc355fb569..5ce5ffd08e 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 @@ -362,16 +362,7 @@ fun CheckoutScreen( ItemizedRewardListContainer( ksString = ksString, rewardsList = rewardsList, - shippingAmount = if (shippingAmount == 0.0) "" - else { - environment.ksCurrency()?.format( - shippingAmount, - project, - true, - RoundingMode.HALF_UP, - true - ) ?: "" - }, + shippingAmount = shippingAmount, initialShippingLocation = shippingLocationString, totalAmount = totalAmountString, totalAmountCurrencyConverted = aboutTotalString, @@ -395,7 +386,8 @@ fun CheckoutScreen( Pair(stringResource(id = R.string.Pledge_without_a_reward), totalAmountString) }, initialBonusSupport = "", - totalBonusSupport = "" + totalBonusSupport = "", + shippingAmount = shippingAmount ) } } 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 index 5c88714f63..33fd9193a4 100644 --- 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 @@ -24,7 +24,13 @@ 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.Environment import com.kickstarter.libs.KSString +import com.kickstarter.libs.utils.DateTimeUtils +import com.kickstarter.libs.utils.RewardViewUtils +import com.kickstarter.libs.utils.extensions.isNotNull +import com.kickstarter.models.Project +import com.kickstarter.models.Reward import com.kickstarter.models.ShippingRule import com.kickstarter.ui.compose.designsystem.KSDividerLineGrey import com.kickstarter.ui.compose.designsystem.KSPrimaryGreenButton @@ -34,6 +40,7 @@ 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) @@ -42,13 +49,45 @@ private fun ConfirmPledgeDetailsScreenPreviewNoRewards() { KSTheme { ConfirmPledgeDetailsScreen( modifier = Modifier, + environment = Environment.builder().build(), + project = Project.builder().build(), + selectedReward = null, onContinueClicked = {}, - initialShippingLocation = "United States", - totalAmount = "$1", - totalAmountCurrencyConverted = "About $1", - initialBonusSupport = "$1", - totalBonusSupport = "$1", - onShippingRuleSelected = {} + rewardsContainAddOns = false, + currentShippingRule = ShippingRule.builder().build(), + totalAmount = 1.0, + initialBonusSupport = 1.0, + totalBonusSupport = 1.0, + maxPledgeAmount = 1000.0, + minPledgeStep = 1.0, + onShippingRuleSelected = {}, + onBonusSupportMinusClicked = {}, + onBonusSupportPlusClicked = {} + ) + } +} + +@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, + currentShippingRule = ShippingRule.builder().build(), + totalAmount = 1001.0, + initialBonusSupport = 1.0, + totalBonusSupport = 1.0, + maxPledgeAmount = 1000.0, + minPledgeStep = 1.0, + onShippingRuleSelected = {}, + onBonusSupportMinusClicked = {}, + onBonusSupportPlusClicked = {} ) } } @@ -60,19 +99,25 @@ 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") }, - shippingAmount = "$5", - initialShippingLocation = "United States", - totalAmount = "$55", - totalAmountCurrencyConverted = "About $", - initialBonusSupport = "$0", - totalBonusSupport = "$0", + rewardsContainAddOns = false, + 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 = {}, - deliveryDateString = stringResource(id = R.string.Estimated_delivery) + " May 2024" + onBonusSupportMinusClicked = {}, + onBonusSupportPlusClicked = {} ) } } @@ -84,18 +129,24 @@ 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") }, - shippingAmount = "$5", - initialShippingLocation = "United States", - totalAmount = "$105", - totalAmountCurrencyConverted = "About $", - initialBonusSupport = "$0", - totalBonusSupport = "$0", + rewardsContainAddOns = true, + shippingAmount = 5.0, + currentShippingRule = ShippingRule.builder().build(), + totalAmount = 105.0, + initialBonusSupport = 0.0, + totalBonusSupport = 0.0, + maxPledgeAmount = 1000.0, + minPledgeStep = 1.0, onShippingRuleSelected = {}, - deliveryDateString = stringResource(id = R.string.Estimated_delivery) + " May 2024" + onBonusSupportMinusClicked = {}, + onBonusSupportPlusClicked = {} ) } } @@ -107,19 +158,25 @@ 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") }, - shippingAmount = "$5", - initialShippingLocation = "United States", - totalAmount = "$55", - totalAmountCurrencyConverted = "About $", - initialBonusSupport = "$0", - totalBonusSupport = "$10", + rewardsContainAddOns = false, + 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 = {}, - deliveryDateString = stringResource(id = R.string.Estimated_delivery) + " May 2024" + onBonusSupportMinusClicked = {}, + onBonusSupportPlusClicked = {} ) } } @@ -131,18 +188,24 @@ 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") }, - shippingAmount = "$5", - initialShippingLocation = "United States", - totalAmount = "$115", - totalAmountCurrencyConverted = "About $", - initialBonusSupport = "$0", - totalBonusSupport = "$10", + rewardsContainAddOns = true, + shippingAmount = 5.0, + currentShippingRule = ShippingRule.builder().build(), + totalAmount = 115.0, + initialBonusSupport = 0.0, + totalBonusSupport = 10.0, + maxPledgeAmount = 1000.0, + minPledgeStep = 1.0, onShippingRuleSelected = {}, - deliveryDateString = stringResource(id = R.string.Estimated_delivery) + " May 2024" + onBonusSupportMinusClicked = {}, + onBonusSupportPlusClicked = {} ) } } @@ -150,23 +213,89 @@ private fun ConfirmPledgeDetailsScreenPreviewAddOnsAndBonusSupport() { @Composable fun ConfirmPledgeDetailsScreen( modifier: Modifier, - ksString: KSString? = null, + environment: Environment?, + project: Project, + selectedReward: Reward?, onContinueClicked: () -> Unit, rewardsList: List> = listOf(), - shippingAmount: String = "", - initialShippingLocation: String? = null, + rewardsContainAddOns: Boolean, + shippingAmount: Double = 0.0, + currentShippingRule: ShippingRule, countryList: List = listOf(), onShippingRuleSelected: (ShippingRule) -> Unit, - totalAmount: String, - totalAmountCurrencyConverted: String = "", - initialBonusSupport: String, - totalBonusSupport: String, - deliveryDateString: String = "" + totalAmount: Double, + initialBonusSupport: Double, + totalBonusSupport: Double, + maxPledgeAmount: Double, + minPledgeStep: Double, + onBonusSupportPlusClicked: () -> Unit, + onBonusSupportMinusClicked: () -> 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 = 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() + } ?: "" + + val deliveryDateString = if (selectedReward?.estimatedDeliveryOn().isNotNull()) { + DateTimeUtils.estimatedDeliveryOn( + requireNotNull( + selectedReward?.estimatedDeliveryOn() + ) + ) + } else "" + + val maxPledgeString = environment?.ksString()?.format( + stringResource(R.string.Enter_an_amount_less_than_max_pledge), + "max_pledge", + maxPledgeAmount.toString() + ) ?: "" + Scaffold( modifier = modifier, bottomBar = { @@ -198,7 +327,7 @@ fun ConfirmPledgeDetailsScreen( Spacer(modifier = Modifier.weight(1f)) Text( - text = totalAmount, + text = totalAmountString, style = typography.subheadlineMedium, color = colors.textPrimary ) @@ -230,13 +359,13 @@ fun ConfirmPledgeDetailsScreen( start = dimensions.paddingMedium, top = dimensions.paddingMedium ), - text = "Confirm your pledge details.", + text = stringResource(id = R.string.Confirm_your_pledge_details), style = typography.title3Bold, color = colors.textPrimary ) } - if (rewardsList.isNotEmpty() && shippingAmount.isNotEmpty() && !initialShippingLocation.isNullOrEmpty()) { + if (rewardsList.isNotEmpty() && shippingLocation.isNotEmpty()) { item { Column( modifier = Modifier.padding( @@ -254,7 +383,7 @@ fun ConfirmPledgeDetailsScreen( Spacer(modifier = Modifier.height(dimensions.paddingMediumSmall)) Row(verticalAlignment = Alignment.CenterVertically) { - if (countryList.isNotEmpty()) { + if (countryList.isNotEmpty() && !rewardsContainAddOns) { CountryInputWithDropdown( interactionSource = interactionSource, countryList = countryList, @@ -262,7 +391,7 @@ fun ConfirmPledgeDetailsScreen( ) } else { Text( - text = initialShippingLocation, + text = shippingLocation, style = typography.subheadline, color = colors.textPrimary ) @@ -271,7 +400,7 @@ fun ConfirmPledgeDetailsScreen( Spacer(modifier = Modifier.weight(1f)) Text( - text = "+ $shippingAmount", + text = "+ $shippingAmountString", style = typography.title3, color = colors.textSecondary ) @@ -283,11 +412,22 @@ fun ConfirmPledgeDetailsScreen( item { BonusSupportContainer( isForNoRewardPledge = rewardsList.isEmpty(), - initialValue = initialBonusSupport, - totalBonusAmount = totalBonusSupport, - onBonusSupportPlusClicked = {}, - onBonusSupportMinusClicked = {} + initialValue = initialBonusSupportString, + totalBonusAmount = totalBonusSupportString, + canAddMore = totalAmount + minPledgeStep <= maxPledgeAmount, + onBonusSupportPlusClicked = onBonusSupportPlusClicked, + onBonusSupportMinusClicked = onBonusSupportMinusClicked ) + + if (totalAmount >= maxPledgeAmount) { + Spacer(modifier = Modifier.height(dimensions.paddingXSmall)) + + Text( + text = maxPledgeString, + style = typography.headline, + color = colors.textAccentRedBold + ) + } } if (rewardsList.isEmpty()) { @@ -308,20 +448,16 @@ fun ConfirmPledgeDetailsScreen( Column(horizontalAlignment = Alignment.End) { Text( - text = totalAmount, + text = totalAmountString, style = typography.headline, color = colors.textPrimary ) - if (totalAmountCurrencyConverted.isNotEmpty()) { + if (aboutTotalString.isNotEmpty()) { Spacer(modifier = Modifier.height(dimensions.paddingXSmall)) Text( - text = ksString?.format( - stringResource(id = R.string.About_reward_amount), - "reward_amount", - totalAmount - ) ?: "About $totalAmount", + text = aboutTotalString, style = typography.footnote, color = colors.textPrimary ) @@ -333,14 +469,15 @@ fun ConfirmPledgeDetailsScreen( } else { item { ItemizedRewardListContainer( - ksString = ksString, + ksString = environment?.ksString(), rewardsList = rewardsList, shippingAmount = shippingAmount, - initialShippingLocation = initialShippingLocation, - totalAmount = totalAmount, - totalAmountCurrencyConverted = totalAmountCurrencyConverted, - initialBonusSupport = initialBonusSupport, - totalBonusSupport = totalBonusSupport, + shippingAmountString = shippingAmountString, + initialShippingLocation = shippingLocation, + totalAmount = totalAmountString, + totalAmountCurrencyConverted = totalAmountConvertedString, + initialBonusSupport = initialBonusSupportString, + totalBonusSupport = totalBonusSupportString, deliveryDateString = deliveryDateString ) } @@ -354,6 +491,7 @@ fun BonusSupportContainer( isForNoRewardPledge: Boolean, initialValue: String, totalBonusAmount: String, + canAddMore: Boolean, onBonusSupportPlusClicked: () -> Unit, onBonusSupportMinusClicked: () -> Unit ) { @@ -384,7 +522,7 @@ fun BonusSupportContainer( ) { KSStepper( onPlusClicked = onBonusSupportPlusClicked, - isPlusEnabled = true, + isPlusEnabled = canAddMore, onMinusClicked = onBonusSupportMinusClicked, isMinusEnabled = initialValue != totalBonusAmount, enabledButtonBackgroundColor = colors.kds_white @@ -422,8 +560,9 @@ fun BonusSupportContainer( fun ItemizedRewardListContainer( ksString: KSString? = null, rewardsList: List> = listOf(), - shippingAmount: String = "", - initialShippingLocation: String? = null, + shippingAmount: Double, + shippingAmountString: String = "", + initialShippingLocation: String = "", totalAmount: String, totalAmountCurrencyConverted: String = "", initialBonusSupport: String, @@ -486,7 +625,7 @@ fun ItemizedRewardListContainer( KSDividerLineGrey() } - if (shippingAmount.isNotEmpty()) { + if (shippingAmount > 0 && initialShippingLocation.isNotEmpty()) { Spacer(modifier = Modifier.height(dimensions.paddingMedium)) Row { @@ -494,7 +633,7 @@ fun ItemizedRewardListContainer( text = ksString?.format( stringResource(id = R.string.Shipping_to_country), "country", - totalAmount + initialShippingLocation ) ?: "Shipping: $initialShippingLocation", style = typography.subheadlineMedium, color = colors.textSecondary @@ -503,7 +642,7 @@ fun ItemizedRewardListContainer( Spacer(modifier = Modifier.weight(1f)) Text( - text = shippingAmount, + text = shippingAmountString, style = typography.subheadlineMedium, color = colors.textSecondary ) @@ -563,8 +702,8 @@ fun ItemizedRewardListContainer( text = ksString?.format( stringResource(id = R.string.About_reward_amount), "reward_amount", - totalAmount - ) ?: "About $totalAmount", + totalAmountCurrencyConverted + ) ?: "About $totalAmountCurrencyConverted", style = typography.footnote, color = colors.textPrimary ) 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 new file mode 100644 index 0000000000..cd2d165f5d --- /dev/null +++ b/app/src/main/java/com/kickstarter/ui/activities/compose/projectpage/ProjectPledgeButtonAndFragmentContainer.kt @@ -0,0 +1,339 @@ +package com.kickstarter.ui.activities.compose.projectpage + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +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.utils.RewardViewUtils +import com.kickstarter.libs.utils.extensions.isNullOrZero +import com.kickstarter.models.Project +import com.kickstarter.models.Reward +import com.kickstarter.models.ShippingRule +import com.kickstarter.ui.compose.designsystem.KSAlertDialog +import com.kickstarter.ui.compose.designsystem.KSPrimaryGreenButton +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.toolbars.compose.TopToolBar +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun ProjectPledgeButtonAndContainerPreview() { + KSTheme { + var expanded by remember { + mutableStateOf(false) + } + val pagerState = rememberPagerState(initialPage = 1, pageCount = { 4 }) + + val coroutineScope = rememberCoroutineScope() + ProjectPledgeButtonAndFragmentContainer( + expanded = expanded, + onContinueClicked = { expanded = !expanded }, + onBackClicked = { + if (pagerState.currentPage > 1) { + coroutineScope.launch { + pagerState.animateScrollToPage( + page = pagerState.currentPage - 1, + animationSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing + ) + ) + } + } else { + expanded = !expanded + } + }, + pagerState = pagerState, + onAddOnsContinueClicked = { + coroutineScope.launch { + pagerState.animateScrollToPage( + page = 2, + animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing) + ) + } + }, + environment = Environment.builder().build(), + rewardsList = listOf(), + addOns = listOf(), + project = Project.builder().build(), + onRewardSelected = {}, + onAddOnAddedOrRemoved = {}, + selectedAddOnsMap = mapOf(), + totalAmount = 0.0, + shippingSelectorIsGone = false, + currentShippingRule = ShippingRule.builder().build(), + onShippingRuleSelected = {}, + showRewardCarouselDialog = false, + onRewardAlertDialogPositiveClicked = {}, + onRewardAlertDialogNegativeClicked = {}, + onConfirmDetailsContinueClicked = {}, + selectedRewardAndAddOnList = listOf(), + onBonusSupportMinusClicked = {}, + onBonusSupportPlusClicked = {} + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ProjectPledgeButtonAndFragmentContainer( + expanded: Boolean, + onContinueClicked: () -> Unit, + onBackClicked: () -> Unit, + pagerState: PagerState, + onAddOnsContinueClicked: () -> Unit, + shippingSelectorIsGone: Boolean, + shippingRules: List = listOf(), + currentShippingRule: ShippingRule, + environment: Environment?, + initialRewardCarouselPosition: Int = 0, + showRewardCarouselDialog: Boolean, + onRewardAlertDialogNegativeClicked: () -> Unit, + onRewardAlertDialogPositiveClicked: () -> Unit, + rewardsList: List, + addOns: List, + project: Project, + onRewardSelected: (reward: Reward) -> Unit, + onAddOnAddedOrRemoved: (Map) -> Unit, + selectedAddOnsMap: Map, + totalAmount: Double, + 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 +) { + Column { + Surface( + modifier = Modifier + .fillMaxWidth(), + shape = if (expanded) { + RectangleShape + } else { + RoundedCornerShape( + topStart = dimensions.radiusLarge, + topEnd = dimensions.radiusLarge + ) + }, + color = colors.backgroundSurfacePrimary, + elevation = dimensions.elevationLarge, + ) { + AnimatedVisibility( + visible = !expanded, + enter = fadeIn( + animationSpec = tween( + durationMillis = 350, + easing = FastOutSlowInEasing + ) + ), + exit = fadeOut( + animationSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing + ) + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(dimensions.paddingMediumLarge) + ) { + KSPrimaryGreenButton( + onClickAction = onContinueClicked, + text = stringResource(id = R.string.Back_this_project), + isEnabled = true + ) + } + } + + AnimatedVisibility( + visible = expanded, + enter = expandVertically( + expandFrom = Alignment.Bottom, + animationSpec = tween( + durationMillis = 250, + easing = FastOutSlowInEasing + ) + ), + exit = shrinkVertically( + shrinkTowards = Alignment.Bottom, + animationSpec = tween( + durationMillis = 350, + easing = FastOutSlowInEasing + ) + ) + ) { + Scaffold( + topBar = { + TopToolBar( + title = stringResource(id = R.string.Back_this_project), + titleColor = colors.textPrimary, + leftOnClickAction = onBackClicked, + leftIconColor = colors.textPrimary, + backgroundColor = colors.backgroundSurfacePrimary, + ) + } + ) { padding -> + Box( + Modifier + .padding(padding) + .fillMaxSize() + ) { + + if (showRewardCarouselDialog) { + KSAlertDialog( + setShowDialog = { }, + headlineText = stringResource(id = R.string.Continue_with_this_reward), + bodyText = stringResource(id = R.string.It_may_not_offer_some_or_all_of_your_add_ons), + leftButtonText = stringResource(id = R.string.No_go_back), + leftButtonAction = onRewardAlertDialogNegativeClicked, + rightButtonText = stringResource(id = R.string.Yes_continue), + rightButtonAction = onRewardAlertDialogPositiveClicked + ) + } + + HorizontalPager( + userScrollEnabled = false, + state = pagerState + ) { page -> + when (page) { + 0 -> { + RewardCarouselScreen( + modifier = Modifier, + lazyRowState = rememberLazyListState( + initialFirstVisibleItemIndex = initialRewardCarouselPosition + ), + environment = environment ?: Environment.builder().build(), + rewards = rewardsList, + project = project, + onRewardSelected = onRewardSelected + ) + } + + 1 -> { + AddOnsScreen( + modifier = Modifier, + environment = environment ?: Environment.builder().build(), + lazyColumnListState = rememberLazyListState(), + countryList = shippingRules, + shippingSelectorIsGone = shippingSelectorIsGone, + currentShippingRule = currentShippingRule, + onShippingRuleSelected = onShippingRuleSelected, + rewardItems = addOns, + project = project, + onItemAddedOrRemoved = onAddOnAddedOrRemoved, + selectedAddOnsMap = selectedAddOnsMap, + onContinueClicked = onAddOnsContinueClicked + ) + } + + 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() }, + onBonusSupportPlusClicked = onBonusSupportPlusClicked, + onBonusSupportMinusClicked = onBonusSupportMinusClicked + ) + } + + 3 -> { + // Pledge page + } + } + } + } + } + } + } + } +} + +fun getRewardListAndPrices( + rewardsList: List, + environment: Environment?, + project: Project +): List> { + return rewardsList.map { reward -> + if (!reward.quantity().isNullOrZero()) { + val title = reward.title() ?: "" + val quantity = reward.quantity() ?: 1 + Pair( + "$title X $quantity", + environment?.ksCurrency()?.let { + RewardViewUtils.styleCurrency( + reward.minimum() * quantity, + project, + it + ).toString() + } ?: "" + ) + } else { + Pair( + reward.title() ?: "", + environment?.ksCurrency()?.let { + RewardViewUtils.styleCurrency( + reward.minimum(), + project, + it + ).toString() + } ?: "" + ) + } + } +} 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 ba006790a5..cbae917544 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 @@ -81,6 +81,7 @@ fun RewardCarouselScreenPreview() { } } } + @Composable fun RewardCarouselScreen( modifier: Modifier, @@ -88,7 +89,7 @@ fun RewardCarouselScreen( environment: Environment, rewards: List, project: Project, - onRewardSelected: () -> Unit + onRewardSelected: (reward: Reward) -> Unit ) { val context = LocalContext.current @@ -115,8 +116,7 @@ fun RewardCarouselScreen( ) } } - ) { - padding -> + ) { padding -> LazyRow( modifier = Modifier .fillMaxWidth() @@ -138,8 +138,14 @@ fun RewardCarouselScreen( val ctaButtonEnabled = when { !reward.hasAddons() && project.backing()?.isBacked(reward) != true -> true - project.backing()?.rewardId() != reward.id() && RewardUtils.isAvailable(project, reward) -> true - reward.hasAddons() && project.backing()?.rewardId() == reward.id() && project.isLive -> true + project.backing()?.rewardId() != reward.id() && RewardUtils.isAvailable( + project, + reward + ) -> true + + reward.hasAddons() && project.backing() + ?.rewardId() == reward.id() && (project.isLive || (project.postCampaignPledgingEnabled() ?: false && project.isInPostCampaignPledgingPhase() ?: false)) -> true + else -> false } val isBacked = project.backing()?.isBacked(reward) ?: false @@ -151,14 +157,17 @@ fun RewardCarouselScreen( 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 + 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, + onRewardSelectClicked = { onRewardSelected(reward) }, amount = environment.ksCurrency()?.let { RewardViewUtils.styleCurrency( reward.minimum(), @@ -182,13 +191,17 @@ fun RewardCarouselScreen( else { environment.ksString()?.let { it.format( - "rewards_info_backer_count_backers", requireNotNull(reward.backersCount()), - "backer_count", NumberUtils.format(requireNotNull(reward.backersCount())) + "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()) { + 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(), @@ -204,7 +217,10 @@ fun RewardCarouselScreen( DateTimeUtils.estimatedDeliveryOn(requireNotNull(reward.estimatedDeliveryOn())) } else "", yourSelectionIsVisible = project.backing()?.isBacked(reward) ?: false, - localPickup = if (RewardUtils.isLocalPickup(reward) && !RewardUtils.isShippable(reward)) { + localPickup = if (RewardUtils.isLocalPickup(reward) && !RewardUtils.isShippable( + reward + ) + ) { reward.localReceiptLocation()?.displayableName() ?: "" } else { "" @@ -212,12 +228,23 @@ fun RewardCarouselScreen( ctaButtonText = stringResource(id = ctaButtonText), expirationDateText = environment.ksString()?.let { - RewardUtils.deadlineCountdownDetail(reward, context, it) + RewardUtils.deadlineCountdownDetail(reward, context, it) + 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) } + RewardUtils.shippingSummary(reward)?.let { + RewardViewUtils.shippingSummary( + context = context, + ksString = ksString, + it + ) + } } else { "" } @@ -226,7 +253,11 @@ fun RewardCarouselScreen( 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)) + ksString.format( + stringResource(id = R.string.Left_count_left_few), + "left_count", + NumberUtils.format(remaining) + ) } else "" } else "" }, 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 5943c08448..abf51ddde5 100644 --- a/app/src/main/java/com/kickstarter/ui/compose/KSRewardCard.kt +++ b/app/src/main/java/com/kickstarter/ui/compose/KSRewardCard.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.kickstarter.R -import com.kickstarter.ui.activities.compose.login.LogInSignUpClickableDisclaimerText import com.kickstarter.ui.compose.designsystem.KSGreenBadge import com.kickstarter.ui.compose.designsystem.KSPrimaryGreenButton import com.kickstarter.ui.compose.designsystem.KSTheme @@ -119,6 +118,7 @@ fun KSRewardCard( Modifier .verticalScroll(rememberScrollState()) .padding(dimensions.paddingMediumLarge) + .weight(weight = 1f, fill = false) ) { if (!amount.isNullOrEmpty()) { @@ -229,13 +229,6 @@ fun KSRewardCard( Spacer(modifier = Modifier.height(dimensions.paddingMediumLarge)) } - LogInSignUpClickableDisclaimerText( - onPrivacyPolicyClicked = {}, - onCookiePolicyClicked = {}, - onTermsOfUseClicked = {} - - ) - FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), diff --git a/app/src/main/java/com/kickstarter/ui/compose/designsystem/KSAlerts.kt b/app/src/main/java/com/kickstarter/ui/compose/designsystem/KSAlerts.kt index 2f87c1a2b8..2e67ba0551 100644 --- a/app/src/main/java/com/kickstarter/ui/compose/designsystem/KSAlerts.kt +++ b/app/src/main/java/com/kickstarter/ui/compose/designsystem/KSAlerts.kt @@ -265,7 +265,7 @@ fun KSDialogVisual( ) { // Headline safeLet(headline, headlineStyle, headlineSpacing) { text, style, space -> - Text(text = text, style = style, color = colors.kds_support_700) + Text(text = text, style = style, color = colors.textPrimary) Spacer(modifier = Modifier.height(space)) } @@ -274,7 +274,7 @@ fun KSDialogVisual( modifier = Modifier.padding(end = dimensions.paddingSmall), text = bodyText, style = bodyStyle, - color = colors.kds_support_700 + color = colors.textPrimary ) Row( @@ -293,7 +293,7 @@ fun KSDialogVisual( }, colors = ButtonDefaults.buttonColors( - backgroundColor = leftButtonColor ?: colors.kds_white + backgroundColor = leftButtonColor ?: colors.backgroundSurfacePrimary ), elevation = ButtonDefaults.elevation(dimensions.none) ) { @@ -321,7 +321,7 @@ fun KSDialogVisual( }, colors = ButtonDefaults.buttonColors( - backgroundColor = rightButtonColor ?: colors.kds_white + backgroundColor = rightButtonColor ?: colors.backgroundSurfacePrimary ), elevation = ButtonDefaults.elevation(dimensions.none) ) { @@ -351,7 +351,7 @@ fun KSAlertDialogNoHeadline( KSDialogVisual( modifier = Modifier .width(dimensions.dialogWidth) - .background(color = colors.kds_white, shape = shapes.small) + .background(color = colors.backgroundSurfacePrimary, shape = shapes.small) .padding( start = dimensions.paddingLarge, top = dimensions.paddingLarge, @@ -388,7 +388,7 @@ fun KSAlertDialog( KSDialogVisual( modifier = Modifier .width(dimensions.dialogWidth) - .background(color = colors.kds_white, shape = shapes.small) + .background(color = colors.backgroundSurfacePrimary, shape = shapes.small) .padding( start = dimensions.paddingLarge, top = dimensions.paddingLarge, diff --git a/app/src/main/java/com/kickstarter/ui/compose/designsystem/KSDimensions.kt b/app/src/main/java/com/kickstarter/ui/compose/designsystem/KSDimensions.kt index c8009ff55b..5be061bb90 100644 --- a/app/src/main/java/com/kickstarter/ui/compose/designsystem/KSDimensions.kt +++ b/app/src/main/java/com/kickstarter/ui/compose/designsystem/KSDimensions.kt @@ -95,7 +95,7 @@ val KSStandardDimensions = KSDimensions( dropDownMenuImageSize = 12.dp, imageSizeMedium = 24.dp, imageSizeLarge = 32.dp, - dialogWidth = 280.dp, + dialogWidth = 328.dp, dialogButtonSpacing = 2.dp, elevationMedium = 8.dp, elevationLarge = 16.dp, diff --git a/app/src/main/java/com/kickstarter/viewmodels/projectpage/AddOnsViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/projectpage/AddOnsViewModel.kt new file mode 100644 index 0000000000..e0147b8ef7 --- /dev/null +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/AddOnsViewModel.kt @@ -0,0 +1,245 @@ +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.rx.transformers.Transformers +import com.kickstarter.libs.utils.RewardUtils +import com.kickstarter.libs.utils.extensions.addToDisposable +import com.kickstarter.libs.utils.extensions.isNotNull +import com.kickstarter.mock.factories.ShippingRuleFactory +import com.kickstarter.models.Location +import com.kickstarter.models.Reward +import com.kickstarter.models.ShippingRule +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 kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +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() +) + +class AddOnsViewModel(val environment: Environment) : ViewModel() { + private val disposables = CompositeDisposable() + private val currentConfig = requireNotNull(environment.currentConfigV2()) + private val apolloClient = requireNotNull(environment.apolloClientV2()) + + private val currentUserReward = PublishSubject.create() + private val shippingRulesObservable = BehaviorSubject.create>() + private val defaultShippingRuleObservable = PublishSubject.create() + + private val mutableAddOnsUIState = MutableStateFlow(AddOnsUIState()) + private var addOns: List = listOf() + 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 + + val addOnsUIState: StateFlow + get() = mutableAddOnsUIState + .asStateFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = AddOnsUIState() + ) + + private val mutableFlowUIRequest = MutableSharedFlow() + val flowUIRequest: SharedFlow + get() = mutableFlowUIRequest + .asSharedFlow() + + init { + currentUserReward + .filter { + !RewardUtils.isDigital(it) && RewardUtils.isShippable(it) && !RewardUtils.isLocalPickup( + it + ) + } + .compose>>( + Transformers.combineLatestPair( + shippingRulesObservable + ) + ) + .switchMap { getDefaultShippingRule(it.second) } + .subscribe { + defaultShippingRuleObservable.onNext(it) + defaultShippingRule = it + currentShippingRule = it + getAddOns(noShippingRule = false) + viewModelScope.launch { + emitCurrentState() + } + }.addToDisposable(disposables) + + val shippingRule = getSelectedShippingRule(defaultShippingRuleObservable, currentUserReward) + + shippingRule + .distinctUntilChanged { rule1, rule2 -> + rule1.location()?.id() == rule2.location()?.id() && rule1.cost() == rule2.cost() + } + .subscribe { + currentShippingRule = it + } + .addToDisposable(disposables) + } + + fun provideProjectData(projectData: ProjectData) { + this.projectData = projectData + + 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 + ).subscribe { shippingRulesEnvelope -> + if (shippingRulesEnvelope.isNotNull()) shippingRulesObservable.onNext( + shippingRulesEnvelope.shippingRules() + ) + shippingRules = shippingRulesEnvelope.shippingRules() + }.addToDisposable(disposables) + } + } + } + } + + private fun getAddOns(noShippingRule: Boolean) { + viewModelScope.launch { + apolloClient + .getProjectAddOns( + slug = projectData.project().slug() ?: "", + locationId = currentShippingRule.location() ?: defaultShippingRule.location() ?: Location.builder().build() + ).asFlow() + .map { addOns -> + if (!addOns.isNullOrEmpty()) { + if (noShippingRule) { + this@AddOnsViewModel.addOns = addOns.filter { !RewardUtils.isShippable(it) } + } else { + this@AddOnsViewModel.addOns = addOns + } + } + emitCurrentState() + }.catch { + // Show some error + }.collect() + } + } + + private fun getDefaultShippingRule(shippingRules: List): Observable { + return this.currentConfig.observable() + .map { it.countryCode() } + .map { countryCode -> + shippingRules.firstOrNull { it.location()?.country() == countryCode } + ?: shippingRules.first() + } + } + + private fun getSelectedShippingRule( + defaultShippingRule: Observable, + reward: Observable + ): Observable { + return Observable.combineLatest( + defaultShippingRule.startWith(ShippingRuleFactory.emptyShippingRule()), + reward + ) { defaultShipping, rw -> + return@combineLatest chooseShippingRule(defaultShipping, rw) + } + } + + private fun chooseShippingRule(defaultShipping: ShippingRule, rw: Reward): ShippingRule = + when { + RewardUtils.isDigital(rw) || !RewardUtils.isShippable(rw) || RewardUtils.isLocalPickup( + rw + ) -> ShippingRuleFactory.emptyShippingRule() + // sameReward -> backingShippingRule // TODO: When changing reward for manage pledge flow + else -> defaultShipping + } + + // 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) + + viewModelScope.launch { + emitCurrentState() + } + + if (shippingSelectorIsGone) getAddOns(noShippingRule = true) + + this.currentUserReward.onNext(reward) + } + + fun onShippingLocationChanged(shippingRule: ShippingRule) { + currentShippingRule = shippingRule + // A new location has been selected, so clear out any previous addons selection + this.currentAddOnsSelections = mutableMapOf() + + viewModelScope.launch { + emitCurrentState() + } + + getAddOns(noShippingRule = false) + } + + fun onAddOnsAddedOrRemoved(currentAddOnsSelections: MutableMap) { + this.currentAddOnsSelections = currentAddOnsSelections + viewModelScope.launch { + emitCurrentState() + } + } + + fun onAddOnsContinueClicked() { + viewModelScope.launch { + // Go to confirm page + mutableFlowUIRequest.emit(FlowUIState(currentPage = 2, expanded = true)) + } + } + + private suspend fun emitCurrentState() { + mutableAddOnsUIState.emit( + AddOnsUIState( + currentShippingRule = currentShippingRule, + shippingSelectorIsGone = shippingSelectorIsGone, + currentAddOnsSelection = currentAddOnsSelections, + addOns = addOns, + shippingRules = shippingRules + ) + ) + } + + class Factory(private val environment: Environment) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return AddOnsViewModel(environment) 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 new file mode 100644 index 0000000000..caec7db671 --- /dev/null +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/CheckoutFlowViewModel.kt @@ -0,0 +1,99 @@ +package com.kickstarter.viewmodels.projectpage + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.kickstarter.libs.Environment +import com.kickstarter.models.Reward +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +data class FlowUIState( + val currentPage: Int = 0, + val expanded: Boolean = false +) + +class CheckoutFlowViewModel(val environment: Environment) : ViewModel() { + + private lateinit var newUserReward: Reward + + private val mutableFlowUIState = MutableStateFlow(FlowUIState()) + val flowUIState: StateFlow + get() = mutableFlowUIState + .asStateFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = FlowUIState() + ) + + fun changePage(requestedFlowState: FlowUIState) { + viewModelScope.launch { + mutableFlowUIState.emit(requestedFlowState) + } + } + + fun userRewardSelection(reward: Reward) { + newUserReward = reward + } + + fun onBackPressed(currentPage: Int) { + viewModelScope.launch { + when (currentPage) { + // From Checkout Screen + 3 -> { + // To Confirm Details + mutableFlowUIState.emit(FlowUIState(currentPage = 2, expanded = true)) + } + + // From Confirm Details Screen + 2 -> { + if (newUserReward.hasAddons()) { + // To Add-ons + mutableFlowUIState.emit(FlowUIState(currentPage = 1, expanded = true)) + } else { + // To Reward Carousel + mutableFlowUIState.emit(FlowUIState(currentPage = 0, expanded = true)) + } + } + + // From Add-ons Screen + 1 -> { + // To Rewards Carousel + mutableFlowUIState.emit(FlowUIState(currentPage = 0, expanded = true)) + } + + // From Rewards Carousel Screen + 0 -> { + // Leave flow + mutableFlowUIState.emit(FlowUIState(currentPage = 0, expanded = false)) + } + } + } + } + + fun onBackThisProjectClicked() { + viewModelScope.launch { + // Open Flow + mutableFlowUIState.emit(FlowUIState(currentPage = 0, expanded = true)) + } + } + + fun onConfirmDetailsContinueClicked() { + viewModelScope.launch { + // Show pledge page + mutableFlowUIState.emit(FlowUIState(currentPage = 3, expanded = true)) + } + } + + class Factory(private val environment: Environment) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return CheckoutFlowViewModel(environment) as 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 new file mode 100644 index 0000000000..7808898090 --- /dev/null +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/ConfirmDetailsViewModel.kt @@ -0,0 +1,353 @@ +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.libs.utils.extensions.isNotNull +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 io.reactivex.Observable +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.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.rx2.asFlow + +data class ConfirmDetailsUIState( + val rewardsAndAddOns: List = listOf(), + val initialBonusSupportAmount: Double = 0.0, + val totalBonusSupportAmount: Double = 0.0, + val shippingAmount: Double = 0.0, + val totalAmount: Double = 0.0, + val currentShippingRule: ShippingRule? = null, + val minStepAmount: Double = 0.0, + val maxPledgeAmount: Double = 0.0 +) + +class ConfirmDetailsViewModel(val environment: Environment) : ViewModel() { + + private val apolloClient = requireNotNull(environment.apolloClientV2()) + private val currentConfig = requireNotNull(environment.currentConfigV2()) + + private lateinit var projectData: ProjectData + private lateinit var userSelectedReward: Reward + private var rewardAndAddOns: List = listOf() + private lateinit var pledgeReason: PledgeReason + private lateinit var defaultShippingRule: 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 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)) + val checkoutPayment: StateFlow + get() = mutableCheckoutPayment + .asStateFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = CheckoutPayment(id = 0L, paymentUrl = 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() + } + projectData.project().rewards()?.let { rewards -> + val reward = rewards.firstOrNull { theOne -> + !theOne.isAddOn() && theOne.isAvailable() && RewardUtils.isShippable(theOne) + } + reward?.let { rw -> + apolloClient.getShippingRules( + reward = rw + ) + .asFlow() + .map { shippingRulesEnvelope -> + if (shippingRulesEnvelope.isNotNull()) { + getDefaultShippingRule(shippingRulesEnvelope.shippingRules()) + .asFlow() + .map { + defaultShippingRule = it + }.catch { + } + .collect() + } + } + .catch { + } + .collect() + } + } + } + } + + 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 + } + + if (::defaultShippingRule.isInitialized) { + shippingAmount = getShippingAmount( + rule = defaultShippingRule, + reason = pledgeReason, + bShippingAmount = null, + rewards = rewardAndAddOns + ) + } + + 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 + + if (::defaultShippingRule.isInitialized) { + shippingAmount = getShippingAmount( + rule = defaultShippingRule, + reason = pledgeReason, + bShippingAmount = null, + rewards = rewardAndAddOns + ) + } + + totalAmount = calculateTotal() + + viewModelScope.launch { + emitCurrentState() + } + } + + private fun calculateTotal(): Double { + var total = 0.0 + total += getRewardsTotalAmount(rewardAndAddOns) + total += initialBonusSupport + addedBonusSupport + total += if (RewardUtils.isNoReward(userSelectedReward)) 0.0 else shippingAmount + return total + } + + /** + * Calculate the shipping amount in case of shippable reward and reward + AddOns + */ + private fun getShippingAmount( + rule: ShippingRule, + reason: PledgeReason, + 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() + + PledgeReason.FIX_PLEDGE, + PledgeReason.UPDATE_PAYMENT, + PledgeReason.UPDATE_PLEDGE -> 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 getDefaultShippingRule(shippingRules: List): Observable { + return this.currentConfig.observable() + .map { it.countryCode() } + .map { countryCode -> + shippingRules.firstOrNull { it.location()?.country() == countryCode } + ?: shippingRules.first() + } + } + + 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 - minStepAmount >= initialBonusSupport) { + addedBonusSupport -= minStepAmount + totalAmount = calculateTotal() + viewModelScope.launch { + emitCurrentState() + } + } + } + + fun onContinueClicked(defaultAction: () -> Unit) { + if (projectData.project().postCampaignPledgingEnabled() == true && projectData.project() + .isInPostCampaignPledgingPhase() == true + ) { + viewModelScope.launch { + apolloClient.createCheckout( + CreateCheckoutData( + project = projectData.project(), + amount = totalAmount.toString(), + locationId = if (::defaultShippingRule.isInitialized) defaultShippingRule.location() + ?.id()?.toString() else null, + rewardsIds = fullIdListForQuantities(rewardAndAddOns), + refTag = projectData.refTagFromIntent() + ) + ) + .asFlow() + .map { checkoutPayment -> + mutableCheckoutPayment.emit(checkoutPayment) + } + .catch { + // Display an error + } + .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 onShippingRuleSelected(shippingRule: ShippingRule) { + defaultShippingRule = shippingRule + shippingAmount = getShippingAmount( + rule = defaultShippingRule, + reason = pledgeReason, + bShippingAmount = null, + rewards = rewardAndAddOns + ) + + viewModelScope.launch { + emitCurrentState() + } + } + + private suspend fun emitCurrentState() { + mutableConfirmDetailsUIState.emit( + ConfirmDetailsUIState( + rewardsAndAddOns = rewardAndAddOns, + initialBonusSupportAmount = initialBonusSupport, + totalBonusSupportAmount = initialBonusSupport + addedBonusSupport, + shippingAmount = shippingAmount, + totalAmount = totalAmount, + currentShippingRule = if (::defaultShippingRule.isInitialized) defaultShippingRule else null, + minStepAmount = minStepAmount, + maxPledgeAmount = maxPledgeAmount + ) + ) + } + + 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/ProjectPageViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/projectpage/ProjectPageViewModel.kt index 02b861eefd..9f09eb70a4 100644 --- a/app/src/main/java/com/kickstarter/viewmodels/projectpage/ProjectPageViewModel.kt +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/ProjectPageViewModel.kt @@ -12,6 +12,7 @@ import com.kickstarter.libs.Either import com.kickstarter.libs.Environment import com.kickstarter.libs.ProjectPagerTabs import com.kickstarter.libs.RefTag +import com.kickstarter.libs.featureflag.FlagKey import com.kickstarter.libs.rx.transformers.Transformers.combineLatestPair import com.kickstarter.libs.rx.transformers.Transformers.errorsV2 import com.kickstarter.libs.rx.transformers.Transformers.ignoreValuesV2 @@ -42,6 +43,7 @@ import com.kickstarter.libs.utils.extensions.isTrue import com.kickstarter.libs.utils.extensions.isUIEmptyValues import com.kickstarter.libs.utils.extensions.metadataForProject import com.kickstarter.libs.utils.extensions.negate +import com.kickstarter.libs.utils.extensions.showLatePledgeFlow import com.kickstarter.libs.utils.extensions.updateProjectWith import com.kickstarter.libs.utils.extensions.userIsCreator import com.kickstarter.models.Backing @@ -264,6 +266,8 @@ interface ProjectPageViewModel { fun onOpenVideoInFullScreen(): Observable> fun updateVideoCloseSeekPosition(): Observable + + fun showLatePledgeFlow(): Observable } class ProjectPageViewModel(val environment: Environment) : @@ -351,6 +355,7 @@ interface ProjectPageViewModel { private val updateTabs = PublishSubject.create>() private val onOpenVideoInFullScreen = PublishSubject.create>() private val updateVideoCloseSeekPosition = BehaviorSubject.create() + private val showLatePledgeFlow = BehaviorSubject.create() val inputs: Inputs = this val outputs: Outputs = this @@ -359,6 +364,7 @@ interface ProjectPageViewModel { val onThirdPartyEventSent = BehaviorSubject.create() val disposables = CompositeDisposable() + init { val progressBarIsGone = PublishSubject.create() @@ -413,9 +419,16 @@ interface ProjectPageViewModel { .compose(errorsV2()) mappedProjectValues - .filter { it.displayPrelaunch().isTrue() } - .map { it.webProjectUrl() } - .subscribe { this.prelaunchUrl.onNext(it) } + .subscribe { + if (it.showLatePledgeFlow()) { + val isFFEnabled = featureFlagClient.getBoolean(FlagKey.ANDROID_POST_CAMPAIGN_PLEDGES) + this.showLatePledgeFlow.onNext(it.showLatePledgeFlow() && isFFEnabled) + } + + if (it.displayPrelaunch().isTrue()) { + this.prelaunchUrl.onNext(it.webProjectUrl()) + } + } .addToDisposable(disposables) val initialProject = mappedProjectValues @@ -1258,6 +1271,8 @@ interface ProjectPageViewModel { override fun backingViewGroupIsVisible(): Observable = this.backingViewGroupIsVisible + override fun showLatePledgeFlow(): Observable = this.showLatePledgeFlow + private fun backingDetailsSubtitle(project: Project): Either? { return project.backing()?.let { backing -> return if (backing.status() == Backing.STATUS_ERRORED) { diff --git a/app/src/main/java/com/kickstarter/viewmodels/projectpage/RewardsSelectionViewModel.kt b/app/src/main/java/com/kickstarter/viewmodels/projectpage/RewardsSelectionViewModel.kt new file mode 100644 index 0000000000..64bf58a0ee --- /dev/null +++ b/app/src/main/java/com/kickstarter/viewmodels/projectpage/RewardsSelectionViewModel.kt @@ -0,0 +1,195 @@ +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.utils.extensions.isBacked +import com.kickstarter.mock.factories.RewardFactory +import com.kickstarter.models.Backing +import com.kickstarter.models.Project +import com.kickstarter.models.Reward +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.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.Locale + +data class RewardSelectionUIState( + val rewardList: List = listOf(), + val initialRewardIndex: Int = 0, + val project: ProjectData = ProjectData.builder().build(), + val showAlertDialog: Boolean = false +) + +class RewardsSelectionViewModel : ViewModel() { + + private lateinit var currentProjectData: ProjectData + private var previousUserBacking: Backing? = null + private var previouslyBackedReward: Reward? = null + private lateinit var newUserReward: Reward + + private val mutableRewardSelectionUIState = MutableStateFlow(RewardSelectionUIState()) + val rewardSelectionUIState: StateFlow + get() = mutableRewardSelectionUIState + .asStateFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = RewardSelectionUIState(), + ) + + private val mutableFlowUIRequest = MutableSharedFlow() + val flowUIRequest: SharedFlow + get() = mutableFlowUIRequest + .asSharedFlow() + + fun provideProjectData(projectData: ProjectData) { + currentProjectData = projectData + previousUserBacking = projectData.backing() + previouslyBackedReward = getReward(previousUserBacking) + val indexOfBackedReward = indexOfBackedReward(project = projectData.project()) + viewModelScope.launch { + mutableRewardSelectionUIState.emit( + RewardSelectionUIState( + rewardList = projectData.project().rewards() ?: listOf(), + initialRewardIndex = indexOfBackedReward, + project = projectData + ) + ) + } + } + + fun onUserRewardSelection(reward: Reward) { + viewModelScope.launch { + val pledgeDataAndReason = pledgeDataAndPledgeReason(currentProjectData, reward) + newUserReward = pledgeDataAndReason.first.reward() + + when (pledgeDataAndReason.second) { + PledgeReason.UPDATE_REWARD -> { + if (previouslyBackedReward?.hasAddons() == true && !newUserReward.hasAddons()) + // Show warning to user + mutableRewardSelectionUIState.emit( + RewardSelectionUIState( + rewardList = currentProjectData.project().rewards() ?: listOf(), + project = currentProjectData, + showAlertDialog = true + ) + ) + + if (previouslyBackedReward?.hasAddons() == false && !newUserReward.hasAddons()) + // Go to confirm page + mutableFlowUIRequest.emit(FlowUIState(currentPage = 2, expanded = true)) + + if (previouslyBackedReward?.hasAddons() == true && newUserReward.hasAddons()) { + if (differentShippingTypes(previouslyBackedReward, newUserReward)) + // Show warning to user + mutableRewardSelectionUIState.emit( + RewardSelectionUIState( + rewardList = currentProjectData.project().rewards() ?: listOf(), + project = currentProjectData, + showAlertDialog = true + ) + ) + // Go to add-ons + else mutableFlowUIRequest.emit(FlowUIState(currentPage = 1, expanded = true)) + } + + if (previouslyBackedReward?.hasAddons() == false && newUserReward.hasAddons()) { + // Go to add-ons + mutableFlowUIRequest.emit(FlowUIState(currentPage = 1, expanded = true)) + } + } + + PledgeReason.PLEDGE -> { + if (newUserReward.hasAddons()) + // Show add-ons + mutableFlowUIRequest.emit(FlowUIState(currentPage = 1, expanded = true)) + else + // Show confirm page + mutableFlowUIRequest.emit(FlowUIState(currentPage = 2, expanded = true)) + } + + else -> { + } + } + } + } + + fun onRewardCarouselAlertClicked(wasPositive: Boolean) { + viewModelScope.launch { + mutableRewardSelectionUIState.emit( + RewardSelectionUIState( + rewardList = currentProjectData.project().rewards() ?: listOf(), + project = currentProjectData, + showAlertDialog = false + ) + ) + if (wasPositive) { + if (newUserReward.hasAddons()) { + // Go to add-ons + mutableFlowUIRequest.emit(FlowUIState(currentPage = 1, expanded = true)) + } else { + // Show confirm page + mutableFlowUIRequest.emit(FlowUIState(currentPage = 2, expanded = true)) + } + } + } + } + + 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 differentShippingTypes(newRW: Reward?, backedRW: Reward): Boolean { + return if (newRW == null) false + else if (newRW.id() == backedRW.id()) false + else { + (newRW.shippingType()?.lowercase(Locale.getDefault()) ?: "") != (backedRW.shippingType()?.lowercase(Locale.getDefault()) ?: "") + } + } + + private fun getReward(backingObj: Backing?): Reward? { + backingObj?.let { backing -> + return backing.reward()?.let { reward -> + if (backing.addOns().isNullOrEmpty()) reward + else reward.toBuilder().hasAddons(true).build() + } ?: RewardFactory.noReward() + } ?: return null + } + + private fun indexOfBackedReward(project: Project): Int { + project.rewards()?.run { + for ((index, reward) in withIndex()) { + if (project.backing()?.isBacked(reward) == true) { + return index + } + } + } + return 0 + } + + class Factory : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return RewardsSelectionViewModel() as T + } + } +} diff --git a/app/src/main/res/layout/activity_project_page.xml b/app/src/main/res/layout/activity_project_page.xml index ccdfbeb82f..9a5fa5c107 100644 --- a/app/src/main/res/layout/activity_project_page.xml +++ b/app/src/main/res/layout/activity_project_page.xml @@ -185,6 +185,12 @@ android:id="@+id/pledge_container_layout" layout="@layout/pledge_container" /> + + () private val onOpenVideoInFullScreen = TestSubscriber>() private val updateVideoCloseSeekPosition = TestSubscriber() + private val postCampaignPledgingEnabled = TestSubscriber() private val disposables = CompositeDisposable() @@ -150,6 +151,103 @@ class ProjectPageViewModelTest : KSRobolectricTestCase() { this.vm.outputs.hideVideoPlayer().subscribe { this.hideVideoPlayer.onNext(it) }.addToDisposable(disposables) this.vm.outputs.onOpenVideoInFullScreen().subscribe { this.onOpenVideoInFullScreen.onNext(it) }.addToDisposable(disposables) this.vm.outputs.updateVideoCloseSeekPosition().subscribe { this.updateVideoCloseSeekPosition.onNext(it) }.addToDisposable(disposables) + this.vm.outputs.showLatePledgeFlow().subscribe { this.postCampaignPledgingEnabled.onNext(it) }.addToDisposable(disposables) + } + + @Test + fun testShowLatePledgeFlow_ProjectDisabled_WhenFFOff() { + val project = ProjectFactory.project().toBuilder() + .isInPostCampaignPledgingPhase(false) + .postCampaignPledgingEnabled(false) + .build() + + val mockFeatureFlagClient = object : MockFeatureFlagClient() { + override fun getBoolean(FlagKey: FlagKey): Boolean { + return false + } + } + + val environment = environment() + .toBuilder() + .featureFlagClient(mockFeatureFlagClient) + .build() + + setUpEnvironment(environment) + + this.vm.configureWith(Intent().putExtra(IntentKey.PROJECT, project)) + this.postCampaignPledgingEnabled.assertNoValues() + } + + @Test + fun testShowLatePledgeFlow_ProjectEnabled_WhenFFOff() { + val project = ProjectFactory.project().toBuilder() + .isInPostCampaignPledgingPhase(true) + .postCampaignPledgingEnabled(true) + .build() + + val mockFeatureFlagClient = object : MockFeatureFlagClient() { + override fun getBoolean(FlagKey: FlagKey): Boolean { + return false + } + } + + val environment = environment() + .toBuilder() + .featureFlagClient(mockFeatureFlagClient) + .build() + + setUpEnvironment(environment) + + this.vm.configureWith(Intent().putExtra(IntentKey.PROJECT, project)) + this.postCampaignPledgingEnabled.assertValue(false) + } + + @Test + fun testShowLatePledgeFlow_ProjectDisabled_WhenFFOn() { + val project = ProjectFactory.project().toBuilder() + .isInPostCampaignPledgingPhase(false) + .postCampaignPledgingEnabled(true) + .build() + + val mockFeatureFlagClient = object : MockFeatureFlagClient() { + override fun getBoolean(FlagKey: FlagKey): Boolean { + return true + } + } + + val environment = environment() + .toBuilder() + .featureFlagClient(mockFeatureFlagClient) + .build() + + setUpEnvironment(environment) + + this.vm.configureWith(Intent().putExtra(IntentKey.PROJECT, project)) + this.postCampaignPledgingEnabled.assertNoValues() + } + + @Test + fun testShowLatePledgeFlow_ProjectEnabled_WhenFFOn() { + val project = ProjectFactory.project().toBuilder() + .isInPostCampaignPledgingPhase(true) + .postCampaignPledgingEnabled(true) + .build() + + val mockFeatureFlagClient = object : MockFeatureFlagClient() { + override fun getBoolean(FlagKey: FlagKey): Boolean { + return true + } + } + + val environment = environment() + .toBuilder() + .featureFlagClient(mockFeatureFlagClient) + .build() + + setUpEnvironment(environment) + + this.vm.configureWith(Intent().putExtra(IntentKey.PROJECT, project)) + this.postCampaignPledgingEnabled.assertValue(true) } @Test