Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-17 12:58:46 +03:00
parent b3bc56ca03
commit 52cc0be3cb
49 changed files with 1039 additions and 327 deletions

View file

@ -44,12 +44,12 @@ class EstimateFeeForGaslessTxUseCase(
suspend operator fun invoke(
userWallet: UserWallet,
amount: BigDecimal,
cryptoCurrencyStatus: CryptoCurrencyStatus,
sendingTokenCurrencyStatus: CryptoCurrencyStatus,
): Either<GetFeeError, TransactionFeeExtended> {
return either {
catch(
block = {
val network = cryptoCurrencyStatus.currency.network
val network = sendingTokenCurrencyStatus.currency.network
val nativeCurrency = currenciesRepository.getNetworkCoin(
userWalletId = userWallet.walletId,
networkId = network.id,
@ -60,7 +60,7 @@ class EstimateFeeForGaslessTxUseCase(
estimateFeeUseCase.invoke(
userWallet = userWallet,
amount = amount,
cryptoCurrencyStatus = cryptoCurrencyStatus,
cryptoCurrencyStatus = sendingTokenCurrencyStatus,
).fold(
ifLeft = { raise(it) },
ifRight = { fee ->
@ -77,7 +77,7 @@ class EstimateFeeForGaslessTxUseCase(
val initialFee = tokenFeeCalculator.estimateInitialFee(
userWallet = userWallet,
amount = amount,
tokenCurrencyStatus = cryptoCurrencyStatus,
txTokenCurrencyStatus = sendingTokenCurrencyStatus,
).bind()
selectFeePaymentStrategy(

View file

@ -6,10 +6,7 @@ import arrow.core.raise.catch
import arrow.core.raise.either
import com.tangem.blockchain.blockchains.ethereum.EthereumWalletManager
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.extensions.Result
import com.tangem.domain.demo.DemoTransactionSender
import com.tangem.domain.demo.models.DemoConfig
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
@ -19,10 +16,8 @@ import com.tangem.domain.tokens.repository.CurrencyChecksRepository
import com.tangem.domain.transaction.GaslessTransactionRepository
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.error.GetFeeError.GaslessError
import com.tangem.domain.transaction.error.mapToFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.raiseIllegalStateError
import com.tangem.domain.utils.convertToSdkAmount
import com.tangem.domain.walletmanager.WalletManagersFacade
import java.math.BigDecimal
@ -43,38 +38,23 @@ class EstimateFeeForTokenUseCase(
suspend operator fun invoke(
userWallet: UserWallet,
tokenCurrencyStatus: CryptoCurrencyStatus,
feeTokenCurrencyStatus: CryptoCurrencyStatus,
sendingTokenCurrencyStatus: CryptoCurrencyStatus,
amount: BigDecimal,
): Either<GetFeeError, TransactionFeeExtended> {
return either {
catch(
block = {
val token = tokenCurrencyStatus.currency
val token = feeTokenCurrencyStatus.currency
if (!currencyChecksRepository.isNetworkSupportedForGaslessTx(token.network)) {
raise(GetFeeError.GaslessError.NetworkIsNotSupported)
raise(GaslessError.NetworkIsNotSupported)
}
val amountData = amount.convertToSdkAmount(tokenCurrencyStatus)
val result = if (userWallet is UserWallet.Cold &&
demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)
) {
demoTransactionSender(userWallet, token).estimateFee(
amount = amountData,
destination = "",
)
} else {
walletManagersFacade.estimateFee(
amount = amountData,
userWalletId = userWallet.walletId,
network = token.network,
)
}
val initialTxFee = when (result) {
is Result.Success -> result.data
is Result.Failure -> raise(result.mapToFeeError())
null -> raise(GetFeeError.UnknownError)
}
val initialTxFee = tokenFeeCalculator.estimateInitialFee(
userWallet = userWallet,
amount = amount,
txTokenCurrencyStatus = sendingTokenCurrencyStatus,
).bind()
val initialFeeEth = initialTxFee.normal as? Fee.Ethereum
?: raiseIllegalStateError(
@ -101,7 +81,7 @@ class EstimateFeeForTokenUseCase(
tokenFeeCalculator.calculateTokenFee(
walletManager = walletManager,
tokenForPayFeeStatus = tokenCurrencyStatus,
tokenForPayFeeStatus = feeTokenCurrencyStatus,
nativeCurrencyStatus = nativeCurrencyStatus,
initialFee = initialFeeEth,
).bind()
@ -126,15 +106,4 @@ class EstimateFeeForTokenUseCase(
?: raiseIllegalStateError("WalletManager type ${walletManager?.javaClass?.name} not supported")
return ethereumWalletManager
}
private suspend fun demoTransactionSender(
userWallet: UserWallet,
cryptoCurrency: CryptoCurrency,
): DemoTransactionSender {
return DemoTransactionSender(
walletManagersFacade
.getOrCreateWalletManager(userWallet.walletId, cryptoCurrency.network)
?: error("WalletManager is null"),
)
}
}

View file

@ -61,11 +61,11 @@ internal class TokenFeeCalculator(
suspend fun estimateInitialFee(
userWallet: UserWallet,
amount: BigDecimal,
tokenCurrencyStatus: CryptoCurrencyStatus,
txTokenCurrencyStatus: CryptoCurrencyStatus,
): Either<GetFeeError, TransactionFee> {
return either {
val network = tokenCurrencyStatus.currency.network
val amountData = amount.convertToSdkAmount(tokenCurrencyStatus)
val network = txTokenCurrencyStatus.currency.network
val amountData = amount.convertToSdkAmount(txTokenCurrencyStatus)
val result = if (userWallet is UserWallet.Cold &&
demoConfig.isDemoCardId(userWallet.scanResponse.card.cardId)
) {

View file

@ -129,7 +129,7 @@ class TokenFeeCalculatorTest {
val result = tokenFeeCalculator.estimateInitialFee(
userWallet = mockUserWallet,
amount = amount,
tokenCurrencyStatus = tokenStatus,
txTokenCurrencyStatus = tokenStatus,
)
// Then
@ -154,7 +154,7 @@ class TokenFeeCalculatorTest {
val result = tokenFeeCalculator.estimateInitialFee(
userWallet = mockUserWallet,
amount = amount,
tokenCurrencyStatus = tokenStatus,
txTokenCurrencyStatus = tokenStatus,
)
// Then
@ -173,7 +173,7 @@ class TokenFeeCalculatorTest {
val result = tokenFeeCalculator.estimateInitialFee(
userWallet = mockUserWallet,
amount = amount,
tokenCurrencyStatus = tokenStatus,
txTokenCurrencyStatus = tokenStatus,
)
// Then

View file

@ -14,6 +14,10 @@ class GetExplorerTransactionUrlUseCase(
return either {
catch(
block = {
if (txHash.isEmpty()) {
raise(TxStatusError.EmptyUrlError)
}
repository.getTxExploreUrl(txHash, networkId).ifEmpty {
raise(TxStatusError.EmptyUrlError)
}

View file

@ -186,6 +186,7 @@ sealed class CommonSendAnalyticEvents(
enum class CommonSendSource(val analyticsName: String) {
Send("Send"),
Swap("Swap"),
SendWithSwap("Send&Swap"),
WalletConnect("WalletConnect"),
NFT("NFT"),

View file

@ -37,6 +37,7 @@ sealed class FeeSelectorParams {
override val feeDisplaySource: FeeDisplaySource,
override val analyticsCategoryName: String,
override val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
val bottomSheetShown: (Boolean) -> Unit = {},
) : FeeSelectorParams()
data class FeeSelectorDetailsParams(

View file

@ -19,7 +19,7 @@ import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.impl.R
@Composable
internal fun FeeBlock(feeSelectorUM: FeeSelectorUM) {
fun FeeBlockSuccess(feeSelectorUM: FeeSelectorUM) {
if (feeSelectorUM !is FeeSelectorUM.Content) return
val feeExtraInfo = feeSelectorUM.feeExtraInfo
val feeFiatRateUM = feeSelectorUM.feeFiatRateUM

View file

@ -65,6 +65,10 @@ internal class DefaultFeeSelectorBlockComponent @AssistedInject constructor(
)
init {
bottomSheetSlot.subscribe {
params.bottomSheetShown(it.child != null)
}
model.uiState
.onEach { onResult(it) }
.launchIn(componentScope)

View file

@ -13,6 +13,7 @@ class FeeSelectorTokenSelectedTransformer(
override fun transform(prevState: FeeSelectorUM): FeeSelectorUM {
return if (prevState is FeeSelectorUM.Content) {
prevState.copy(
isPrimaryButtonEnabled = false,
selectedFeeItem = FeeItem.Loading,
feeItems = persistentListOf(FeeItem.Loading),
feeExtraInfo = prevState.feeExtraInfo.copy(

View file

@ -21,7 +21,7 @@ import com.tangem.core.ui.utils.DateTimeFormatters
import com.tangem.core.ui.utils.toPx
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.v2.common.ui.FeeBlock
import com.tangem.features.send.v2.common.ui.FeeBlockSuccess
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.send.ui.state.SendUM
@ -106,7 +106,7 @@ private fun SuccessContent(
onClick = {},
)
destinationBlockComponent.Content(modifier = Modifier)
FeeBlock(feeSelectorUM = sendUM.feeSelectorUM)
FeeBlockSuccess(feeSelectorUM = sendUM.feeSelectorUM)
SpacerH(16.dp)
}
}

View file

@ -21,7 +21,7 @@ import com.tangem.core.ui.utils.toPx
import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.features.nft.component.NFTDetailsBlockComponent
import com.tangem.features.send.v2.api.subcomponents.destination.SendDestinationBlockComponent
import com.tangem.features.send.v2.common.ui.FeeBlock
import com.tangem.features.send.v2.common.ui.FeeBlockSuccess
import com.tangem.features.send.v2.common.ui.state.ConfirmUM
import com.tangem.features.send.v2.impl.R
import com.tangem.features.send.v2.sendnft.ui.state.NFTSendUM
@ -109,7 +109,7 @@ private fun SuccessContent(
}
nftDetailsBlockComponent.Content(modifier = Modifier)
destinationBlockComponent.Content(modifier = Modifier)
FeeBlock(feeSelectorUM = nftSendUM.feeSelectorUM)
FeeBlockSuccess(feeSelectorUM = nftSendUM.feeSelectorUM)
SpacerH(16.dp)
}
}

View file

@ -263,13 +263,14 @@ internal class SendWithSwapConfirmModel @Inject constructor(
estimateFeeForTokenUseCase(
amount = amountValue,
userWallet = params.userWallet,
tokenCurrencyStatus = maybeToken,
feeTokenCurrencyStatus = maybeToken,
sendingTokenCurrencyStatus = primaryCurrencyStatus,
)
} else {
estimateFeeForGaslessTxUseCase(
amount = amountValue,
userWallet = params.userWallet,
cryptoCurrencyStatus = primaryCurrencyStatus,
sendingTokenCurrencyStatus = primaryCurrencyStatus,
)
}
}

View file

@ -1,10 +1,14 @@
package com.tangem.feature.swap.domain
import arrow.core.Either
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
import com.tangem.feature.swap.domain.models.domain.PermissionOptions
@ -37,7 +41,7 @@ interface SwapInteractor {
* @param providers list of providers to find quote
* @param amountToSwap amount you want to swap
* @param reduceBalanceBy amount to reduce from balance (used for fee calculation)
* @param selectedFee selected fee to swap
* @param txFeeSealedState selected fee to swap
* @return
*/
@Suppress("LongParameterList")
@ -50,7 +54,7 @@ interface SwapInteractor {
providers: List<SwapProvider>,
amountToSwap: String,
reduceBalanceBy: BigDecimal,
selectedFee: FeeType = FeeType.NORMAL,
txFeeSealedState: TxFeeSealedState,
): Map<SwapProvider, SwapState>
/**
@ -132,6 +136,50 @@ interface SwapInteractor {
averageDuration: Int? = null,
)
/**
* Loads fee for swap transaction
*
* @param fromToken token from which want to swap
* @param fromAccount account from which swap will be made
* @param toToken token that receive after swap
* @param toAccount account to which receive token after swap
* @param amount amount you want to swap
* @param reduceBalanceBy amount to reduce from balance (used for fee calculation)
* @param selectedFeeToken selected token to pay fee or null to pay fee with coin
*/
@Suppress("LongParameterList")
suspend fun loadFeeForSwapTransaction(
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
amount: String,
reduceBalanceBy: BigDecimal,
provider: SwapProvider,
selectedFeeToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended>
/**
* Loads fee for swap transaction
*
* @param fromToken token from which want to swap
* @param fromAccount account from which swap will be made
* @param toToken token that receive after swap
* @param toAccount account to which receive token after swap
* @param amount amount you want to swap
* @param reduceBalanceBy amount to reduce from balance (used for fee calculation)
*/
@Suppress("LongParameterList")
suspend fun loadFeeForSwapTransaction(
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
amount: String,
reduceBalanceBy: BigDecimal,
provider: SwapProvider,
): Either<GetFeeError, TransactionFee>
interface Factory {
fun create(selectedWalletId: UserWalletId): SwapInteractor
}

View file

@ -6,7 +6,12 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.domain.ExchangeStatusModel
import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo
import com.tangem.feature.swap.domain.models.domain.PairsWithProviders
import com.tangem.feature.swap.domain.models.domain.QuoteModel
import com.tangem.feature.swap.domain.models.domain.RateType
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import java.math.BigDecimal
interface SwapRepository {

View file

@ -4,6 +4,7 @@ import com.tangem.blockchain.common.transaction.Fee
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck
import com.tangem.feature.swap.domain.TransactionFeeResult
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
@ -91,11 +92,11 @@ data class RequestApproveStateData(
sealed class TxFeeState {
data class MultipleFeeState(
val normalFee: TxFee,
val priorityFee: TxFee,
val normalFee: TxFee.Legacy,
val priorityFee: TxFee.Legacy,
) : TxFeeState() {
fun getFeeByType(feeType: FeeType): TxFee {
fun getFeeByType(feeType: FeeType): TxFee.Legacy {
return when (feeType) {
FeeType.NORMAL -> normalFee
FeeType.PRIORITY -> priorityFee
@ -104,23 +105,33 @@ sealed class TxFeeState {
}
data class SingleFeeState(
val fee: TxFee,
val fee: TxFee.Legacy,
) : TxFeeState()
data object Empty : TxFeeState()
}
data class TxFee(
val feeValue: BigDecimal,
val feeFiatFormatted: String,
val feeCryptoFormatted: String,
val feeIncludeOtherNativeFee: BigDecimal,
val feeFiatFormattedWithNative: String,
val feeCryptoFormattedWithNative: String,
val cryptoSymbol: String,
val feeType: FeeType,
val fee: Fee,
)
sealed class TxFee {
abstract val fee: Fee
data class FeeComponent(
override val fee: Fee,
val transactionFeeResult: TransactionFeeResult,
val selectedToken: CryptoCurrencyStatus?,
) : TxFee()
data class Legacy(
val feeValue: BigDecimal,
val feeFiatFormatted: String,
val feeCryptoFormatted: String,
val feeIncludeOtherNativeFee: BigDecimal,
val feeFiatFormattedWithNative: String,
val feeCryptoFormattedWithNative: String,
val cryptoSymbol: String,
val feeType: FeeType,
override val fee: Fee,
) : TxFee()
}
enum class FeeType {
NORMAL, PRIORITY

View file

@ -58,6 +58,8 @@ dependencies {
implementation(projects.features.swap.domain.models)
implementation(projects.features.wallet.api)
implementation(projects.features.swap.api)
implementation(projects.features.sendV2.api)
implementation(projects.features.sendV2.impl)
/** AndroidX */
implementation(deps.androidx.activity.compose)
@ -87,6 +89,10 @@ dependencies {
implementation(deps.kotlin.serialization)
implementation(deps.kotlin.immutable.collections)
implementation(deps.timber)
implementation(deps.decompose.ext.compose)
/** Tangem libs */
implementation(tangemDeps.blockchain)
/** DI */
implementation(deps.hilt.android)

View file

@ -3,17 +3,31 @@ package com.tangem.feature.swap
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.common.ui.swapStoriesScreen.SwapStoriesScreen
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.model.SwapModel
import com.tangem.feature.swap.models.SwapCardState.SwapCardData
import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.ui.SwapScreen
import com.tangem.feature.swap.ui.SwapSelectTokenScreen
import com.tangem.feature.swap.ui.SwapSuccessScreen
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.swap.SwapComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -22,7 +36,9 @@ import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
internal class DefaultSwapComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: SwapComponent.Params,
@Assisted private val params: SwapComponent.Params,
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
private val sendFeatureToggles: SendFeatureToggles,
) : SwapComponent, AppComponentContext by appComponentContext {
private val model: SwapModel = getOrCreateModel(params)
@ -34,8 +50,71 @@ internal class DefaultSwapComponent @AssistedInject constructor(
)
}
val slotNavigation = SlotNavigation<FeeSelectorConfig>()
val childSlot = childSlot(
source = slotNavigation,
serializer = null,
childFactory = { config, context ->
createSwapFeeSelectorBlockComponent(
context = childByContext(context),
config = config,
)
},
)
private fun createSwapFeeSelectorBlockComponent(
context: AppComponentContext,
config: FeeSelectorConfig,
): SwapFeeSelectorBlockComponent {
return swapFeeSelectorBlockComponentFactory.create(
context = context,
params = SwapFeeSelectorBlockComponent.Params(
repository = model.feeSelectorRepository,
userWalletId = params.userWalletId,
sendingCryptoCurrencyStatus = config.sendingCurrencyStatus,
feeCryptoCurrencyStatus = config.feeCurrencyStatus,
analyticsParams = SwapFeeSelectorBlockComponent.AnalyticsParams(
analyticsCategoryName = CommonSendAnalyticEvents.SEND_CATEGORY,
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Swap,
),
),
)
}
data class FeeSelectorConfig(
val sendingCurrencyStatus: CryptoCurrencyStatus,
val feeCurrencyStatus: CryptoCurrencyStatus,
)
@Suppress("LongMethod")
@Composable
override fun Content(modifier: Modifier) {
if (sendFeatureToggles.isGaslessTransactionsEnabled) {
val sendCardData = model.uiState.sendCardData as? SwapCardData
val feePaidCryptoCurrency = model.dataState.feePaidCryptoCurrency
LaunchedEffect(sendCardData?.token?.currency, feePaidCryptoCurrency?.currency) {
val sendingCryptoCurrencyStatus = sendCardData?.token ?: run {
slotNavigation.dismiss()
return@LaunchedEffect
}
val feeCurrencyStatus = feePaidCryptoCurrency ?: run {
slotNavigation.dismiss()
return@LaunchedEffect
}
slotNavigation.activate(
FeeSelectorConfig(
sendingCurrencyStatus = sendingCryptoCurrencyStatus,
feeCurrencyStatus = feeCurrencyStatus,
),
)
}
}
val feeSelectorChildStackState by childSlot.subscribeAsState()
val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance
Crossfade(
modifier = Modifier.background(TangemTheme.colors.background.secondary),
targetState = model.currentScreen,
@ -47,16 +126,30 @@ internal class DefaultSwapComponent @AssistedInject constructor(
if (storiesConfig != null) {
SwapStoriesScreen(config = storiesConfig)
} else {
SwapScreen(stateHolder = model.uiState)
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
SwapNavScreen.Main -> SwapScreen(stateHolder = model.uiState)
SwapNavScreen.Main -> SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
SwapNavScreen.Success -> {
val successState = model.uiState.successState
val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle()
if (successState != null) {
SwapSuccessScreen(state = successState, model.uiState.onBackClicked)
SwapSuccessScreen(
state = successState,
feeSelectorUM = feeSelectorState,
onBack = model.uiState.onBackClicked,
)
} else {
SwapScreen(stateHolder = model.uiState)
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
SwapNavScreen.SelectToken -> {
@ -64,7 +157,10 @@ internal class DefaultSwapComponent @AssistedInject constructor(
if (tokenState != null) {
SwapSelectTokenScreen(state = tokenState, onBack = model.uiState.onBackClicked)
} else {
SwapScreen(stateHolder = model.uiState)
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
}

View file

@ -0,0 +1,99 @@
package com.tangem.feature.swap.component
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import arrow.core.Either
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class SwapFeeSelectorBlockComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Params,
feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val feeSelectorBlockComponent =
feeSelectorBlockComponentFactory.create(
context = child("swapFeeSelectorBlock"),
params = FeeSelectorParams.FeeSelectorBlockParams(
state = params.repository.state.value,
userWalletId = params.userWalletId,
onLoadFee = params.repository::loadFee,
onLoadFeeExtended = if (params.repository is ModelRepositoryExtended) {
params.repository::loadFeeExtended
} else {
null
},
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.ExcludeLow,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.sendingCryptoCurrencyStatus,
analyticsCategoryName = params.analyticsParams.analyticsCategoryName,
analyticsSendSource = params.analyticsParams.analyticsSendSource,
bottomSheetShown = params.repository::choosingInProgress,
),
onResult = params.repository::onResult,
)
init {
params.repository.state
.onEach(feeSelectorBlockComponent::updateState)
.launchIn(componentScope)
}
@Composable
override fun Content(modifier: Modifier) {
feeSelectorBlockComponent.Content(modifier = modifier)
}
interface ModelRepository {
val state: StateFlow<FeeSelectorUM>
get() = MutableStateFlow<FeeSelectorUM>(FeeSelectorUM.Loading)
fun onResult(newState: FeeSelectorUM)
suspend fun loadFee(): Either<GetFeeError, TransactionFee>
fun choosingInProgress(updatedState: Boolean)
}
interface ModelRepositoryExtended : ModelRepository {
suspend fun loadFeeExtended(
selectedToken: CryptoCurrencyStatus? = null,
): Either<GetFeeError, TransactionFeeExtended>
}
class AnalyticsParams(
val analyticsCategoryName: String,
val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
)
class Params(
val userWalletId: UserWalletId,
val sendingCryptoCurrencyStatus: CryptoCurrencyStatus,
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
val analyticsParams: AnalyticsParams,
val repository: ModelRepository,
)
@AssistedFactory
interface Factory : ComponentFactory<Params, SwapFeeSelectorBlockComponent>
}

View file

@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import arrow.core.Either
import arrow.core.getOrElse
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull
@ -52,17 +53,23 @@ import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.feature.swap.analytics.StoriesEvents
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.TransactionFeeResult
import com.tangem.feature.swap.domain.TxFeeSealedState
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.ExpressException
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.market.SwapMarketsListBatchFlowManager
@ -73,6 +80,9 @@ import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.router.SwapRouter
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.swap.SwapComponent
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
@ -124,6 +134,8 @@ internal class SwapModel @Inject constructor(
private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val sendFeatureToggles: SendFeatureToggles,
private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
) : Model() {
@ -169,7 +181,7 @@ internal class SwapModel @Inject constructor(
private val searchDebouncer = Debouncer()
private val singleTaskScheduler = SingleTaskScheduler<Map<SwapProvider, SwapState>>()
private var dataState by mutableStateOf(SwapProcessDataState())
var dataState by mutableStateOf(SwapProcessDataState())
var uiState: SwapStateHolder by mutableStateOf(
stateBuilder.createInitialLoadingState(
@ -180,6 +192,8 @@ internal class SwapModel @Inject constructor(
)
private set
val feeSelectorRepository = FeeSelectorRepository()
// shows currency order (direct - swap initial to selected, reversed = selected to initial)
private var isOrderReversed = false
private val lastAmount = mutableStateOf(INITIAL_AMOUNT)
@ -359,6 +373,7 @@ internal class SwapModel @Inject constructor(
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(hasAvailableTokens = isAnyAvailableTokens))
}
@Suppress("LongMethod")
private fun initTokens(isReverseFromTo: Boolean) {
modelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
@ -394,6 +409,21 @@ internal class SwapModel @Inject constructor(
isReverseFromTo = isReverseFromTo,
)
val fromCryptoCurrency = if (isOrderReversed) {
dataState.toCryptoCurrency
} else {
dataState.fromCryptoCurrency
}
fromCryptoCurrency?.let { cryptoCurrency ->
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = cryptoCurrency,
).getOrNull(),
)
}
(dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
@ -524,6 +554,7 @@ internal class SwapModel @Inject constructor(
reduceBalanceBy: BigDecimal,
toProvidersList: List<SwapProvider>,
isSilent: Boolean = false,
updateFeeBlock: Boolean = true,
) {
singleTaskScheduler.cancelTask()
if (!isSilent) {
@ -535,6 +566,7 @@ internal class SwapModel @Inject constructor(
toAccount = toAccount,
mainTokenId = initialCurrencyFrom.id.value,
)
feeSelectorRepository.state.value = FeeSelectorUM.Loading
}
singleTaskScheduler.scheduleTask(
modelScope,
@ -546,11 +578,12 @@ internal class SwapModel @Inject constructor(
amount = amount,
reduceBalanceBy = reduceBalanceBy,
toProvidersList = toProvidersList,
updateFeeBlock = updateFeeBlock,
),
)
}
private fun startLoadingQuotesFromLastState(isSilent: Boolean = false) {
private fun startLoadingQuotesFromLastState(isSilent: Boolean = false, updateFeeBlock: Boolean = true) {
val fromCurrency = dataState.fromCryptoCurrency
val toCurrency = dataState.toCryptoCurrency
val amount = dataState.amount
@ -564,6 +597,7 @@ internal class SwapModel @Inject constructor(
isSilent = isSilent,
reduceBalanceBy = dataState.reduceBalanceBy,
toProvidersList = findSwapProviders(fromCurrency, toCurrency),
updateFeeBlock = updateFeeBlock,
)
}
}
@ -576,6 +610,7 @@ internal class SwapModel @Inject constructor(
amount: String,
reduceBalanceBy: BigDecimal,
toProvidersList: List<SwapProvider>,
updateFeeBlock: Boolean = true,
): PeriodicTask<Map<SwapProvider, SwapState>> {
return PeriodicTask(
delay = UPDATE_DELAY,
@ -596,7 +631,7 @@ internal class SwapModel @Inject constructor(
providers = toProvidersList,
amountToSwap = amount,
reduceBalanceBy = reduceBalanceBy,
selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
txFeeSealedState = getSelectedFeeState(),
)
}
},
@ -612,12 +647,17 @@ internal class SwapModel @Inject constructor(
tokenSwapInfoForProviders = successStates.entries
.associate { it.key.providerId to it.value.toTokenInfo },
)
if (updateFeeBlock) {
modelScope.launch { feeSelectorReloadTrigger.triggerUpdate() }
}
} else {
feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError)
Timber.e("Accidentally empty quotes list")
}
},
onError = { error ->
Timber.e("Error when loading quotes: $error")
feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError)
uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() }
},
)
@ -657,7 +697,7 @@ internal class SwapModel @Inject constructor(
swapProvider = provider,
bestRatedProviderId = bestRatedProviderId,
isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1,
selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL,
isReverseSwapPossible = isReverseSwapPossible(),
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
hideFee = tangemPayInput?.isWithdrawal == true,
@ -799,8 +839,8 @@ internal class SwapModel @Inject constructor(
}
}
private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee? {
val selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL
private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee.Legacy? {
val selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL
return when (val txFee = state.txFee) {
TxFeeState.Empty -> null
is TxFeeState.MultipleFeeState -> {
@ -827,7 +867,7 @@ internal class SwapModel @Inject constructor(
return
}
val fromCurrency = requireNotNull(dataState.fromCryptoCurrency)
val fee = dataState.selectedFee
val fee = getSelectedFee()
if (fee == null && tangemPayInput?.isWithdrawal != true) {
makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
@ -855,7 +895,10 @@ internal class SwapModel @Inject constructor(
makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
return@onSuccess
}
sendSuccessSwapEvent(fromCurrency.currency, fee.feeType)
sendSuccessSwapEvent(
fromCurrency.currency,
(getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL,
)
val url = getExplorerTransactionUrlUseCase(
txHash = swapTransactionState.txHash,
networkId = fromCurrency.currency.network.id,
@ -967,7 +1010,7 @@ internal class SwapModel @Inject constructor(
private fun sendSuccessEvent() {
val provider = dataState.selectedProvider ?: return
val fee = dataState.selectedFee?.feeType ?: return
val fee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL
val fromCurrency = dataState.fromCryptoCurrency?.currency ?: return
val toCurrency = dataState.toCryptoCurrency?.currency ?: return
val fromDerivationIndex = dataState.fromAccount?.derivationIndex?.value
@ -1027,6 +1070,7 @@ internal class SwapModel @Inject constructor(
}.onSuccess { swapTransactionState ->
when (swapTransactionState) {
is SwapTransactionState.TxSent -> {
// TODO [REDACTED_TASK_KEY] gasless analytics
sendApproveSuccessEvent(fromToken, feeForPermission.feeType, approveType)
updateWalletBalance()
uiState = stateBuilder.loadingPermissionState(uiState)
@ -1242,12 +1286,14 @@ internal class SwapModel @Inject constructor(
.onEach { (account, currencyStatus) ->
Timber.d("${coin.id} balance is ${currencyStatus.value.amount ?: "null"}")
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = currencyStatus,
).getOrNull() ?: currencyStatus,
)
if (isFromCurrency) {
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = currencyStatus,
).getOrNull() ?: currencyStatus,
)
}
uiState = when {
isFromCurrency && currencyStatus.currency.id == dataState.fromCryptoCurrency?.currency?.id -> {
@ -1280,12 +1326,14 @@ internal class SwapModel @Inject constructor(
.onEach { status ->
Timber.d("${coin.id} balance is ${status.value.amount ?: "null"}")
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = status,
).getOrNull() ?: status,
)
if (isFromCurrency) {
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = status,
).getOrNull() ?: status,
)
}
uiState = when {
isFromCurrency && status.currency.id == dataState.fromCryptoCurrency?.currency?.id -> {
@ -1492,7 +1540,7 @@ internal class SwapModel @Inject constructor(
uiState = stateBuilder.updateApproveType(uiState, approveType)
},
onClickFee = {
val selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL
val selectedFee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL
val txFeeState =
dataState.getCurrentLoadedSwapState()?.txFee as? TxFeeState.MultipleFeeState ?: return@UiActions
uiState = stateBuilder.showSelectFeeBottomSheet(
@ -1503,9 +1551,9 @@ internal class SwapModel @Inject constructor(
uiState = stateBuilder.dismissBottomSheet(uiState)
}
},
onSelectFeeType = { feeType ->
onSelectFeeType = { txFee ->
uiState = stateBuilder.dismissBottomSheet(uiState)
dataState = dataState.copy(selectedFee = feeType)
dataState = dataState.copy(selectedFee = txFee)
modelScope.launch(dispatchers.io) {
startLoadingQuotesFromLastState(false)
}
@ -1527,6 +1575,10 @@ internal class SwapModel @Inject constructor(
val swapState = dataState.lastLoadedSwapStates[provider]
val fromToken = dataState.fromCryptoCurrency
if (provider != null && swapState != null && fromToken != null) {
modelScope.launch {
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerUpdate()
}
analyticsEventHandler.send(SwapEvents.ProviderChosen(provider))
uiState = stateBuilder.dismissBottomSheet(uiState)
setupLoadedState(
@ -1860,7 +1912,11 @@ internal class SwapModel @Inject constructor(
destinationAddress = transaction?.txTo.orEmpty(),
tokenSymbol = fromCurrencyStatus.currency.symbol,
amount = dataState.amount.orEmpty(),
fee = dataState.selectedFee?.feeCryptoFormatted.orEmpty(),
fee = when (val fee = getSelectedFee()) {
is TxFee.FeeComponent -> fee.fee.amount.value?.toString()
is TxFee.Legacy -> fee.feeCryptoFormatted
null -> ""
},
),
)
@ -1900,6 +1956,137 @@ internal class SwapModel @Inject constructor(
}
}
private fun getSelectedFeeState(): TxFeeSealedState {
if (!sendFeatureToggles.isGaslessTransactionsEnabled) {
return TxFeeSealedState.Legacy(
txFeeState = TxFeeState.Empty,
selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
)
}
val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content
?: return TxFeeSealedState.Legacy(
txFeeState = TxFeeState.Empty,
selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
)
val transactionFeeExtended = feeStateUM.feeExtraInfo.transactionFeeExtended
return TxFeeSealedState.Component(
txFee = TxFee.FeeComponent(
transactionFeeResult = transactionFeeExtended?.let { TransactionFeeResult.from(it) }
?: TransactionFeeResult.from(feeStateUM.fees),
fee = feeStateUM.selectedFeeItem.fee,
selectedToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus,
),
)
}
private fun getSelectedFee(): TxFee? {
if (!sendFeatureToggles.isGaslessTransactionsEnabled) {
return dataState.selectedFee
}
val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content ?: return null
val transactionFeeExtended = feeStateUM.feeExtraInfo.transactionFeeExtended
return TxFee.FeeComponent(
transactionFeeResult = transactionFeeExtended?.let { TransactionFeeResult.from(it) }
?: TransactionFeeResult.from(feeStateUM.fees),
fee = feeStateUM.selectedFeeItem.fee,
selectedToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus,
)
}
inner class FeeSelectorRepository : SwapFeeSelectorBlockComponent.ModelRepositoryExtended {
override val state = MutableStateFlow<FeeSelectorUM>(FeeSelectorUM.Loading)
override suspend fun loadFeeExtended(
selectedToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended> {
val sendCardData =
uiState.sendCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError)
val receiveCardData =
uiState.receiveCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError)
val fromToken = sendCardData.token ?: return Either.Left(GetFeeError.UnknownError)
val toToken = receiveCardData.token ?: return Either.Left(GetFeeError.UnknownError)
val selectedProvider = dataState.selectedProvider ?: return Either.Left(GetFeeError.UnknownError)
if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) {
return Either.Left(GetFeeError.UnknownError)
}
return swapInteractor.loadFeeForSwapTransaction(
fromToken = fromToken,
fromAccount = dataState.fromAccount,
toToken = toToken,
toAccount = dataState.toAccount,
provider = selectedProvider,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
selectedFeeToken = selectedToken,
)
}
override fun onResult(newState: FeeSelectorUM) {
state.value = newState
// If fee currency is same as from currency, we need to reload quotes to update fee info
if (newState is FeeSelectorUM.Content &&
dataState.fromCryptoCurrency?.currency?.id == newState.feeExtraInfo.feeCryptoCurrencyStatus.currency.id
) {
// block swap button until fee is loaded
uiState = uiState.copy(
swapButton = uiState.swapButton.copy(
isEnabled = false,
),
)
modelScope.launch {
startLoadingQuotesFromLastState(
isSilent = true,
updateFeeBlock = false,
)
}
}
}
override suspend fun loadFee(): Either<GetFeeError, TransactionFee> {
val sendCardData =
uiState.sendCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError)
val receiveCardData =
uiState.receiveCardData as? SwapCardState.SwapCardData ?: return Either.Left(GetFeeError.UnknownError)
val fromToken = sendCardData.token ?: return Either.Left(GetFeeError.UnknownError)
val toToken = receiveCardData.token ?: return Either.Left(GetFeeError.UnknownError)
val selectedProvider = dataState.selectedProvider ?: return Either.Left(GetFeeError.UnknownError)
if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) {
return Either.Left(GetFeeError.UnknownError)
}
return swapInteractor.loadFeeForSwapTransaction(
fromToken = fromToken,
fromAccount = dataState.fromAccount,
toToken = toToken,
toAccount = dataState.toAccount,
provider = selectedProvider,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
)
}
override fun choosingInProgress(updatedState: Boolean) {
// We shouldn't load quotes while user is choosing fee
if (updatedState) {
singleTaskScheduler.cancelTask()
} else {
startLoadingQuotesFromLastState(
isSilent = true,
updateFeeBlock = false,
)
}
}
}
private companion object {
const val INITIAL_AMOUNT = ""
const val UPDATE_DELAY = 10000L

View file

@ -15,6 +15,7 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
import com.tangem.feature.swap.domain.models.domain.SwapFeeState
import com.tangem.feature.swap.domain.models.ui.*
@ -164,7 +165,7 @@ internal class SwapNotificationsFactory(
addExistentialWarningNotification(
existentialDeposit = quoteModel.currencyCheck?.existentialDeposit,
feeAmount = fee?.feeValue.orZero(),
feeAmount = fee?.fee?.amount?.value.orZero(),
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = fromCurrencyStatus,
onReduceClick = { reduceBy, reduceByDiff, _ ->
@ -187,7 +188,7 @@ internal class SwapNotificationsFactory(
if (!isCardano) {
addDustWarningNotification(
dustValue = quoteModel.currencyCheck?.dustValue,
feeValue = fee?.feeValue.orZero(),
feeValue = fee?.fee?.amount?.value.orZero(),
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = fromCurrencyStatus,
feeCurrencyStatus = feeCryptoCurrencyStatus,
@ -275,7 +276,7 @@ internal class SwapNotificationsFactory(
}
}
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee? {
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee.Legacy? {
return when (txFeeState) {
TxFeeState.Empty -> null
is TxFeeState.SingleFeeState -> txFeeState.fee
@ -296,7 +297,9 @@ internal class SwapNotificationsFactory(
val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
feeEnoughState.feeCurrency != fromToken
val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network)
val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) &&
quoteModel.swapProvider.type == ExchangeProviderType.CEX
if (shouldShowCoverWarning && !isGaslessAvailable) {
add(
SwapNotificationUM.Error.UnableToCoverFeeWarning(

View file

@ -22,7 +22,7 @@ data class SwapProcessDataState(
val reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
val approveDataModel: RequestApproveStateData? = null,
val swapDataModel: SwapDataModel? = null,
val selectedFee: TxFee? = null,
val selectedFee: TxFee.Legacy? = null,
val tokensDataState: TokensDataStateExpress? = null,
val selectedProvider: SwapProvider? = null,
val lastLoadedSwapStates: Map<SwapProvider, SwapState> = emptyMap(),

View file

@ -7,7 +7,7 @@ import com.tangem.core.ui.extensions.TextReference
data class SwapSuccessStateHolder(
val timestamp: Long,
val txUrl: String,
val fee: TextReference,
val fee: TextReference?,
val rate: TextReference,
val shouldShowStatusButton: Boolean,
val providerName: TextReference,

View file

@ -24,7 +24,7 @@ data class UiActions(
val onStoriesClose: (Int) -> Unit,
val onRetryClick: () -> Unit,
val onClickFee: () -> Unit,
val onSelectFeeType: (TxFee) -> Unit,
val onSelectFeeType: (TxFee.Legacy) -> Unit,
val onProviderClick: (String) -> Unit,
val onProviderSelect: (String) -> Unit,
val onBuyClick: (CryptoCurrency) -> Unit,

View file

@ -826,7 +826,6 @@ internal class StateBuilder(
onStatusClick: () -> Unit,
txUrl: String,
): SwapStateHolder {
val fee = requireNotNull(dataState.selectedFee)
val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency)
val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency)
val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO
@ -846,7 +845,9 @@ internal class StateBuilder(
shouldShowStatusButton = shouldShowStatus,
providerIcon = providerState.iconUrl,
rate = providerState.subtitle,
fee = stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})"),
fee = dataState.selectedFee?.let { fee ->
stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})")
},
fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount),
toTitle = getToCardAccountTitle(toAccount = dataState.toAccount),
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),

View file

@ -1,24 +1,27 @@
package com.tangem.feature.swap.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
import com.tangem.feature.swap.presentation.R
@Composable
internal fun SwapScreen(stateHolder: SwapStateHolder) {
internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?) {
BackHandler(onBack = stateHolder.onBackClicked)
Scaffold(
@ -36,6 +39,17 @@ internal fun SwapScreen(stateHolder: SwapStateHolder) {
SwapScreenContent(
state = stateHolder,
feeBlock = if (feeSelectorBlockComponent != null) {
@Composable { modifier: Modifier ->
feeSelectorBlockComponent.Content(
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
)
}
} else {
null
},
modifier = Modifier.padding(scaffoldPaddings),
)

View file

@ -49,7 +49,11 @@ import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@Composable
internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modifier) {
internal fun SwapScreenContent(
state: SwapStateHolder,
modifier: Modifier = Modifier,
feeBlock: @Composable ((Modifier) -> Unit)? = null,
) {
val keyboard by keyboardAsState()
Box(
@ -74,7 +78,11 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi
ProviderItemBlock(state = state.providerState)
FeeItemBlock(state = state.fee)
if (feeBlock != null) {
feeBlock(Modifier.fillMaxWidth())
} else {
FeeItemBlock(state = state.fee)
}
if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications)

View file

@ -29,14 +29,16 @@ import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.feature.swap.models.SwapSuccessStateHolder
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.preview.SwapSuccessStatePreview
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.common.ui.FeeBlockSuccess
@Composable
fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) {
fun SwapSuccessScreen(state: SwapSuccessStateHolder, feeSelectorUM: FeeSelectorUM?, onBack: () -> Unit) {
Scaffold(
modifier = Modifier.systemBarsPadding(),
containerColor = TangemTheme.colors.background.secondary,
content = { padding ->
SwapSuccessScreenContent(padding = padding, state = state)
SwapSuccessScreenContent(padding = padding, feeSelectorUM = feeSelectorUM, state = state)
},
topBar = {
AppBarWithBackButton(
@ -58,7 +60,11 @@ fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) {
}
@Composable
private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: PaddingValues) {
private fun SwapSuccessScreenContent(
state: SwapSuccessStateHolder,
feeSelectorUM: FeeSelectorUM?,
padding: PaddingValues,
) {
Column(
modifier = Modifier
.fillMaxSize()
@ -101,7 +107,10 @@ private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: Pad
.background(TangemTheme.colors.background.action),
)
SpacerH16()
if (state.fee != TextReference.EMPTY) {
if (feeSelectorUM != null) {
FeeBlockSuccess(feeSelectorUM)
} else if (state.fee != null && state.fee != TextReference.EMPTY) {
InputRowDefault(
title = TextReference.Res(R.string.common_network_fee_title),
text = state.fee,
@ -205,7 +214,7 @@ private fun SwapSuccessScreenButtons(
@Composable
private fun Preview_Success() {
TangemThemePreview {
SwapSuccessScreen(SwapSuccessStatePreview.state) {}
SwapSuccessScreen(SwapSuccessStatePreview.state, null) {}
}
}
// endregion preview