add currency mask

This commit is contained in:
nikita-kladov
2023-04-21 14:59:15 +03:00
parent f13d2b1585
commit edcd763786
7 changed files with 708 additions and 10 deletions
@@ -0,0 +1,206 @@
package com.yandex.div.core.util.mask
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.text.NumberFormat
import java.util.Locale
import kotlin.math.abs
internal class CurrencyInputMask(
locale: Locale,
private val onError: ((Exception) -> Unit)
) : BaseInputMask(MaskData("", emptyList(), false)) {
private val currencyKey = '¤'
private val separators = listOf('.', ',')
private var currencyFormatter: NumberFormat = NumberFormat
.getCurrencyInstance(locale)
.clearFormatter()
private val decimalFormatSymbols: DecimalFormatSymbols
get() = (currencyFormatter as DecimalFormat).decimalFormatSymbols
private val String.withNbsp: String
get() = replace(' ', ' ')
fun updateCurrencyParams(locale: Locale) {
val currentValue = currencyFormatter.parse(value) ?: 0
currencyFormatter = NumberFormat.getCurrencyInstance(locale).clearFormatter()
invalidateMaskDataForFormatted(currentValue)
applyChangeFrom(currencyFormatter.format(currentValue))
}
private fun invalidateMaskDataForFormatted(forValue: Number) {
val formatted = currencyFormatter.format(forValue)
val maskPattern = formatPattern(formatted)
val decoding = listOf(
MaskKey(
key = '#',
filter = "\\d",
placeholder = '0'
),
MaskKey(
key = decimalFormatSymbols.decimalSeparator,
filter = "[${decimalFormatSymbols.decimalSeparator}]",
placeholder = decimalFormatSymbols.decimalSeparator
)
)
updateMaskData(MaskData(maskPattern, decoding, maskData.alwaysVisible), false)
}
override fun applyChangeFrom(newValue: String, position: Int?) {
val diff = TextDiff.build(value, newValue.withNbsp)
val decimalSeparator = decimalFormatSymbols.decimalSeparator
val oldSeparatorIndex = value.indexOfLast { it == decimalSeparator }
val newSeparatorIndex = newValue.indexOfLast { it == decimalSeparator }
val needInvalidateMask = oldSeparatorIndex != newSeparatorIndex ||
(oldSeparatorIndex == -1 && newSeparatorIndex == -1)
val clearedValue = newValue.toValidFormat(diff)
cleanup(diff)
val rawValue = currencyFormatter.parse(clearedValue) ?: 0
if (needInvalidateMask) {
invalidateMaskDataForFormatted(rawValue)
}
replaceChars(clearedValue, 0)
cursorPosition =
if (value.length > diff.start && value[diff.start] == decimalFormatSymbols.groupingSeparator) {
position ?: cursorPosition
} else {
abs(value.length - (newValue.length - (position ?: cursorPosition)))
}
}
private fun NumberFormat.clearFormatter(): NumberFormat {
(this as? DecimalFormat)?.apply {
val pattern = toPattern().filter { it != currencyKey }.trim()
applyPattern(pattern)
}
return this
}
private fun String.toValidFormat(diff: TextDiff): String {
if (isBlank()) return decimalFormatSymbols.zeroDigit.toString()
val separatorChar = decimalFormatSymbols.decimalSeparator
var separatorOutOfDiffIndex: Int = -1
var index = 0
while (index < this.length) {
if (this[index] == separatorChar && !inDiff(diff, index)) {
separatorOutOfDiffIndex = index
break
}
index++
}
val replaceCharInDiff =
if (diff.added == 1 && diff.removed == 0) {
val diffChar = this[diff.start]
if (diffChar in separators) diff.start else -1
} else -1
val maxSeparatorOffset = currencyFormatter.maximumFractionDigits
var leftToInsert = maxSeparatorOffset
if (separatorOutOfDiffIndex != -1) {
index = separatorOutOfDiffIndex
while (index < length) {
if (this[index].isDigit() && !inDiff(diff, index)) leftToInsert--
index++
}
} else {
var oldSeparatorLeft = false
forEachIndexed { i, char ->
val inDiff = inDiff(diff, i)
when {
char == separatorChar -> {
oldSeparatorLeft = true
}
!inDiff && oldSeparatorLeft && char.isDigit() -> {
leftToInsert--
}
}
}
}
val containsSeparator = contains(separatorChar) || replaceCharInDiff != -1
return buildString {
index = this@toValidFormat.length - 1
var separatorInserted = false
while (index >= 0) {
val char = this@toValidFormat[index]
val canInsertSeparator = length <= maxSeparatorOffset
if (char.isDigit()) {
if (inDiff(diff, index) && !separatorInserted && containsSeparator) {
if (leftToInsert > 0) {
append(char)
leftToInsert--
}
} else {
append(char)
}
} else if (canInsertSeparator && separatorOutOfDiffIndex == -1 && index == replaceCharInDiff) {
append(separatorChar)
separatorInserted = true
} else if (canInsertSeparator && char == separatorChar && (separatorOutOfDiffIndex == index || separatorOutOfDiffIndex == -1)) {
append(separatorChar)
separatorInserted = true
separatorOutOfDiffIndex = index
}
index--
}
}
.reversed()
.trimStart(decimalFormatSymbols.zeroDigit)
}
private fun inDiff(diff: TextDiff, index: Int): Boolean =
diff.start <= index && index < diff.start + diff.added
private fun formatPattern(pattern: String): String {
return buildString {
pattern.forEach { char ->
when {
char.isDigit() -> append('#')
else -> append(char)
}
}
}
}
override fun onException(exception: Exception) {
onError.invoke(exception)
}
}
@@ -2,9 +2,9 @@ package com.yandex.div.core.util.mask
internal open class FixedLengthInputMask(
initialMaskData: MaskData,
private val onError: ((Exception) -> Unit)? = null
private val onError: ((Exception) -> Unit)
) : BaseInputMask(initialMaskData) {
override fun onException(exception: Exception) {
onError?.invoke(exception)
onError.invoke(exception)
}
}
@@ -2,12 +2,14 @@ package com.yandex.div.core.view2.divs
import android.graphics.drawable.Drawable
import android.text.InputType
import android.text.method.DigitsKeyListener
import android.view.View
import android.widget.EditText
import android.widget.TextView
import com.yandex.div.core.dagger.DivScope
import com.yandex.div.core.expression.variables.TwoWayStringVariableBinder
import com.yandex.div.core.util.mask.BaseInputMask
import com.yandex.div.core.util.mask.CurrencyInputMask
import com.yandex.div.core.util.mask.FixedLengthInputMask
import com.yandex.div.core.util.toIntSafely
import com.yandex.div.core.view2.Div2View
@@ -16,9 +18,11 @@ import com.yandex.div.core.view2.DivViewBinder
import com.yandex.div.core.view2.divs.widgets.DivInputView
import com.yandex.div.core.view2.errors.ErrorCollectors
import com.yandex.div.json.expressions.ExpressionResolver
import com.yandex.div2.DivCurrencyInputMask
import com.yandex.div2.DivFixedLengthInputMask
import com.yandex.div2.DivInput
import com.yandex.div2.DivSizeUnit
import java.util.Locale
import java.util.regex.PatternSyntaxException
import javax.inject.Inject
@@ -278,11 +282,24 @@ internal class DivInputBinder @Inject constructor(
val errorCollector = errorCollectors.getOrCreate(divView.dataTag, divView.divData)
val defaultKeyListener = keyListener
val catchCommonMaskException = { exception: Exception, other: () -> Unit ->
when (exception) {
is PatternSyntaxException -> errorCollector.logError(
IllegalArgumentException("Invalid regex pattern '${exception.pattern}'.")
)
else -> other()
}
}
val updateMaskData = { _: Any ->
val divInputMask = div.mask?.value()
inputMask = when (divInputMask) {
is DivFixedLengthInputMask -> {
keyListener = defaultKeyListener
val maskData = BaseInputMask.MaskData(
divInputMask.pattern.evaluate(resolver),
divInputMask.patternElements.map {
@@ -296,14 +313,41 @@ internal class DivInputBinder @Inject constructor(
)
inputMask?.apply { updateMaskData(maskData) } ?: FixedLengthInputMask(maskData) {
when (it) {
is PatternSyntaxException -> errorCollector.logError(
IllegalArgumentException("Invalid regex pattern '${it.pattern}'.")
)
}
catchCommonMaskException(it) { }
}
}
else -> null
is DivCurrencyInputMask -> {
val evaluatedLocaleTag = divInputMask.locale?.evaluate(resolver)
val locale = if (evaluatedLocaleTag != null) {
Locale.forLanguageTag(evaluatedLocaleTag)
.apply {
val finalLanguageTag = toLanguageTag()
if (finalLanguageTag != evaluatedLocaleTag) {
val exception = IllegalArgumentException("Original locale tag '$evaluatedLocaleTag' is not equals to final one '$finalLanguageTag'")
errorCollector.logWarning(exception)
}
}
} else {
Locale.getDefault()
}
keyListener = DigitsKeyListener.getInstance("1234567890.,")
inputMask?.apply {
(inputMask as CurrencyInputMask)
.updateCurrencyParams(locale)
} ?: CurrencyInputMask(locale) {
catchCommonMaskException(it) { }
}
}
else -> {
keyListener = defaultKeyListener
null
}
}
onMaskUpdate(inputMask)
@@ -311,7 +355,7 @@ internal class DivInputBinder @Inject constructor(
when (val inputMask = div.mask?.value()) {
is DivFixedLengthInputMask -> {
addSubscription(inputMask.pattern.observeAndGet(resolver, updateMaskData))
addSubscription(inputMask.pattern.observe(resolver, updateMaskData))
inputMask.patternElements.forEach { patternElement ->
addSubscription(patternElement.key.observe(resolver, updateMaskData))
patternElement.regex?.let { addSubscription(it.observe(resolver, updateMaskData)) }
@@ -319,7 +363,12 @@ internal class DivInputBinder @Inject constructor(
}
addSubscription(inputMask.alwaysVisible.observe(resolver, updateMaskData))
}
is DivCurrencyInputMask -> {
inputMask.locale?.observe(resolver, updateMaskData)?.let { addSubscription(it) }
}
else -> Unit
}
updateMaskData(Unit)
}
}
@@ -0,0 +1,328 @@
package com.yandex.div.core.util.mask
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Locale
@RunWith(RobolectricTestRunner::class)
class CurrencyMaskTest {
private val russianLocale = Locale.forLanguageTag("ru-RU")
private val deutschLocale = Locale.forLanguageTag("de-DE")
private val englishLocale = Locale.forLanguageTag("en-US")
@Test
fun `insert decimal to empty string`() {
val currencyMask = createCurrencyMask()
currencyMask.applyChangesFromWithNbsp("1", 1)
currencyMask.assertMask("1", 1)
}
@Test
fun `insert multiple decimals at once to empty string`() {
val currencyMask = createCurrencyMask()
currencyMask.applyChangesFromWithNbsp("12345", 5)
currencyMask.assertMask("12 345", 6)
}
@Test
fun `insert one decimal`() {
val currencyMask = createCurrencyMask().withValue("1")
currencyMask.applyChangesFromWithNbsp("12", 2)
currencyMask.assertMask("12", 2)
}
@Test
fun `insert multiple decimals`() {
val currencyMask = createCurrencyMask().withValue("1")
currencyMask.applyChangesFromWithNbsp("12345", 5)
currencyMask.assertMask("12 345", 6)
}
@Test
fun `remove one decimal`() {
val currencyMask = createCurrencyMask().withValue("12345")
currencyMask.applyChangesFromWithNbsp("12 45", 3)
currencyMask.assertMask("1 245", 3)
}
@Test
fun `remove multiple decimals`() {
val currencyMask = createCurrencyMask().withValue("123456")
currencyMask.applyChangesFromWithNbsp("12 56", 3)
currencyMask.assertMask("1 256", 3)
}
@Test
fun `deutsch currency mask`() {
val currencyMask = createCurrencyMask(deutschLocale)
currencyMask.applyChangesFromWithNbsp("1.234,56", 8)
currencyMask.assertMask("1.234,56", 8)
}
@Test
fun `english currency mask`() {
val currencyMask = createCurrencyMask(englishLocale)
currencyMask.applyChangesFromWithNbsp("1,234.56", 8)
currencyMask.assertMask("1,234.56", 8)
}
@Test
fun `insert comma english currency mask`() {
val currencyMask = createCurrencyMask(englishLocale).withValue("123456")
currencyMask.applyChangesFromWithNbsp("123,4,56", 6)
currencyMask.assertMask("1,234.56", 6)
}
@Test
fun `input separator`() {
val currencyMask = createCurrencyMask().withValue("1234")
currencyMask.applyChangesFromWithNbsp("1 234,", 6)
currencyMask.assertMask("1 234,", 6)
}
@Test
fun `remove separator`() {
val currencyMask = createCurrencyMask().withValue("1234,")
currencyMask.applyChangesFromWithNbsp("1 234", 5)
currencyMask.assertMask("1 234", 5)
}
@Test
fun `insert decimal after separator`() {
val currencyMask = createCurrencyMask().withValue("1234,")
currencyMask.applyChangesFromWithNbsp("1 234,5", 7)
currencyMask.assertMask("1 234,5", 7)
}
@Test
fun `remove decimal after separator`() {
val currencyMask = createCurrencyMask().withValue("1234,56")
currencyMask.applyChangesFromWithNbsp("1 234,6", 6)
currencyMask.assertMask("1 234,6", 6)
}
@Test
fun `insert separator with overflow not allowed`() {
val currencyMask = createCurrencyMask().withValue("12345678")
currencyMask.applyChangesFromWithNbsp("12 34,5 678", 6)
currencyMask.assertMask("12 345 678", 5)
}
@Test
fun `insert separator without overflow allowed`() {
val currencyMask = createCurrencyMask().withValue("123456")
currencyMask.applyChangesFromWithNbsp("123 4,56", 6)
currencyMask.assertMask("1 234,56", 6)
}
@Test
fun `remove separator from a middle`() {
val currencyMask = createCurrencyMask().withValue("1234,56")
currencyMask.applyChangesFromWithNbsp("1 23456", 5)
currencyMask.assertMask("123 456", 5)
}
@Test
fun `change locale with value`() {
val currencyMask = createCurrencyMask().withValue("1 234,56")
currencyMask.assertMask("1 234,56", 0)
currencyMask.updateCurrencyParams(deutschLocale)
currencyMask.assertMask("1.234,56", 0)
}
@Test
fun `input dot as separator for deutsch locale`() {
val currencyMask = createCurrencyMask(deutschLocale).withValue("1234567")
currencyMask.applyChangesFromWithNbsp("1.234.5.67", 8)
currencyMask.assertMask("12.345,67", 7)
}
@Test
fun `input dot as separator for english locale`() {
val currencyMask = createCurrencyMask(englishLocale).withValue("1234567")
currencyMask.applyChangesFromWithNbsp("1,234,5.67", 8)
currencyMask.assertMask("12,345.67", 7)
}
@Test
fun `input comma as separator for english locale`() {
val currencyMask = createCurrencyMask(englishLocale).withValue("1234567")
currencyMask.applyChangesFromWithNbsp("1,234,5,67", 8)
currencyMask.assertMask("12,345.67", 7)
}
@Test
fun `input invalid character`() {
val currencyMask = createCurrencyMask().withValue("1234,5")
currencyMask.applyChangesFromWithNbsp("1 23a4,5", 5)
currencyMask.assertMask("1 234,5", 4)
}
@Test
fun `input second separator before one`() {
val currencyMask = createCurrencyMask().withValue("1234,56")
currencyMask.applyChangesFromWithNbsp("1 2,34,56", 4)
currencyMask.assertMask("1 234,56", 3)
}
@Test
fun `input second separator after one`() {
val currencyMask = createCurrencyMask().withValue("1234,56")
currencyMask.applyChangesFromWithNbsp("1 234,5,6", 8)
currencyMask.assertMask("1 234,56", 7)
}
@Test
fun `fully clear field`() {
val currencyMask = createCurrencyMask().withValue("1234,56")
currencyMask.applyChangesFromWithNbsp("", 0)
currencyMask.assertMask("0", 1)
}
@Test
fun `type digit after leading zero`() {
val currencyMask = createCurrencyMask()
currencyMask.applyChangesFromWithNbsp("01", 1)
currencyMask.assertMask("1", 0)
}
@Test
fun `type zero digits as leading`() {
val currencyMask = createCurrencyMask().withValue("1 234")
currencyMask.applyChangesFromWithNbsp("0 001 234", 4)
currencyMask.assertMask("1 234", 0)
}
@Test
fun `type char at end with overflow`() {
val currencyMask = createCurrencyMask().withValue("123,45")
currencyMask.applyChangesFromWithNbsp("123,465", 6)
currencyMask.assertMask("123,45", 5)
}
@Test
fun `type char and separator at end with overflow`() {
val currencyMask = createCurrencyMask().withValue("12345")
currencyMask.applyChangesFromWithNbsp("12 3,645", 6)
currencyMask.assertMask("123,45", 4)
}
@Test
fun `type char and separator at end without overflow`() {
val currencyMask = createCurrencyMask().withValue("123,4")
currencyMask.applyChangesFromWithNbsp("123,45", 6)
currencyMask.assertMask("123,45", 6)
}
@Test
fun `add character in repeated substring`() {
val currencyMask = createCurrencyMask().withValue("11111")
currencyMask.applyChangesFromWithNbsp("11 1111", 5)
currencyMask.assertMask("111 111", 5)
}
@Test
fun `remove character in repeated substring`() {
val currencyMask = createCurrencyMask().withValue("11111")
currencyMask.applyChangesFromWithNbsp("11 11", 4)
currencyMask.assertMask("1 111", 4)
}
@Test
fun `remove cluster separator`() {
val currencyMask = createCurrencyMask().withValue("12345")
currencyMask.applyChangesFromWithNbsp("12345", 2)
currencyMask.assertMask("12 345", 2)
}
private fun createCurrencyMask(locale: Locale = russianLocale): CurrencyInputMask {
return CurrencyInputMask(locale) { }
}
private fun CurrencyInputMask.applyChangesFromWithNbsp(value: String, position: Int?) {
applyChangeFrom(value.withNbsp(), position)
}
private fun CurrencyInputMask.withValue(value: String): CurrencyInputMask {
applyChangeFrom(value.withNbsp())
return this
}
private fun CurrencyInputMask.assertMask(
expectedMaskedValue: String,
expectedCursorPosition: Int
) {
Assert.assertEquals(expectedMaskedValue.withNbsp(), value)
Assert.assertEquals(expectedCursorPosition, cursorPosition)
}
private fun String.withNbsp(): String = replace(' ', ' ')
}
@@ -2,7 +2,7 @@ package com.yandex.div.core.util.mask
internal class TestFixedLengthInputMask(
initialMaskData: MaskData
) : FixedLengthInputMask(initialMaskData) {
) : FixedLengthInputMask(initialMaskData, { }) {
fun publicCalculateInsertableSubstring(substring: String, start: Int): String =
calculateInsertableSubstring(substring, start)
@@ -0,0 +1,105 @@
{
"templates": {
"pretty_input": {
"type": "input",
"height": {
"type": "wrap_content"
},
"background": [
{
"type": "solid",
"color": "#0e000000"
}
],
"border": {
"corner_radius": 8
},
"margins": {
"left": 16,
"top": 8,
"right": 16,
"bottom": 8
},
"paddings": {
"left": 16,
"top": 10,
"right": 16,
"bottom": 10
},
"font_size": 16,
"font_weight": "medium",
"text_color": "#000000",
"hint_color": "#888888",
"highlight_color": "#e0bae3",
"line_height": 22
}
},
"card": {
"log_id": "ui_test_card",
"variables": [
{
"name": "input",
"type": "string",
"value": "1234567"
},
{
"name": "raw_input",
"type": "string",
"value": "12345"
},
{
"name": "locale",
"type": "string",
"value": "en-US"
}
],
"states": [
{
"state_id": 0,
"div": {
"type": "container",
"orientation": "vertical",
"width": {
"type": "match_parent"
},
"height": {
"type": "wrap_content"
},
"paddings": {
"top": 8,
"bottom": 8
},
"items": [
{
"type": "text",
"text": "Raw value: @{input}",
"font_size": 20,
"margins": {
"left": 16,
"top": 8,
"right": 16,
"bottom": 8
}
},
{
"type": "pretty_input",
"text_variable": "input",
"hint_text": "0",
"keyboard_type": "number",
"mask": {
"type": "currency",
"raw_text_variable": "raw_input",
"locale": "@{locale == '' ? 'en-US' : locale}"
}
},
{
"type": "pretty_input",
"text_variable": "locale",
"hint_text": "Currency locale"
}
]
}
}
]
}
}
+10
View File
@@ -1742,6 +1742,16 @@
"DivInput"
],
"file": "phone_input_mask.json"
},
{
"title": "Currency input mask",
"platforms": [
"android"
],
"tags": [
"DivInput"
],
"file": "currency_input_mask.json"
}
]
}