Skip to content

Commit

Permalink
Add minimum amount error for boosts.
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-signal authored and cody-signal committed Dec 7, 2022
1 parent 1618141 commit 0bef37b
Show file tree
Hide file tree
Showing 9 changed files with 99 additions and 5 deletions.
Expand Up @@ -85,6 +85,16 @@ fun DonationsConfiguration.getSubscriptionLevels(): Map<Int, LevelConfiguration>
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<Currency, FiatMoney> {
return getFilteredCurrencies(paymentMethodAvailability)
.mapKeys { Currency.getInstance(it.key.uppercase()) }
.mapValues { FiatMoney(it.value.minimum, it.key) }
}

private fun DonationsConfiguration.getFilteredCurrencies(paymentMethodAvailability: PaymentMethodAvailability): Map<String, DonationsConfiguration.CurrencyConfiguration> {
val userPaymentMethods = paymentMethodAvailability.toSet()
val availableCurrencyCodes = PlatformCurrencyUtil.getAvailableCurrencyCodes()
Expand Down
Expand Up @@ -64,6 +64,13 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
.map { it.getBoostBadges().first() }
}

fun getMinimumDonationAmounts(): Single<Map<Currency, FiatMoney>> {
return Single.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) }
.flatMap { it.flattenResult() }
.subscribeOn(Schedulers.io())
.map { it.getMinimumDonationAmounts() }
}

fun waitForOneTimeRedemption(
price: FiatMoney,
paymentIntentId: String,
Expand Down
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<SelectionModel>(isEnabled = isEnabled) {
Expand All @@ -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
}
}

Expand All @@ -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<MaterialButton>
get() {
Expand All @@ -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
Expand Down
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Expand Up @@ -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<String> = emptyList()
val selectableCurrencyCodes: List<String> = emptyList(),
private val minimumDonationAmounts: Map<Currency, FiatMoney> = 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(
Expand Down
Expand Up @@ -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<Map<Currency, List<Boost>>> = oneTimeDonationRepository.getBoosts().toObservable()
val oneTimeCurrency: Observable<Currency> = SignalStore.donationsValues().observableOneTimeCurrency

Expand Down
18 changes: 16 additions & 2 deletions app/src/main/res/layout/boost_preference.xml
@@ -1,13 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="10dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter">
android:layout_marginEnd="@dimen/dsl_settings_gutter"
tools:viewBindingIgnore="true">

<com.google.android.material.button.MaterialButton
android:id="@+id/boost_1"
Expand Down Expand Up @@ -130,4 +130,18 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/boost_4" />

<TextView
android:id="@+id/boost_custom_too_small"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAlignment="center"
android:textAppearance="@style/Signal.Text.BodySmall"
android:textColor="@color/signal_colorError"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/boost_custom"
tools:text="@string/Boost__the_minimum_amount_you_can_donate_is_s" />

</androidx.constraintlayout.widget.ConstraintLayout>
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Expand Up @@ -4341,6 +4341,9 @@
<string name="ManageDonationsFragment__gift_a_badge">Gift a badge</string>

<string name="Boost__enter_custom_amount">Enter Custom Amount</string>
<string name="Boost__one_time_contribution">One-time contribution</string>
<!-- Error label when the amount is smaller than what we can accept -->
<string name="Boost__the_minimum_amount_you_can_donate_is_s">The minimum amount you can donate is %s</string>

<string name="MySupportPreference__s_per_month">%1$s/month</string>
<string name="MySupportPreference__renews_s">Renews %1$s</string>
Expand Down
Expand Up @@ -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) {
Expand Down

0 comments on commit 0bef37b

Please sign in to comment.