commit 9cfe2527d089d7cb3ea1dc329c073aafd947ef17
parent 6badaed3e9dfef0d14b95b17a0a1331698d7d87b
Author: Iván Ávalos <avalos@disroot.org>
Date: Mon, 19 Oct 2026 12:20:51 +0200
[wallet] fix amount rendering aesthetics
Diffstat:
2 files changed, 89 insertions(+), 46 deletions(-)
diff --git a/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt b/taler-kotlin-android/src/main/java/net/taler/common/Amount.kt
@@ -16,7 +16,13 @@
package net.taler.common
+import android.icu.number.NumberFormatter
+import android.icu.number.Precision
+import android.icu.text.DecimalFormatSymbols
+import android.icu.util.Currency
+import android.icu.util.ULocale
import android.os.Build
+import androidx.annotation.RequiresApi
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
@@ -25,8 +31,8 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.math.RoundingMode
import java.text.DecimalFormat
-import java.text.DecimalFormatSymbols
import java.text.NumberFormat
+import java.util.Locale
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.roundToInt
@@ -242,41 +248,46 @@ data class Amount(
fun toString(
showSymbol: Boolean = true,
negative: Boolean = false,
- symbols: DecimalFormatSymbols = DecimalFormat().decimalFormatSymbols,
+ symbols: java.text.DecimalFormatSymbols = DecimalFormat().decimalFormatSymbols,
+ locale: Locale = Locale.getDefault(),
): String {
- // We clone the object to safely/cleanly modify it
- val s = symbols.clone() as DecimalFormatSymbols
val amount = (if (negative) "-$amountStr" else amountStr).toBigDecimal()
-
- // No currency spec, so we render normally
- if (spec == null) {
+ val symbol = spec?.symbol ?: currency
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ // In newer phones > 2019, do full, locale-based formatting
+ val uLocale = ULocale.forLocale(locale)
+ val blueprintCurrency = Currency.getInstance(uLocale)
+ NumberFormatter.with()
+ .unit(blueprintCurrency)
+ .unitWidth(
+ if (showSymbol) {
+ if (symbol.length > 1)
+ NumberFormatter.UnitWidth.ISO_CODE else
+ NumberFormatter.UnitWidth.SHORT
+ } else NumberFormatter.UnitWidth.HIDDEN)
+ .locale(uLocale)
+ .symbols(symbols.toIcu(locale))
+ .precision(Precision.minMaxFraction(
+ spec?.numFractionalTrailingZeroDigits ?: 2,
+ MAX_FRACTION_LENGTH))
+ .format(amount)
+ .toString()
+ .replace(blueprintCurrency.currencyCode, symbol)
+ .replace(blueprintCurrency.symbol, symbol)
+ } else {
+ // In older phones <= 2019, always do "$CURRENCY $AMOUNT"
+ val s = symbols.clone() as java.text.DecimalFormatSymbols
val format = NumberFormat.getInstance()
+ format.minimumFractionDigits = spec?.numFractionalTrailingZeroDigits ?: 2
format.maximumFractionDigits = MAX_FRACTION_LENGTH
- format.minimumFractionDigits = 2
- if (Build.VERSION.SDK_INT >= 34) {
- s.groupingSeparator = s.monetaryGroupingSeparator
- }
- s.decimalSeparator = s.monetaryDecimalSeparator
(format as DecimalFormat).decimalFormatSymbols = s
val fmt = format.format(amount)
- return if (showSymbol) "$fmt $currency" else fmt
- }
-
- // There is currency spec, so we can do things right
- val format = NumberFormat.getCurrencyInstance()
- format.minimumFractionDigits = spec.numFractionalTrailingZeroDigits
- format.maximumFractionDigits = MAX_FRACTION_LENGTH
- s.currencySymbol = spec.symbol ?: ""
- (format as DecimalFormat).decimalFormatSymbols = s
-
- val fmt = format.format(amount)
- return if (showSymbol) {
- // If no symbol, then we use the currency string
- (if (spec.symbol != null) fmt else "$fmt $currency").trim()
- } else {
- // We should do better than manually removing the symbol here
- fmt.replace(s.currencySymbol, "").trim()
+ if (showSymbol) {
+ "$currency $fmt"
+ } else {
+ fmt
+ }
}
}
@@ -310,3 +321,27 @@ internal object KotlinXAmountSerializer : KSerializer<Amount> {
return Amount.fromJSONString(decoder.decodeString())
}
}
+
+@RequiresApi(Build.VERSION_CODES.N)
+fun java.text.DecimalFormatSymbols.toIcu(targetLocale: Locale? = null): DecimalFormatSymbols {
+ val targetLocale = targetLocale ?: Locale.getDefault()
+ val icuSymbols = DecimalFormatSymbols(targetLocale)
+ icuSymbols.zeroDigit = this.zeroDigit
+ icuSymbols.groupingSeparator = this.groupingSeparator
+ icuSymbols.decimalSeparator = this.decimalSeparator
+ icuSymbols.perMill = this.perMill
+ icuSymbols.percent = this.percent
+ icuSymbols.digit = this.digit
+ icuSymbols.patternSeparator = this.patternSeparator
+ icuSymbols.infinity = this.infinity
+ icuSymbols.naN = this.naN
+ icuSymbols.minusSign = this.minusSign
+ icuSymbols.currencySymbol = this.currencySymbol
+ icuSymbols.internationalCurrencySymbol = this.internationalCurrencySymbol
+ icuSymbols.exponentSeparator = this.exponentSeparator
+ icuSymbols.monetaryDecimalSeparator = this.monetaryDecimalSeparator
+ if (Build.VERSION.SDK_INT >= 34) {
+ icuSymbols.monetaryGroupingSeparator = this.monetaryGroupingSeparator
+ }
+ return icuSymbols
+}
+\ No newline at end of file
diff --git a/taler-kotlin-android/src/test/java/net/taler/common/AmountTest.kt b/taler-kotlin-android/src/test/java/net/taler/common/AmountTest.kt
@@ -22,6 +22,7 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.text.DecimalFormatSymbols
+import java.util.Locale
class AmountTest {
@@ -186,6 +187,7 @@ class AmountTest {
currency: String,
symbol: String,
) {
+ val locale = Locale.forLanguageTag("en")
val symbols = DecimalFormatSymbols.getInstance()
symbols.decimalSeparator = '.'
symbols.groupingSeparator = ','
@@ -199,26 +201,31 @@ class AmountTest {
assertEquals(fraction, amount.fraction)
// The amount without currency spec
- assertEquals("$noSpecAmount $currency", amount.toString(symbols = symbols))
- assertEquals(noSpecAmount, amount.toString(symbols = symbols, showSymbol = false))
- assertEquals("-$noSpecAmount $currency", amount.toString(symbols = symbols, negative = true))
- assertEquals("-$noSpecAmount", amount.toString(symbols = symbols, showSymbol = false, negative = true))
+ assertEquals("$currency $noSpecAmount", amount.toString(symbols = symbols, locale = locale))
+ assertEquals(noSpecAmount, amount.toString(symbols = symbols, showSymbol = false, locale = locale))
+ assertEquals("$currency -$noSpecAmount", amount.toString(symbols = symbols, negative = true, locale = locale))
+ assertEquals("-$noSpecAmount", amount.toString(symbols = symbols, showSymbol = false, negative = true, locale = locale))
// The amount with currency spec
val withSpec = amount.withSpec(spec)
- assertEquals(specAmount, withSpec.toString(symbols = symbols, showSymbol = false))
- assertEquals(specAmount, withSpec.toString(symbols = symbols, showSymbol = false))
- assertEquals("-$specAmount", withSpec.toString(symbols = symbols, showSymbol = false, negative = true))
- assertEquals("-$specAmount", withSpec.toString(symbols = symbols, showSymbol = false, negative = true))
-
- // FIXME: this test is locale-dependent
- if (spec.symbol != null) {
- assertEquals("${symbol}$specAmount", withSpec.toString(symbols = symbols))
- assertEquals("-${symbol}$specAmount", withSpec.toString(symbols = symbols, negative = true))
- } else {
- assertEquals("$specAmount $currency", withSpec.toString(symbols = symbols))
- assertEquals("-$specAmount $currency", withSpec.toString(symbols = symbols, negative = true))
- }
+ assertEquals(specAmount, withSpec.toString(symbols = symbols, showSymbol = false, locale = locale))
+ assertEquals(specAmount, withSpec.toString(symbols = symbols, showSymbol = false, locale = locale))
+ assertEquals("-$specAmount", withSpec.toString(symbols = symbols, showSymbol = false, negative = true, locale = locale))
+ assertEquals("-$specAmount", withSpec.toString(symbols = symbols, showSymbol = false, negative = true, locale = locale))
+
+ // FIXME: unit test always SDK_INT = 0, so this path will never be tested
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ if (spec.symbol != null) {
+ assertEquals("${symbol}$specAmount", withSpec.toString(symbols = symbols, locale = locale))
+ assertEquals("-${symbol}$specAmount", withSpec.toString(symbols = symbols, negative = true, locale = locale))
+ } else {
+ assertEquals("$specAmount $currency", withSpec.toString(symbols = symbols, locale = locale))
+ assertEquals("-$specAmount $currency", withSpec.toString(symbols = symbols, negative = true, locale = locale))
+ }
+ } else {
+ assertEquals("$currency $specAmount", withSpec.toString(symbols = symbols, locale = locale))
+ assertEquals("$currency -$specAmount", withSpec.toString(symbols = symbols, negative = true, locale = locale))
+ }
}
@Test