Updated on 2026-08-14

This commit is contained in:
Tangem 2024-04-17 12:17:05 +01:00
commit e8116d8ea0
13 changed files with 151 additions and 48 deletions

View file

@ -4,20 +4,20 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import com.tangem.core.ui.res.TangemTheme
import com.tangem.tap.features.tokens.impl.presentation.states.NetworkItemState
import kotlinx.collections.immutable.ImmutableCollection
@ -41,10 +41,7 @@ internal fun BriefNetworksList(
exit = fadeOut(),
) {
Row(horizontalArrangement = Arrangement.spacedBy(TangemTheme.dimens.spacing4)) {
val iterator = networks.iterator()
var index = 0
while (iterator.hasNext()) {
val network = iterator.next()
for ((index, network) in networks.withIndex()) {
if (index < MAX_VISIBLE_BRIEF_ICONS) {
key(network.name + network.protocolName) {
BriefNetworkItem(model = network)
@ -59,7 +56,6 @@ internal fun BriefNetworksList(
break
}
}
index++
}
}
}
@ -109,8 +105,16 @@ internal fun BriefNetworkItem(model: NetworkItemState, modifier: Modifier = Modi
}
}
@Suppress("MagicNumber")
@Composable
internal fun HasMoreItem(moreCount: Int) {
val count = if (moreCount > 99) 99 else moreCount
val themeTextStyle = TangemTheme.typography.overline.copy(
letterSpacing = TextUnit(value = 0f, type = TextUnitType.Sp),
)
var textStyle by remember(themeTextStyle) { mutableStateOf(themeTextStyle) }
var readyToDraw by remember(themeTextStyle) { mutableStateOf(false) }
Box(
modifier = Modifier
.size(size = TangemTheme.dimens.size20)
@ -118,10 +122,20 @@ internal fun HasMoreItem(moreCount: Int) {
.background(TangemTheme.colors.control.unchecked),
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = "+$moreCount",
style = TangemTheme.typography.overline,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(TangemTheme.dimens.spacing4)
.align(Alignment.Center)
.drawWithContent { if (readyToDraw) drawContent() },
text = "+$count",
style = textStyle,
overflow = TextOverflow.Clip,
onTextLayout = { textLayoutResult ->
if (textLayoutResult.didOverflowHeight) {
textStyle = textStyle.copy(fontSize = textStyle.fontSize * 0.9)
} else {
readyToDraw = true
}
},
)
}
}

View file

@ -427,7 +427,7 @@
<string name="send_additional_field_already_included">Уже содержится в введенном адресе</string>
<string name="send_alert_fee_coverage_subract_text">Вычесть</string>
<string name="send_alert_fee_coverage_title">Недостаточно средств для покрытия комиссии сети. Вычесть недостающую сумму для покрытия комиссии из отправляемой суммы?</string>
<string name="send_alert_fee_too_high_text">Сумма комиссии в %@ раз превышает рекомендованную. Убедитесь, что указанная комиссия верна.</string>
<string name="send_alert_fee_too_high_text">Сумма комиссии в %s раз превышает рекомендованную. Убедитесь, что указанная комиссия верна.</string>
<string name="send_alert_fee_too_low_text">Вы указали комиссию ниже рекомендуемой, это может привести к задержке исполнения вашей транзакции. Продолжить?</string>
<string name="send_alert_transaction_failed_text">Причина: %1$s\nКод: %2$s</string>
<string name="send_alert_transaction_failed_title">Транзакция не выполнена</string>
@ -537,9 +537,9 @@
<string name="toast_balances_hidden">Балансы скрыты</string>
<string name="toast_balances_shown">Балансы показаны</string>
<string name="toast_undo">Отменить</string>
<string name="token_button_unavailability_generic_description">Выбранная операция в данный момент недоступна. Попробуйте позже.</string>
<string name="token_button_unavailability_reason_buy_unavailable">В данный момент покупка монеты %s недоступна. Но мы работаем над её добавлением.</string>
<string name="token_button_unavailability_reason_empty_balance">У вас нет средств для отправки. Пополните счет, чтобы иметь возможность отправить с него средства.</string>
<string name="token_button_unavailability_reason_no_quotes">Выбранная операция в данный момент недоступна. Попробуйте позже.</string>
<string name="token_button_unavailability_reason_not_exchangeable">Обмен %s не доступен. Но мы работаем над его добавлением.</string>
<string name="token_button_unavailability_reason_sell_unavailable">В данный момент продажа монеты %s недоступна. Но мы работаем над её добавлением.</string>
<string name="token_details_generate_xpub">Сгенерировать XPUB</string>
@ -548,6 +548,7 @@
<string name="token_details_hide_alert_title">Скрыть %s</string>
<string name="token_details_hide_token">Скрыть токен</string>
<string name="token_details_token_type_subtitle">%1$s токен в сети %%image%% %2$s</string>
<string name="token_details_token_type_subtitle_no_standard">Токен в сети %%image%% %1$s</string>
<string name="token_details_unable_hide_alert_message">Токен %1$s является основной валютой в сети %2$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети.</string>
<string name="token_details_unable_hide_alert_title">Невозможно скрыть %s</string>
<string name="token_swap_changelly_promotion_message">Обменивайте этот токен на другие с %1$s комиссии за обслуживание с %2$s по %3$s февраля.</string>

View file

@ -534,9 +534,9 @@
<string name="toast_balances_hidden">Balances hidden</string>
<string name="toast_balances_shown">Balances shown</string>
<string name="toast_undo">Undo</string>
<string name="token_button_unavailability_generic_description">This operation is currently unavailable. Please try again later.</string>
<string name="token_button_unavailability_reason_buy_unavailable">The purchase of the %s is currently unavailable. But we are working on adding it.</string>
<string name="token_button_unavailability_reason_empty_balance">You do not have funds to send. Top up your account to be able to send funds from it.</string>
<string name="token_button_unavailability_reason_no_quotes">This operation is currently unavailable. Please try again later.</string>
<string name="token_button_unavailability_reason_not_exchangeable">%s swap is not available. But we are working on adding it.</string>
<string name="token_button_unavailability_reason_sell_unavailable">Sell of the %s coin is currently unavailable. But we are working on adding it.</string>
<string name="token_details_generate_xpub">Generate XPUB</string>
@ -545,6 +545,7 @@
<string name="token_details_hide_alert_title">Hide %s</string>
<string name="token_details_hide_token">Hide token</string>
<string name="token_details_token_type_subtitle">%1$s token in %%image%% %2$s network</string>
<string name="token_details_token_type_subtitle_no_standard">Token in %%image%% %1$s network</string>
<string name="token_details_unable_hide_alert_message">The %1$s token is the main currency on the %2$s network and cannot be hidden as long as you have other tokens on this network in the list.</string>
<string name="token_details_unable_hide_alert_title">Unable to hide %s</string>
<string name="token_swap_changelly_promotion_message">Exchange this token for another at %1$s service fees from February %2$s-%3$s.</string>

View file

@ -11,6 +11,9 @@ import java.util.Locale
private const val TEXT_CHUNK_THOUSAND = 3
private const val POINT_SEPARATOR = '.'
private const val COMMA_SEPARATOR = ','
private const val SCIENTIFIC_NOTATION = 'e'
const val DECIMAL_SEPARATOR_LIMIT = 1
@Composable
fun rememberDecimalFormat(): DecimalFormat {
@ -152,6 +155,47 @@ fun BigDecimal.parseBigDecimal(decimals: Int, roundingMode: RoundingMode = Round
}
}
/**
* Universal amount string parser to [BigDecimal]
* Able to parse values with only ONE separator, assuming separator is COMMA.
* Otherwise returns null.
*/
fun String.parseBigDecimalOrNull() = runCatching {
// Filtering value containing more than one either grouping or decimal separator.
// We assume there will be only decimal separator, otherwise parsing will fail.
// Step 1. Exclude formatted (100,000.0) except scientific notation (100.000e10)
val excludeFormatted = this.count {
!it.isDigit() && !it.equals(SCIENTIFIC_NOTATION, ignoreCase = true)
} > DECIMAL_SEPARATOR_LIMIT
// Step 2. Exclude wrong scientific notation (100e100e100)
val excludeWrongScientific = this.count {
it.equals(SCIENTIFIC_NOTATION, ignoreCase = true)
} > DECIMAL_SEPARATOR_LIMIT
if (excludeFormatted || excludeWrongScientific) return null
// An attempt to parse value with POINT decimal separator
val parsed = this.toBigDecimalOrNull()
if (parsed == null) {
// If parsing with POINT separator fails trying to parse with COMMA separator
val decimalFormatSymbol = DecimalFormatSymbols().apply {
decimalSeparator = COMMA_SEPARATOR
}
val decimalFormat = DecimalFormat().apply {
decimalFormatSymbols = decimalFormatSymbol
isParseBigDecimal = true
}
// Return either number or null if fails
decimalFormat.parse(this) as? BigDecimal
} else {
// If parsing with POINT separator succeeds return number
parsed
}
}.getOrNull()
private fun Int.getWithIntegerDecimals(before: String, separator: Char, after: String): String = if (this == 0) {
before
} else {

View file

@ -1,6 +1,7 @@
package com.tangem.data.qrscanning.repository
import com.tangem.blockchain.common.Blockchain
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.qrscanning.models.QrResult
import com.tangem.domain.qrscanning.models.SourceType
import com.tangem.domain.qrscanning.repository.QrScanningEventsRepository
@ -44,7 +45,7 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
when (it.key) {
Parameter.Amount -> {
// According to BIP-0021, the value is specified in decimals. No conversion needed
result.amount = it.value.toBigDecimalOrNull()
result.amount = it.value.parseBigDecimalOrNull()
}
Parameter.Message,
Parameter.Memo,
@ -52,16 +53,17 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
result.memo = URLDecoder.decode(it.value, "UTF-8")
}
Parameter.Address -> {
// If 'address' parameter is exists, then currency must be TOKEN.
val tokenCurrency = cryptoCurrency as? CryptoCurrency.Token ?: return QrResult()
// Overrides destination address for token transfers (ERC-681)
if (cryptoCurrency is CryptoCurrency.Token) {
// `address` parameter is used only if the contract address, encoded in the QR,
// matches the contract address of the token.
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routine
if (cryptoCurrency.contractAddress.equals(address, ignoreCase = true)) {
result.address = it.value
} else {
return QrResult()
}
// `address` parameter is used only if the contract address, encoded in the QR,
// matches the contract address of the token.
// Otherwise, the scanned string is likely malformed, and we stop the entire parsing routin
if (tokenCurrency.contractAddress.equals(address, ignoreCase = true)) {
result.address = it.value
} else {
return QrResult()
}
}
Parameter.Value,
@ -69,7 +71,7 @@ internal class DefaultQrScanningEventsRepository : QrScanningEventsRepository {
-> {
// Extra convert parses scientific notation to decimal
// This is necessary to be able comparing BigDecimal values
result.amount = it.value.toBigDecimalOrNull()
result.amount = it.value.parseBigDecimalOrNull()
?.toPlainString()?.toBigDecimalOrNull()
?.divide(BigDecimal.TEN.pow(cryptoCurrency.decimals))
}

View file

@ -152,11 +152,6 @@ internal class DefaultQrScanningEventsRepositoryTest {
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2$function?$addressParam=$addressParamValue",
QrResult(address = address2),
cryptoCurrency,
)
positiveCase(
"$garbage$schema2:$address2?$someParam=$someParamValue&$valueParam=$someAmountParamValue",
QrResult(address = address2),
@ -177,6 +172,11 @@ internal class DefaultQrScanningEventsRepositoryTest {
QrResult(address = address2, amount = BigDecimal("0.000000023")),
cryptoCurrency,
)
negativeCase(
"$garbage$schema2:$address2$function?$addressParam=$addressParamValue",
QrResult(address = address2),
cryptoCurrency,
)
}
@Test
@ -232,11 +232,21 @@ internal class DefaultQrScanningEventsRepositoryTest {
QrResult(address = address2, amount = BigDecimal("2300")),
tokenCryptoCurrency,
)
positiveCase(
"$address4?$addressParam=$addressParamValue",
QrResult(address = addressParamValue),
tokenCryptoCurrency,
)
negativeCase(
"$address2?$someParam=$someParamValue&$amountParam=$amountParamValue",
QrResult(address = address2, amount = BigDecimal("123.123"), memo = memoParamValueUtf8),
tokenCryptoCurrency,
)
negativeCase(
"$address4?$addressParam=$addressParamValue",
QrResult(address = addressParamValue),
cryptoCurrency,
)
}
private fun positiveCase(input: String, expected: QrResult, cryptoCurrency: CryptoCurrency) {

View file

@ -92,9 +92,9 @@ internal class SendFragment : ComposeFragment() {
delay(QR_SCAN_DELAY)
// Delayed launch is needed in order for the UI to be drawn and to process the sent events.
// If do not use the delay, then etAmount error field is not displayed when
// If do not use the delay, then error field is not displayed when
// inserting an incorrect amount by shareUri
viewModel.onRecipientAddressScanned(it)
viewModel.onQrCodeScanned(it)
}
}
}

View file

@ -569,12 +569,14 @@ internal class SendViewModel @Inject constructor(
// endregion
// region recipient state clicks
fun onRecipientAddressScanned(address: String) {
fun onQrCodeScanned(address: String) {
viewModelScope.launch(dispatchers.main) {
parseQrCodeUseCase(address, cryptoCurrency).fold(
ifRight = { parsedCode ->
onRecipientAddressValueChange(parsedCode.address, EnterAddressSource.QRCode)
parsedCode.amount?.let { onAmountValueChange(it.toPlainString()) }
parsedCode.amount?.let {
onAmountValueChange(it.parseBigDecimal(decimals = cryptoCurrency.decimals))
}
parsedCode.memo?.let { onRecipientMemoValueChange(it) }
},
ifLeft = {

View file

@ -57,6 +57,21 @@ internal object TokenDetailsPreviewData {
),
)
val tokenInfoBlockStateWithLongNameNoStandard = TokenInfoBlockState(
name = "Tether (USDT) with long name test",
iconState = TokenInfoBlockState.IconState.TokenIcon(
url = "https://s3.eu-central-1.amazonaws.com/tangem.api/coins/large/stellar.png",
fallbackTint = Color.Cyan,
fallbackBackground = Color.Blue,
isGrayscale = false,
),
currency = TokenInfoBlockState.Currency.Token(
standardName = null,
networkIcon = R.drawable.img_shibarium_22,
networkName = "Shibarium",
),
)
val tokenInfoBlockState = TokenInfoBlockState(
name = "Tether USDT",
iconState = TokenInfoBlockState.IconState.CustomTokenIcon(

View file

@ -19,7 +19,7 @@ internal data class TokenInfoBlockState(
* @param networkIcon - token's network icon.
*/
data class Token(
val standardName: String,
val standardName: String?,
val networkName: String,
@DrawableRes val networkIcon: Int,
) : Currency()

View file

@ -8,6 +8,7 @@ import com.tangem.core.ui.extensions.networkIconResId
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.feature.tokendetails.presentation.tokendetails.state.*
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsActionButton
import com.tangem.feature.tokendetails.presentation.tokendetails.state.components.TokenDetailsPullToRefreshConfig
@ -40,7 +41,7 @@ internal class TokenDetailsSkeletonStateConverter(
currency = when (value) {
is CryptoCurrency.Coin -> TokenInfoBlockState.Currency.Native
is CryptoCurrency.Token -> TokenInfoBlockState.Currency.Token(
standardName = value.network.standardType.name,
standardName = value.network.standardType.getSpecifiedNameOrNull(),
networkName = value.network.name,
networkIcon = value.networkIconResId,
)
@ -65,6 +66,9 @@ internal class TokenDetailsSkeletonStateConverter(
)
}
private fun Network.StandardType.getSpecifiedNameOrNull(): String? =
name.takeIf { this !is Network.StandardType.Unspecified }
private fun createMenu(cryptoCurrency: CryptoCurrency): TokenDetailsAppBarMenuConfig = TokenDetailsAppBarMenuConfig(
items = buildList {
if (featureToggles.isGenerateXPubEnabled() && isBitcoin(cryptoCurrency.network.id.value)) {

View file

@ -105,13 +105,22 @@ private const val SEPARATOR = " %image% "
@Composable
private fun extractNetwork(tokenCurrency: TokenInfoBlockState.Currency.Token): ExtractedTokenNetworkText {
val splitString = stringResource(
id = R.string.token_details_token_type_subtitle,
formatArgs = arrayOf(
tokenCurrency.standardName,
tokenCurrency.networkName,
),
).split(SEPARATOR)
val splitString = if (tokenCurrency.standardName != null) {
stringResource(
id = R.string.token_details_token_type_subtitle,
formatArgs = arrayOf(
tokenCurrency.standardName,
tokenCurrency.networkName,
),
).split(SEPARATOR)
} else {
stringResource(
id = R.string.token_details_token_type_subtitle_no_standard,
formatArgs = arrayOf(
tokenCurrency.networkName,
),
).split(SEPARATOR)
}
return remember(splitString) {
ExtractedTokenNetworkText(
@ -153,5 +162,6 @@ private class TokenInfoStateProvider : CollectionPreviewParameterProvider<TokenI
TokenDetailsPreviewData.tokenInfoBlockState,
TokenDetailsPreviewData.tokenInfoBlockStateWithLongName,
TokenDetailsPreviewData.tokenInfoBlockStateWithLongNameInMainCurrency,
TokenDetailsPreviewData.tokenInfoBlockStateWithLongNameNoStandard,
),
)

@ -1 +1 @@
Subproject commit 0e4934995b8e7c69862ef80362e86bd166bd1288
Subproject commit d8e6da9ab3a0442657822352cb6274237d88e77d