taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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:
Mtaler-kotlin-android/src/main/java/net/taler/common/Amount.kt | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mtaler-kotlin-android/src/test/java/net/taler/common/AmountTest.kt | 41++++++++++++++++++++++++-----------------
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