Updated on 2026-08-14

This commit is contained in:
Tangem 2024-04-22 16:57:35 +01:00
commit cbcb1dae0b
49 changed files with 490 additions and 242 deletions

View file

@ -2,8 +2,8 @@ package com.tangem.tap.common.analytics.converters
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.common.core.TangemSdkError
import com.tangem.domain.demo.DemoTransactionSender
import com.tangem.tap.common.analytics.events.AnalyticsParam
import com.tangem.tap.features.demo.DemoTransactionSender
import com.tangem.utils.converter.Converter
/**

View file

@ -1,7 +1,7 @@
package com.tangem.tap.di.domain
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.transaction.FeeRepository
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.transaction.usecase.CreateTransactionUseCase
@ -22,20 +22,24 @@ internal object TransactionDomainModule {
@Provides
@ViewModelScoped
fun provideGetFeeUseCase(walletManagersFacade: WalletManagersFacade): GetFeeUseCase {
return GetFeeUseCase(walletManagersFacade)
return GetFeeUseCase(
walletManagersFacade = walletManagersFacade,
demoConfig = DemoConfig(),
)
}
@Provides
@ViewModelScoped
fun provideSendTransactionUseCase(
isDemoCardUseCase: IsDemoCardUseCase,
cardSdkConfigRepository: CardSdkConfigRepository,
transactionRepository: TransactionRepository,
walletManagersFacade: WalletManagersFacade,
): SendTransactionUseCase {
return SendTransactionUseCase(
isDemoCardUseCase = isDemoCardUseCase,
demoConfig = DemoConfig(),
cardSdkConfigRepository = cardSdkConfigRepository,
transactionRepository = transactionRepository,
walletManagersFacade = walletManagersFacade,
)
}

View file

@ -7,7 +7,7 @@ import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchain.extensions.Result
import com.tangem.common.extensions.isZero
import com.tangem.tap.common.redux.AppState
import com.tangem.tap.features.demo.DemoTransactionSender
import com.tangem.domain.demo.DemoTransactionSender
import com.tangem.tap.features.demo.isDemoCard
import com.tangem.tap.features.send.redux.AmountActionUi
import com.tangem.tap.features.send.redux.FeeAction

View file

@ -31,7 +31,7 @@ import com.tangem.tap.common.redux.global.GlobalAction
import com.tangem.tap.domain.TangemSigner
import com.tangem.tap.domain.TapError
import com.tangem.tap.domain.configurable.warningMessage.WarningMessage
import com.tangem.tap.features.demo.DemoTransactionSender
import com.tangem.domain.demo.DemoTransactionSender
import com.tangem.tap.features.demo.isDemoCard
import com.tangem.tap.features.send.redux.*
import com.tangem.tap.features.send.redux.FeeAction.RequestFee

View file

@ -135,7 +135,7 @@ class MoonPayService(
.appendQueryParameter("apiKey", apiKey)
.appendQueryParameter("baseCurrencyCode", moonpayCurrency.currencyCode.uppercase())
.appendQueryParameter("refundWalletAddress", walletAddress)
.appendQueryParameter("redirectURL", "tangem://redirect_sell")
.appendQueryParameter("redirectURL", "tangem://redirect_sell?currency_id=${cryptoCurrency.id.value}")
if (isDarkTheme) uri.appendQueryParameter("theme", "dark")

View file

@ -11,6 +11,7 @@ class SellCurrencyDeepLink(val onReceive: (data: Data) -> Unit) : DeepLink {
transactionId = params["transactionId"] ?: return,
baseCurrencyAmount = params["baseCurrencyAmount"] ?: return,
depositWalletAddress = params["depositWalletAddress"] ?: return,
currencyId = params["currency_id"] ?: return,
depositWalletAddressTag = params["depositWalletAddressTag"],
)
@ -21,6 +22,7 @@ class SellCurrencyDeepLink(val onReceive: (data: Data) -> Unit) : DeepLink {
val transactionId: String,
val baseCurrencyAmount: String,
val depositWalletAddress: String,
val currencyId: String,
val depositWalletAddressTag: String?,
)
}

View file

@ -9,7 +9,7 @@
},
{
"name": "REDESIGNED_SEND_SCREEN_ENABLED",
"version": "5.9.0"
"version": "5.9.1"
},
{
"name": "GENERAL_USER_WALLETS_LIST_MANAGER_ENABLED",

View file

@ -51,6 +51,10 @@
<string name="card_settings_reset_card_to_factory">Заводские настройки</string>
<string name="card_settings_security_mode">Тип безопасности</string>
<string name="card_settings_title">Настройки карты</string>
<string name="cardano_coin_will_be_send_with_token_description">Ввиду особенностей сети Cardano при транзакции токена %1$s помимо комиссии сети будет списано %2$s</string>
<string name="cardano_insufficient_balance_to_send_token_description">Для совершения транзакции %1$s, вам необходимо внести немного %2$s (%3$s), чтобы покрыть комиссию сети и минимальное значение ADA для отправки.</string>
<string name="cardano_insufficient_balance_to_send_token_title">Недостаточно ADA для отправки токена</string>
<string name="cardano_max_amount_has_token_description">Вывод всего баланса ADA невозможен при наличии средств на токенах сети Cardano. Сначала выведите средства на ваших токенах.</string>
<string name="common_accept">Принять</string>
<string name="common_access_denied">Доступ запрещен</string>
<string name="common_apply">Применить</string>
@ -96,7 +100,8 @@
<string name="common_no_address">Нет адреса</string>
<string name="common_ok">OK</string>
<string name="common_origin_card">Основная карта</string>
<string name="common_passphrase">Кодовая фраза</string>
<string name="common_passphrase">Парольная фраза</string>
<string name="common_paste">Вставить</string>
<string name="common_read_more">Подробнее</string>
<string name="common_receive">Получить</string>
<string name="common_reject">Отклонить</string>
@ -183,9 +188,9 @@
<string name="express_choose_providers_subtitle">Tangem предоставляет доступ к обмену через сторонних поставщиков в соответствии с их правилами</string>
<string name="express_choose_providers_title">Выберите провайдера</string>
<string name="express_error_code">Произошла ошибка. Код: %s</string>
<string name="express_error_provider_unavailable">К сожалению обмен указанной пары через выбранного провайдера на данный момент не возможен. Попробуйте совершить обмен позже. (Код: %s)</string>
<string name="express_error_provider_unavailable">К сожалению, обмен указанной пары через выбранного провайдера на данный момент невозможен. Попробуйте совершить обмен позже. (Код: %s)</string>
<string name="express_error_swap_pair_unavailable">Выбранный провайдер не доступен для обмена. Попробуйте позже. (Код: %s)</string>
<string name="express_error_swap_unavailable">В данный момент обмен не возможен. Попробуйте позже. (Код: %s)</string>
<string name="express_error_swap_unavailable">В данный момент обмен невозможен. Попробуйте позже. (Код: %s)</string>
<string name="express_estimated_amount">Курс обмена</string>
<string name="express_exchange_by">Обмен через %s</string>
<string name="express_exchange_notification_failed_text">Чтобы вернуть ваши деньги, посетите сайт провайдера</string>
@ -288,7 +293,7 @@
<string name="onboarding_activation_error_title">Ошибка активации</string>
<string name="onboarding_alert_message_not_max_backup_cards_added">Вы добавили одну резервную карту. После того, как процесс будет завершен, Вы больше не сможете добавить карт. Если у Вас есть еще одна карта, добавьте ее в резервную копию. Хотите продолжить?</string>
<string name="onboarding_backup_exit_warning">Процесс резервного копирования почти завершен. Вы не можете выйти из него сейчас.</string>
<string name="onboarding_bottom_sheet_passphrase_description">Кодовая фраза — это расширенная функция безопасности, которую используют криптокошельки. Она добавляет дополнительное слово или фразу по вашему выбору к уже существующей seed - фразе, чтобы разблокировать совершенно новый набор адресов.</string>
<string name="onboarding_bottom_sheet_passphrase_description">Парольная фраза — это расширенная функция безопасности, которую используют криптокошельки. Она добавляет дополнительное слово или фразу по вашему выбору к уже существующей seed - фразе, чтобы разблокировать совершенно новый набор адресов.</string>
<string name="onboarding_button_add_backup_card">Добавить резервную карту</string>
<string name="onboarding_button_backup_card_format">Сканировать карту #%d</string>
<string name="onboarding_button_backup_now">Создать резервную копию</string>
@ -478,7 +483,7 @@
<string name="send_notification_transaction_delay_title">Возможны задержки по транзакции</string>
<string name="send_notification_transaction_limit_text">Из-за ограничений %1$s в одну транзакцию может поместиться только %2$s UTXO. Это означает, что вы можете отправить только %3$s или меньше. Вам нужно уменьшить сумму.</string>
<string name="send_notification_transaction_limit_title">Лимит транзакции</string>
<string name="send_optional_field">Необязательное</string>
<string name="send_optional_field">Опционально</string>
<string name="send_qrcode_scan_info">Пожалуйста, совместите свой QR-код с квадратом, чтобы отсканировать его. Убедитесь, что вы сканируете адрес в сети %s.</string>
<string name="send_recent_transactions">Последние</string>
<string name="send_recipient">Получатель</string>

View file

@ -50,6 +50,10 @@
<string name="card_settings_reset_card_to_factory">Reset to Factory Settings</string>
<string name="card_settings_security_mode">Security Mode</string>
<string name="card_settings_title">Card Settings</string>
<string name="cardano_coin_will_be_send_with_token_description">Due to the peculiarities of the Cardano network, when transacting the %1$s token, in addition to the network commission, %2$s will be charged</string>
<string name="cardano_insufficient_balance_to_send_token_description">To make a %1$s transaction, you must deposit some %2$s (%3$s) to cover the network fee and minimum ADA value</string>
<string name="cardano_insufficient_balance_to_send_token_title">Insufficient ADA to token transfer</string>
<string name="cardano_max_amount_has_token_description">Withdrawal of the entire ADA balance is not possible if funds are available in Cardano network tokens. First, withdraw funds on your tokens.</string>
<string name="common_accept">Accept</string>
<string name="common_access_denied">Access denied</string>
<string name="common_apply">Apply</string>
@ -96,6 +100,7 @@
<string name="common_ok">OK</string>
<string name="common_origin_card">Primary Card</string>
<string name="common_passphrase">Passphrase</string>
<string name="common_paste">Paste</string>
<string name="common_read_more">Read more</string>
<string name="common_receive">Receive</string>
<string name="common_reject">Reject</string>
@ -427,6 +432,8 @@
<string name="send_alert_transaction_failed_text">Reason: %1$s\nCode: %2$s</string>
<string name="send_alert_transaction_failed_title">The transaction is not completed</string>
<string name="send_amount_label">Amount</string>
<string name="send_base_fee">Base fee</string>
<string name="send_base_fee_footer">Represents the part of the transaction fee that goes to the miner</string>
<string name="send_date_format">%1$s, %2$s</string>
<string name="send_destination_hint_address">Address</string>
<string name="send_destination_tag_field">Destination Tag</string>
@ -474,6 +481,8 @@
<string name="send_notification_transaction_limit_text">Due to %1$s limitations only %2$s UTXOs can fit in a single transaction. This means you can only send %3$s or less. You need to reduce the amount.</string>
<string name="send_notification_transaction_limit_title">Transaction limitation</string>
<string name="send_optional_field">Optional</string>
<string name="send_priority_fee">Priority fee</string>
<string name="send_priority_fee_footer">Represents the minimum gasUsed multiplier required for a transaction to be included in a block. This is the part of the transaction fee that is burnt.</string>
<string name="send_qrcode_scan_info">Please align your QR code with the square to scan it. Ensure you scan %s network address.</string>
<string name="send_recent_transactions">Recent</string>
<string name="send_recipient">Recipient</string>

View file

@ -47,14 +47,15 @@ inline fun <reified T> SegmentedButtons(
selectedColor: Color = TangemTheme.colors.background.action,
dividerColor: Color = TangemTheme.colors.stroke.primary,
showIndication: Boolean = true,
selectedItem: T? = null,
initialSelectedItem: T? = null,
isEnabled: Boolean = true,
crossinline buttonContent: @Composable (T) -> Unit,
) {
if (config.isEmpty() || config.size == 1) return
var selected by remember {
val selectedIndex = if (selectedItem == null) 0 else config.indexOf(selectedItem)
mutableIntStateOf(selectedIndex)
var selectedIndex by remember {
val index = if (initialSelectedItem == null) 0 else config.indexOf(initialSelectedItem)
mutableIntStateOf(index)
}
Row(
@ -69,7 +70,7 @@ inline fun <reified T> SegmentedButtons(
val rightRadius = if (index == config.lastIndex) TangemTheme.dimens.radius26 else TangemTheme.dimens.radius0
val animateColor by animateColorAsState(
targetValue = if (index == selected) selectedColor else color,
targetValue = if (index == selectedIndex) selectedColor else color,
label = "Segmented Button Selected Color Animation",
animationSpec = spring(stiffness = Spring.StiffnessMedium),
)
@ -86,11 +87,12 @@ inline fun <reified T> SegmentedButtons(
),
)
.clickable(
enabled = isEnabled,
interactionSource = remember { MutableInteractionSource() },
indication = if (showIndication) LocalIndication.current else null,
) {
selectedIndex = index
onClick(config[index])
selected = index
},
) {
buttonContent.invoke(config[index])

View file

@ -4,11 +4,16 @@ import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import com.tangem.core.ui.R
import com.tangem.core.ui.components.currency.DefaultCurrencyIcon
private const val GRAY_SCALE_SATURATION = 0f
private const val GRAY_SCALE_ALPHA = 0.4f
/**
* Simple icon from network
*
@ -20,16 +25,19 @@ import com.tangem.core.ui.components.currency.DefaultCurrencyIcon
fun FiatIcon(
url: String?,
size: Dp,
isGrayscale: Boolean,
modifier: Modifier = Modifier,
@DrawableRes fallbackResId: Int = R.drawable.ic_shape_circle,
) {
val iconData: Any = if (url.isNullOrBlank()) fallbackResId else url
val alpha = if (isGrayscale) GRAY_SCALE_ALPHA else 1f
val colorFilter = if (isGrayscale) GrayscaleColorFilter else null
DefaultCurrencyIcon(
iconData = iconData,
size = size,
alpha = 1f,
colorFilter = null,
alpha = alpha,
colorFilter = colorFilter,
errorIcon = {
Image(
painter = painterResource(id = fallbackResId),
@ -38,4 +46,7 @@ fun FiatIcon(
},
modifier = modifier,
)
}
}
private val GrayscaleColorFilter: ColorFilter
get() = ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(GRAY_SCALE_SATURATION) })

View file

@ -20,6 +20,26 @@ class CryptoCurrencyToIconStateConverter : Converter<CryptoCurrencyStatus, Token
}
}
fun convertCustom(
value: CryptoCurrencyStatus,
forceGrayscale: Boolean,
showCustomTokenBadge: Boolean,
): TokenIconState {
return when (val currency = value.currency) {
is CryptoCurrency.Coin -> getIconStateForCoin(
coin = currency,
isUnreachable = value.value.isError,
forceGrayscale = forceGrayscale,
)
is CryptoCurrency.Token -> getIconStateForToken(
token = currency,
isErrorStatus = value.value.isError,
forceGrayscale = forceGrayscale,
showCustomBadge = showCustomTokenBadge,
)
}
}
fun convert(currency: CryptoCurrency): TokenIconState {
return when (currency) {
is CryptoCurrency.Coin -> getIconStateForCoin(currency, isUnreachable = false)
@ -27,18 +47,27 @@ class CryptoCurrencyToIconStateConverter : Converter<CryptoCurrencyStatus, Token
}
}
private fun getIconStateForCoin(coin: CryptoCurrency.Coin, isUnreachable: Boolean): TokenIconState.CoinIcon {
private fun getIconStateForCoin(
coin: CryptoCurrency.Coin,
isUnreachable: Boolean,
forceGrayscale: Boolean = false,
): TokenIconState.CoinIcon {
return TokenIconState.CoinIcon(
url = coin.iconUrl,
fallbackResId = coin.networkIconResId,
isGrayscale = coin.network.isTestnet || isUnreachable,
isGrayscale = forceGrayscale || coin.network.isTestnet || isUnreachable,
showCustomBadge = coin.isCustom,
)
}
private fun getIconStateForToken(token: CryptoCurrency.Token, isErrorStatus: Boolean): TokenIconState {
val isGrayscale = token.network.isTestnet || isErrorStatus
val background = token.tryGetBackgroundForTokenIcon(isGrayscale)
private fun getIconStateForToken(
token: CryptoCurrency.Token,
isErrorStatus: Boolean,
showCustomBadge: Boolean = true,
forceGrayscale: Boolean = false,
): TokenIconState {
val grayScale = forceGrayscale || token.network.isTestnet || isErrorStatus
val background = token.tryGetBackgroundForTokenIcon(grayScale)
val tint = getTintForTokenIcon(background)
return if (token.isCustom && token.iconUrl == null) {
@ -46,16 +75,16 @@ class CryptoCurrencyToIconStateConverter : Converter<CryptoCurrencyStatus, Token
tint = tint,
background = background,
networkBadgeIconResId = token.networkIconResId,
isGrayscale = isGrayscale,
isGrayscale = grayScale,
)
} else {
TokenIconState.TokenIcon(
url = token.iconUrl,
networkBadgeIconResId = token.networkIconResId,
isGrayscale = isGrayscale,
isGrayscale = grayScale,
fallbackTint = tint,
fallbackBackground = background,
showCustomBadge = token.isCustom, // `true` for tokens with custom derivation
showCustomBadge = token.isCustom && showCustomBadge, // `true` for tokens with custom derivation
)
}
}

View file

@ -50,7 +50,7 @@ fun PasteButton(isPasteButtonVisible: Boolean, onClick: (String) -> Unit, modifi
modifier = modifier,
) {
Text(
text = "Paste",
text = stringResource(R.string.common_paste),
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary2,
modifier = Modifier

View file

@ -16,7 +16,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import com.tangem.core.ui.R
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.toDateFormat
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toTimeFormat
/**
@ -47,7 +47,11 @@ fun TransactionDoneTitle(@StringRes titleRes: Int, date: Long, modifier: Modifie
.padding(top = TangemTheme.dimens.spacing16),
)
Text(
text = stringResource(id = R.string.send_date_format, date.toDateFormat(), date.toTimeFormat()),
text = stringResource(
id = R.string.send_date_format,
date.toTimeFormat(DateTimeFormatters.dateFormatter),
date.toTimeFormat(),
),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier

View file

@ -40,6 +40,29 @@ object BigDecimalFormatter {
}
}
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.DOWN
}
return formatter.format(cryptoAmount).let {
if (cryptoCurrency.symbol.isEmpty()) {
it
} else {
it + "\u2009${cryptoCurrency.symbol}"
}
}
}
fun formatCryptoAmount(
cryptoAmount: BigDecimal?,
cryptoCurrency: CryptoCurrency,

View file

@ -11,7 +11,7 @@ import org.joda.time.format.DateTimeFormatter
* If [this] timestamp is today or yesterday, returns relative date,
* otherwise returns formatting date.
*/
fun Long.toDateFormat(formatter: DateTimeFormatter = DateTimeFormatters.dateFormatter): String {
fun Long.toDateFormatWithTodayYesterday(formatter: DateTimeFormatter = DateTimeFormatters.dateFormatter): String {
val localDate = DateTime(this, DateTimeZone.getDefault())
return if (localDate.isToday() || localDate.isYesterday()) {
DateUtils.getRelativeTimeSpanString(

View file

@ -10,4 +10,5 @@ android {
dependencies {
implementation(deps.tangem.blockchain)
implementation(deps.tangem.card.core)
}

View file

@ -1,4 +1,4 @@
package com.tangem.tap.features.demo
package com.tangem.domain.demo
import com.tangem.blockchain.common.*
import com.tangem.blockchain.common.transaction.Fee

View file

@ -184,8 +184,9 @@ sealed class CryptoCurrency : Parcelable {
}
private companion object {
// should use delimiters that could be used in URL not like path or query delimiters
const val PREFIX_DELIMITER = '_'
const val SUFFIX_DELIMITER = '#'
const val SUFFIX_DELIMITER = ';'
const val DERIVATION_PATH_DELIMITER = 'd'
}
}

View file

@ -4,7 +4,7 @@ import com.tangem.core.ui.extensions.TextReference
sealed class SendTransactionError {
object DemoCardError : SendTransactionError()
data object DemoCardError : SendTransactionError()
data class DataError(val message: String?) : SendTransactionError()
@ -12,7 +12,7 @@ sealed class SendTransactionError {
data class BlockchainSdkError(val code: Int, val message: String?) : SendTransactionError()
object UserCancelledError : SendTransactionError()
data object UserCancelledError : SendTransactionError()
data class CreateAccountUnderfunded(val amount: String) : SendTransactionError()

View file

@ -6,10 +6,12 @@ import com.tangem.blockchain.common.Amount
import com.tangem.blockchain.common.AmountType
import com.tangem.blockchain.common.Token
import com.tangem.blockchain.extensions.Result
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.demo.DemoTransactionSender
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.domain.wallets.models.UserWallet
import java.math.BigDecimal
/**
@ -17,23 +19,31 @@ import java.math.BigDecimal
*/
class GetFeeUseCase(
private val walletManagersFacade: WalletManagersFacade,
private val demoConfig: DemoConfig,
) {
suspend operator fun invoke(
amount: BigDecimal,
destination: String,
userWalletId: UserWalletId,
userWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
) = either {
catch(
block = {
val result = requireNotNull(
walletManagersFacade.getFee(
amount = convertCryptoCurrencyToAmount(cryptoCurrency, amount),
val amountData = convertCryptoCurrencyToAmount(cryptoCurrency, amount)
val result = if (demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)) {
demoTransactionSender(userWallet, cryptoCurrency).getFee(
amount = amountData,
destination = destination,
userWalletId = userWalletId,
)
} else {
walletManagersFacade.getFee(
amount = amountData,
destination = destination,
userWalletId = userWallet.walletId,
network = cryptoCurrency.network,
),
) { "Fee is null" }
) ?: error("Fee is null")
}
val maybeFee = when (result) {
is Result.Success -> result.data
@ -47,6 +57,17 @@ class GetFeeUseCase(
)
}
private suspend fun demoTransactionSender(
userWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
): DemoTransactionSender {
return DemoTransactionSender(
walletManagersFacade
.getOrCreateWalletManager(userWallet.walletId, cryptoCurrency.network)
?: error("WalletManager is null"),
)
}
private fun convertCryptoCurrencyToAmount(cryptoCurrency: CryptoCurrency, amount: BigDecimal) = Amount(
currencySymbol = cryptoCurrency.symbol,
value = amount,

View file

@ -5,6 +5,7 @@ import arrow.core.left
import arrow.core.right
import com.tangem.blockchain.common.BlockchainSdkError
import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.TransactionSigner
import com.tangem.blockchain.extensions.SimpleResult
import com.tangem.blockchain.network.ResultChecker
import com.tangem.common.core.TangemSdkError
@ -12,20 +13,23 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.card.repository.CardSdkConfigRepository
import com.tangem.domain.common.TapWorkarounds.isStart2Coin
import com.tangem.domain.demo.IsDemoCardUseCase
import com.tangem.domain.demo.DemoConfig
import com.tangem.domain.demo.DemoTransactionSender
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.transaction.R
import com.tangem.domain.transaction.TransactionRepository
import com.tangem.domain.transaction.error.SendTransactionError
import com.tangem.domain.transaction.error.SendTransactionError.Companion.USER_CANCELLED_ERROR_CODE
import com.tangem.domain.walletmanager.WalletManagersFacade
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.sdk.extensions.localizedDescriptionRes
import com.tangem.utils.toFormattedString
class SendTransactionUseCase(
private val isDemoCardUseCase: IsDemoCardUseCase,
private val demoConfig: DemoConfig,
private val cardSdkConfigRepository: CardSdkConfigRepository,
private val transactionRepository: TransactionRepository,
private val walletManagersFacade: WalletManagersFacade,
) {
suspend operator fun invoke(
txData: TransactionData,
@ -39,8 +43,13 @@ class SendTransactionUseCase(
cardSdkConfigRepository.setLinkedTerminal(false)
}
val sendResult = try {
if (isDemoCardUseCase(cardId = userWallet.cardId)) {
SendTransactionError.DemoCardError.left()
if (demoConfig.isDemoCardId(cardId = userWallet.cardId)) {
sendDemo(
userWallet = userWallet,
network = network,
transactionData = txData,
signer = signer,
)
} else {
transactionRepository.sendTransaction(
txData = txData,
@ -66,6 +75,27 @@ class SendTransactionUseCase(
)
}
private suspend fun sendDemo(
userWallet: UserWallet,
network: Network,
transactionData: TransactionData,
signer: TransactionSigner,
): Either<SendTransactionError, SimpleResult> {
val demoTransactionSender = DemoTransactionSender(
walletManagersFacade
.getOrCreateWalletManager(userWallet.walletId, network)
?: error("WalletManager is null"),
)
val result = demoTransactionSender.send(transactionData = transactionData, signer = signer)
return if (result is SimpleResult.Failure && result.error.customMessage.contains(DemoTransactionSender.ID)) {
SendTransactionError.DemoCardError.left()
} else {
result.right()
}
}
private fun handleError(result: SimpleResult.Failure): SendTransactionError {
if (ResultChecker.isNetworkError(result)) {
return SendTransactionError.NetworkError(

View file

@ -93,7 +93,7 @@ private fun SegmentSeedBlock(state: SegmentSeedState, modifier: Modifier = Modif
SegmentedButtons(
modifier = modifier.padding(horizontal = TangemTheme.dimens.spacing76),
config = state.seedSegments,
selectedItem = state.selectedSeedType,
initialSelectedItem = state.selectedSeedType,
onClick = {
state.onSelectType(it)
},

View file

@ -117,6 +117,7 @@ internal class SendStateFactory(
recipientState = state.recipientState
?: recipientStateConverter.convert(SendRecipientStateConverter.Data(destinationAddress, memo)),
feeState = state.feeState ?: feeStateConverter.convert(Unit),
sendState = confirmStateConverter.convert(Unit),
isEditingDisabled = true,
cryptoCurrencyName = cryptoCurrencyStatusProvider().currency.name,
)

View file

@ -48,6 +48,7 @@ internal sealed class SendStates {
val walletBalance: TextReference,
val tokenIconState: TokenIconState,
val segmentedButtonConfig: PersistentList<SendAmountSegmentedButtonsConfig>,
val isSegmentedButtonsEnabled: Boolean,
val amountTextField: SendTextField.AmountField,
val notifications: ImmutableList<SendNotification>,
val appCurrencyCode: String,

View file

@ -31,6 +31,7 @@ internal class SendAmountStateConverter(
val status = cryptoCurrencyStatusProvider()
val fiat = formatFiatAmount(status.value.fiatAmount, appCurrency.code, appCurrency.symbol)
val crypto = formatCryptoAmount(status.value.amount, status.currency.symbol, status.currency.decimals)
val noFeeRate = status.value.fiatRate.isNullOrZero()
return SendStates.AmountState(
walletName = userWallet.name,
@ -42,22 +43,23 @@ internal class SendAmountStateConverter(
isFeeLoading = false,
appCurrencyCode = appCurrency.code,
subtractedFee = null,
segmentedButtonConfig = if (status.value.fiatRate.isNullOrZero()) {
persistentListOf()
} else {
persistentListOf(
SendAmountSegmentedButtonsConfig(
title = stringReference(status.currency.symbol),
iconState = iconStateConverter.convert(status),
isFiat = false,
segmentedButtonConfig = persistentListOf(
SendAmountSegmentedButtonsConfig(
title = stringReference(status.currency.symbol),
iconState = iconStateConverter.convertCustom(
value = status,
forceGrayscale = noFeeRate,
showCustomTokenBadge = false,
),
SendAmountSegmentedButtonsConfig(
title = stringReference(appCurrency.code),
iconUrl = appCurrency.iconSmallUrl,
isFiat = true,
),
)
},
isFiat = false,
),
SendAmountSegmentedButtonsConfig(
title = stringReference(appCurrency.code),
iconUrl = appCurrency.iconSmallUrl,
isFiat = true,
),
),
isSegmentedButtonsEnabled = !noFeeRate,
)
}
}

View file

@ -19,11 +19,13 @@ internal class SendAmountSubtractConverter(
val feeValue = feeState.fee?.amount?.value ?: return state
val amountTextField = amountState.amountTextField
val amountValue = amountTextField.cryptoAmount.value ?: return state
val balance = cryptoCurrencyStatus.value.amount ?: return state
val fiatRate = cryptoCurrencyStatus.value.fiatRate
val cryptoDecimals = amountTextField.cryptoAmount.decimals
val fiatDecimals = amountTextField.fiatAmount.decimals
val feeDiff = amountState.subtractedFee?.let { feeValue.minus(it) } ?: feeValue
val feeDiff = amountState.subtractedFee?.let { feeValue.minus(it) }
?: amountValue.minus(balance.minus(feeValue))
val decimalCryptoValue = amountValue.minus(feeDiff)
if (decimalCryptoValue < BigDecimal.ZERO) return state

View file

@ -20,10 +20,10 @@ import com.tangem.features.send.impl.presentation.analytics.SendAnalyticEvents
import com.tangem.features.send.impl.presentation.state.*
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
import com.tangem.features.send.impl.presentation.state.fee.FeeType
import com.tangem.features.send.impl.presentation.state.fee.checkIfFeeTooHigh
import com.tangem.features.send.impl.presentation.viewmodel.SendClickIntents
import com.tangem.lib.crypto.BlockchainUtils.isDogecoin
import com.tangem.utils.Provider
import com.tangem.utils.isNullOrZero
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.math.BigDecimal
import java.math.BigInteger
@Suppress("LongParameterList")
internal class SendNotificationFactory(
@ -57,6 +56,7 @@ internal class SendNotificationFactory(
val amountValue = state.amountState?.amountTextField?.cryptoAmount?.value ?: BigDecimal.ZERO
buildList {
// errors
addFeeUnreachableNotification(feeState.feeSelectorState)
addExceedBalanceNotification(feeAmount, amountValue)
addExceedsBalanceNotification(feeState.fee)
addMinimumAmountErrorNotification(feeAmount, amountValue)
@ -70,7 +70,7 @@ internal class SendNotificationFactory(
}.toImmutableList()
}
fun dismissNotificationState(clazz: Class<out SendNotification>): SendUiState {
fun dismissNotificationState(clazz: Class<out SendNotification>, isIgnored: Boolean = false): SendUiState {
val state = currentStateProvider()
val sendState = state.sendState ?: return state
val notificationsToRemove = sendState.notifications.filterIsInstance(clazz)
@ -78,50 +78,35 @@ internal class SendNotificationFactory(
updatedNotifications.removeAll(notificationsToRemove)
return state.copy(
sendState = sendState.copy(
ignoreAmountReduce = true,
ignoreAmountReduce = isIgnored,
notifications = updatedNotifications.toImmutableList(),
),
)
}
private fun MutableList<SendNotification>.addFeeUnreachableNotification(feeSelectorState: FeeSelectorState) {
if (feeSelectorState is FeeSelectorState.Error) {
add(SendNotification.Warning.NetworkFeeUnreachable(clickIntents::feeReload))
}
}
private fun MutableList<SendNotification>.addExceedBalanceNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
) {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val coinCryptoCurrencyStatus = coinCryptoCurrencyStatusProvider()
val feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatusProvider()
val cryptoAmount = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val coinCryptoAmount = coinCryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val showNotification = if (cryptoCurrencyStatus.currency.id == feePaidCryptoCurrencyStatus?.currency?.id) {
receivedAmount > cryptoAmount || feeAmount > coinCryptoAmount
} else {
receivedAmount + feeAmount > cryptoAmount
}
val isCurrentNotFeePaidCurrency = cryptoCurrencyStatus.currency.id != feePaidCryptoCurrencyStatus?.currency?.id
if (isCurrentNotFeePaidCurrency) return
val showNotification = receivedAmount + feeAmount > cryptoAmount
if (showNotification) {
add(SendNotification.Error.TotalExceedsBalance)
}
}
private fun MutableList<SendNotification>.addMinimumAmountErrorNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
) {
val coinCryptoCurrencyStatus = coinCryptoCurrencyStatusProvider()
val totalAmount = feeAmount + receivedAmount
val balance = coinCryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
if (isDogecoin(coinCryptoCurrencyStatus.currency.network.id.value)) {
val minimum = BigDecimal(DOGECOIN_MINIMUM)
if (receivedAmount < minimum || balance - totalAmount < minimum) {
add(SendNotification.Error.MinimumAmountError(DOGECOIN_MINIMUM))
}
}
}
// todo temporarily disabling notification
// private suspend fun MutableList<SendNotification>.addReserveAmountErrorNotification(recipientAddress: String) {
// val userWalletId = userWalletProvider().walletId
@ -198,7 +183,7 @@ internal class SendNotificationFactory(
if (currencyDeposit != null && currencyDeposit > diff) {
add(
SendNotification.Error.ExistentialDeposit(
BigDecimalFormatter.formatCryptoAmount(
BigDecimalFormatter.formatCryptoAmountUncapped(
cryptoAmount = currencyDeposit,
cryptoCurrency = cryptoCurrency,
),
@ -233,6 +218,20 @@ internal class SendNotificationFactory(
}
}
private fun MutableList<SendNotification>.addMinimumAmountErrorNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
) {
val coinCryptoCurrencyStatus = coinCryptoCurrencyStatusProvider()
val minimum = BigDecimal(DOGECOIN_MINIMUM)
val isDogecoin = isDogecoin(coinCryptoCurrencyStatus.currency.network.id.value)
val isExceedDustLimit = checkDustLimits(feeAmount, receivedAmount, minimum)
if (isDogecoin && isExceedDustLimit) {
add(SendNotification.Error.MinimumAmountError(DOGECOIN_MINIMUM))
}
}
private suspend fun MutableList<SendNotification>.addDustWarningNotification(
feeAmount: BigDecimal,
receivedAmount: BigDecimal,
@ -241,18 +240,12 @@ internal class SendNotificationFactory(
val dustValue = currencyChecksRepository.getDustValue(
userWalletProvider().walletId,
cryptoCurrencyStatus.currency.network,
)
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
if (dustValue != null && !balance.isNullOrZero() && receivedAmount < balance) {
val totalAmount = feeAmount + receivedAmount
val change = balance - totalAmount
val isChangeLowerThanDust = change < dustValue && change != BigDecimal.ZERO
val isShowWarning = totalAmount < dustValue || isChangeLowerThanDust
if (isShowWarning) {
add(
SendNotification.Error.MinimumAmountError(dustValue.toPlainString()),
)
}
) ?: return
if (checkDustLimits(feeAmount, receivedAmount, dustValue)) {
add(
SendNotification.Error.MinimumAmountError(dustValue.toPlainString()),
)
}
}
@ -274,13 +267,9 @@ internal class SendNotificationFactory(
private fun MutableList<SendNotification>.addTooHighNotification(feeSelectorState: FeeSelectorState) {
if (feeSelectorState !is FeeSelectorState.Content) return
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return
val highValue = multipleFees.priority.amount.value ?: return
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
val diff = (customValue / highValue).toBigInteger()
if (feeSelectorState.selectedFee == FeeType.Custom && diff > FEE_MAX_DIFF) {
add(SendNotification.Warning.TooHigh(diff.toString()))
checkIfFeeTooHigh(feeSelectorState) { diff ->
add(SendNotification.Warning.TooHigh(diff))
}
}
@ -361,8 +350,17 @@ internal class SendNotificationFactory(
return Blockchain.fromNetworkId(this.currency.network.backendId) == Blockchain.Arbitrum
}
private fun checkDustLimits(feeAmount: BigDecimal, receivedAmount: BigDecimal, dustValue: BigDecimal): Boolean {
val cryptoCurrencyStatus = cryptoCurrencyStatusProvider()
val balance = cryptoCurrencyStatus.value.amount ?: BigDecimal.ZERO
val totalAmount = feeAmount + receivedAmount
val change = balance - totalAmount
val isChangeLowerThanDust = change < dustValue && change > BigDecimal.ZERO
return receivedAmount < dustValue || isChangeLowerThanDust
}
companion object {
private const val DOGECOIN_MINIMUM = "0.01"
internal val FEE_MAX_DIFF = BigInteger("5")
}
}

View file

@ -1,10 +1,12 @@
package com.tangem.features.send.impl.presentation.state.fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.core.ui.utils.parseToBigDecimal
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.features.send.impl.presentation.state.SendUiState
import com.tangem.features.send.impl.presentation.state.confirm.SendNotificationFactory
import java.math.BigDecimal
import java.math.RoundingMode
/**
* Check if sending amount with fee is greater than balance
@ -32,14 +34,16 @@ internal fun checkIfFeeTooLow(state: SendUiState): Boolean {
/**
* Check if custom fee is too high
*/
internal fun checkIfFeeTooHigh(state: SendUiState, onShow: (String) -> Unit): Boolean {
val feeSelectorState = state.feeState?.feeSelectorState as? FeeSelectorState.Content ?: return false
internal fun checkIfFeeTooHigh(feeSelectorState: FeeSelectorState.Content, onShow: (String) -> Unit): Boolean {
val multipleFees = feeSelectorState.fees as? TransactionFee.Choosable ?: return false
val highValue = multipleFees.priority.amount.value ?: return false
val customAmount = feeSelectorState.customValues.firstOrNull() ?: return false
val customValue = customAmount.value.parseToBigDecimal(customAmount.decimals)
val diff = (customValue / highValue).toBigInteger()
val isShow = feeSelectorState.selectedFee == FeeType.Custom && diff > SendNotificationFactory.FEE_MAX_DIFF
if (isShow) onShow(diff.toString())
val diff = customValue / highValue
val isShow = feeSelectorState.selectedFee == FeeType.Custom && diff > FEE_MAX_DIFF
if (isShow) onShow(diff.parseBigDecimal(ZERO_DECIMALS, RoundingMode.HALF_UP))
return isShow
}
}
private val FEE_MAX_DIFF = BigDecimal("5")
private const val ZERO_DECIMALS = 0

View file

@ -15,6 +15,8 @@ internal sealed class FeeSelectorState {
val customValues: ImmutableList<SendTextField.CustomFee> = persistentListOf(),
) : FeeSelectorState()
data object Loading : FeeSelectorState()
data object Error : FeeSelectorState()
}

View file

@ -47,6 +47,11 @@ internal class FeeStateFactory(
return state.copy(
amountState = state.amountState?.copy(isFeeLoading = true),
feeState = feeState.copy(
feeSelectorState = if (feeState.feeSelectorState is FeeSelectorState.Content) {
feeState.feeSelectorState
} else {
FeeSelectorState.Loading
},
notifications = persistentListOf(),
isPrimaryButtonEnabled = false,
),

View file

@ -14,7 +14,7 @@ internal class SendFeeStateConverter(
override fun convert(value: Unit): SendStates.FeeState {
return SendStates.FeeState(
feeSelectorState = FeeSelectorState.Error,
feeSelectorState = FeeSelectorState.Loading,
fee = null,
notifications = persistentListOf(),
rate = feeCryptoCurrencyStatusProvider()?.value?.fiatRate,

View file

@ -49,6 +49,7 @@ internal object AmountStatePreviewData {
isError = false,
error = TextReference.EMPTY,
),
isSegmentedButtonsEnabled = true,
)
val fiatAmountState = amountState.copy(

View file

@ -4,7 +4,7 @@ import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toDateFormat
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
@ -81,7 +81,7 @@ internal class SendRecipientHistoryListConverter(
}
private fun TxHistoryItem.extractTimestamp(): TextReference {
val date = timestampInMillis.toDateFormat(
val date = timestampInMillis.toDateFormatWithTodayYesterday(
formatter = DateTimeFormatters.dateDDMMYYYY,
)
val time = timestampInMillis.toTimeFormat()

View file

@ -18,22 +18,25 @@ internal class SendRecipientWalletListConverter :
}
}
private fun List<AvailableWallet?>.filterWallets() = this.filterNotNull()
.groupBy { item -> item.name }
.values.map {
it.mapIndexed { index, item ->
val name = if (it.size > 1) {
"${item.name} ${index.inc()}"
} else {
item.name
private fun List<AvailableWallet?>.filterWallets(): PersistentList<SendRecipientListContent> {
var walletsCounter = 0
return this.filterNotNull()
.groupBy { item -> item.name }
.values.map {
it.mapIndexed { index, item ->
val name = if (it.size > 1) {
"${item.name} ${index.inc()}"
} else {
item.name
}
SendRecipientListContent(
id = "${WALLET_KEY_TAG}${walletsCounter++}",
title = TextReference.Str(item.address),
subtitle = TextReference.Str(name),
)
}
SendRecipientListContent(
id = "${WALLET_KEY_TAG}$index",
title = TextReference.Str(item.address),
subtitle = TextReference.Str(name),
)
}
}
.flatten()
.toPersistentList()
.flatten()
.toPersistentList()
}
}

View file

@ -29,6 +29,7 @@ internal fun LazyListScope.buttons(
segmentedButtonConfig: PersistentList<SendAmountSegmentedButtonsConfig>,
clickIntents: SendClickIntents,
isMaxButtonEnabled: Boolean,
isSegmentedButtonsEnabled: Boolean,
) {
item(
key = AMOUNT_BUTTONS_KEY,
@ -48,8 +49,12 @@ internal fun LazyListScope.buttons(
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
clickIntents.onCurrencyChangeClick(it.isFiat)
},
isEnabled = isSegmentedButtonsEnabled,
) {
SendAmountCurrencyButton(it)
SendAmountCurrencyButton(
button = it,
isSegmentedButtonsEnabled = isSegmentedButtonsEnabled,
)
}
} else {
SpacerWMax()
@ -72,7 +77,7 @@ internal fun LazyListScope.buttons(
}
@Composable
private fun SendAmountCurrencyButton(button: SendAmountSegmentedButtonsConfig) {
private fun SendAmountCurrencyButton(button: SendAmountSegmentedButtonsConfig, isSegmentedButtonsEnabled: Boolean) {
Row(
modifier = Modifier
.fillMaxSize()
@ -86,17 +91,16 @@ private fun SendAmountCurrencyButton(button: SendAmountSegmentedButtonsConfig) {
FiatIcon(
url = button.iconUrl,
size = TangemTheme.dimens.size18,
isGrayscale = !isSegmentedButtonsEnabled,
modifier = Modifier.size(TangemTheme.dimens.size18),
)
} else {
button.iconState?.let {
TokenIcon(
state = it,
shouldDisplayNetwork = false,
modifier = Modifier
.size(TangemTheme.dimens.size18),
)
}
} else if (button.iconState != null) {
TokenIcon(
state = button.iconState,
shouldDisplayNetwork = false,
modifier = Modifier
.size(TangemTheme.dimens.size18),
)
}
Text(
text = button.title.resolveReference(),

View file

@ -36,6 +36,7 @@ internal fun SendAmountContent(
segmentedButtonConfig = amountState.segmentedButtonConfig,
clickIntents = clickIntents,
isMaxButtonEnabled = !amountState.isFeeLoading,
isSegmentedButtonsEnabled = amountState.isSegmentedButtonsEnabled,
)
notifications(amountState.notifications)
}

View file

@ -1,21 +1,22 @@
package com.tangem.features.send.impl.presentation.ui.send
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.rows.SelectorRowItem
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.features.send.impl.R
import com.tangem.features.send.impl.presentation.state.SendStates
import com.tangem.features.send.impl.presentation.state.fee.FeeSelectorState
@ -26,20 +27,12 @@ import com.tangem.features.send.impl.presentation.utils.getFiatReference
@Composable
internal fun FeeBlock(feeState: SendStates.FeeState, isSuccess: Boolean, onClick: () -> Unit) {
val fee = feeState.fee ?: return
val feeSelectorState = feeState.feeSelectorState as? FeeSelectorState.Content ?: return
val (title, icon) = when (feeSelectorState.selectedFee) {
FeeType.Slow -> R.string.common_fee_selector_option_slow to R.drawable.ic_tortoise_24
FeeType.Market -> R.string.common_fee_selector_option_market to R.drawable.ic_bird_24
FeeType.Fast -> R.string.common_fee_selector_option_fast to R.drawable.ic_hare_24
FeeType.Custom -> R.string.common_fee_selector_option_custom to R.drawable.ic_edit_24
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.clickable(enabled = !isSuccess) { onClick() }
.clickable(enabled = !isSuccess && feeState.fee != null) { onClick() }
.padding(TangemTheme.dimens.spacing12),
) {
Text(
@ -47,17 +40,71 @@ internal fun FeeBlock(feeState: SendStates.FeeState, isSuccess: Boolean, onClick
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
)
SelectorRowItem(
titleRes = title,
iconRes = icon,
preDot = getCryptoReference(fee.amount, feeState.isFeeApproximate),
postDot = getFiatReference(fee.amount, feeState.rate, feeState.appCurrency),
ellipsizeOffset = fee.amount.currencySymbol.length,
isSelected = true,
showDivider = false,
paddingValues = PaddingValues(),
Box(
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
)
) {
val feeSelectorState = feeState.feeSelectorState
val feeAmount = feeState.fee?.amount
val (title, icon) = if (feeSelectorState is FeeSelectorState.Content) {
when (feeSelectorState.selectedFee) {
FeeType.Slow -> R.string.common_fee_selector_option_slow to R.drawable.ic_tortoise_24
FeeType.Market -> R.string.common_fee_selector_option_market to R.drawable.ic_bird_24
FeeType.Fast -> R.string.common_fee_selector_option_fast to R.drawable.ic_hare_24
FeeType.Custom -> R.string.common_fee_selector_option_custom to R.drawable.ic_edit_24
}
} else {
R.string.common_fee_selector_option_market to R.drawable.ic_bird_24
}
SelectorRowItem(
titleRes = title,
iconRes = icon,
preDot = getCryptoReference(feeAmount, feeState.isFeeApproximate),
postDot = feeAmount?.let { getFiatReference(it, feeState.rate, feeState.appCurrency) },
ellipsizeOffset = feeAmount?.currencySymbol?.length,
isSelected = true,
showDivider = false,
paddingValues = PaddingValues(),
)
FeeLoading(feeSelectorState)
FeeError(feeSelectorState)
}
}
}
@Composable
private fun BoxScope.FeeLoading(feeSelectorState: FeeSelectorState) {
AnimatedContent(
targetState = feeSelectorState,
label = "Fee Loading State Change",
modifier = Modifier.align(Alignment.CenterEnd),
) {
if (it == FeeSelectorState.Loading) {
RectangleShimmer(
radius = TangemTheme.dimens.radius3,
modifier = Modifier.size(
height = TangemTheme.dimens.size12,
width = TangemTheme.dimens.size90,
),
)
}
}
}
@Composable
private fun BoxScope.FeeError(feeSelectorState: FeeSelectorState) {
AnimatedContent(
targetState = feeSelectorState,
label = "Fee Error State Change",
modifier = Modifier.align(Alignment.CenterEnd),
) {
if (it == FeeSelectorState.Error) {
Text(
text = BigDecimalFormatter.EMPTY_BALANCE_SIGN,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.body2,
)
}
}
}

View file

@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@ -40,6 +41,7 @@ internal fun RecipientBlock(
.padding(TangemTheme.dimens.spacing12),
) {
AddressBlock(recipientState.addressTextField)
MemoBlock(recipientState.memoTextField)
}
}
@ -70,6 +72,28 @@ private fun AddressBlock(address: SendTextField.RecipientAddress) {
}
}
@Composable
private fun MemoBlock(memo: SendTextField.RecipientMemo?) {
val showMemo = memo != null && memo.value.isNotBlank()
if (showMemo) {
HorizontalDivider(
color = TangemTheme.colors.icon.inactive,
modifier = Modifier.padding(vertical = TangemTheme.dimens.spacing12),
)
Text(
text = memo?.label?.resolveReference().orEmpty(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
)
Text(
text = memo?.value.orEmpty(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing8),
)
}
}
// region Preview
@Preview
@Composable

View file

@ -24,6 +24,7 @@ import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.error.CurrencyStatusError
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.tokens.utils.convertToAmount
import com.tangem.domain.transaction.error.GetFeeError
@ -359,13 +360,14 @@ internal class SendViewModel @Inject constructor(
stateRouter.showSend()
}
transactionId != null && amount != null && destinationAddress != null -> {
loadFee()
uiState = stateFactory.getReadyState(amount, destinationAddress, memo)
stateRouter.showFee()
stateRouter.showSend()
updateNotifications()
}
else -> {
getWalletsAndRecent()
uiState = stateFactory.getReadyState()
getWalletsAndRecent()
stateRouter.showRecipient()
updateNotifications()
}
@ -406,7 +408,8 @@ internal class SendViewModel @Inject constructor(
return if (!isMultiCurrency) {
val status = getCryptoCurrencyStatusSyncUseCase(walletId).getOrNull()
val address = status?.value?.networkAddress.takeIf {
status?.currency?.network?.id == cryptoCurrency.network.id
status?.currency?.network?.id == cryptoCurrency.network.id &&
status.currency.network.derivationPath !is Network.DerivationPath.Custom
}
address?.let {
AvailableWallet(
@ -417,7 +420,8 @@ internal class SendViewModel @Inject constructor(
} else {
val statuses = getCryptoCurrencyStatusesSyncUseCase(walletId).getOrNull()
val walletCurrency = statuses?.firstOrNull {
it.currency.network.id == cryptoCurrency.network.id
it.currency.network.id == cryptoCurrency.network.id &&
it.currency.network.derivationPath !is Network.DerivationPath.Custom
}
val address = walletCurrency?.value?.networkAddress
address?.let {
@ -522,8 +526,9 @@ internal class SendViewModel @Inject constructor(
)
return true
}
val feeSelectorState = uiState.feeState?.feeSelectorState as? FeeSelectorState.Content ?: return false
return checkIfFeeTooHigh(
state = uiState,
feeSelectorState = feeSelectorState,
onShow = { diff ->
uiState = eventStateFactory.getFeeTooHighAlert(
diff = diff,
@ -702,6 +707,7 @@ internal class SendViewModel @Inject constructor(
if (result == null) {
onFeeLoadFailed(isShowStatus, isToNextState)
}
updateNotifications()
updateFeeNotifications()
}.saveIn(feeJobHolder)
.invokeOnCompletion {
@ -735,7 +741,7 @@ internal class SendViewModel @Inject constructor(
return getFeeUseCase.invoke(
amount = amount,
destination = recipientState.addressTextField.value,
userWalletId = userWalletId,
userWallet = userWallet,
cryptoCurrency = cryptoCurrency,
)
}
@ -792,10 +798,11 @@ internal class SendViewModel @Inject constructor(
override fun onAmountReduceClick(reducedAmount: BigDecimal, clazz: Class<out SendNotification>) {
uiState = amountStateFactory.getOnAmountValueChange(reducedAmount.parseBigDecimal(cryptoCurrency.decimals))
uiState = sendNotificationFactory.dismissNotificationState(clazz)
updateNotifications()
}
override fun onNotificationCancel(clazz: Class<out SendNotification>) {
uiState = sendNotificationFactory.dismissNotificationState(clazz)
uiState = sendNotificationFactory.dismissNotificationState(clazz = clazz, isIgnored = true)
}
private fun verifyAndSendTransaction() {

View file

@ -126,14 +126,15 @@ class SwapDomainModule {
@Provides
@Singleton
fun provideSendTransactionUseCase(
@SwapScope isDemoCardUseCase: IsDemoCardUseCase,
cardSdkConfigRepository: CardSdkConfigRepository,
transactionRepository: TransactionRepository,
walletManagersFacade: WalletManagersFacade,
): SendTransactionUseCase {
return SendTransactionUseCase(
isDemoCardUseCase = isDemoCardUseCase,
demoConfig = DemoConfig(),
cardSdkConfigRepository = cardSdkConfigRepository,
transactionRepository = transactionRepository,
walletManagersFacade = walletManagersFacade,
)
}

View file

@ -646,6 +646,7 @@ internal class StateBuilder(
}
private fun getWarningForError(dataError: DataError, fromToken: CryptoCurrency): SwapWarning {
val providerErrorMessage = getProviderErrorMessage(dataError)
return when (dataError) {
is DataError.ExchangeTooSmallAmountError -> SwapWarning.GeneralError(
notificationConfig = NotificationConfig(
@ -674,10 +675,16 @@ internal class StateBuilder(
} else {
resourceReference(R.string.warning_express_refresh_required_title)
},
subtitle = if (dataError is DataError.UnknownError) {
resourceReference(R.string.common_unknown_error)
} else {
resourceReference(R.string.express_error_code, wrappedList(dataError.code.toString()))
subtitle = when {
dataError is DataError.UnknownError -> {
resourceReference(R.string.common_unknown_error)
}
providerErrorMessage != null -> {
providerErrorMessage
}
else -> {
resourceReference(R.string.express_error_code, wrappedList(dataError.code.toString()))
}
},
iconResId = R.drawable.img_attention_20,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(
@ -972,7 +979,7 @@ internal class StateBuilder(
return uiState.copy(
alert = SwapWarning.GenericWarning(
message = if (swapTransactionState is SwapTransactionState.ExpressError) {
getAlertErrorMessage(swapTransactionState.dataError)
getProviderErrorMessage(swapTransactionState.dataError)
} else {
null
},
@ -987,7 +994,7 @@ internal class StateBuilder(
)
}
private fun getAlertErrorMessage(dataError: DataError): TextReference? {
private fun getProviderErrorMessage(dataError: DataError): TextReference? {
return when (dataError) {
is DataError.SwapsAreUnavailableNowError -> resourceReference(
id = R.string.express_error_swap_unavailable,
@ -998,6 +1005,7 @@ internal class StateBuilder(
formatArgs = wrappedList(dataError.code),
)
is DataError.ExchangeProviderNotActiveError,
is DataError.ExchangeProviderNotFoundError,
is DataError.ExchangeProviderNotAvailableError,
is DataError.ExchangeProviderProviderInternalError,
-> resourceReference(

View file

@ -4,7 +4,7 @@ import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.ui.components.currency.tokenicon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.core.ui.utils.toDateFormat
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrency
@ -71,7 +71,7 @@ internal class TokenDetailsSwapTransactionsStateConverter(
txUrl = transaction.status?.txExternalUrl,
txExternalId = transaction.status?.txExternalId,
timestamp = TextReference.Str(
"${timestamp.toDateFormat()}, ${timestamp.toTimeFormat()}",
"${timestamp.toDateFormatWithTodayYesterday()}, ${timestamp.toTimeFormat()}",
),
fiatSymbol = appCurrency.symbol,
statuses = getStatuses(transaction.status?.status),

View file

@ -4,7 +4,7 @@ import androidx.paging.*
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.state.TxHistoryState.TxHistoryItemState
import com.tangem.core.ui.utils.toDateFormat
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.domain.txhistory.models.TxHistoryItem
import com.tangem.feature.tokendetails.presentation.tokendetails.state.TokenDetailsState
import com.tangem.feature.tokendetails.presentation.tokendetails.viewmodels.TokenDetailsClickIntents
@ -68,7 +68,7 @@ internal class TokenDetailsTxHistoryItemFlowConverter(
// Use raw timestamp to get date
// If [afterDate] is the first transaction in the flow, add the group title
val afterDate = after.getTimestamp()?.toDateFormat() ?: return@insertSeparators null
val afterDate = after.getTimestamp()?.toDateFormatWithTodayYesterday() ?: return@insertSeparators null
if (before is TxHistoryItemState.Title) {
return@insertSeparators TxHistoryItemState.GroupTitle(
title = afterDate,
@ -80,7 +80,7 @@ internal class TokenDetailsTxHistoryItemFlowConverter(
* If [beforeDate] is not equals to [afterDate], then [afterDate] is first transaction in
* the new group
*/
val beforeDate = before.getTimestamp()?.toDateFormat() ?: return@insertSeparators null
val beforeDate = before.getTimestamp()?.toDateFormatWithTodayYesterday() ?: return@insertSeparators null
return@insertSeparators if (beforeDate != afterDate) {
TxHistoryItemState.GroupTitle(
title = afterDate,

View file

@ -10,7 +10,6 @@ import com.tangem.blockchain.common.address.AddressType
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.core.deeplink.global.BuyCurrencyDeepLink
import com.tangem.core.deeplink.global.SellCurrencyDeepLink
import com.tangem.core.ui.components.bottomsheets.tokenreceive.AddressModel
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.extensions.resourceReference
@ -165,7 +164,6 @@ internal class TokenDetailsViewModel @Inject constructor(
viewModel = this,
deepLinks = listOf(
BuyCurrencyDeepLink(::onBuyCurrencyDeepLink),
SellCurrencyDeepLink(::onSellCurrencyDeepLink),
),
)
}
@ -175,20 +173,6 @@ internal class TokenDetailsViewModel @Inject constructor(
analyticsEventsHandler.send(TokenScreenAnalyticsEvent.Bought(currency.symbol))
}
private fun onSellCurrencyDeepLink(data: SellCurrencyDeepLink.Data) {
sendCurrency(
status = cryptoCurrencyStatus ?: return,
transactionInfo = data.let {
TransactionInfo(
transactionId = it.transactionId,
destinationAddress = it.depositWalletAddress,
amount = it.baseCurrencyAmount,
tag = it.depositWalletAddressTag,
)
},
)
}
override fun onCreate(owner: LifecycleOwner) {
analyticsEventsHandler.send(
event = TokenScreenAnalyticsEvent.DetailsScreenOpened(token = cryptoCurrency.symbol),

View file

@ -8,16 +8,14 @@ import com.tangem.core.deeplink.DeepLinksRegistry
import com.tangem.core.deeplink.global.BuyCurrencyDeepLink
import com.tangem.core.deeplink.global.SellCurrencyDeepLink
import com.tangem.domain.redux.ReduxStateHolder
import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyUseCase
import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetNetworkCoinStatusUseCase
import com.tangem.domain.tokens.*
import com.tangem.domain.tokens.legacy.TradeCryptoAction
import com.tangem.domain.tokens.legacy.TradeCryptoAction.TransactionInfo
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.models.analytics.TokenScreenAnalyticsEvent
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.models.UserWalletId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
@ -29,23 +27,20 @@ internal class WalletDeepLinksHandler @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val getCryptoCurrencyUseCase: GetCryptoCurrencyUseCase,
private val getCryptoCurrencyStatusSyncUseCase: GetCryptoCurrencyStatusSyncUseCase,
private val getCryptoCurrencyStatusesSyncUseCase: GetCryptoCurrencyStatusesSyncUseCase,
private val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase,
private val getNetworkCoinStatusUseCase: GetNetworkCoinStatusUseCase,
private val reduxStateHolder: ReduxStateHolder,
) {
private var deepLinks: List<DeepLink> = emptyList()
private var deepLinksMap = mutableMapOf<UserWalletId, List<DeepLink>>()
fun registerForSingleCurrencyWallets(viewModel: ViewModel, userWallet: UserWallet) {
if (userWallet.isMultiCurrency) {
deepLinksRegistry.unregister(deepLinks)
} else {
if (deepLinks.isEmpty()) {
deepLinks = getDeepLinks(userWallet, viewModel.viewModelScope)
}
deepLinksRegistry.register(deepLinks)
fun registerForWallet(viewModel: ViewModel, userWallet: UserWallet) {
val deepLinks = deepLinksMap.getOrPut(userWallet.walletId) {
getDeepLinks(userWallet, viewModel.viewModelScope)
}
deepLinksRegistry.unregisterByIds(deepLinks.map { it.id })
deepLinksRegistry.register(deepLinks)
viewModel.addCloseable {
deepLinksRegistry.unregister(deepLinks)
@ -60,20 +55,23 @@ internal class WalletDeepLinksHandler @Inject constructor(
}
},
)
val buyCurrencyDeepLink = BuyCurrencyDeepLink(
onReceive = {
scope.launch {
onBuyCurrencyDeepLink(userWallet)
}
},
)
return listOf(sellCurrencyDeepLink, buyCurrencyDeepLink)
return buildList {
add(sellCurrencyDeepLink)
if (!userWallet.isMultiCurrency) {
add(
BuyCurrencyDeepLink(
onReceive = {
scope.launch { onBuyCurrencyDeepLink(userWallet) }
},
),
)
}
}
}
private suspend fun onSellCurrencyDeepLink(userWallet: UserWallet, data: SellCurrencyDeepLink.Data) {
val cryptoCurrencyStatus = getCryptoCurrencyStatusSyncUseCase(userWallet.walletId)
.getOrNull() ?: return
val cryptoCurrencyStatus = findCryptoCurrencyStatus(userWallet, data.currencyId) ?: return
val feeCurrencyStatus = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWallet.walletId,
cryptoCurrencyStatus,
@ -110,6 +108,19 @@ internal class WalletDeepLinksHandler @Inject constructor(
analyticsEventHandler.send(TokenScreenAnalyticsEvent.Bought(cryptoCurrency.symbol))
}
private suspend fun findCryptoCurrencyStatus(
userWallet: UserWallet,
currencyIdValue: String,
): CryptoCurrencyStatus? {
return if (userWallet.isMultiCurrency) {
getCryptoCurrencyStatusesSyncUseCase(userWallet.walletId).getOrNull()?.let { currencies ->
currencies.find { currencyIdValue == it.currency.id.value }
}
} else {
getCryptoCurrencyStatusSyncUseCase(userWallet.walletId).getOrNull()
}
}
private fun sendCoin(
userWallet: UserWallet,
cryptoCurrencyStatus: CryptoCurrencyStatus,

View file

@ -4,7 +4,7 @@ import androidx.paging.*
import com.tangem.core.ui.components.transactions.state.TransactionState
import com.tangem.core.ui.components.transactions.state.TxHistoryState
import com.tangem.core.ui.components.transactions.state.TxHistoryState.TxHistoryItemState
import com.tangem.core.ui.utils.toDateFormat
import com.tangem.core.ui.utils.toDateFormatWithTodayYesterday
import com.tangem.feature.wallet.presentation.wallet.viewmodels.intents.WalletClickIntents
import com.tangem.utils.converter.Converter
import kotlinx.coroutines.CoroutineScope
@ -50,7 +50,7 @@ internal class TxHistoryItemFlowConverter(
// Use raw timestamp to get date
// If [afterDate] is the first transaction in the flow, add the group title
val afterDate = after.getTimestamp()?.toDateFormat() ?: return@insertSeparators null
val afterDate = after.getTimestamp()?.toDateFormatWithTodayYesterday() ?: return@insertSeparators null
if (before is TxHistoryItemState.Title) {
return@insertSeparators TxHistoryItemState.GroupTitle(afterDate, itemKey = UUID.randomUUID().toString())
}
@ -59,7 +59,7 @@ internal class TxHistoryItemFlowConverter(
* If [beforeDate] is not equals to [afterDate], then [afterDate] is first transaction in
* the new group
*/
val beforeDate = before.getTimestamp()?.toDateFormat() ?: return@insertSeparators null
val beforeDate = before.getTimestamp()?.toDateFormatWithTodayYesterday() ?: return@insertSeparators null
return@insertSeparators if (beforeDate != afterDate) {
TxHistoryItemState.GroupTitle(afterDate, itemKey = UUID.randomUUID().toString())
} else {

View file

@ -154,7 +154,7 @@ internal class WalletViewModel @Inject constructor(
selectedWalletAnalyticsSender.send(selectedWallet)
}
walletDeepLinksHandler.registerForSingleCurrencyWallets(
walletDeepLinksHandler.registerForWallet(
viewModel = this,
userWallet = selectedWallet,
)

View file

@ -85,7 +85,7 @@ leakcanary = "2.13"
# endregion Other libraries
# region Tangem
tangemBlockchainSdk = "release-app_5.9-600"
tangemBlockchainSdk = "release-app_5.9-605"
#tangemBlockchainSdk = "0.0.1" # Keep it! - used for local builds
tangemCardSdk = "release-app_5.9-343"
#tangemCardSdk = "0.0.1" # Keep it! - used for local builds ^