summaryrefslogtreecommitdiff
path: root/wallet
diff options
context:
space:
mode:
authorIván Ávalos <avalos@disroot.org>2024-02-05 13:47:52 -0600
committerTorsten Grote <t@grobox.de>2024-02-06 09:52:02 -0300
commitd604f8edd4a0bd75d25336c6b62ebbbb31cbef17 (patch)
tree7f2ef13d14fe1a23e0f22d6a9333d8d001e7da47 /wallet
parent64ab3911badef4446a119d54bed6ce0d8ca9e4cc (diff)
downloadtaler-android-d604f8edd4a0bd75d25336c6b62ebbbb31cbef17.tar.gz
taler-android-d604f8edd4a0bd75d25336c6b62ebbbb31cbef17.tar.bz2
taler-android-d604f8edd4a0bd75d25336c6b62ebbbb31cbef17.zip
[wallet] Improved amount input (better suited for DD51)
bug 0008329
Diffstat (limited to 'wallet')
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt218
1 files 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