From d604f8edd4a0bd75d25336c6b62ebbbb31cbef17 Mon Sep 17 00:00:00 2001 From: Iván Ávalos Date: Mon, 5 Feb 2024 13:47:52 -0600 Subject: [wallet] Improved amount input (better suited for DD51) bug 0008329 --- .../net/taler/wallet/compose/AmountInputField.kt | 218 +++++++++++++-------- 1 file changed, 135 insertions(+), 83 deletions(-) diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt index 20f8006..077c89a 100644 --- a/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt +++ b/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt @@ -16,15 +16,11 @@ package net.taler.wallet.compose -import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import android.os.Build import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,10 +33,12 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import net.taler.common.Amount import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.roundToLong @Composable fun AmountInputField( @@ -51,25 +49,29 @@ fun AmountInputField( supportingText: @Composable (() -> Unit)? = null, isError: Boolean = false, keyboardActions: KeyboardActions = KeyboardActions.Default, + decimalFormatSymbols: DecimalFormatSymbols = DecimalFormat().decimalFormatSymbols, + numberOfDecimals: Int = 2, ) { - val decimalSeparator = DecimalFormat().decimalFormatSymbols.decimalSeparator var amountInput by remember { mutableStateOf(value) } // React to external changes val amountValue = remember(amountInput, value) { - transformOutput(amountInput, decimalSeparator, '.').let { - if (value != it) value else amountInput + transformOutput(amountInput).let { + if (value != it) transformInput(value, numberOfDecimals) else amountInput } } OutlinedTextField( value = amountValue, onValueChange = { input -> - val filtered = transformOutput(input, decimalSeparator, '.') - if (Amount.isValidAmountStr(filtered)) { - amountInput = transformInput(input, decimalSeparator, '.') - // tmpIn = input - onValueChange(filtered) + if (input.matches("0+".toRegex())) { + amountInput = "0" + onValueChange("") + } else transformOutput(input, numberOfDecimals)?.let { filtered -> + if (Amount.isValidAmountStr(filtered) && !input.contains("-")) { + amountInput = input.trimStart('0') + onValueChange(filtered) + } } }, modifier = modifier, @@ -77,91 +79,141 @@ fun AmountInputField( label = label, supportingText = supportingText, isError = isError, - visualTransformation = AmountInputVisualTransformation(decimalSeparator), - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Decimal), + visualTransformation = AmountInputVisualTransformation( + symbols = decimalFormatSymbols, + fixedCursorAtTheEnd = true, + numberOfDecimals = numberOfDecimals, + ), + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.NumberPassword), keyboardActions = keyboardActions, singleLine = true, maxLines = 1, ) } +// 500 -> 5.0 +private fun transformOutput( + input: String, + numberOfDecimals: Int = 2, +) = if (input.isEmpty()) "0" else { + input.toLongOrNull()?.let { it / 10.0.pow(numberOfDecimals) }?.toBigDecimal()?.toPlainString() +} + +// 5.0 -> 500 +private fun transformInput( + output: String, + numberOfDecimals: Int = 2, +) = if (output.isEmpty()) "0" else { + (output.toDouble() * 10.0.pow(numberOfDecimals)).roundToLong().toString() +} + +// Source: https://github.com/banmarkovic/CurrencyAmountInput + private class AmountInputVisualTransformation( - private val decimalSeparator: Char, -) : VisualTransformation { + private val symbols: DecimalFormatSymbols, + private val fixedCursorAtTheEnd: Boolean = true, + private val numberOfDecimals: Int = 2, +): VisualTransformation { override fun filter(text: AnnotatedString): TransformedText { - val value = text.text - val output = transformOutput(value, '.', decimalSeparator) - val newText = AnnotatedString(output) - return TransformedText( - newText, CursorOffsetMapping( + val thousandsSeparator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + symbols.monetaryGroupingSeparator + } else { + symbols.groupingSeparator + } + val decimalSeparator = symbols.monetaryDecimalSeparator + val zero = symbols.zeroDigit + + val inputText = text.text + + val intPart = inputText + .dropLast(numberOfDecimals) + .reversed() + .chunked(3) + .joinToString(thousandsSeparator.toString()) + .reversed() + .ifEmpty { + zero.toString() + } + + val fractionPart = inputText.takeLast(numberOfDecimals).let { + if (it.length != numberOfDecimals) { + List(numberOfDecimals - it.length) { + zero + }.joinToString("") + it + } else { + it + } + } + + val formattedNumber = intPart + decimalSeparator + fractionPart + + val newText = AnnotatedString( + text = formattedNumber, + spanStyles = text.spanStyles, + paragraphStyles = text.paragraphStyles + ) + + val offsetMapping = if (fixedCursorAtTheEnd) { + FixedCursorOffsetMapping( + contentLength = inputText.length, + formattedContentLength = formattedNumber.length + ) + } else { + MovableCursorOffsetMapping( unmaskedText = text.toString(), - maskedText = newText.toString().replace(decimalSeparator, '.'), + maskedText = newText.toString(), + decimalDigits = numberOfDecimals ) - ) + } + + return TransformedText(newText, offsetMapping) } - private class CursorOffsetMapping( - private val unmaskedText: String, - private val maskedText: String, + private class FixedCursorOffsetMapping( + private val contentLength: Int, + private val formattedContentLength: Int, ) : OffsetMapping { - override fun originalToTransformed(offset: Int) = when { - unmaskedText.startsWith('.') -> if (offset == 0) 0 else (offset + 1) // ".x" -> "0.x" - else -> offset - } - - override fun transformedToOriginal(offset: Int) = when { - unmaskedText == "" -> 0 // "0" -> "" - unmaskedText == "." -> if (offset < 1) 0 else 1 // "0.0" -> "." - unmaskedText.startsWith('.') -> if (offset < 1) 0 else (offset - 1) // "0.x" -> ".x" - unmaskedText.endsWith('.') && offset == maskedText.length -> offset - 1 // "x.0" -> "x." - else -> offset // "x" -> "x" - } + override fun originalToTransformed(offset: Int): Int = formattedContentLength + override fun transformedToOriginal(offset: Int): Int = contentLength } -} -private fun transformInput( - input: String, - inputDecimalSeparator: Char = '.', - outputDecimalSeparator: Char = '.', -) = input.trim().replace(inputDecimalSeparator, outputDecimalSeparator) + private class MovableCursorOffsetMapping( + private val unmaskedText: String, + private val maskedText: String, + private val decimalDigits: Int + ) : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = + when { + unmaskedText.length <= decimalDigits -> { + maskedText.length - (unmaskedText.length - offset) + } + else -> { + offset + offsetMaskCount(offset, maskedText) + } + } -private fun transformOutput( - input: String, - inputDecimalSeparator: Char = '.', - outputDecimalSeparator: Char = '.', -) = transformInput(input, inputDecimalSeparator, outputDecimalSeparator).let { - when { - it.isEmpty() -> "0" - it == "$outputDecimalSeparator" -> "0${outputDecimalSeparator}0" - it.startsWith(outputDecimalSeparator) -> "0$it" - it.endsWith(outputDecimalSeparator) -> "${it}0" - else -> it - } -} + override fun transformedToOriginal(offset: Int): Int = + when { + unmaskedText.length <= decimalDigits -> { + max(unmaskedText.length - (maskedText.length - offset), 0) + } + else -> { + offset - maskedText.take(offset).count { !it.isDigit() } + } + } -@Preview -@Composable -fun AmountInputFieldPreview() { - var value by remember { mutableStateOf("0") } - TalerSurface { - Column( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalArrangement = spacedBy(16.dp), - ) { - AmountInputField( - value = value, - onValueChange = { value = it }, - label = { Text("Amount input:") }, - supportingText = { Text("This amount is nice.") }, - ) - AmountInputField( - value = value, - onValueChange = { value = it }, - label = { Text("Error in amount input:") }, - supportingText = { Text("Amount is invalid.") }, - isError = true, - ) + private fun offsetMaskCount(offset: Int, maskedText: String): Int { + var maskOffsetCount = 0 + var dataCount = 0 + for (maskChar in maskedText) { + if (!maskChar.isDigit()) { + maskOffsetCount++ + } else if (++dataCount > offset) { + break + } + } + return maskOffsetCount } } -} +} \ No newline at end of file -- cgit v1.2.3