Improve text entry for boosts.

fork-5.53.8
Alex Hart 2021-12-03 13:55:42 -04:00 zatwierdzone przez GitHub
rodzic 131a400921
commit 84833c9ad3
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
4 zmienionych plików z 120 dodań i 373 usunięć

Wyświetl plik

@ -3,15 +3,17 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.boost
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorSet import android.animation.AnimatorSet
import android.animation.ObjectAnimator import android.animation.ObjectAnimator
import android.text.Editable import android.text.InputFilter
import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.text.TextWatcher import android.text.TextWatcher
import android.text.method.DigitsKeyListener import android.text.method.DigitsKeyListener
import android.view.Gravity
import android.view.View import android.view.View
import androidx.annotation.VisibleForTesting import android.view.inputmethod.EditorInfo
import android.widget.TextView
import androidx.appcompat.widget.AppCompatEditText import androidx.appcompat.widget.AppCompatEditText
import androidx.core.animation.doOnEnd import androidx.core.animation.doOnEnd
import androidx.core.text.isDigitsOnly
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import org.signal.core.util.money.FiatMoney import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R 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.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.MappingAdapter import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.StringUtil
import org.thoughtcrime.securesms.util.ViewUtil 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.DecimalFormatSymbols
import java.text.NumberFormat import java.text.NumberFormat
import java.util.Currency 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 * 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 boost4: MaterialButton = itemView.findViewById(R.id.boost_4)
private val boost5: MaterialButton = itemView.findViewById(R.id.boost_5) private val boost5: MaterialButton = itemView.findViewById(R.id.boost_5)
private val boost6: MaterialButton = itemView.findViewById(R.id.boost_6) 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 val custom: AppCompatEditText = itemView.findViewById(R.id.boost_custom)
private var textChangedWatcher: TextWatcher? = null
private val boostButtons: List<MaterialButton> private val boostButtons: List<MaterialButton>
get() { get() {
return if (ViewUtil.isLtr(context)) { return if (ViewUtil.isLtr(context)) {
@ -133,8 +137,6 @@ data class Boost(
} }
} }
private var filter: MoneyFilter? = null
init { init {
custom.filters = emptyArray() custom.filters = emptyArray()
} }
@ -157,20 +159,33 @@ data class Boost(
} }
} }
if (filter == null || filter?.currency != model.currency) { currencyStart.text = model.currency.symbol
custom.removeTextChangedListener(filter) currencyEnd.text = model.currency.symbol
filter = MoneyFilter(model.currency, custom) { if (model.currency.defaultFractionDigits > 0) {
model.onCustomAmountChanged(it) 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.keyListener = filter custom.inputType = EditorInfo.TYPE_CLASS_NUMBER
custom.addTextChangedListener(filter) custom.filters = arrayOf()
custom.setText("")
} }
custom.removeTextChangedListener(textChangedWatcher)
textChangedWatcher = AfterTextChanged {
model.onCustomAmountChanged(it.toString())
}
custom.addTextChangedListener(textChangedWatcher)
custom.setText("")
custom.setOnFocusChangeListener { _, hasFocus -> 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) model.onCustomAmountFocusChanged(hasFocus)
} }
@ -181,6 +196,51 @@ data class Boost(
custom.clearFocus() 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) { private class HeadingViewHolder(itemView: View) : MappingViewHolder<HeadingModel>(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 { companion object {
fun register(adapter: MappingAdapter) { fun register(adapter: MappingAdapter) {
adapter.registerFactory(SelectionModel::class.java, MappingAdapter.LayoutFactory({ SelectionViewHolder(it) }, R.layout.boost_preference)) adapter.registerFactory(SelectionModel::class.java, MappingAdapter.LayoutFactory({ SelectionViewHolder(it) }, R.layout.boost_preference))

Wyświetl plik

@ -23,7 +23,6 @@ import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
import org.thoughtcrime.securesms.util.StringUtil import org.thoughtcrime.securesms.util.StringUtil
import org.thoughtcrime.securesms.util.livedata.Store import org.thoughtcrime.securesms.util.livedata.Store
import java.lang.NumberFormatException
import java.math.BigDecimal import java.math.BigDecimal
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.DecimalFormatSymbols import java.text.DecimalFormatSymbols
@ -194,7 +193,8 @@ class BoostViewModel(
store.update { store.update {
it.copy( it.copy(
isCustomAmountFocused = false, isCustomAmountFocused = false,
selectedBoost = boost selectedBoost = boost,
customAmount = FiatMoney(BigDecimal.ZERO, it.currencySelection)
) )
} }
} }
@ -218,7 +218,9 @@ class BoostViewModel(
} }
fun setCustomAmountFocused(isFocused: Boolean) { 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>) private data class BoostInfo(val boosts: List<Boost>, val defaultBoost: Boost?, val boostBadge: Badge, val supportedCurrencies: Set<Currency>)

Wyświetl plik

@ -112,6 +112,23 @@
app:strokeWidth="1.5dp" app:strokeWidth="1.5dp"
tools:text="$100" /> 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 <androidx.appcompat.widget.AppCompatEditText
android:id="@+id/boost_custom" android:id="@+id/boost_custom"
android:layout_width="0dp" android:layout_width="0dp"
@ -122,12 +139,29 @@
android:hint="@string/Boost__enter_custom_amount" android:hint="@string/Boost__enter_custom_amount"
android:imeOptions="actionDone" android:imeOptions="actionDone"
android:inputType="numberDecimal" android:inputType="numberDecimal"
android:paddingStart="21dp"
android:paddingEnd="21dp"
android:textAppearance="@style/Signal.Text.Body" android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/signal_text_primary" android:textColor="@color/signal_text_primary"
android:textColorHint="@color/signal_text_primary" android:textColorHint="@color/signal_text_primary"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toStartOf="@id/boost_currency_end"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toEndOf="@id/boost_currency_start"
app:layout_constraintTop_toBottomOf="@id/boost_4" /> 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> </androidx.constraintlayout.widget.ConstraintLayout>

Wyświetl plik

@ -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)
}
}