Revert "Improve text entry for boosts."

This reverts commit 84833c9ad3.
fork-5.53.8
Alex Hart 2021-12-03 17:58:03 -04:00
rodzic 8501fdffc6
commit e3638791d9
4 zmienionych plików z 373 dodań i 120 usunięć

Wyświetl plik

@ -3,17 +3,15 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.animation.Animator
import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.text.InputFilter
import android.text.SpannableStringBuilder
import android.text.Editable
import android.text.Spanned
import android.text.TextWatcher
import android.text.method.DigitsKeyListener
import android.view.Gravity
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.annotation.VisibleForTesting
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
@ -23,12 +21,14 @@ 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 org.thoughtcrime.securesms.util.text.AfterTextChanged
import org.thoughtcrime.securesms.util.visible
import java.lang.Integer.min
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,12 +122,8 @@ 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<MaterialButton>
get() {
return if (ViewUtil.isLtr(context)) {
@ -137,6 +133,8 @@ data class Boost(
}
}
private var filter: MoneyFilter? = null
init {
custom.filters = emptyArray()
}
@ -159,33 +157,20 @@ data class Boost(
}
}
currencyStart.text = model.currency.symbol
currencyEnd.text = model.currency.symbol
if (filter == null || filter?.currency != model.currency) {
custom.removeTextChangedListener(filter)
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()
filter = MoneyFilter(model.currency, custom) {
model.onCustomAmountChanged(it)
}
custom.keyListener = filter
custom.addTextChangedListener(filter)
custom.setText("")
}
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)
}
@ -196,51 +181,6 @@ 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<HeadingModel>(itemView) {
@ -252,6 +192,121 @@ 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))

Wyświetl plik

@ -23,6 +23,7 @@ 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
@ -193,8 +194,7 @@ class BoostViewModel(
store.update {
it.copy(
isCustomAmountFocused = false,
selectedBoost = boost,
customAmount = FiatMoney(BigDecimal.ZERO, it.currencySelection)
selectedBoost = boost
)
}
}
@ -218,9 +218,7 @@ 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<Boost>, val defaultBoost: Boost?, val boostBadge: Badge, val supportedCurrencies: Set<Currency>)

Wyświetl plik

@ -112,23 +112,6 @@
app:strokeWidth="1.5dp"
tools:text="$100" />
<TextView
android:id="@+id/boost_currency_start"
android:layout_width="wrap_content"
android:layout_height="48sp"
android:layout_marginEnd="12dp"
android:background="@drawable/rounded_rectangle_secondary"
android:gravity="center"
android:minWidth="48sp"
android:textAppearance="@style/Signal.Text.Body"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/boost_custom"
app:layout_constraintEnd_toStartOf="@id/boost_custom"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/boost_custom"
tools:text="$"
tools:visibility="visible" />
<androidx.appcompat.widget.AppCompatEditText
android:id="@+id/boost_custom"
android:layout_width="0dp"
@ -139,29 +122,12 @@
android:hint="@string/Boost__enter_custom_amount"
android:imeOptions="actionDone"
android:inputType="numberDecimal"
android:paddingStart="21dp"
android:paddingEnd="21dp"
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_text_primary"
android:textColorHint="@color/signal_text_primary"
app:layout_constraintEnd_toStartOf="@id/boost_currency_end"
app:layout_constraintStart_toEndOf="@id/boost_currency_start"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/boost_4" />
<TextView
android:id="@+id/boost_currency_end"
android:layout_width="wrap_content"
android:layout_height="48sp"
android:layout_marginStart="12dp"
android:background="@drawable/rounded_rectangle_secondary"
android:gravity="center"
android:minWidth="48sp"
android:textAppearance="@style/Signal.Text.Body"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/boost_custom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/boost_custom"
app:layout_constraintTop_toTopOf="@id/boost_custom"
tools:text="$" />
</androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -0,0 +1,234 @@
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)
}
}