Updated on 2026-08-14

This commit is contained in:
Tangem 2023-02-14 16:38:34 +03:00
parent 865bf08157
commit 7945ad6c57
10 changed files with 146 additions and 109 deletions

View file

@ -1,29 +0,0 @@
package com.tangem.core.ui.utils
/**
* Formats input [String] for InputField, to remove wrong symbols, letters etc
* Use [decimals] for cut this number symbols after floating point
*
* Example (with 8 decimals):
* input string - ab123.46377372ab53
* result string 123.46377372
*/
fun getValidatedNumberWithFixedDecimals(text: String, decimals: Int): String {
val comma = ','
val dot = '.'
val filteredChars = text.replace(comma, dot).filterIndexed { index, c ->
val isOneOrZeroPoint = c == dot && index != 0 && text.count { it == dot } <= 1
val isIndexPointIndex = c == dot && index != 0 && text.indexOf(dot) == index
c.isDigit() || isIndexPointIndex || isOneOrZeroPoint
}
// If dot is present, take first 3 digits before decimal and first decimals digits after decimal
return if (filteredChars.count { it == dot } == 1) {
val beforeDecimal = filteredChars.substringBefore(dot)
val afterDecimal = filteredChars.substringAfter(dot)
beforeDecimal + dot + afterDecimal.take(decimals)
}
// If there is no dot, just take all digits
else {
filteredChars
}
}

View file

@ -0,0 +1,68 @@
package com.tangem.core.ui.utils
import java.text.DecimalFormat
class InputNumberFormatter(
numberFormat: DecimalFormat,
) {
private val symbols = numberFormat.decimalFormatSymbols
/**
* Formats input [String] for InputField, to remove wrong symbols, letters etc
* Use [decimals] for cut this number symbols after floating point
*
* Example (with 8 decimals):
* input string - ab123.46377372ab53
* result string 123.46377372
*/
fun getValidatedNumberWithFixedDecimals(text: String, decimals: Int): String {
val thousandsSeparator = symbols.groupingSeparator
val decimalSeparator = symbols.decimalSeparator
if (text.startsWith("0") && text.length > 1 && text[1] != decimalSeparator) {
return "0"
}
val filteredChars = text.replace(thousandsSeparator.toString(), "").filterIndexed { index, c ->
val isOneOrZeroPoint = c == decimalSeparator && index != 0 && text.count { it == decimalSeparator } <= 1
val isIndexPointIndex = c == decimalSeparator && index != 0 && text.indexOf(decimalSeparator) == index
c.isDigit() || isIndexPointIndex || isOneOrZeroPoint
}
// If dot is present, take first digits before decimal and first decimals digits after decimal
return if (filteredChars.count { it == decimalSeparator } == 1) {
val beforeDecimal = filteredChars.substringBefore(decimalSeparator)
val afterDecimal = filteredChars.substringAfter(decimalSeparator)
beforeDecimal + decimalSeparator + afterDecimal.take(decimals)
}
// If there is no dot, just take all digits
else {
filteredChars
}
}
fun formatWithThousands(text: String, decimals: Int): String {
val thousandsSeparator = symbols.groupingSeparator
val decimalSeparator = symbols.decimalSeparator
return if (text.count { it == decimalSeparator } == 1) {
val beforeDecimal = text.substringBefore(decimalSeparator)
.reversed()
.chunked(TEXT_CHUNK_THOUSAND)
.joinToString(thousandsSeparator.toString())
.reversed()
val afterDecimal = text.substringAfter(decimalSeparator)
beforeDecimal + decimalSeparator + afterDecimal.take(decimals)
}
// If there is no dot, just take all digits
else {
text.reversed()
.chunked(TEXT_CHUNK_THOUSAND)
.joinToString(thousandsSeparator.toString())
.reversed()
}
}
companion object {
private const val TEXT_CHUNK_THOUSAND = 3
}
}

View file

@ -3,24 +3,23 @@ package com.tangem.utils
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.text.NumberFormat
import java.util.*
// todo determine where to place this extensions
fun BigDecimal.toFormattedString(
decimals: Int,
roundingMode: RoundingMode = RoundingMode.DOWN,
locale: Locale = Locale.US,
locale: Locale = Locale.getDefault(),
): String {
val symbols = DecimalFormatSymbols(locale)
val df = DecimalFormat().apply {
decimalFormatSymbols = symbols
val formatter = NumberFormat.getInstance(locale) as? DecimalFormat
val df = formatter?.apply {
maximumFractionDigits = decimals
minimumFractionDigits = 0
isGroupingUsed = false
isGroupingUsed = true
this.roundingMode = roundingMode
}
return df.format(this)
return df?.format(this) ?: this.toPlainString()
}
@Suppress("MagicNumber")
@ -48,44 +47,17 @@ fun BigDecimal.toFiatString(
formatWithSpaces: Boolean = false,
): String {
val fiatValue = rateValue.multiply(this)
return fiatValue.toFormattedFiatValue(fiatCurrencyName, formatWithSpaces)
}
fun BigDecimal.toFormattedFiatValue(
fiatCurrencyName: String,
formatWithSpaces: Boolean = false,
): String {
val fiatValue = this.setScale(2, RoundingMode.HALF_UP)
.let { if (formatWithSpaces) it.formatWithSpaces() else it }
return "$fiatValue $fiatCurrencyName"
}
@Suppress("MagicNumber")
fun BigDecimal.formatWithSpaces(): String {
val str = this.toString()
var integerStr = str.substringBefore('.')
val reminderStr = str.substringAfter('.')
val packets = arrayListOf<String>()
var index: Int = integerStr.length
while (0 < index) {
if (index <= 3) {
packets.add(integerStr)
break
}
index -= 3
packets.add(integerStr.substring(startIndex = index))
integerStr = integerStr.substring(startIndex = 0, endIndex = index)
val formatter = NumberFormat.getInstance(Locale.getDefault()) as? DecimalFormat
val df = formatter?.apply {
maximumFractionDigits = 2
minimumFractionDigits = 0
isGroupingUsed = true
this.roundingMode = roundingMode
}
return buildString {
packets.reversed().forEachIndexed { index, packet ->
append(packet)
if (index != packets.lastIndex) append(' ')
}
if (reminderStr.isNotBlank()) {
append('.')
append(reminderStr)
}
val formatted = if (formatWithSpaces) {
"${df?.format(fiatValue)} $fiatCurrencyName"
} else {
"${df?.format(fiatValue)}$fiatCurrencyName"
}
return formatted
}

View file

@ -146,7 +146,7 @@ internal class SwapInteractorImpl @Inject constructor(
amountToSwap: String,
): SwapState {
syncWalletBalanceForTokens(networkId, listOf(fromToken, toToken))
val amountDecimal = amountToSwap.toBigDecimalOrNull()
val amountDecimal = toBigDecimalOrNull(amountToSwap)
if (amountDecimal == null || amountDecimal.compareTo(BigDecimal.ZERO) == 0) {
return createEmptyAmountState(fromToken, toToken)
}
@ -196,7 +196,7 @@ internal class SwapInteractorImpl @Inject constructor(
currencyToGet: Currency,
amountToSwap: String,
): TxState {
val amount = requireNotNull(amountToSwap.toBigDecimalOrNull()) { "wrong amount format, use only digits" }
val amount = requireNotNull(toBigDecimalOrNull(amountToSwap)) { "wrong amount format, use only digits" }
val estimatedGas =
increaseByPercents(TWENTY_FIVE_PERCENTS, swapData.transaction.gas.toIntOrNull() ?: DEFAULT_GAS)
val fee = transactionManager.calculateFee(
@ -596,6 +596,10 @@ internal class SwapInteractorImpl @Inject constructor(
return value * (percents / 100 + 1)
}
private fun toBigDecimalOrNull(amountToSwap: String): BigDecimal? {
return amountToSwap.replace(",", ".").toBigDecimalOrNull()
}
companion object {
private const val DEFAULT_SLIPPAGE = 2
private const val ZERO_BALANCE = "0"

View file

@ -1,5 +1,7 @@
package com.tangem.feature.swap.models
import androidx.compose.ui.text.input.TextFieldValue
data class SwapStateHolder(
val sendCardData: SwapCardData,
val receiveCardData: SwapCardData,
@ -30,9 +32,9 @@ data class SwapStateHolder(
data class SwapCardData(
val type: TransactionCardType,
val amount: String?,
val amountEquivalent: String?,
val coinId: String?,
val amountTextFieldValue: TextFieldValue?,
val tokenIconUrl: String,
val tokenCurrency: String,
val balance: String,

View file

@ -24,12 +24,13 @@ import androidx.compose.ui.text.ParagraphIntrinsics
import androidx.compose.ui.text.font.createFontFamilyResolver
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.core.ui.res.TangemTheme
@Suppress("MagicNumber", "LongMethod")
@Composable
internal fun AutoSizeTextField(
amount: String,
textFieldValue: TextFieldValue,
onAmountChange: (String) -> Unit,
onFocusChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
@ -40,7 +41,7 @@ internal fun AutoSizeTextField(
var shrunkFontSize = TangemTheme.typography.h2.fontSize
val calculateIntrinsics = @Composable {
ParagraphIntrinsics(
text = amount,
text = textFieldValue.text,
style = TangemTheme.typography.h2.copy(
color = TangemTheme.colors.text.primary1,
fontSize = shrunkFontSize,
@ -63,8 +64,10 @@ internal fun AutoSizeTextField(
)
CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) {
BasicTextField(
value = amount,
onValueChange = onAmountChange,
value = textFieldValue,
onValueChange = {
onAmountChange.invoke(it.text)
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
@ -76,7 +79,7 @@ internal fun AutoSizeTextField(
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
decorationBox = { innerTextField ->
if (amount.isBlank()) {
if (textFieldValue.text.isBlank()) {
Text(
text = "0",
color = TangemTheme.colors.text.disabled,

View file

@ -1,5 +1,7 @@
package com.tangem.feature.swap.ui
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.domain.Currency
@ -37,8 +39,8 @@ internal class StateBuilder(val actions: UiActions) {
blockchainId = networkInfo.blockchainId,
sendCardData = SwapCardData(
type = TransactionCardType.SendCard(actions.onAmountChanged, actions.onAmountSelected),
amount = null,
amountEquivalent = null,
amountTextFieldValue = null,
tokenIconUrl = initialCurrency.logoUrl,
tokenCurrency = initialCurrency.symbol,
coinId = initialCurrency.id,
@ -48,10 +50,10 @@ internal class StateBuilder(val actions: UiActions) {
),
receiveCardData = SwapCardData(
type = TransactionCardType.ReceiveCard(),
amount = null,
amountEquivalent = null,
tokenIconUrl = "",
tokenCurrency = "",
amountTextFieldValue = null,
canSelectAnotherToken = false,
balance = "",
isNotNativeToken = false,
@ -80,7 +82,7 @@ internal class StateBuilder(val actions: UiActions) {
return uiStateHolder.copy(
sendCardData = SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard),
amount = uiStateHolder.sendCardData.amount,
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = null,
tokenIconUrl = fromToken.logoUrl,
tokenCurrency = fromToken.symbol,
@ -91,7 +93,7 @@ internal class StateBuilder(val actions: UiActions) {
),
receiveCardData = SwapCardData(
type = TransactionCardType.ReceiveCard(),
amount = null,
amountTextFieldValue = null,
amountEquivalent = null,
tokenIconUrl = toToken.logoUrl,
tokenCurrency = toToken.symbol,
@ -130,7 +132,7 @@ internal class StateBuilder(val actions: UiActions) {
return uiStateHolder.copy(
sendCardData = SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard),
amount = uiStateHolder.sendCardData.amount,
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = quoteModel.fromTokenInfo.tokenFiatBalance,
tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl,
coinId = quoteModel.fromTokenInfo.coinId,
@ -141,7 +143,7 @@ internal class StateBuilder(val actions: UiActions) {
),
receiveCardData = SwapCardData(
type = TransactionCardType.ReceiveCard(),
amount = quoteModel.toTokenInfo.tokenAmount.formatToUIRepresentation(),
amountTextFieldValue = TextFieldValue(quoteModel.toTokenInfo.tokenAmount.formatToUIRepresentation()),
amountEquivalent = quoteModel.toTokenInfo.tokenFiatBalance,
tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl,
coinId = quoteModel.toTokenInfo.coinId,
@ -172,7 +174,7 @@ internal class StateBuilder(val actions: UiActions) {
return uiStateHolder.copy(
sendCardData = SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard),
amount = uiStateHolder.sendCardData.amount,
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl,
coinId = uiStateHolder.sendCardData.coinId,
@ -183,7 +185,7 @@ internal class StateBuilder(val actions: UiActions) {
),
receiveCardData = SwapCardData(
type = TransactionCardType.ReceiveCard(),
amount = "0",
amountTextFieldValue = TextFieldValue("0"),
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl,
coinId = uiStateHolder.receiveCardData.coinId,
@ -252,7 +254,10 @@ internal class StateBuilder(val actions: UiActions) {
fun updateSwapAmount(uiState: SwapStateHolder, amount: String): SwapStateHolder {
return uiState.copy(
sendCardData = uiState.sendCardData.copy(
amount = amount,
amountTextFieldValue = TextFieldValue(
text = amount,
selection = TextRange(amount.length),
),
),
)
}

View file

@ -27,6 +27,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.constraintlayout.compose.ConstraintLayout
@ -163,7 +164,7 @@ private fun MainInfo(state: SwapStateHolder) {
TransactionCard(
type = state.sendCardData.type,
balance = state.sendCardData.balance,
amount = state.sendCardData.amount,
textFieldValue = state.sendCardData.amountTextFieldValue,
amountEquivalent = state.sendCardData.amountEquivalent,
tokenIconUrl = state.sendCardData.tokenIconUrl,
tokenCurrency = state.sendCardData.tokenCurrency,
@ -180,7 +181,7 @@ private fun MainInfo(state: SwapStateHolder) {
TransactionCard(
type = state.receiveCardData.type,
balance = state.receiveCardData.balance,
amount = state.receiveCardData.amount,
textFieldValue = state.receiveCardData.amountTextFieldValue,
amountEquivalent = state.receiveCardData.amountEquivalent,
tokenIconUrl = state.receiveCardData.tokenIconUrl,
tokenCurrency = state.receiveCardData.tokenCurrency,
@ -360,7 +361,7 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U
private val sendCard = SwapCardData(
type = TransactionCardType.SendCard({}) {},
amount = "1 000 000",
amountTextFieldValue = TextFieldValue(),
amountEquivalent = "1 000 000",
tokenIconUrl = "",
tokenCurrency = "DAI",
@ -372,7 +373,7 @@ private val sendCard = SwapCardData(
private val receiveCard = SwapCardData(
type = TransactionCardType.ReceiveCard(),
amount = "1 000 000",
amountTextFieldValue = TextFieldValue(),
amountEquivalent = "1 000 000",
tokenIconUrl = "",
tokenCurrency = "DAI",

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@ -55,8 +56,8 @@ fun TransactionCard(
balance: String,
tokenIconUrl: String,
tokenCurrency: String,
amount: String?,
amountEquivalent: String?,
textFieldValue: TextFieldValue?,
modifier: Modifier = Modifier,
@DrawableRes iconPlaceholder: Int? = null,
@DrawableRes networkIconRes: Int? = null,
@ -79,8 +80,8 @@ fun TransactionCard(
Content(
type = type,
amount = amount,
amountEquivalent = amountEquivalent,
textFieldValue = textFieldValue,
)
}
@ -169,8 +170,8 @@ private fun Header(
@Composable
private fun Content(
type: TransactionCardType,
amount: String?,
amountEquivalent: String?,
textFieldValue: TextFieldValue?,
) {
Row(
modifier = Modifier
@ -192,9 +193,9 @@ private fun Content(
val sumTextModifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32)
when (type) {
is TransactionCardType.ReceiveCard -> {
if (amount != null) {
if (textFieldValue != null) {
ResizableText(
text = amount,
text = textFieldValue.text,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h2,
fontSizeRange = FontSizeRange(min = 16.sp, max = TangemTheme.typography.h2.fontSize),
@ -216,7 +217,7 @@ private fun Content(
is TransactionCardType.SendCard -> {
AutoSizeTextField(
modifier = sumTextModifier,
amount = amount ?: "",
textFieldValue = textFieldValue ?: TextFieldValue(),
onAmountChange = { type.onAmountChanged(it) },
onFocusChange = type.onFocusChanged,
)
@ -376,13 +377,13 @@ private fun Preview_SwapMainCard_InDarkTheme() {
private fun TransactionCardPreview() {
TransactionCard(
type = TransactionCardType.SendCard({}) {},
amount = "1 000 000",
amountEquivalent = "1 000 000",
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue(),
)
}

View file

@ -8,7 +8,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.utils.getValidatedNumberWithFixedDecimals
import com.tangem.core.ui.utils.InputNumberFormatter
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
@ -32,6 +32,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.*
import javax.inject.Inject
import kotlin.properties.Delegates
@ -53,6 +56,8 @@ internal class SwapViewModel @Inject constructor(
private val stateBuilder = StateBuilder(
actions = createUiActions(),
)
private val inputNumberFormatter =
InputNumberFormatter(NumberFormat.getInstance(Locale.getDefault()) as DecimalFormat)
private val amountDebouncer = Debouncer()
private val singleTaskScheduler = SingleTaskScheduler<SwapState>()
@ -332,9 +337,12 @@ internal class SwapViewModel @Inject constructor(
toCurrency = newToToken,
)
isOrderReversed = !isOrderReversed
lastAmount.value =
cutAmountWithDecimals(blockchainInteractor.getTokenDecimals(newFromToken), lastAmount.value)
uiState = stateBuilder.updateSwapAmount(uiState, lastAmount.value)
val decimals = blockchainInteractor.getTokenDecimals(newFromToken)
lastAmount.value = cutAmountWithDecimals(decimals, lastAmount.value)
uiState = stateBuilder.updateSwapAmount(
uiState,
inputNumberFormatter.formatWithThousands(lastAmount.value, decimals),
)
startLoadingQuotes(newFromToken, newToToken, lastAmount.value)
}
}
@ -343,9 +351,11 @@ internal class SwapViewModel @Inject constructor(
val fromToken = dataState.fromCurrency
val toToken = dataState.toCurrency
if (fromToken != null && toToken != null) {
val cutValue = cutAmountWithDecimals(blockchainInteractor.getTokenDecimals(fromToken), value)
uiState = stateBuilder.updateSwapAmount(uiState, cutValue)
val decimals = blockchainInteractor.getTokenDecimals(fromToken)
val cutValue = cutAmountWithDecimals(decimals, value)
lastAmount.value = cutValue
uiState =
stateBuilder.updateSwapAmount(uiState, inputNumberFormatter.formatWithThousands(cutValue, decimals))
amountDebouncer.debounce(DEBOUNCE_AMOUNT_DELAY, viewModelScope) {
startLoadingQuotes(fromToken, toToken, lastAmount.value)
}
@ -367,7 +377,7 @@ internal class SwapViewModel @Inject constructor(
}
private fun cutAmountWithDecimals(maxDecimals: Int, amount: String): String {
return getValidatedNumberWithFixedDecimals(amount, maxDecimals)
return inputNumberFormatter.getValidatedNumberWithFixedDecimals(amount, maxDecimals)
}
private fun makeDefaultAlert() {