Updated on 2026-08-14

This commit is contained in:
Tangem 2024-10-23 20:01:15 +03:00
commit cab30e9431
186 changed files with 3729 additions and 2030 deletions

View file

@ -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,

View file

@ -24,9 +24,10 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.annotatedReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.utils.BigDecimalFormatter
import java.math.BigDecimal
/**
@ -98,7 +99,7 @@ private fun InputRowImageSelectorPreview(
append(" ")
withStyle(style = SpanStyle(color = TangemTheme.colors.text.accent)) {
append(
BigDecimalFormatter.formatPercent(BigDecimal.ZERO, true),
BigDecimal.ZERO.format { percent() },
)
}
},

View file

@ -2,30 +2,57 @@
package com.tangem.core.ui.components.sheetscaffold
import android.graphics.Bitmap
import android.graphics.BlurMaskFilter
import android.renderscript.Allocation
import android.renderscript.Element
import android.renderscript.RenderScript
import android.renderscript.ScriptIntrinsicBlur
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.util.fastMaxOfOrNull
import androidx.compose.ui.zIndex
import androidx.core.graphics.withSave
import androidx.core.graphics.withTranslation
import com.tangem.core.ui.components.sheetscaffold.TangemSheetValue.*
import com.tangem.core.ui.extensions.softLayerShadow
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.toPx
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.pow
import kotlin.math.roundToInt
/**
@ -51,13 +78,6 @@ import kotlin.math.roundToInt
* [Dp.Unspecified] for a sheet that spans the entire screen width.
* @param sheetShape the shape of the bottom sheet
* @param sheetContainerColor the background color of the bottom sheet
* @param sheetContentColor the preferred content color provided by the bottom sheet to its
* children. Defaults to the matching content color for [sheetContainerColor], or if that is not a
* color from the theme, this will keep the same content color set above the bottom sheet.
* @param sheetTonalElevation when [sheetContainerColor] is [ColorScheme.surface], a translucent
* primary color overlay is applied on top of the container. A higher tonal elevation value will
* result in a darker color in light theme and lighter color in dark theme. See also: [Surface].
* @param sheetShadowElevation the shadow elevation of the bottom sheet
* @param sheetSwipeEnabled whether the sheet swiping is enabled and should react to the user's
* input
* @param topBar top app bar of the screen, typically a [SmallTopAppBar]
@ -81,10 +101,7 @@ fun TangemBottomSheetScaffold(
sheetPeekHeight: Dp,
sheetMaxWidth: Dp = 640.dp,
sheetShape: Shape = TangemTheme.shapes.bottomSheetLarge,
sheetContainerColor: Color = Color.White, // FIXME
sheetContentColor: Color = contentColorFor(sheetContainerColor),
sheetTonalElevation: Dp = 0.dp,
sheetShadowElevation: Dp = 1.dp,
sheetContainerColor: Color = Color.White,
sheetSwipeEnabled: Boolean = true,
topBar: @Composable (() -> Unit)? = null,
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
@ -109,9 +126,6 @@ fun TangemBottomSheetScaffold(
sheetSwipeEnabled = sheetSwipeEnabled,
shape = sheetShape,
containerColor = sheetContainerColor,
contentColor = sheetContentColor,
tonalElevation = sheetTonalElevation,
shadowElevation = sheetShadowElevation,
content = sheetContent,
)
},
@ -178,9 +192,6 @@ private fun StandardBottomSheet(
sheetSwipeEnabled: Boolean,
shape: Shape,
containerColor: Color,
contentColor: Color,
tonalElevation: Dp,
shadowElevation: Dp,
content: @Composable ColumnScope.() -> Unit,
) {
val scope = rememberCoroutineScope()
@ -202,7 +213,7 @@ private fun StandardBottomSheet(
Modifier
}
Surface(
Column(
modifier = Modifier
.widthIn(max = sheetMaxWidth)
.fillMaxWidth()
@ -251,16 +262,20 @@ private fun StandardBottomSheet(
state = state.anchoredDraggableState,
orientation = orientation,
enabled = sheetSwipeEnabled,
),
shape = shape,
color = containerColor,
contentColor = contentColor,
tonalElevation = tonalElevation,
shadowElevation = shadowElevation,
)
.softLayerShadow(
radius = 8.dp,
color = Color.Black.copy(
alpha = if (isSystemInDarkTheme()) .16f else .08f
),
shape = shape,
offset = DpOffset(x = 0.dp, y = (-4).dp),
isAlphaContentClip = true
)
.background(containerColor, shape)
.clip(shape),
) {
Column(Modifier.fillMaxWidth()) {
content()
}
content()
}
}
@ -293,7 +308,7 @@ private fun BottomSheetScaffoldLayout(
),
) {
(topBarMeasurables, bodyMeasurables, bottomSheetMeasurables, snackbarHostMeasurables),
constraints,
constraints,
->
val layoutWidth = constraints.maxWidth
val layoutHeight = constraints.maxHeight
@ -321,7 +336,7 @@ private fun BottomSheetScaffoldLayout(
PartiallyExpanded -> sheetOffset().roundToInt() - snackbarHeight
Expanded,
Hidden,
-> layoutHeight - snackbarHeight
-> layoutHeight - snackbarHeight
}
// Placement order is important for elevation

View file

@ -79,6 +79,7 @@ fun getActiveIconRes(blockchainId: String): Int {
"energy-web-chain", "energy-web-chain/test" -> R.drawable.img_energy_web_22
"energy-web-x", "energy-web-x/test" -> R.drawable.img_energy_web_22
"core", "core/test" -> R.drawable.img_core_22
"casper-network", "casper-network/test" -> R.drawable.img_casper_22
else -> R.drawable.ic_alert_24
}
}
@ -159,6 +160,7 @@ fun getActiveIconResByNetworkId(networkId: String): Int {
"energy-web-chain", "energy-web-chain/test" -> R.drawable.img_energy_web_22
"energy-web-x", "energy-web-x/test" -> R.drawable.img_energy_web_22
"core", "core/test" -> R.drawable.img_core_22
"casper-network", "casper-network/test" -> R.drawable.img_casper_22
else -> R.drawable.ic_alert_24
}
}
@ -236,6 +238,7 @@ fun getActiveIconResByCoinId(coinId: String): Int {
"energy-web-chain", "energy-web-chain/test" -> R.drawable.img_energy_web_22
"energy-web-x", "energy-web-x/test" -> R.drawable.img_energy_web_22
"core", "core/test" -> R.drawable.img_core_22
"casper-network" -> R.drawable.img_casper_22
else -> R.drawable.ic_alert_24
}
}
@ -316,6 +319,7 @@ fun getGreyedOutIconRes(blockchainId: String): Int {
"energy-web-chain", "energy-web-chain/test" -> R.drawable.ic_energy_web_22
"energy-web-x", "energy-web-x/test" -> R.drawable.ic_energy_web_22
"core", "core/test" -> R.drawable.ic_core_22
"casper-network", "casper-network/test" -> R.drawable.ic_casper_22
else -> R.drawable.ic_alert_24
}
}
@ -396,6 +400,7 @@ fun getGreyedOutIconResByNetworkId(networkId: String): Int {
"energy-web-chain", "energy-web-chain/test" -> R.drawable.ic_energy_web_22
"energy-web-x", "energy-web-x/test" -> R.drawable.ic_energy_web_22
"core", "core/test" -> R.drawable.ic_core_22
"casper-network", "casper-network/test" -> R.drawable.ic_casper_22
else -> R.drawable.ic_alert_24
}
}

View file

@ -0,0 +1,95 @@
package com.tangem.core.ui.extensions
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
fun Modifier.softLayerShadow(
radius: Dp = 8.dp,
color: Color = Color.Black.copy(alpha = .23f),
shape: Shape = RectangleShape,
spread: Dp = 0.dp,
offset: DpOffset = DpOffset(x = 0.dp, y = 2.dp),
isAlphaContentClip: Boolean = false,
): Modifier = this.drawWithCache {
val radiusPx = radius.toPx()
require(radiusPx > 0.0F)
val paint = Paint().apply {
this.color = color
asFrameworkPaint().apply {
isDither = true
isAntiAlias = true
setShadowLayer(
radiusPx,
offset.x.toPx(),
offset.y.toPx(),
color.toArgb(),
)
}
}
val shapeOutline = shape.createOutline(
size = size,
layoutDirection = LayoutDirection.Rtl,
density = this,
)
val shapePath = Path().apply {
addOutline(outline = shapeOutline)
}
val drawShadowBlock: DrawScope.() -> Unit = {
drawIntoCanvas { canvas ->
canvas.withSave {
if (spread.value != 0.0F) {
canvas.scale(
sx = spreadScale(
spread = spread.toPx(),
size = size.width,
),
sy = spreadScale(
spread = spread.toPx(),
size = size.height,
),
pivotX = center.x,
pivotY = center.y,
)
}
canvas.drawOutline(
outline = shapeOutline,
paint = paint,
)
}
}
}
onDrawBehind {
if (isAlphaContentClip) {
clipShadowByPath(
path = shapePath,
block = drawShadowBlock,
)
} else {
drawShadowBlock()
}
}
}
@Suppress("UnnecessaryParentheses")
private fun spreadScale(spread: Float, size: Float): Float = 1.0F + ((spread / size) * 2.0F)
private fun DrawScope.clipShadowByPath(path: Path, block: DrawScope.() -> Unit) {
clipPath(
path = path,
clipOp = ClipOp.Difference,
block = block,
)
}

View file

@ -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
}

View file

@ -0,0 +1,247 @@
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 crypto amounts with a fixed number of decimals.
*/
fun BigDecimalCryptoFormat.anyDecimals(maxDecimals: Int = decimals, minDecimals: Int = decimals) =
BigDecimalFormat { value ->
val formatter = NumberFormat.getCurrencyInstance(locale).apply {
currency = usdCurrency
maximumFractionDigits = maxDecimals
minimumFractionDigits = minDecimals
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", "<BTC 0.000001".
*/
fun BigDecimalCryptoFormat.fee(canBeLower: Boolean = false) = BigDecimalFormat { value ->
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)
if (cryptoCurrencySymbol.isBlank()) {
return withoutSymbol
}
val last = withoutSymbol.lastOrNull() ?: return cryptoCurrencySymbol
append(withoutSymbol)
if (last.isNotWhitespace()) {
append(CURRENCY_SPACE)
}
append(cryptoCurrencySymbol)
}
str.startsWith(fiatCurrencySymbol) -> {
if (addStartSpace) {
append(CURRENCY_SPACE)
}
val withoutSymbol = str.drop(fiatCurrencySymbol.length)
val first = withoutSymbol.firstOrNull()
?: return cryptoCurrencySymbol
if (cryptoCurrencySymbol.isBlank()) {
return withoutSymbol
}
append(cryptoCurrencySymbol)
if (first.isNotWhitespace()) {
append(CURRENCY_SPACE)
}
append(withoutSymbol)
}
else -> append(str)
}
}
}

View file

@ -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<BigDecimal, Int> {
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

View file

@ -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)
}

View file

@ -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) }
}

View file

@ -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)
}

View file

@ -0,0 +1,34 @@
package com.tangem.core.ui.format.bigdecimal
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Locale
open class BigDecimalSimpleFormat(
val decimals: Int,
val locale: Locale = Locale.getDefault(),
) : BigDecimalFormat {
override fun invoke(value: BigDecimal): String = default()(value)
}
// == Initializers ==
fun BigDecimalFormatScope.simple(decimals: Int, locale: Locale = Locale.getDefault()) = BigDecimalSimpleFormat(
decimals = decimals,
locale = locale,
)
// == Formatters ==
fun BigDecimalSimpleFormat.default() = BigDecimalFormat { value ->
val formatter = NumberFormat.getInstance(locale).apply {
maximumFractionDigits = decimals
minimumFractionDigits = 0
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
formatter.format(value)
}

View file

@ -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
}
}
}

View file

@ -1,28 +1,22 @@
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
private const val CAN_BE_LOWER_SIGN = LOWER_SIGN
private val FORMAT_THRESHOLD = BigDecimal("0.01")
private val FIAT_FORMAT_THRESHOLD = BigDecimal("0.01")
private val CRYPTO_FEE_FORMAT_THRESHOLD = BigDecimal("0.000001")
private const val FIAT_MARKET_DEFAULT_DIGITS = 2
private const val FIAT_MARKET_EXTENDED_DIGITS = 6
@ -30,161 +24,7 @@ object BigDecimalFormatter {
private val usdCurrency = Currency.getInstance("USD")
@Deprecated(
"Use formatCryptoAmount2",
replaceWith = ReplaceWith("formatCryptoAmount2"),
)
fun formatCryptoAmount(
cryptoAmount: BigDecimal?,
cryptoCurrency: String,
decimals: Int,
locale: Locale = Locale.getDefault(),
): String {
if (cryptoAmount == null) return EMPTY_BALANCE_SIGN
val formatter = NumberFormat.getNumberInstance(locale).apply {
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 8)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
return formatter.format(cryptoAmount).let {
if (cryptoCurrency.isEmpty()) {
it
} else {
it + "\u2009$cryptoCurrency"
}
}
}
// 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,
)
}
fun formatCryptoAmountShorted(
cryptoAmount: BigDecimal?,
cryptoCurrency: String,
decimals: Int,
locale: Locale = Locale.getDefault(),
): String {
if (cryptoAmount == null) return EMPTY_BALANCE_SIGN
val formatter = if (cryptoAmount.isMoreThanThreshold()) {
NumberFormat.getNumberInstance(locale).apply {
maximumFractionDigits = 2
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
} else {
NumberFormat.getNumberInstance(locale).apply {
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 6)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.DOWN
}
}
return formatter.format(cryptoAmount).let {
if (cryptoCurrency.isEmpty()) {
it
} else {
it + "\u2009$cryptoCurrency"
}
}
}
fun formatCryptoAmountUncapped(
cryptoAmount: BigDecimal?,
cryptoCurrency: CryptoCurrency,
locale: Locale = Locale.getDefault(),
): String {
if (cryptoAmount == null) return EMPTY_BALANCE_SIGN
val formatter = NumberFormat.getNumberInstance(locale).apply {
maximumFractionDigits = cryptoCurrency.decimals
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
return formatter.format(cryptoAmount).let {
if (cryptoCurrency.symbol.isEmpty()) {
it
} else {
it + "\u2009${cryptoCurrency.symbol}"
}
}
}
fun formatCryptoFeeAmount(
cryptoAmount: BigDecimal?,
cryptoCurrency: String,
decimals: Int,
canBeLower: Boolean = false,
locale: Locale = Locale.getDefault(),
): String {
if (cryptoAmount == null) return EMPTY_BALANCE_SIGN
val formatter = NumberFormat.getNumberInstance(locale).apply {
maximumFractionDigits = decimals.coerceAtMost(maximumValue = 6)
minimumFractionDigits = 2
isGroupingUsed = true
roundingMode = RoundingMode.HALF_UP
}
val amountFormatted = if (cryptoAmount.checkCryptoThreshold()) {
buildString {
append(CAN_BE_LOWER_SIGN)
append(
formatter.format(CRYPTO_FEE_FORMAT_THRESHOLD),
)
}
} else {
buildString {
if (canBeLower) {
append(CAN_BE_LOWER_SIGN)
}
append(formatter.format(cryptoAmount))
}
}
return if (cryptoCurrency.isEmpty()) {
amountFormatted
} else {
amountFormatted + "\u2009$cryptoCurrency"
}
}
fun formatCryptoAmount(
cryptoAmount: BigDecimal?,
cryptoCurrency: CryptoCurrency,
locale: Locale = Locale.getDefault(),
): String {
return formatCryptoAmount(cryptoAmount, cryptoCurrency.symbol, cryptoCurrency.decimals, locale)
}
@Deprecated("Use BigDecimal.format")
fun formatFiatAmount(
fiatAmount: BigDecimal?,
fiatCurrencyCode: String,
@ -226,6 +66,7 @@ object BigDecimalFormatter {
}
}
@Deprecated("Use BigDecimal.format")
fun formatFiatAmountUncapped(
fiatAmount: BigDecimal?,
fiatCurrencyCode: String,
@ -251,6 +92,7 @@ object BigDecimalFormatter {
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
}
@Deprecated("Use BigDecimal.format")
fun formatFiatPriceUncapped(
fiatAmount: BigDecimal?,
fiatCurrencyCode: String,
@ -273,6 +115,7 @@ object BigDecimalFormatter {
.replace(formatterCurrency.getSymbol(locale), fiatCurrencySymbol)
}
@Deprecated("Use BigDecimal.format")
fun getFiatPriceUncappedWithScale(value: BigDecimal): Pair<BigDecimal, Int> {
return if (value < BigDecimal.ONE) {
val leadingZeroes = value.scale() - value.precision()
@ -288,47 +131,6 @@ 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)
}
fun formatPercent(
percent: BigDecimal,
useAbsoluteValue: Boolean,
locale: Locale = Locale.getDefault(),
maxFractionDigits: Int = 2,
minFractionDigits: Int = 2,
): String {
val formatter = NumberFormat.getPercentInstance(locale).apply {
maximumFractionDigits = maxFractionDigits
minimumFractionDigits = minFractionDigits
roundingMode = RoundingMode.HALF_UP
}
val value = if (useAbsoluteValue) percent.abs() else percent
return formatter.format(value)
}
fun formatWithSymbol(amount: String, symbol: String) = "$amount\u2009$symbol"
private fun BigDecimal.isMoreThanThreshold() = this > FORMAT_THRESHOLD
private fun getCurrency(code: String): Currency {
return runCatching { Currency.getInstance(code) }
.getOrElse { e ->
@ -341,231 +143,5 @@ 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
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M7.746,13.948V8.129C7.746,7.991 7.797,7.854 7.901,7.763C7.983,7.69 8.081,7.653 8.195,7.653H15.5V5.4H15.396C14.708,5.4 14.149,5.961 14.149,6.652V6.673C14.149,7.015 13.886,7.314 13.546,7.323C13.205,7.332 12.91,7.051 12.91,6.702V6.643C12.91,5.957 12.355,5.401 11.672,5.401H7.789C7.351,5.401 6.958,5.5 6.611,5.697C6.264,5.895 5.992,6.168 5.795,6.515C5.599,6.863 5.5,7.257 5.5,7.696V14.354C5.5,14.794 5.599,15.188 5.795,15.535C5.993,15.883 6.264,16.156 6.611,16.354C6.958,16.552 7.351,16.65 7.789,16.65H15.498V14.397H8.22C8.082,14.397 7.946,14.346 7.855,14.242C7.782,14.159 7.745,14.061 7.745,13.946L7.746,13.948Z"
android:fillColor="#000000"/>
</vector>

View file

@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<group>
<clip-path
android:pathData="M11,0L11,0A11,11 0,0 1,22 11L22,11A11,11 0,0 1,11 22L11,22A11,11 0,0 1,0 11L0,11A11,11 0,0 1,11 0z"/>
<path
android:pathData="M11,0L11,0A11,11 0,0 1,22 11L22,11A11,11 0,0 1,11 22L11,22A11,11 0,0 1,0 11L0,11A11,11 0,0 1,11 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M0,0h22v22h-22z"
android:fillColor="#FF2D2E"/>
<path
android:pathData="M7.746,13.948V8.129C7.746,7.991 7.797,7.854 7.901,7.763C7.983,7.69 8.081,7.653 8.195,7.653H15.5V5.4H15.396C14.708,5.4 14.149,5.961 14.149,6.652V6.673C14.149,7.015 13.886,7.314 13.546,7.323C13.205,7.332 12.91,7.051 12.91,6.702V6.643C12.91,5.957 12.355,5.401 11.672,5.401H7.789C7.351,5.401 6.958,5.5 6.611,5.697C6.264,5.895 5.992,6.168 5.795,6.515C5.599,6.863 5.5,7.257 5.5,7.696V14.354C5.5,14.794 5.599,15.188 5.795,15.535C5.993,15.883 6.264,16.156 6.611,16.354C6.958,16.552 7.351,16.65 7.789,16.65H15.498V14.397H8.22C8.082,14.397 7.946,14.346 7.855,14.242C7.782,14.159 7.745,14.061 7.745,13.946L7.746,13.948Z"
android:fillColor="#ffffff"/>
</group>
</vector>

View file

@ -0,0 +1,407 @@
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))
}
// === anyDecimals() ===
@Test
fun `anyDecimals smoke`() {
val testValue = BigDecimal("0.123412341234")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 5,
locale = testLocale,
).anyDecimals()
}
Truth.assertThat(formatted)
.isEqualTo("0.12341".addSymbolWithSpaceLeft(symbol))
}
@Test
fun `anyDecimals zero`() {
val testValue = BigDecimal("0.123412341234")
val formatted = testValue.format {
crypto(
symbol = symbol,
decimals = 0,
locale = testLocale,
).anyDecimals()
}
Truth.assertThat(formatted)
.isEqualTo("0".addSymbolWithSpaceLeft(symbol))
}
}

View file

@ -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())
}
}

View file

@ -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("!")
}
}

View file

@ -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("%"))
}
}

View file

@ -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"