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 8e4d5be382..19e4b0e41e 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 @@ -3,15 +3,17 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.boost import android.animation.Animator import android.animation.AnimatorSet import android.animation.ObjectAnimator -import android.text.Editable +import android.text.InputFilter +import android.text.SpannableStringBuilder import android.text.Spanned import android.text.TextWatcher import android.text.method.DigitsKeyListener +import android.view.Gravity import android.view.View -import androidx.annotation.VisibleForTesting +import android.view.inputmethod.EditorInfo +import android.widget.TextView import androidx.appcompat.widget.AppCompatEditText import androidx.core.animation.doOnEnd -import androidx.core.text.isDigitsOnly import com.google.android.material.button.MaterialButton import org.signal.core.util.money.FiatMoney import org.thoughtcrime.securesms.R @@ -21,14 +23,12 @@ import org.thoughtcrime.securesms.components.settings.PreferenceModel import org.thoughtcrime.securesms.payments.FiatMoneyUtil import org.thoughtcrime.securesms.util.MappingAdapter import org.thoughtcrime.securesms.util.MappingViewHolder -import org.thoughtcrime.securesms.util.StringUtil import org.thoughtcrime.securesms.util.ViewUtil -import java.lang.Integer.min +import org.thoughtcrime.securesms.util.text.AfterTextChanged +import org.thoughtcrime.securesms.util.visible import java.text.DecimalFormatSymbols import java.text.NumberFormat import java.util.Currency -import java.util.Locale -import java.util.regex.Pattern /** * A Signal Boost is a one-time ephemeral show of support. Each boost level @@ -122,8 +122,12 @@ data class Boost( private val boost4: MaterialButton = itemView.findViewById(R.id.boost_4) private val boost5: MaterialButton = itemView.findViewById(R.id.boost_5) private val boost6: MaterialButton = itemView.findViewById(R.id.boost_6) + private val currencyStart: TextView = itemView.findViewById(R.id.boost_currency_start) + private val currencyEnd: TextView = itemView.findViewById(R.id.boost_currency_end) private val custom: AppCompatEditText = itemView.findViewById(R.id.boost_custom) + private var textChangedWatcher: TextWatcher? = null + private val boostButtons: List get() { return if (ViewUtil.isLtr(context)) { @@ -133,8 +137,6 @@ data class Boost( } } - private var filter: MoneyFilter? = null - init { custom.filters = emptyArray() } @@ -157,20 +159,33 @@ data class Boost( } } - if (filter == null || filter?.currency != model.currency) { - custom.removeTextChangedListener(filter) + currencyStart.text = model.currency.symbol + currencyEnd.text = model.currency.symbol - filter = MoneyFilter(model.currency, custom) { - model.onCustomAmountChanged(it) - } - - custom.keyListener = filter - custom.addTextChangedListener(filter) - - custom.setText("") + if (model.currency.defaultFractionDigits > 0) { + custom.inputType = EditorInfo.TYPE_CLASS_NUMBER or EditorInfo.TYPE_NUMBER_FLAG_DECIMAL + custom.filters = arrayOf(DecimalPlacesFilter(model.currency.defaultFractionDigits, custom.keyListener as DigitsKeyListener)) + } else { + custom.inputType = EditorInfo.TYPE_CLASS_NUMBER + custom.filters = arrayOf() } + custom.removeTextChangedListener(textChangedWatcher) + + textChangedWatcher = AfterTextChanged { + model.onCustomAmountChanged(it.toString()) + } + + custom.addTextChangedListener(textChangedWatcher) + custom.setText("") + custom.setOnFocusChangeListener { _, hasFocus -> + val isCurrencyAtFrontOfNumber = currencyIsAtFrontOfNumber(model.currency) + + currencyStart.visible = isCurrencyAtFrontOfNumber && hasFocus + currencyEnd.visible = !isCurrencyAtFrontOfNumber && hasFocus + + custom.gravity = if (hasFocus) (Gravity.START or Gravity.CENTER_VERTICAL) else Gravity.CENTER model.onCustomAmountFocusChanged(hasFocus) } @@ -181,6 +196,51 @@ data class Boost( custom.clearFocus() } } + + private fun currencyIsAtFrontOfNumber(currency: Currency): Boolean { + val formatter = NumberFormat.getCurrencyInstance().apply { + this.currency = currency + } + return formatter.format(1).startsWith(currency.symbol) + } + } + + /** + * Restricts output of the given Digits filter to the given number of decimal places. + */ + private class DecimalPlacesFilter(private val decimalPlaces: Int, private val digitsKeyListener: DigitsKeyListener) : InputFilter { + + private val decimalSeparator = DecimalFormatSymbols.getInstance().decimalSeparator + private val builder = SpannableStringBuilder() + + override fun filter(source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int): CharSequence? { + val keyListenerResult = digitsKeyListener.filter(source, start, end, dest, dstart, dend) + + builder.clear() + builder.clearSpans() + + val toInsert = keyListenerResult ?: source.substring(start, end) + + builder.append(dest) + + if (dstart == dend) { + builder.insert(dstart, toInsert) + } else { + builder.replace(dstart, dend, toInsert) + } + + val separatorIndex = builder.indexOf(decimalSeparator) + return if (separatorIndex > -1) { + val suffix = builder.split(decimalSeparator).last() + if (suffix.length > decimalPlaces) { + dest.subSequence(dstart, dend) + } else { + null + } + } else { + null + } + } } private class HeadingViewHolder(itemView: View) : MappingViewHolder(itemView) { @@ -192,121 +252,6 @@ data class Boost( } } - @VisibleForTesting - class MoneyFilter(val currency: Currency, private val text: AppCompatEditText? = null, private val onCustomAmountChanged: (String) -> Unit = {}) : DigitsKeyListener(false, true), TextWatcher { - - val separator = DecimalFormatSymbols.getInstance().decimalSeparator - val separatorCount = min(1, currency.defaultFractionDigits) - val symbol: String = currency.getSymbol(Locale.getDefault()) - - /** - * From Character.isDigit: - * - * * '\u0030' through '\u0039', ISO-LATIN-1 digits ('0' through '9') - * * '\u0660' through '\u0669', Arabic-Indic digits - * * '\u06F0' through '\u06F9', Extended Arabic-Indic digits - * * '\u0966' through '\u096F', Devanagari digits - * * '\uFF10' through '\uFF19', Fullwidth digits - */ - val digitsGroup: String = "[\\u0030-\\u0039]|[\\u0660-\\u0669]|[\\u06F0-\\u06F9]|[\\u0966-\\u096F]|[\\uFF10-\\uFF19]" - val zeros: String = "\\u0030|\\u0660|\\u06F0|\\u0966|\\uFF10" - - val pattern: Pattern = "($digitsGroup)*([$separator]){0,$separatorCount}($digitsGroup){0,${currency.defaultFractionDigits}}".toPattern() - val symbolPattern: Regex = """\s*${Regex.escape(symbol)}\s*""".toRegex() - val leadingZeroesPattern: Regex = """^($zeros)*""".toRegex() - - override fun filter( - source: CharSequence, - start: Int, - end: Int, - dest: Spanned, - dstart: Int, - dend: Int - ): CharSequence? { - - val result = dest.subSequence(0, dstart).toString() + source.toString() + dest.subSequence(dend, dest.length) - val resultWithoutCurrencyPrefix = StringUtil.stripBidiIndicator(result.removePrefix(symbol).removeSuffix(symbol).trim()) - - if (resultWithoutCurrencyPrefix.length == 1 && !resultWithoutCurrencyPrefix.isDigitsOnly() && resultWithoutCurrencyPrefix != separator.toString()) { - return dest.subSequence(dstart, dend) - } - - val matcher = pattern.matcher(resultWithoutCurrencyPrefix) - - if (!matcher.matches()) { - return dest.subSequence(dstart, dend) - } - - return null - } - - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit - - override fun afterTextChanged(s: Editable?) { - if (s.isNullOrEmpty()) return - - val hasSymbol = s.startsWith(symbol) || s.endsWith(symbol) - if (hasSymbol && symbolPattern.matchEntire(s.toString()) != null) { - s.clear() - } else if (!hasSymbol) { - val formatter = NumberFormat.getCurrencyInstance() - formatter.currency = currency - - if (s.contains(separator)) { - formatter.minimumFractionDigits = s.split(separator).last().length - } else { - formatter.minimumFractionDigits = 0 - } - - formatter.maximumFractionDigits = currency.defaultFractionDigits - - val value = s.toString().toDoubleOrNull() - - if (value != null) { - val formatted = formatter.format(value) - - text?.removeTextChangedListener(this) - - s.replace(0, s.length, formatted) - if (formatted.endsWith(symbol)) { - val result: MatchResult? = symbolPattern.find(formatted) - if (result != null && result.range.first < s.length) { - text?.setSelection(result.range.first) - } - } - - text?.addTextChangedListener(this) - } - } - - val withoutSymbol = s.removePrefix(symbol).removeSuffix(symbol).trim().toString() - val withoutLeadingZeroes: String = try { - NumberFormat.getInstance().apply { - isGroupingUsed = false - - if (s.contains(separator)) { - minimumFractionDigits = s.split(separator).last().length - } - }.format(withoutSymbol.toBigDecimal()) + (if (withoutSymbol.endsWith(separator)) separator else "") - } catch (e: NumberFormatException) { - withoutSymbol - } - - if (withoutSymbol != withoutLeadingZeroes) { - text?.removeTextChangedListener(this) - - val start = s.indexOf(withoutSymbol) - s.replace(start, start + withoutSymbol.length, withoutLeadingZeroes) - - text?.addTextChangedListener(this) - } - - onCustomAmountChanged(s.removePrefix(symbol).removeSuffix(symbol).trim().toString()) - } - } - companion object { fun register(adapter: MappingAdapter) { adapter.registerFactory(SelectionModel::class.java, MappingAdapter.LayoutFactory({ SelectionViewHolder(it) }, R.layout.boost_preference)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt index 66c5e5d6ea..09b79ae5b2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostViewModel.kt @@ -23,7 +23,6 @@ import org.thoughtcrime.securesms.util.InternetConnectionObserver import org.thoughtcrime.securesms.util.PlatformCurrencyUtil import org.thoughtcrime.securesms.util.StringUtil import org.thoughtcrime.securesms.util.livedata.Store -import java.lang.NumberFormatException import java.math.BigDecimal import java.text.DecimalFormat import java.text.DecimalFormatSymbols @@ -194,7 +193,8 @@ class BoostViewModel( store.update { it.copy( isCustomAmountFocused = false, - selectedBoost = boost + selectedBoost = boost, + customAmount = FiatMoney(BigDecimal.ZERO, it.currencySelection) ) } } @@ -218,7 +218,9 @@ class BoostViewModel( } fun setCustomAmountFocused(isFocused: Boolean) { - store.update { it.copy(isCustomAmountFocused = isFocused) } + store.update { + it.copy(isCustomAmountFocused = isFocused) + } } private data class BoostInfo(val boosts: List, val defaultBoost: Boost?, val boostBadge: Badge, val supportedCurrencies: Set) diff --git a/app/src/main/res/layout/boost_preference.xml b/app/src/main/res/layout/boost_preference.xml index 69963d6bd1..7c5d6a4dc3 100644 --- a/app/src/main/res/layout/boost_preference.xml +++ b/app/src/main/res/layout/boost_preference.xml @@ -112,6 +112,23 @@ app:strokeWidth="1.5dp" tools:text="$100" /> + + + \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt b/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt deleted file mode 100644 index 1c1cef3e63..0000000000 --- a/app/src/test/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/BoostTest__MoneyFilter.kt +++ /dev/null @@ -1,234 +0,0 @@ -package org.thoughtcrime.securesms.components.settings.app.subscription.boost - -import android.app.Application -import android.text.SpannableStringBuilder -import junit.framework.Assert.assertEquals -import junit.framework.Assert.assertNotNull -import junit.framework.Assert.assertNull -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import java.util.Currency -import java.util.Locale - -@Suppress("ClassName") -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE, application = Application::class) -class BoostTest__MoneyFilter { - - private val usd = Currency.getInstance("USD") - private val yen = Currency.getInstance("JPY") - private val inr = Currency.getInstance("INR") - - @Before - fun setUp() { - Locale.setDefault(Locale.US) - } - - @Test - fun `Given USD, when I enter 5, then I expect $ 5`() { - val testSubject = Boost.MoneyFilter(usd) - val editable = SpannableStringBuilder("5") - - testSubject.afterTextChanged(editable) - - assertEquals("$5", editable.toString()) - } - - @Test - fun `Given USD, when I enter 5dot00, then I expect successful filter`() { - val testSubject = Boost.MoneyFilter(usd) - val editable = SpannableStringBuilder("5.00") - val dest = SpannableStringBuilder() - - testSubject.afterTextChanged(editable) - val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) - - assertNull(filterResult) - } - - @Test - fun `Given USD, when I enter 5dot00, then I expect 5 from text change`() { - var result = "" - val testSubject = Boost.MoneyFilter(usd) { - result = it - } - - val editable = SpannableStringBuilder("5.00") - testSubject.afterTextChanged(editable) - - assertEquals("5.00", result) - } - - @Test - fun `Given USD, when I enter 00005dot00, then I expect 5 from text change`() { - val testSubject = Boost.MoneyFilter(usd) - val editable = SpannableStringBuilder("00005.00") - - testSubject.afterTextChanged(editable) - - assertEquals("$5.00", editable.toString()) - } - - @Test - fun `Given USD, when I enter 5dot000, then I expect successful filter`() { - val testSubject = Boost.MoneyFilter(yen) - val editable = SpannableStringBuilder("5.000") - val dest = SpannableStringBuilder() - - testSubject.afterTextChanged(editable) - val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) - - assertNull(filterResult) - } - - @Test - fun `Given USD, when I enter 5dot, then I expect successful filter`() { - val testSubject = Boost.MoneyFilter(usd) - val editable = SpannableStringBuilder("5.") - val dest = SpannableStringBuilder() - - testSubject.afterTextChanged(editable) - val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) - - assertNull(filterResult) - } - - @Test - fun `Given JPY, when I enter 5, then I expect yen 5`() { - val testSubject = Boost.MoneyFilter(yen) - val editable = SpannableStringBuilder("5") - - testSubject.afterTextChanged(editable) - - assertEquals("¥5", editable.toString()) - } - - @Test - fun `Given JPY, when I enter 5, then I expect 5 from text change`() { - var result = "" - val testSubject = Boost.MoneyFilter(yen) { - result = it - } - - val editable = SpannableStringBuilder("5") - - testSubject.afterTextChanged(editable) - - assertEquals("5", result) - } - - @Test - fun `Given JPY, when I enter 5, then I expect successful filter`() { - val testSubject = Boost.MoneyFilter(yen) - val editable = SpannableStringBuilder("5") - val dest = SpannableStringBuilder() - - testSubject.afterTextChanged(editable) - val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) - - assertNull(filterResult) - } - - @Test - fun `Given JPY, when I enter 5dot, then I expect unsuccessful filter`() { - val testSubject = Boost.MoneyFilter(yen) - val editable = SpannableStringBuilder("¥5.") - val dest = SpannableStringBuilder() - - testSubject.afterTextChanged(editable) - val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) - - assertNotNull(filterResult) - } - - @Test - fun `Given MR and INR, when I enter 5dot55, then I expect localized`() { - Locale.setDefault(Locale.forLanguageTag("mr")) - - val testSubject = Boost.MoneyFilter(inr) - val editable = SpannableStringBuilder("5.55") - - testSubject.afterTextChanged(editable) - - assertEquals("₹५.५५", editable.toString()) - } - - @Test - fun `Given MR and INR, when I enter dot, then I expect it to be retained in output`() { - Locale.setDefault(Locale.forLanguageTag("mr")) - - val testSubject = Boost.MoneyFilter(inr) - val editable = SpannableStringBuilder("₹५.") - - testSubject.afterTextChanged(editable) - - assertEquals("₹५.", editable.toString()) - } - - @Test - fun `Given RTL indicator, when I enter five, then I expect successful match`() { - val testSubject = Boost.MoneyFilter(yen) - val editable = SpannableStringBuilder("\u200F5") - val dest = SpannableStringBuilder() - - testSubject.afterTextChanged(editable) - val filterResult = testSubject.filter(editable, 0, editable.length, dest, 0, 0) - - assertNull(filterResult) - } - - @Test - fun `Given USD, when I enter 1dot05, then I expect 1dot05`() { - var result = "" - val testSubject = Boost.MoneyFilter(usd) { - result = it - } - - val editable = SpannableStringBuilder("$1.05") - testSubject.afterTextChanged(editable) - - assertEquals("1.05", result) - } - - @Test - fun `Given USD, when I enter 0dot05, then I expect 0dot05`() { - var result = "" - val testSubject = Boost.MoneyFilter(usd) { - result = it - } - - val editable = SpannableStringBuilder("$0.05") - testSubject.afterTextChanged(editable) - - assertEquals("0.05", result) - } - - @Test - fun `Given USD, when I enter dot1, then I expect 0dot1`() { - var result = "" - val testSubject = Boost.MoneyFilter(usd) { - result = it - } - - val editable = SpannableStringBuilder("$.1") - testSubject.afterTextChanged(editable) - - assertEquals("0.1", result) - } - - @Test - fun `Given USD, when I enter dot0, then I expect 0dot0`() { - var result = "" - val testSubject = Boost.MoneyFilter(usd) { - result = it - } - - val editable = SpannableStringBuilder(".0") - testSubject.afterTextChanged(editable) - - assertEquals("0.0", result) - } -}