From 6465307d0ad616ee58090d52a1e3e86065e2c90a Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 15 Oct 2024 12:07:48 +0300 Subject: [PATCH] Updated on 2026-08-14 --- build.gradle.kts | 12 + core/ui/build.gradle.kts | 5 + .../AmountVisualTransformation.kt | 33 +- .../bigdecimal/BigDecimalCompactFormat.kt | 159 ++++++++ .../bigdecimal/BigDecimalCryptoFormat.kt | 218 ++++++++++ .../format/bigdecimal/BigDecimalFiatFormat.kt | 144 +++++++ .../ui/format/bigdecimal/BigDecimalFormat.kt | 29 ++ .../bigdecimal/BigDecimalFormatConstants.kt | 20 + .../bigdecimal/BigDecimalPercentFormat.kt | 39 ++ .../tangem/core/ui/format/bigdecimal/Utils.kt | 15 + .../core/ui/utils/BigDecimalFormatter.kt | 287 +------------- .../bigdecimal/BigDecimalCryptoFormatTest.kt | 373 ++++++++++++++++++ .../bigdecimal/BigDecimalFiatFormatTest.kt | 297 ++++++++++++++ .../format/bigdecimal/BigDecimalFormatTest.kt | 29 ++ .../bigdecimal/BigDecimalPercentFormatTest.kt | 70 ++++ .../core/ui/format/bigdecimal/TestUtils.kt | 7 + .../model/converters/InsightsConverter.kt | 22 +- .../impl/model/converters/MetricsConverter.kt | 26 +- .../converters/MarketsTokenItemConverter.kt | 17 +- 19 files changed, 1499 insertions(+), 303 deletions(-) create mode 100644 core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCompactFormat.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormat.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFormat.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFormatConstants.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalPercentFormat.kt create mode 100644 core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/Utils.kt create mode 100644 core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormatTest.kt create mode 100644 core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt create mode 100644 core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFormatTest.kt create mode 100644 core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalPercentFormatTest.kt create mode 100644 core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/TestUtils.kt diff --git a/build.gradle.kts b/build.gradle.kts index e71d217293..188c0a6ef6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + plugins { alias(deps.plugins.kotlin.android) apply false alias(deps.plugins.kotlin.jvm) apply false @@ -20,6 +22,16 @@ interface Injected { val fs: FileSystemOperations } +// Test Logging +subprojects { + tasks.withType { + testLogging { + exceptionFormat = TestExceptionFormat.FULL + showStandardStreams = true + } + } +} + val assembleInternalQA by tasks.registering { group = "build" description = "Builds internal APK to 'build/outputs' directory" diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 3781bde624..c7fb1b5984 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -52,4 +52,9 @@ dependencies { api(deps.jodatime) implementation(deps.timber) implementation(deps.markdown) + + /** Tests */ + testImplementation(deps.test.junit) + testImplementation(deps.test.mockk) + testImplementation(deps.test.truth) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt b/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt index 8f10352030..17521b19b9 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/components/fields/visualtransformations/AmountVisualTransformation.kt @@ -4,10 +4,15 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation -import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants +import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CURRENCY_SPACE +import com.tangem.core.ui.format.bigdecimal.getJavaCurrencyByCode import com.tangem.core.ui.utils.defaultFormat import com.tangem.core.ui.utils.formatWithThousands +import timber.log.Timber import java.text.DecimalFormat +import java.text.NumberFormat +import java.util.Locale class AmountVisualTransformation( private val decimals: Int, @@ -25,13 +30,13 @@ class AmountVisualTransformation( val formattedText = if (formattedAmount.isNotEmpty() && symbol != null) { AnnotatedString( if (currencyCode != null) { - BigDecimalFormatter.formatFiatEditableAmount( + formatFiatEditableAmount( fiatAmount = formattedAmount, fiatCurrencyCode = currencyCode, fiatCurrencySymbol = symbol, ) } else { - BigDecimalFormatter.formatWithSymbol(formattedAmount, symbol) + formatWithSymbol(formattedAmount, symbol) }, ) } else { @@ -45,6 +50,28 @@ class AmountVisualTransformation( ) } + private fun formatFiatEditableAmount( + fiatAmount: String?, + fiatCurrencyCode: String, + fiatCurrencySymbol: String, + locale: Locale = Locale.getDefault(), + ): String { + if (fiatAmount == null) return BigDecimalFormatConstants.EMPTY_BALANCE_SIGN + + val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode) + val numberFormatter = NumberFormat.getCurrencyInstance(locale).apply { + currency = formatterCurrency + } + val formatter = requireNotNull(numberFormatter as? DecimalFormat) { + Timber.e("NumberFormat is null") + return BigDecimalFormatConstants.EMPTY_BALANCE_SIGN + } + return "${formatter.positivePrefix}$fiatAmount${formatter.positiveSuffix}" + .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) + } + + private fun formatWithSymbol(amount: String, symbol: String) = "$amount$CURRENCY_SPACE$symbol" + private class OffsetMappingImpl( private val text: String, private val formattedText: AnnotatedString, diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCompactFormat.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCompactFormat.kt new file mode 100644 index 0000000000..f083a63c47 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCompactFormat.kt @@ -0,0 +1,159 @@ +package com.tangem.core.ui.format.bigdecimal + +import android.icu.text.CompactDecimalFormat +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Locale + +// == Formatters == + +/** + * Formats the amount in compact format. + * "123456.6" -> "$123.457K" + * "12345.6" -> "$123.046K" + * @param threeDigitsMethod if true, will format the amount always with 3 significant digits + */ +fun BigDecimalFiatFormat.compact(threeDigitsMethod: Boolean = false): BigDecimalFormat = BigDecimalFormat { value -> + if (value < BigDecimal.ONE) { + return@BigDecimalFormat defaultAmount()(value) + } + + val rawAmount = formatCompactAmount( + amount = value, + locale = locale, + threeDigitsMethod = threeDigitsMethod, + ) + + addFiatCurrencySymbolToStringAmount( + amount = rawAmount, + fiatCurrencyCode = fiatCurrencyCode, + fiatCurrencySymbol = fiatCurrencySymbol, + locale = locale, + ) +} + +/** + * Formats the amount in compact format. + * "123456.6" -> "ETH 123.457K" + * "12345.6" -> "123.046K ETH" + * @param threeDigitsMethod if true, will format the amount always with 3 significant digits + */ +fun BigDecimalCryptoFormat.compact(threeDigitsMethod: Boolean = false): BigDecimalFormat = BigDecimalFormat { value -> + if (value < BigDecimal.ONE) { + return@BigDecimalFormat defaultAmount()(value) + } + + val rawAmount = formatCompactAmount( + amount = value, + locale = locale, + threeDigitsMethod = threeDigitsMethod, + ) + + addFiatCurrencySymbolToStringAmount( + amount = rawAmount, + fiatCurrencyCode = BigDecimalFormatConstants.usdCurrency.currencyCode, + fiatCurrencySymbol = BigDecimalFormatConstants.usdCurrency.symbol, + locale = locale, + ).replaceFiatSymbolWithCrypto( + fiatCurrencySymbol = BigDecimalFormatConstants.usdCurrency.symbol, + cryptoCurrencySymbol = symbol, + ) +} + +/** + * Formats the amount in compact format. + * ex. "123456.6" -> "123.46K", "12345.6" -> "123.05K" + * Negative amount is not supported! + */ +fun BigDecimalFormatScope.rawCompact(locale: Locale = Locale.getDefault()) = BigDecimalFormat { value -> + if (value < BigDecimal.ZERO) { + return@BigDecimalFormat value.toPlainString() + } + + formatCompactAmount( + amount = value, + locale = locale, + threeDigitsMethod = false, + ) +} + +// == Helpers == + +/** + * "123456.6" -> "123.457K" + * "12345.6" -> "123.046K" + * Negative amount is not supported + * @param threeDigitsMethod if true, will format the amount always with 3 significant digits + * @param scale the number of digits to the right of the decimal point + */ +@Suppress("MagicNumber") +private fun formatCompactAmount( + amount: BigDecimal, + locale: Locale = Locale.getDefault(), + threeDigitsMethod: Boolean = false, +): String { + if (threeDigitsMethod) { + val scaledAmount = amount.setScale(0, RoundingMode.HALF_UP) + val digitsCount = scaledAmount.toString().count() + val digitsToFormat = 6 - when (digitsCount % 3) { + 0 -> 0 + 1 -> 2 + else -> 1 + } + + val formatter = CompactDecimalFormat.getInstance( + locale, + CompactDecimalFormat.CompactStyle.SHORT, + ).apply { + minimumSignificantDigits = 4 + maximumSignificantDigits = digitsToFormat + } + + return formatter.format(scaledAmount) + } else { + val scaledAmount = amount.setScale(0, RoundingMode.HALF_UP) + val digitsCount = scaledAmount.toString().count() + val digitsToFormat = 5 - when (digitsCount % 3) { + 0 -> 0 + 1 -> 2 + else -> 1 + } + + val formatter = CompactDecimalFormat.getInstance( + locale, + CompactDecimalFormat.CompactStyle.SHORT, + ).apply { + minimumSignificantDigits = 2 + maximumSignificantDigits = digitsToFormat + } + + return formatter.format(scaledAmount) + } +} + +/** + * Adds a proper currency symbol for the provided formatted [amount] + * ex. '10.0k" -> "$10.0k", "string" -> "$string" + */ +private fun addFiatCurrencySymbolToStringAmount( + amount: String, + fiatCurrencyCode: String, + fiatCurrencySymbol: String, + locale: Locale = Locale.getDefault(), +): String { + val sampleAmount = BigDecimal.TEN + val currency = getJavaCurrencyByCode(fiatCurrencyCode) + + val formatter = NumberFormat.getCurrencyInstance(locale).apply { + maximumFractionDigits = 0 + minimumFractionDigits = 0 + this.currency = currency + } + + val formatted = formatter.format(sampleAmount) + .replace(currency.getSymbol(locale), fiatCurrencySymbol) + .replace(sampleAmount.toString(), amount) + + return formatted +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormat.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormat.kt new file mode 100644 index 0000000000..6973261be0 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormat.kt @@ -0,0 +1,218 @@ +package com.tangem.core.ui.format.bigdecimal + +import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CAN_BE_LOWER_SIGN +import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CRYPTO_FEE_FORMAT_THRESHOLD +import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CURRENCY_SPACE +import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.FORMAT_THRESHOLD +import com.tangem.domain.tokens.model.CryptoCurrency +import com.tangem.utils.extensions.isNotWhitespace +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Currency +import java.util.Locale + +open class BigDecimalCryptoFormat( + val symbol: String, + val decimals: Int, + val locale: Locale = Locale.getDefault(), +) : BigDecimalFormat { + + override fun invoke(value: BigDecimal): String = defaultAmount()(value) +} + +class BigDecimalCryptoFormatFull( + val cryptoCurrency: CryptoCurrency, + locale: Locale = Locale.getDefault(), +) : BigDecimalCryptoFormat( + symbol = cryptoCurrency.symbol, + decimals = cryptoCurrency.decimals, + locale = locale, +) { + override fun invoke(value: BigDecimal): String = defaultAmount()(value) +} + +// == Initializers == + +fun BigDecimalFormatScope.crypto( + symbol: String, + decimals: Int, + locale: Locale = Locale.getDefault(), +): BigDecimalCryptoFormat { + return BigDecimalCryptoFormat( + symbol = symbol, + decimals = decimals, + locale = locale, + ) +} + +fun BigDecimalFormatScope.crypto( + cryptoCurrency: CryptoCurrency, + locale: Locale = Locale.getDefault(), +): BigDecimalCryptoFormat { + return BigDecimalCryptoFormat( + symbol = cryptoCurrency.symbol, + decimals = cryptoCurrency.decimals, + locale = locale, + ) +} + +// == Formatters == + +fun BigDecimalCryptoFormat.defaultAmount() = BigDecimalFormat { value -> + val formatter = NumberFormat.getCurrencyInstance(locale).apply { + currency = usdCurrency + maximumFractionDigits = decimals.coerceAtMost(maximumValue = 8) + minimumFractionDigits = 2 + isGroupingUsed = true + roundingMode = RoundingMode.HALF_UP + } + + formatter.format(value) + .replaceFiatSymbolWithCrypto( + fiatCurrencySymbol = usdCurrency.getSymbol(locale), + cryptoCurrencySymbol = symbol, + ) +} + +fun BigDecimalCryptoFormat.shorted() = BigDecimalFormat { value -> + val formatter = if (value.isMoreThanThreshold()) { + NumberFormat.getCurrencyInstance(locale).apply { + currency = usdCurrency + maximumFractionDigits = 2 + minimumFractionDigits = 2 + isGroupingUsed = true + roundingMode = RoundingMode.HALF_UP + } + } else { + NumberFormat.getCurrencyInstance(locale).apply { + currency = usdCurrency + maximumFractionDigits = decimals.coerceAtMost(maximumValue = 6) + minimumFractionDigits = 2 + isGroupingUsed = true + roundingMode = RoundingMode.DOWN + } + } + + formatter.format(value) + .replaceFiatSymbolWithCrypto( + fiatCurrencySymbol = usdCurrency.getSymbol(locale), + cryptoCurrencySymbol = symbol, + ) +} + +/** + * Format for displaying crypto amounts with their original decimals. + */ +fun BigDecimalCryptoFormat.uncapped() = BigDecimalFormat { value -> + val formatter = NumberFormat.getCurrencyInstance(locale).apply { + currency = usdCurrency + maximumFractionDigits = decimals + minimumFractionDigits = 2 + isGroupingUsed = true + roundingMode = RoundingMode.HALF_UP + } + + formatter.format(value) + .replaceFiatSymbolWithCrypto( + fiatCurrencySymbol = usdCurrency.getSymbol(locale), + cryptoCurrencySymbol = symbol, + ) +} + +/** + * Format for displaying fees. + * If the fee is less than the threshold, it will be displayed as a fixed value "<0.000001 BTC", " + val formatter = NumberFormat.getCurrencyInstance(locale).apply { + currency = usdCurrency + maximumFractionDigits = decimals.coerceAtMost(maximumValue = 6) + minimumFractionDigits = 2 + isGroupingUsed = true + roundingMode = RoundingMode.HALF_UP + } + + if (value.lessThanFeeCryptoThreshold()) { + buildString { + append(CAN_BE_LOWER_SIGN) + append( + formatter + .format(CRYPTO_FEE_FORMAT_THRESHOLD) + .replaceFiatSymbolWithCrypto( + fiatCurrencySymbol = usdCurrency.getSymbol(locale), + cryptoCurrencySymbol = symbol, + addStartSpace = true, + ), + ) + } + } else { + buildString { + if (canBeLower) { + append(CAN_BE_LOWER_SIGN) + } + append( + formatter.format(value) + .replaceFiatSymbolWithCrypto( + fiatCurrencySymbol = usdCurrency.getSymbol(locale), + cryptoCurrencySymbol = symbol, + addStartSpace = canBeLower, + ), + ) + } + } +} + +// == Helpers == + +private fun BigDecimal.isMoreThanThreshold() = this > FORMAT_THRESHOLD + +private fun BigDecimal.lessThanFeeCryptoThreshold() = this > BigDecimal.ZERO && this < CRYPTO_FEE_FORMAT_THRESHOLD + +private val usdCurrency = Currency.getInstance(Locale.US) + +// Replaces fiat currency symbol with crypto currency symbol +// with respect to the position of the symbol and whitespace +internal fun String.replaceFiatSymbolWithCrypto( + fiatCurrencySymbol: String, + cryptoCurrencySymbol: String, + addStartSpace: Boolean = false, +): String { + val str = this + if (str.isEmpty()) return str + + return buildString { + when { + str.endsWith(fiatCurrencySymbol) -> { + val withoutSymbol = str.dropLast(fiatCurrencySymbol.length) + val last = withoutSymbol.lastOrNull() ?: return cryptoCurrencySymbol + + append(withoutSymbol) + + if (last.isNotWhitespace()) { + append(CURRENCY_SPACE) + } + + append(cryptoCurrencySymbol) + } + str.startsWith(fiatCurrencySymbol) -> { + if (addStartSpace) { + append(CURRENCY_SPACE) + } + + append(cryptoCurrencySymbol) + + val withoutSymbol = str.drop(fiatCurrencySymbol.length) + val first = withoutSymbol.firstOrNull() + ?: return cryptoCurrencySymbol + + if (first.isNotWhitespace()) { + append(CURRENCY_SPACE) + } + + append(withoutSymbol) + } + else -> append(str) + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt new file mode 100644 index 0000000000..4822754075 --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormat.kt @@ -0,0 +1,144 @@ +package com.tangem.core.ui.format.bigdecimal + +import com.tangem.core.ui.format.bigdecimal.BigDecimalFormatConstants.CAN_BE_LOWER_SIGN +import com.tangem.utils.StringsSigns.TILDE_SIGN +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Locale + +open class BigDecimalFiatFormat( + val fiatCurrencyCode: String, + val fiatCurrencySymbol: String, + val locale: Locale = Locale.getDefault(), +) : BigDecimalFormat { + override fun invoke(p1: BigDecimal): String = error("") +} + +// == Initializers == + +fun BigDecimalFormatScope.fiat( + fiatCurrencyCode: String, + fiatCurrencySymbol: String, + locale: Locale = Locale.getDefault(), +): BigDecimalFiatFormat { + return BigDecimalFiatFormat( + fiatCurrencyCode = fiatCurrencyCode, + fiatCurrencySymbol = fiatCurrencySymbol, + locale = locale, + ) +} + +// == Formatters == + +/** + * Formats fiat amount with default precision. + */ +fun BigDecimalFiatFormat.defaultAmount(): BigDecimalFormat = BigDecimalFormat { value -> + val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode) + + val formatter = NumberFormat.getCurrencyInstance(locale).apply { + currency = formatterCurrency + maximumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS + minimumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS + roundingMode = RoundingMode.HALF_UP + } + + if (value.isLessThanThreshold()) { + buildString { + append(CAN_BE_LOWER_SIGN) + append( + formatter.format(FIAT_FORMAT_THRESHOLD) + .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol), + ) + } + } else { + formatter.format(value) + .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) + } +} + +/** + * Formats fiat amount with default precision and adds tilde sign + */ +fun BigDecimalFiatFormat.approximateAmount(): BigDecimalFormat = BigDecimalFormat { value -> + val formattedAmount = defaultAmount()(value) + + if (value.isLessThanThreshold()) { + formattedAmount + } else { + buildString { + append(TILDE_SIGN) + append(formattedAmount) + } + } +} + +/** + * Formats fiat amount with extended precision. + */ +fun BigDecimalFiatFormat.uncapped(): BigDecimalFormat = BigDecimalFormat { value -> + val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode) + + val digits = if (value.isLessThanThreshold()) { + FIAT_MARKET_EXTENDED_DIGITS + } else { + FIAT_MARKET_DEFAULT_DIGITS + } + + val formatter = NumberFormat.getCurrencyInstance(locale).apply { + currency = formatterCurrency + maximumFractionDigits = digits + minimumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS + roundingMode = RoundingMode.HALF_UP + } + + formatter.format(value) + .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) +} + +/** + * Formats fiat price with precision calculated based on the value. + * @see getFiatPriceAmountWithScale + */ +fun BigDecimalFiatFormat.price(): BigDecimalFormat = BigDecimalFormat { value -> + val formatterCurrency = getJavaCurrencyByCode(fiatCurrencyCode) + + val (priceAmount, finalScale) = getFiatPriceAmountWithScale(value = value) + + val formatter = NumberFormat.getCurrencyInstance(locale).apply { + currency = formatterCurrency + maximumFractionDigits = finalScale + minimumFractionDigits = FIAT_MARKET_DEFAULT_DIGITS + roundingMode = RoundingMode.HALF_UP + } + + formatter.format(priceAmount) + .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) +} + +// == Helpers == + +private fun BigDecimal.isLessThanThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD + +private fun getFiatPriceAmountWithScale(value: BigDecimal): Pair { + return if (value < BigDecimal.ONE) { + val leadingZeroes = value.scale() - value.precision() + val scale = leadingZeroes + FRACTIONAL_PART_LENGTH_AFTER_LEADING_ZEROES + + val amount = value + .setScale(scale, RoundingMode.HALF_UP) + .stripTrailingZeros() + + amount to amount.scale() + } else { + value to FIAT_MARKET_DEFAULT_DIGITS + } +} + +// == Constants == + +private val FIAT_FORMAT_THRESHOLD = BigDecimal("0.01") +private const val FIAT_MARKET_DEFAULT_DIGITS = 2 +private const val FIAT_MARKET_EXTENDED_DIGITS = 6 +private const val FRACTIONAL_PART_LENGTH_AFTER_LEADING_ZEROES = 4 \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFormat.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFormat.kt new file mode 100644 index 0000000000..c685db7f1d --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFormat.kt @@ -0,0 +1,29 @@ +package com.tangem.core.ui.format.bigdecimal + +import java.math.BigDecimal + +interface BigDecimalFormatScope { + companion object { val Empty = object : BigDecimalFormatScope {} } +} + +fun interface BigDecimalFormat : (BigDecimal) -> String, BigDecimalFormatScope + +inline fun BigDecimal.format(block: BigDecimalFormatScope.() -> BigDecimalFormat): String { + return BigDecimalFormatScope.Empty.block()(this) +} + +inline fun BigDecimal?.format( + fallbackString: String = BigDecimalFormatConstants.EMPTY_BALANCE_SIGN, + block: BigDecimalFormatScope.() -> BigDecimalFormat, +): String { + if (this == null) return fallbackString + return BigDecimalFormatScope.Empty.block()(this) +} + +fun BigDecimal?.format( + format: BigDecimalFormat, + fallbackString: String = BigDecimalFormatConstants.EMPTY_BALANCE_SIGN, +): String { + if (this == null) return fallbackString + return format(this) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFormatConstants.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFormatConstants.kt new file mode 100644 index 0000000000..59835ab84c --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalFormatConstants.kt @@ -0,0 +1,20 @@ +package com.tangem.core.ui.format.bigdecimal + +import com.tangem.utils.StringsSigns.DASH_SIGN +import com.tangem.utils.StringsSigns.LOWER_SIGN +import java.math.BigDecimal +import java.util.Currency +import java.util.Locale + +object BigDecimalFormatConstants { + + const val EMPTY_BALANCE_SIGN = DASH_SIGN + const val CAN_BE_LOWER_SIGN = LOWER_SIGN + val FORMAT_THRESHOLD = BigDecimal("0.01") + + const val CURRENCY_SPACE = '\u00a0' + + val CRYPTO_FEE_FORMAT_THRESHOLD = BigDecimal("0.000001") + + val usdCurrency: Currency by lazy { Currency.getInstance(Locale.US) } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalPercentFormat.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalPercentFormat.kt new file mode 100644 index 0000000000..b7019d1d3c --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/BigDecimalPercentFormat.kt @@ -0,0 +1,39 @@ +package com.tangem.core.ui.format.bigdecimal + +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.NumberFormat +import java.util.Locale + +class BigDecimalPercentFormat( + val withoutSign: Boolean = true, + val locale: Locale = Locale.getDefault(), +) : BigDecimalFormat { + override fun invoke(value: BigDecimal): String = default()(value) +} + +// == Initializers == + +fun BigDecimalFormatScope.percent( + withoutSign: Boolean = true, + locale: Locale = Locale.getDefault(), +): BigDecimalPercentFormat { + return BigDecimalPercentFormat( + withoutSign = withoutSign, + locale = locale, + ) +} + +// == Formatters == + +private fun BigDecimalPercentFormat.default(): BigDecimalFormat = BigDecimalFormat { value -> + val formatter = NumberFormat.getPercentInstance(locale).apply { + maximumFractionDigits = 2 + minimumFractionDigits = 2 + roundingMode = RoundingMode.HALF_UP + } + + val valueToFormat = if (withoutSign) value.abs() else value + + formatter.format(valueToFormat) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/Utils.kt b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/Utils.kt new file mode 100644 index 0000000000..94509b276f --- /dev/null +++ b/core/ui/src/main/java/com/tangem/core/ui/format/bigdecimal/Utils.kt @@ -0,0 +1,15 @@ +package com.tangem.core.ui.format.bigdecimal + +import java.util.Currency + +fun getJavaCurrencyByCode(code: String): Currency { + return runCatching { Currency.getInstance(code) } + .getOrElse { e -> + // Currency code is not valid ISO 4217 code + if (e is IllegalArgumentException) { + BigDecimalFormatConstants.usdCurrency + } else { + throw e + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt index 4c44809cfa..fd6a353bb1 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/BigDecimalFormatter.kt @@ -1,20 +1,17 @@ package com.tangem.core.ui.utils -import android.icu.text.CompactDecimalFormat import com.tangem.domain.tokens.model.CryptoCurrency import com.tangem.utils.StringsSigns.DASH_SIGN import com.tangem.utils.StringsSigns.LOWER_SIGN import com.tangem.utils.StringsSigns.TILDE_SIGN -import com.tangem.utils.extensions.isNotWhitespace -import timber.log.Timber import java.math.BigDecimal import java.math.RoundingMode -import java.text.DecimalFormat import java.text.NumberFormat import java.util.Currency import java.util.Locale @Suppress("LargeClass") +@Deprecated("Use BigDecimal.format") object BigDecimalFormatter { const val EMPTY_BALANCE_SIGN = DASH_SIGN @@ -30,10 +27,7 @@ object BigDecimalFormatter { private val usdCurrency = Currency.getInstance("USD") - @Deprecated( - "Use formatCryptoAmount2", - replaceWith = ReplaceWith("formatCryptoAmount2"), - ) + @Deprecated("Use BigDecimal.format") fun formatCryptoAmount( cryptoAmount: BigDecimal?, cryptoCurrency: String, @@ -58,30 +52,7 @@ object BigDecimalFormatter { } } - // Migrate to this method from formatCryptoAmount ([REDACTED_TASK_KEY]) - fun formatCryptoAmount2( - cryptoAmount: BigDecimal?, - cryptoCurrency: String, - decimals: Int, - locale: Locale = Locale.getDefault(), - ): String { - if (cryptoAmount == null) return EMPTY_BALANCE_SIGN - - val formatter = NumberFormat.getCurrencyInstance(locale).apply { - currency = usdCurrency - maximumFractionDigits = decimals.coerceAtMost(maximumValue = 8) - minimumFractionDigits = 2 - isGroupingUsed = true - roundingMode = RoundingMode.HALF_UP - } - - return formatter.format(cryptoAmount) - .replaceFiatSymbolWithCrypto( - fiatCurrencySymbol = usdCurrency.symbol, - cryptoCurrencySymbol = cryptoCurrency, - ) - } - + @Deprecated("Use BigDecimal.format") fun formatCryptoAmountShorted( cryptoAmount: BigDecimal?, cryptoCurrency: String, @@ -115,6 +86,7 @@ object BigDecimalFormatter { } } + @Deprecated("Use BigDecimal.format") fun formatCryptoAmountUncapped( cryptoAmount: BigDecimal?, cryptoCurrency: CryptoCurrency, @@ -138,6 +110,7 @@ object BigDecimalFormatter { } } + @Deprecated("Use BigDecimal.format") fun formatCryptoFeeAmount( cryptoAmount: BigDecimal?, cryptoCurrency: String, @@ -177,6 +150,7 @@ object BigDecimalFormatter { } } + @Deprecated("Use BigDecimal.format") fun formatCryptoAmount( cryptoAmount: BigDecimal?, cryptoCurrency: CryptoCurrency, @@ -185,6 +159,7 @@ object BigDecimalFormatter { return formatCryptoAmount(cryptoAmount, cryptoCurrency.symbol, cryptoCurrency.decimals, locale) } + @Deprecated("Use BigDecimal.format") fun formatFiatAmount( fiatAmount: BigDecimal?, fiatCurrencyCode: String, @@ -226,6 +201,7 @@ object BigDecimalFormatter { } } + @Deprecated("Use BigDecimal.format") fun formatFiatAmountUncapped( fiatAmount: BigDecimal?, fiatCurrencyCode: String, @@ -251,6 +227,7 @@ object BigDecimalFormatter { .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) } + @Deprecated("Use BigDecimal.format") fun formatFiatPriceUncapped( fiatAmount: BigDecimal?, fiatCurrencyCode: String, @@ -273,6 +250,7 @@ object BigDecimalFormatter { .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) } + @Deprecated("Use BigDecimal.format") fun getFiatPriceUncappedWithScale(value: BigDecimal): Pair { return if (value < BigDecimal.ONE) { val leadingZeroes = value.scale() - value.precision() @@ -288,26 +266,7 @@ object BigDecimalFormatter { } } - fun formatFiatEditableAmount( - fiatAmount: String?, - fiatCurrencyCode: String, - fiatCurrencySymbol: String, - locale: Locale = Locale.getDefault(), - ): String { - if (fiatAmount == null) return EMPTY_BALANCE_SIGN - - val formatterCurrency = getCurrency(fiatCurrencyCode) - val numberFormatter = NumberFormat.getCurrencyInstance(locale).apply { - currency = formatterCurrency - } - val formatter = requireNotNull(numberFormatter as? DecimalFormat) { - Timber.e("NumberFormat is null") - return EMPTY_BALANCE_SIGN - } - return "${formatter.positivePrefix}$fiatAmount${formatter.positiveSuffix}" - .replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol) - } - + @Deprecated("Use BigDecimal.format") fun formatPercent( percent: BigDecimal, useAbsoluteValue: Boolean, @@ -341,230 +300,6 @@ object BigDecimalFormatter { } } - /** - * Adds a proper currency sign for the provided formatted [amount] - * ex. '10.0k" -> "$10.0k", "string" -> "$string" - */ - private fun addCurrencySymbolToStringAmount( - amount: String, - fiatCurrencyCode: String, - fiatCurrencySymbol: String, - locale: Locale = Locale.getDefault(), - ): String { - val sampleAmount = BigDecimal.TEN - val currency = getCurrency(fiatCurrencyCode) - - val formatter = NumberFormat.getCurrencyInstance(locale).apply { - maximumFractionDigits = 0 - minimumFractionDigits = 0 - this.currency = currency - } - - val formatted = formatter.format(sampleAmount) - .replace(currency.getSymbol(locale), fiatCurrencySymbol) - .replace(sampleAmount.toString(), amount) - - return formatted - } - - /** - * Adds a proper currency sign for the provided formatted [amount] - * ex. '10.0k" -> "ETH 10.0k", "string" -> "ETH string" - */ - private fun addCryptoCurrencySymbolToStringAmount( - amount: String, - cryptoCurrencySymbol: String, - locale: Locale = Locale.getDefault(), - ): String { - val sampleAmount = BigDecimal.TEN - - val formatter = NumberFormat.getCurrencyInstance(locale).apply { - maximumFractionDigits = 0 - minimumFractionDigits = 0 - currency = usdCurrency - } - - val formatted = formatter.format(sampleAmount) - .replace(sampleAmount.toString(), amount) - - return formatted.replaceFiatSymbolWithCrypto( - fiatCurrencySymbol = usdCurrency.symbol, - cryptoCurrencySymbol = cryptoCurrencySymbol, - ) - } - - /** - * "123456.6" -> "$123.457K" - * "12345.6" -> "$123.046K" - * Negative amount is not supported - * @param threeDigitsMethod if true, will format the amount always with 3 significant digits - * @param scale the number of digits to the right of the decimal point - */ - @Suppress("MagicNumber") - fun formatCompactFiatAmount( - amount: BigDecimal?, - fiatCurrencyCode: String, - fiatCurrencySymbol: String, - threeDigitsMethod: Boolean = false, - scale: Int = 0, - locale: Locale = Locale.getDefault(), - ): String { - if (amount == null) return EMPTY_BALANCE_SIGN - - if (amount < BigDecimal.ONE) { - return formatFiatPriceUncapped( - fiatAmount = amount, - fiatCurrencyCode = fiatCurrencyCode, - fiatCurrencySymbol = fiatCurrencySymbol, - locale = locale, - ) - } - - val rawAmount = formatCompactAmount( - amount = amount, - locale = locale, - threeDigitsMethod = threeDigitsMethod, - scale = scale, - ) - - return addCurrencySymbolToStringAmount( - amount = rawAmount, - fiatCurrencyCode = fiatCurrencyCode, - fiatCurrencySymbol = fiatCurrencySymbol, - locale = locale, - ) - } - - /** - * "123456.6" -> "ETH 123.457K" - * "12345.6" -> "123.046K ETH" - * Negative amount is not supported - * @param threeDigitsMethod if true, will format the amount always with 3 significant digits - * @param scale the number of digits to the right of the decimal point - */ - fun formatCompactCryptoAmount( - amount: BigDecimal?, - cryptoCurrencySymbol: String, - threeDigitsMethod: Boolean = false, - decimals: Int = 0, - locale: Locale = Locale.getDefault(), - ): String { - if (amount == null) return EMPTY_BALANCE_SIGN - - if (amount < BigDecimal.ONE) { - return formatCryptoAmount2( - cryptoAmount = amount, - cryptoCurrency = cryptoCurrencySymbol, - decimals = decimals, - locale = locale, - ) - } - - val rawAmount = formatCompactAmount( - amount = amount, - locale = locale, - threeDigitsMethod = threeDigitsMethod, - scale = decimals, - ) - - return addCryptoCurrencySymbolToStringAmount( - amount = rawAmount, - cryptoCurrencySymbol = cryptoCurrencySymbol, - locale = locale, - ) - } - - /** - * "123456.6" -> "123.457K" - * "12345.6" -> "123.046K" - * Negative amount is not supported - * @param threeDigitsMethod if true, will format the amount always with 3 significant digits - * @param scale the number of digits to the right of the decimal point - */ - @Suppress("MagicNumber") - fun formatCompactAmount( - amount: BigDecimal, - locale: Locale = Locale.getDefault(), - threeDigitsMethod: Boolean = false, - scale: Int = 0, - ): String { - if (threeDigitsMethod) { - val scaledAmount = amount.setScale(scale, RoundingMode.HALF_UP) - val digitsCount = scaledAmount.toString().count() - val digitsToFormat = 6 - when (digitsCount % 3) { - 0 -> 0 - 1 -> 2 - else -> 1 - } - - val formatter = CompactDecimalFormat.getInstance( - locale, - CompactDecimalFormat.CompactStyle.SHORT, - ).apply { - minimumSignificantDigits = 4 - maximumSignificantDigits = digitsToFormat - } - - return formatter.format(amount.setScale(scale, RoundingMode.HALF_UP)) - } else { - val scaledAmount = amount.setScale(scale, RoundingMode.HALF_UP) - val digitsCount = scaledAmount.toString().count() - val digitsToFormat = 5 - when (digitsCount % 3) { - 0 -> 0 - 1 -> 2 - else -> 1 - } - - val formatter = CompactDecimalFormat.getInstance( - locale, - CompactDecimalFormat.CompactStyle.SHORT, - ).apply { - minimumSignificantDigits = 2 - maximumSignificantDigits = digitsToFormat - } - - return formatter.format(amount.setScale(scale, RoundingMode.HALF_UP)) - } - } - - // Replaces fiat currency symbol with crypto currency symbol - // with respect to the position of the symbol and whitespace - private fun String.replaceFiatSymbolWithCrypto(fiatCurrencySymbol: String, cryptoCurrencySymbol: String): String { - val str = this - if (str.isEmpty()) return str - - return buildString { - when { - str.endsWith(fiatCurrencySymbol) -> { - val withoutSymbol = str.dropLast(fiatCurrencySymbol.length) - val last = withoutSymbol.lastOrNull() ?: return cryptoCurrencySymbol - - append(withoutSymbol) - - if (last.isNotWhitespace()) { - append("\u2009") - } - - append(cryptoCurrencySymbol) - } - str.startsWith(fiatCurrencySymbol) -> { - append(cryptoCurrencySymbol) - - val withoutSymbol = str.drop(fiatCurrencySymbol.length) - val first = withoutSymbol.firstOrNull() - ?: return cryptoCurrencySymbol - - if (first.isNotWhitespace()) { - append("\u2009") - } - - append(withoutSymbol) - } - else -> append(str) - } - } - } - private fun BigDecimal.checkFiatThreshold() = this > BigDecimal.ZERO && this < FIAT_FORMAT_THRESHOLD private fun BigDecimal.checkCryptoThreshold() = this > BigDecimal.ZERO && this < CRYPTO_FEE_FORMAT_THRESHOLD diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormatTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormatTest.kt new file mode 100644 index 0000000000..cc6fa5e40e --- /dev/null +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalCryptoFormatTest.kt @@ -0,0 +1,373 @@ +package com.tangem.core.ui.format.bigdecimal + +import com.google.common.truth.Truth +import org.junit.Test +import java.math.BigDecimal +import java.util.Locale + +internal class BigDecimalCryptoFormatTest { + + private val testLocale = Locale.US + private val testLocale2 = Locale.GERMANY + private val symbol = "BTC" + + // === defaultAmount() === + + @Test + fun `defaultAmount (usually used as a user balance)`() { + val testValue = BigDecimal("0.123456789999") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 8, + locale = testLocale, + ).defaultAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("0.12345679".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `defaultAmount (usually used as a user balance) alter locale`() { + val testValue = BigDecimal("0.123456789999") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 8, + locale = testLocale2, + ).defaultAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("0,12345679".addSymbolWithSpaceRight(symbol)) + } + + @Test + fun `defaultAmount decimals more than 8`() { + val testValue = BigDecimal("0.123456789999") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 10, + locale = testLocale, + ).defaultAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("0.12345679".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `defaultAmount decimals more than 8 (short value)`() { + val testValue = BigDecimal("0.12345") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 10, + locale = testLocale, + ).defaultAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("0.12345".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `defaultAmount decimals minimal (short value)`() { + val testValue = BigDecimal("0.12345") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 2, + locale = testLocale, + ).defaultAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("0.12".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `defaultAmount less than 2 decimals`() { + val testValue = BigDecimal("0.12345") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 0, + locale = testLocale, + ).defaultAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("0.12".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `defaultAmount grouping`() { + val testValue = BigDecimal("12345678.11") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 0, + locale = testLocale, + ).defaultAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("12,345,678.11".addSymbolWithSpaceLeft(symbol)) + } + + // === shorted() === + + @Test + fun `shorted amount smoke`() { + val testValue = BigDecimal("50000.126123") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 8, + locale = testLocale, + ).shorted() + } + + Truth.assertThat(formatted) + .isEqualTo("50,000.13".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `shorted amount decimals less than 2 grouping`() { + val testValue = BigDecimal("50000.126123") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 1, + locale = testLocale, + ).shorted() + } + + Truth.assertThat(formatted) + .isEqualTo("50,000.13".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `shorted amount less than threshold`() { + val testValue = BigDecimal("0.0034567899") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 4, + locale = testLocale, + ).shorted() + } + + Truth.assertThat(formatted) + .isEqualTo("0.0034".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `shorted amount less than threshold, more decimals`() { + val testValue = BigDecimal("0.00345678") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 8, + locale = testLocale, + ).shorted() + } + + Truth.assertThat(formatted) + .isEqualTo("0.003456".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `shorted amount diff locale half up`() { + val testValue = BigDecimal("50000.126123") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 8, + locale = testLocale2, + ).shorted() + } + + Truth.assertThat(formatted) + .isEqualTo("50.000,13".addSymbolWithSpaceRight(symbol)) + } + + // === uncapped() === + + @Test + fun `uncapped amount`() { + val testValue = BigDecimal("50000.123412341234") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 10, + locale = testLocale, + ).uncapped() + } + + Truth.assertThat(formatted) + .isEqualTo("50,000.1234123412".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `uncapped amount diff locale`() { + val testValue = BigDecimal("50000.123412341234") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 10, + locale = testLocale2, + ).uncapped() + } + + Truth.assertThat(formatted) + .isEqualTo("50.000,1234123412".addSymbolWithSpaceRight(symbol)) + } + + @Test + fun `uncapped amount half up`() { + val testValue = BigDecimal("50000.12341234125") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 10, + locale = testLocale2, + ).uncapped() + } + + Truth.assertThat(formatted) + .isEqualTo("50.000,1234123413".addSymbolWithSpaceRight(symbol)) + } + + @Test + fun `uncapped amount min decimals`() { + val testValue = BigDecimal("50000.12341234125") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 1, + locale = testLocale2, + ).uncapped() + } + + Truth.assertThat(formatted) + .isEqualTo("50.000,12".addSymbolWithSpaceRight(symbol)) + } + + // === fee === + + @Test + fun `fee amount`() { + val testValue = BigDecimal("0.000123412341234") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 10, + locale = testLocale, + ).fee() + } + + Truth.assertThat(formatted) + .isEqualTo("0.000123".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `fee amount diff locale`() { + val testValue = BigDecimal("0.000123412341234") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 10, + locale = testLocale2, + ).fee() + } + + Truth.assertThat(formatted) + .isEqualTo("0,000123".addSymbolWithSpaceRight(symbol)) + } + + @Test + fun `fee amount canBeLower true`() { + val testValue = BigDecimal("0.000123412341234") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 10, + locale = testLocale, + ).fee(canBeLower = true) + } + + Truth.assertThat(formatted) + .isEqualTo("<" + CURRENCY_SPACE_FOR_TESTS + "0.000123".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `fee amount canBeLower true (diff locale)`() { + val testValue = BigDecimal("0.000123412341234") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 10, + locale = testLocale2, + ).fee(canBeLower = true) + } + + Truth.assertThat(formatted) + .isEqualTo("<" + "0,000123".addSymbolWithSpaceRight(symbol)) + } + + @Test + fun `fee amount lee than threshold`() { + val testValue = BigDecimal("0.0000001234") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 10, + locale = testLocale, + ).fee() + } + + Truth.assertThat(formatted) + .isEqualTo("<" + CURRENCY_SPACE_FOR_TESTS + "0.000001".addSymbolWithSpaceLeft(symbol)) + } + + @Test + fun `fee amount min decimals half up`() { + val testValue = BigDecimal("0.125412341234") + + val formatted = testValue.format { + crypto( + symbol = symbol, + decimals = 1, + locale = testLocale, + ).fee() + } + + Truth.assertThat(formatted) + .isEqualTo("0.13".addSymbolWithSpaceLeft(symbol)) + } +} \ No newline at end of file diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt new file mode 100644 index 0000000000..d5c2cb310e --- /dev/null +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFiatFormatTest.kt @@ -0,0 +1,297 @@ +package com.tangem.core.ui.format.bigdecimal + +import com.google.common.truth.Truth +import org.junit.Test +import java.math.BigDecimal +import java.util.Locale + +internal class BigDecimalFiatFormatTest { + + val testLocale = Locale.US + val testLocale2 = Locale.GERMANY + + val usdCurrencyCode = "USD" + val usdSymbol = "$" + + private fun String.addUsdSymbolLeft() = usdSymbol + this + + // === defaultAmount() === + + @Test + fun `defaultAmount smoke`() { + val testValue = BigDecimal("1234.1234") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).defaultAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("1,234.12".addUsdSymbolLeft()) + } + + @Test + fun `defaultAmount half up`() { + val testValue = BigDecimal("1234.125") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).defaultAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("1,234.13".addUsdSymbolLeft()) + } + + @Test + fun `defaultAmount diff locale`() { + val testValue = BigDecimal("1234.1234") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale2, + ).defaultAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("1.234,12".addSymbolWithSpaceRight(usdSymbol)) + } + + @Test + fun `defaultAmount less threshold`() { + val testValue = BigDecimal("0.002234") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).defaultAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("<" + "0.01".addUsdSymbolLeft()) + } + + @Test + fun `defaultAmount less threshold diff locale`() { + val testValue = BigDecimal("0.002234") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale2, + ).defaultAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("<" + "0,01".addSymbolWithSpaceRight(usdSymbol)) + } + + // === approximateAmount() === + + @Test + fun `approximateAmount smoke`() { + val testValue = BigDecimal("1234.1234") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).approximateAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("~" + "1,234.12".addUsdSymbolLeft()) + } + + @Test + fun `approximateAmount half up`() { + val testValue = BigDecimal("1234.125") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).approximateAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("~" + "1,234.13".addUsdSymbolLeft()) + } + + @Test + fun `approximateAmount diff locale`() { + val testValue = BigDecimal("1234.1234") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale2, + ).approximateAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("~" + "1.234,12".addSymbolWithSpaceRight(usdSymbol)) + } + + @Test + fun `approximateAmount less threshold`() { + val testValue = BigDecimal("0.002234") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).approximateAmount() + } + + Truth.assertThat(formatted) + .isEqualTo("<" + "0.01".addUsdSymbolLeft()) + } + + // === uncapped() === + + @Test + fun `uncapped smoke`() { + val testValue = BigDecimal("1234.1234") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).uncapped() + } + + Truth.assertThat(formatted) + .isEqualTo("1,234.12".addUsdSymbolLeft()) + } + + @Test + fun `uncapped less threshold`() { + val testValue = BigDecimal("0.00121") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).uncapped() + } + + Truth.assertThat(formatted) + .isEqualTo("0.00121".addUsdSymbolLeft()) + } + + @Test + fun `uncapped decimals overflow`() { + val testValue = BigDecimal("0.00123412341234") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).uncapped() + } + + Truth.assertThat(formatted) + .isEqualTo("0.001234".addUsdSymbolLeft()) + } + + // === price() === + + @Test + fun `price smoke`() { + val testValue = BigDecimal("1234.1234") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).price() + } + + Truth.assertThat(formatted) + .isEqualTo("1,234.12".addUsdSymbolLeft()) + } + + @Test + fun `price diff locale`() { + val testValue = BigDecimal("1234.1234") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale2, + ).price() + } + + Truth.assertThat(formatted) + .isEqualTo("1.234,12".addSymbolWithSpaceRight(usdSymbol)) + } + + @Test + fun `price less threshold`() { + val testValue = BigDecimal("0.99987") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).price() + } + + Truth.assertThat(formatted) + .isEqualTo("0.9999".addUsdSymbolLeft()) + } + + @Test + fun `price less threshold more decimals strip zeros`() { + val testValue = BigDecimal("0.0000123000") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).price() + } + + Truth.assertThat(formatted) + .isEqualTo("0.0000123".addUsdSymbolLeft()) + } + + @Test + fun `price less threshold too much decimals strip zeros`() { + val testValue = BigDecimal("0.000000000000000000001230001234000") + + val formatted = testValue.format { + fiat( + fiatCurrencyCode = usdCurrencyCode, + fiatCurrencySymbol = usdSymbol, + locale = testLocale, + ).price() + } + + Truth.assertThat(formatted) + .isEqualTo("0.00000000000000000000123".addUsdSymbolLeft()) + } +} \ No newline at end of file diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFormatTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFormatTest.kt new file mode 100644 index 0000000000..63061a9116 --- /dev/null +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalFormatTest.kt @@ -0,0 +1,29 @@ +package com.tangem.core.ui.format.bigdecimal + +import com.google.common.truth.Truth +import org.junit.Test +import java.math.BigDecimal + +internal class BigDecimalFormatTest { + + @Test + fun smoke() { + val value = BigDecimal("1234") + val bgformat = BigDecimalFormat { bg -> + bg.toPlainString() + "!" + } + val expected = "1234!" + + Truth.assertThat( + value.format(bgformat), + ).isEqualTo(expected) + + Truth.assertThat( + value.format { bgformat }, + ).isEqualTo(expected) + + Truth.assertThat( + null.format(fallbackString = "!") { bgformat }, + ).isEqualTo("!") + } +} \ No newline at end of file diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalPercentFormatTest.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalPercentFormatTest.kt new file mode 100644 index 0000000000..3d3be8c25f --- /dev/null +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/BigDecimalPercentFormatTest.kt @@ -0,0 +1,70 @@ +package com.tangem.core.ui.format.bigdecimal + +import com.google.common.truth.Truth +import org.junit.Test +import java.math.BigDecimal +import java.util.Locale + +internal class BigDecimalPercentFormatTest { + + val testLocale = Locale.US + val testLocale2 = Locale.GERMANY + + @Test + fun smoke() { + val value = BigDecimal("00.34") + + val formatted = value.format { + percent(locale = testLocale) + } + + Truth.assertThat(formatted).isEqualTo("34.00%") + } + + @Test + fun negative() { + val value = BigDecimal("00.34").negate() + + val formatted = value.format { + percent(locale = testLocale) + } + + Truth.assertThat(formatted).isEqualTo("34.00%") + } + + @Test + fun `negative with sign`() { + val value = BigDecimal("00.34").negate() + + val formatted = value.format { + percent( + withoutSign = false, + locale = testLocale, + ) + } + + Truth.assertThat(formatted).isEqualTo("-34.00%") + } + + @Test + fun `default more decimals half up`() { + val value = BigDecimal("00.345678").negate() + + val formatted = value.format { + percent(locale = testLocale) + } + + Truth.assertThat(formatted).isEqualTo("34.57%") + } + + @Test + fun `default diff locale`() { + val value = BigDecimal("00.345678").negate() + + val formatted = value.format { + percent(locale = testLocale2) + } + + Truth.assertThat(formatted).isEqualTo("34,57".addSymbolWithSpaceRight("%")) + } +} \ No newline at end of file diff --git a/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/TestUtils.kt b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/TestUtils.kt new file mode 100644 index 0000000000..436873335d --- /dev/null +++ b/core/ui/src/test/kotlin/com/tangem/core/ui/format/bigdecimal/TestUtils.kt @@ -0,0 +1,7 @@ +package com.tangem.core.ui.format.bigdecimal + +internal const val CURRENCY_SPACE_FOR_TESTS = '\u00a0' + +internal fun String.addSymbolWithSpaceRight(symbol: String): String = "$this$CURRENCY_SPACE_FOR_TESTS$symbol" + +internal fun String.addSymbolWithSpaceLeft(symbol: String): String = "$symbol$CURRENCY_SPACE_FOR_TESTS$this" \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt index fb93bd86a2..65273ee0bb 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/InsightsConverter.kt @@ -3,7 +3,10 @@ package com.tangem.features.markets.details.impl.model.converters import androidx.compose.runtime.Stable import com.tangem.core.ui.extensions.resourceReference import com.tangem.core.ui.extensions.wrappedList -import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.format.bigdecimal.compact +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.format.bigdecimal.rawCompact import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.PriceChangeInterval import com.tangem.domain.markets.TokenMarketInfo @@ -142,14 +145,17 @@ internal class InsightsConverter( private fun BigDecimal.convertChange(isFiatValue: Boolean = false): String { val value = if (isFiatValue) { - val currency = appCurrency() - BigDecimalFormatter.formatCompactFiatAmount( - amount = this.abs(), - fiatCurrencyCode = currency.code, - fiatCurrencySymbol = currency.symbol, - ) + this.abs().format { + val currency = appCurrency() + fiat( + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + ).compact() + } } else { - BigDecimalFormatter.formatCompactAmount(amount = this.abs()) + this.abs().format { + rawCompact() + } } return when { diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt index 48b3217b3d..c91039839b 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/details/impl/model/converters/MetricsConverter.kt @@ -2,7 +2,10 @@ package com.tangem.features.markets.details.impl.model.converters import androidx.compose.runtime.Stable import com.tangem.core.ui.extensions.resourceReference -import com.tangem.core.ui.utils.BigDecimalFormatter +import com.tangem.core.ui.format.bigdecimal.compact +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarketInfo import com.tangem.features.markets.details.impl.ui.state.InfoBottomSheetContent @@ -129,18 +132,21 @@ internal class MetricsConverter( if (this == null) return StringsSigns.DASH_SIGN return if (crypto) { - BigDecimalFormatter.formatCompactCryptoAmount( - amount = this, - cryptoCurrencySymbol = tokenSymbol, - ) + format { + crypto( + symbol = tokenSymbol, + decimals = 2, + ).compact() + } } else { val currency = appCurrency() - BigDecimalFormatter.formatCompactFiatAmount( - amount = this, - fiatCurrencyCode = currency.code, - fiatCurrencySymbol = currency.symbol, - ) + format { + fiat( + fiatCurrencyCode = currency.code, + fiatCurrencySymbol = currency.symbol, + ).compact() + } } } } \ No newline at end of file diff --git a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt index b59acbc16d..f4d62e02de 100644 --- a/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt +++ b/features/markets/impl/src/main/kotlin/com/tangem/features/markets/tokenlist/impl/model/converters/MarketsTokenItemConverter.kt @@ -5,6 +5,9 @@ import com.tangem.common.ui.charts.state.MarketChartRawData import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter import com.tangem.common.ui.charts.state.sorted import com.tangem.core.ui.components.marketprice.PriceChangeType +import com.tangem.core.ui.format.bigdecimal.compact +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format import com.tangem.core.ui.utils.BigDecimalFormatter import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.markets.TokenMarket @@ -71,12 +74,14 @@ internal class MarketsTokenItemConverter( private fun TokenMarket.getMarketCap(): String? { val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null - return BigDecimalFormatter.formatCompactFiatAmount( - amount = value, - fiatCurrencyCode = appCurrency.code, - fiatCurrencySymbol = appCurrency.symbol, - threeDigitsMethod = true, - ) + return value.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ).compact( + threeDigitsMethod = true, + ) + } } private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price {