diff --git a/app/src/main/java/com/tangem/tap/features/tokens/impl/presentation/ui/BriefNetworksList.kt b/app/src/main/java/com/tangem/tap/features/tokens/impl/presentation/ui/BriefNetworksList.kt index d5328878eb..e122327ff8 100644 --- a/app/src/main/java/com/tangem/tap/features/tokens/impl/presentation/ui/BriefNetworksList.kt +++ b/app/src/main/java/com/tangem/tap/features/tokens/impl/presentation/ui/BriefNetworksList.kt @@ -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 + } + }, ) } } \ No newline at end of file diff --git a/core/res/src/main/res/values-ru/strings.xml b/core/res/src/main/res/values-ru/strings.xml index b4bd309592..5d25d41f8e 100644 --- a/core/res/src/main/res/values-ru/strings.xml +++ b/core/res/src/main/res/values-ru/strings.xml @@ -427,7 +427,7 @@ Уже содержится в введенном адресе Вычесть Недостаточно средств для покрытия комиссии сети. Вычесть недостающую сумму для покрытия комиссии из отправляемой суммы? - Сумма комиссии в %@ раз превышает рекомендованную. Убедитесь, что указанная комиссия верна. + Сумма комиссии в %s раз превышает рекомендованную. Убедитесь, что указанная комиссия верна. Вы указали комиссию ниже рекомендуемой, это может привести к задержке исполнения вашей транзакции. Продолжить? Причина: %1$s\nКод: %2$s Транзакция не выполнена @@ -537,9 +537,9 @@ Балансы скрыты Балансы показаны Отменить + Выбранная операция в данный момент недоступна. Попробуйте позже. В данный момент покупка монеты %s недоступна. Но мы работаем над её добавлением. У вас нет средств для отправки. Пополните счет, чтобы иметь возможность отправить с него средства. - Выбранная операция в данный момент недоступна. Попробуйте позже. Обмен %s не доступен. Но мы работаем над его добавлением. В данный момент продажа монеты %s недоступна. Но мы работаем над её добавлением. Сгенерировать XPUB @@ -548,6 +548,7 @@ Скрыть %s Скрыть токен %1$s токен в сети %%image%% %2$s + Токен в сети %%image%% %1$s Токен %1$s является основной валютой в сети %2$s и не может быть скрыт до тех пор, пока у вас в списке есть другие токены этой сети. Невозможно скрыть %s Обменивайте этот токен на другие с %1$s комиссии за обслуживание с %2$s по %3$s февраля. diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 9310a0153a..a099c577d4 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -534,9 +534,9 @@ Balances hidden Balances shown Undo + This operation is currently unavailable. Please try again later. The purchase of the %s is currently unavailable. But we are working on adding it. You do not have funds to send. Top up your account to be able to send funds from it. - This operation is currently unavailable. Please try again later. %s swap is not available. But we are working on adding it. Sell of the %s coin is currently unavailable. But we are working on adding it. Generate XPUB @@ -545,6 +545,7 @@ Hide %s Hide token %1$s token in %%image%% %2$s network + Token in %%image%% %1$s network 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. Unable to hide %s Exchange this token for another at %1$s service fees from February %2$s-%3$s. diff --git a/core/ui/src/main/java/com/tangem/core/ui/utils/DecimalFormatterExt.kt b/core/ui/src/main/java/com/tangem/core/ui/utils/DecimalFormatterExt.kt index 7db27cf7f4..5006df1d7a 100644 --- a/core/ui/src/main/java/com/tangem/core/ui/utils/DecimalFormatterExt.kt +++ b/core/ui/src/main/java/com/tangem/core/ui/utils/DecimalFormatterExt.kt @@ -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 { diff --git a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt index 87729e8bc4..cce12822c5 100644 --- a/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt +++ b/data/qr-scanning/src/main/java/com/tangem/data/qrscanning/repository/DefaultQrScanningEventsRepository.kt @@ -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)) } diff --git a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt index ad1f6244a7..3b3cd6df38 100644 --- a/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt +++ b/data/qr-scanning/src/test/java/com/tangem/data/qrscanning/DefaultQrScanningEventsRepositoryTest.kt @@ -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) { diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/SendFragment.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/SendFragment.kt index cebe88712f..64e8f1e992 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/SendFragment.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/SendFragment.kt @@ -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) } } } diff --git a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt index 2d397422cf..ffad3f3633 100644 --- a/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt +++ b/features/send/impl/src/main/java/com/tangem/features/send/impl/presentation/viewmodel/SendViewModel.kt @@ -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 = { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt index 77f27a4cac..69d878168f 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/TokenDetailsPreviewData.kt @@ -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( diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenInfoBlockState.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenInfoBlockState.kt index 7c1e1eda2b..f0ec9e8d43 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenInfoBlockState.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/TokenInfoBlockState.kt @@ -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() diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt index dbc7ff632b..163a1516ae 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/state/factory/TokenDetailsSkeletonStateConverter.kt @@ -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)) { diff --git a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenInfoBlock.kt b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenInfoBlock.kt index 3254286ed2..3982a8dc21 100644 --- a/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenInfoBlock.kt +++ b/features/tokendetails/impl/src/main/kotlin/com/tangem/feature/tokendetails/presentation/tokendetails/ui/components/TokenInfoBlock.kt @@ -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