From 0bef37bfc1467366f9c8075607eb6b86122aa2a3 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 6 Dec 2022 15:55:10 -0400 Subject: [PATCH] Add minimum amount error for boosts. --- .../DonationsConfigurationExtensions.kt | 10 ++++++++ .../subscription/OneTimeDonationRepository.kt | 7 ++++++ .../settings/app/subscription/boost/Boost.kt | 20 +++++++++++++++- .../donate/DonateToSignalFragment.kt | 3 +++ .../donate/DonateToSignalState.kt | 10 ++++++-- .../donate/DonateToSignalViewModel.kt | 9 +++++++ app/src/main/res/layout/boost_preference.xml | 18 ++++++++++++-- app/src/main/res/values/strings.xml | 3 +++ .../DonationsConfigurationExtensionsKtTest.kt | 24 +++++++++++++++++++ 9 files changed, 99 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensions.kt index ecc1c4f6cd0..afbb52f8c3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensions.kt @@ -85,6 +85,16 @@ fun DonationsConfiguration.getSubscriptionLevels(): Map return levels.filterKeys { SUBSCRIPTION_LEVELS.contains(it) }.toSortedMap() } +/** + * Get a map describing the minimum donation amounts per currency. + * This returns only the currencies available to the user. + */ +fun DonationsConfiguration.getMinimumDonationAmounts(paymentMethodAvailability: PaymentMethodAvailability = DefaultPaymentMethodAvailability): Map { + return getFilteredCurrencies(paymentMethodAvailability) + .mapKeys { Currency.getInstance(it.key.uppercase()) } + .mapValues { FiatMoney(it.value.minimum, it.key) } +} + private fun DonationsConfiguration.getFilteredCurrencies(paymentMethodAvailability: PaymentMethodAvailability): Map { val userPaymentMethods = paymentMethodAvailability.toSet() val availableCurrencyCodes = PlatformCurrencyUtil.getAvailableCurrencyCodes() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt index d5e4a87e0f5..e0490195439 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/OneTimeDonationRepository.kt @@ -64,6 +64,13 @@ class OneTimeDonationRepository(private val donationsService: DonationsService) .map { it.getBoostBadges().first() } } + fun getMinimumDonationAmounts(): Single> { + return Single.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) } + .flatMap { it.flattenResult() } + .subscribeOn(Schedulers.io()) + .map { it.getMinimumDonationAmounts() } + } + fun waitForOneTimeRedemption( price: FiatMoney, paymentIntentId: String, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt index 9511d7b98e1..fd364a634df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt @@ -10,6 +10,7 @@ import android.text.Spanned import android.text.TextWatcher import android.text.method.DigitsKeyListener import android.view.View +import android.widget.TextView import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.AppCompatEditText import androidx.core.animation.doOnEnd @@ -26,6 +27,7 @@ import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder +import org.thoughtcrime.securesms.util.visible import java.lang.Integer.min import java.text.DecimalFormatSymbols import java.text.NumberFormat @@ -102,7 +104,9 @@ data class Boost( val currency: Currency, override val isEnabled: Boolean, val onBoostClick: (View, Boost) -> Unit, + val minimumAmount: FiatMoney, val isCustomAmountFocused: Boolean, + val isCustomAmountTooSmall: Boolean, val onCustomAmountChanged: (String) -> Unit, val onCustomAmountFocusChanged: (Boolean) -> Unit, ) : PreferenceModel(isEnabled = isEnabled) { @@ -113,7 +117,10 @@ data class Boost( newItem.boosts == boosts && newItem.selectedBoost == selectedBoost && newItem.currency == currency && - newItem.isCustomAmountFocused == isCustomAmountFocused + newItem.isCustomAmountFocused == isCustomAmountFocused && + newItem.isCustomAmountTooSmall == isCustomAmountTooSmall && + newItem.minimumAmount.amount == minimumAmount.amount && + newItem.minimumAmount.currency == minimumAmount.currency } } @@ -126,6 +133,7 @@ data class Boost( private val boost5: MaterialButton = itemView.findViewById(R.id.boost_5) private val boost6: MaterialButton = itemView.findViewById(R.id.boost_6) private val custom: AppCompatEditText = itemView.findViewById(R.id.boost_custom) + private val error: TextView = itemView.findViewById(R.id.boost_custom_too_small) private val boostButtons: List get() { @@ -145,6 +153,16 @@ data class Boost( override fun bind(model: SelectionModel) { itemView.isEnabled = model.isEnabled + error.text = context.getString( + R.string.Boost__the_minimum_amount_you_can_donate_is_s, + FiatMoneyUtil.format( + context.resources, model.minimumAmount, + FiatMoneyUtil.formatOptions().trimZerosAfterDecimal() + ) + ) + + error.visible = model.isCustomAmountTooSmall + model.boosts.zip(boostButtons).forEach { (boost, button) -> val isSelected = boost == model.selectedBoost && !model.isCustomAmountFocused button.isSelected = isSelected diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt index d6d26ac6d26..4fc6438213c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -242,6 +242,7 @@ class DonateToSignalFragment : when (state.donateToSignalType) { DonateToSignalType.ONE_TIME -> displayOneTimeSelection(state.areFieldsEnabled, state.oneTimeDonationState) DonateToSignalType.MONTHLY -> displayMonthlySelection(state.areFieldsEnabled, state.monthlyDonationState) + DonateToSignalType.GIFT -> error("This fragment does not support gifts.") } space(20.dp) @@ -310,6 +311,8 @@ class DonateToSignalFragment : selectedBoost = state.selectedBoost, currency = state.customAmount.currency, isCustomAmountFocused = state.isCustomAmountFocused, + isCustomAmountTooSmall = state.shouldDisplayCustomAmountTooSmallError, + minimumAmount = state.minimumDonationAmountOfSelectedCurrency, isEnabled = areFieldsEnabled, onBoostClick = { view, boost -> startAnimationAboveSelectedBoost(view) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt index dcb29929a24..ca57e4dac00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalState.kt @@ -81,9 +81,15 @@ data class DonateToSignalState( val customAmount: FiatMoney = FiatMoney(BigDecimal.ZERO, selectedCurrency), val isCustomAmountFocused: Boolean = false, val donationStage: DonationStage = DonationStage.INIT, - val selectableCurrencyCodes: List = emptyList() + val selectableCurrencyCodes: List = emptyList(), + private val minimumDonationAmounts: Map = emptyMap() ) { - val isSelectionValid: Boolean = if (isCustomAmountFocused) customAmount.amount > BigDecimal.ZERO else selectedBoost != null + val minimumDonationAmountOfSelectedCurrency: FiatMoney = minimumDonationAmounts[selectedCurrency] ?: FiatMoney(BigDecimal.ZERO, selectedCurrency) + private val isCustomAmountTooSmall: Boolean = if (isCustomAmountFocused) customAmount.amount < minimumDonationAmountOfSelectedCurrency.amount else false + private val isCustomAmountZero: Boolean = customAmount.amount == BigDecimal.ZERO + + val isSelectionValid: Boolean = if (isCustomAmountFocused) !isCustomAmountTooSmall else selectedBoost != null + val shouldDisplayCustomAmountTooSmallError: Boolean = isCustomAmountTooSmall && !isCustomAmountZero } data class MonthlyDonationState( diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt index f06aba198a9..12bb2388caf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt @@ -214,6 +214,15 @@ class DonateToSignalViewModel( } ) + oneTimeDonationDisposables += oneTimeDonationRepository.getMinimumDonationAmounts().subscribeBy( + onSuccess = { amountMap -> + store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(minimumDonationAmounts = amountMap)) } + }, + onError = { + Log.w(TAG, "Could not load minimum custom donation amounts.", it) + } + ) + val boosts: Observable>> = oneTimeDonationRepository.getBoosts().toObservable() val oneTimeCurrency: Observable = SignalStore.donationsValues().observableOneTimeCurrency diff --git a/app/src/main/res/layout/boost_preference.xml b/app/src/main/res/layout/boost_preference.xml index 23a60e761d9..e1876d1a3c2 100644 --- a/app/src/main/res/layout/boost_preference.xml +++ b/app/src/main/res/layout/boost_preference.xml @@ -1,13 +1,13 @@ + android:layout_marginEnd="@dimen/dsl_settings_gutter" + tools:viewBindingIgnore="true"> + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8837dbce195..e7eb129e0bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4341,6 +4341,9 @@ Gift a badge Enter Custom Amount + One-time contribution + + The minimum amount you can donate is %s %1$s/month Renews %1$s diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensionsKtTest.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensionsKtTest.kt index 42d2484bf62..08bbe37f389 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensionsKtTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/DonationsConfigurationExtensionsKtTest.kt @@ -131,6 +131,30 @@ class DonationsConfigurationExtensionsKtTest { assertTrue(boostAmounts.map { it.key.currencyCode }.containsAll(setOf("USD", "BIF"))) } + @Test + fun `Given all methods are available, when I getMinimumDonationAmounts, then I expect BIF`() { + val minimumDonationAmounts = testSubject.getMinimumDonationAmounts(AllPaymentMethodsAvailability) + + assertEquals(1, minimumDonationAmounts.size) + assertNotNull(minimumDonationAmounts[Currency.getInstance("BIF")]) + } + + @Test + fun `Given only PayPal available, when I getMinimumDonationAmounts, then I expect BIF and JPY`() { + val minimumDonationAmounts = testSubject.getMinimumDonationAmounts(PayPalOnly) + + assertEquals(2, minimumDonationAmounts.size) + assertTrue(minimumDonationAmounts.map { it.key.currencyCode }.containsAll(setOf("JPY", "BIF"))) + } + + @Test + fun `Given only Card available, when I getMinimumDonationAmounts, then I expect BIF and USD`() { + val minimumDonationAmounts = testSubject.getMinimumDonationAmounts(CardOnly) + + assertEquals(2, minimumDonationAmounts.size) + assertTrue(minimumDonationAmounts.map { it.key.currencyCode }.containsAll(setOf("USD", "BIF"))) + } + @Test fun `Given GIFT_LEVEL, When I getBadge, then I expect the gift badge`() { mockkStatic(ApplicationDependencies::class) {