Updated on 2026-08-14

This commit is contained in:
Tangem 2026-01-16 13:36:43 +03:00
parent f08687d985
commit 155b6516d9
6 changed files with 193 additions and 17 deletions

View file

@ -2,7 +2,9 @@ package com.tangem.features.swap
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
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 java.math.BigDecimal
@ -15,6 +17,8 @@ interface SwapComponent : ComposableContentComponent {
val isInitialReverseOrder: Boolean = false,
val screenSource: String,
val tangemPayInput: TangemPayInput? = null,
val preselectedToToken: CryptoCurrencyStatus? = null,
val preselectedAccount: Account.CryptoPortfolio? = null,
) {
data class TangemPayInput(
val cryptoAmount: BigDecimal,

View file

@ -26,6 +26,9 @@ dependencies {
implementation(projects.common.uiMarkets)
implementation(projects.common.uiCharts)
/** Data modules */
implementation(projects.data.common)
/** Domain modules **/
implementation(projects.domain.models)
implementation(projects.domain.appCurrency)
@ -60,6 +63,7 @@ dependencies {
implementation(projects.features.swap.api)
implementation(projects.features.sendV2.api)
implementation(projects.features.sendV2.impl)
implementation(projects.features.feed.api)
/** AndroidX */
implementation(deps.androidx.activity.compose)

View file

@ -7,6 +7,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
@ -17,15 +18,18 @@ 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.decompose.ComposableBottomSheetComponent
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.AddToPortfolioRoute
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.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.swap.SwapComponent
@ -39,10 +43,19 @@ internal class DefaultSwapComponent @AssistedInject constructor(
@Assisted private val params: SwapComponent.Params,
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
private val sendFeatureToggles: SendFeatureToggles,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
) : SwapComponent, AppComponentContext by appComponentContext {
private val model: SwapModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = AddToPortfolioRoute.serializer(),
key = BOTTOM_SHEET_SLOT_KEY,
handleBackButton = false,
childFactory = { configuration, context -> bottomSheetChild(context) },
)
init {
lifecycle.subscribe(
onStart = model::onStart,
@ -54,6 +67,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
val childSlot = childSlot(
source = slotNavigation,
serializer = null,
key = FEE_SELECTOR_SLOT_KEY,
childFactory = { config, context ->
createSwapFeeSelectorBlockComponent(
context = childByContext(context),
@ -114,6 +128,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
val feeSelectorChildStackState by childSlot.subscribeAsState()
val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance
val bottomSheet by bottomSheetSlot.subscribeAsState()
Crossfade(
modifier = Modifier.background(TangemTheme.colors.background.secondary),
@ -165,10 +180,29 @@ internal class DefaultSwapComponent @AssistedInject constructor(
}
}
}
bottomSheet.child?.instance?.BottomSheet()
}
@Suppress("UnsafeCallOnNullableType")
private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent {
return addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(
addToPortfolioManager = model.addToPortfolioManager!!,
callback = model.addToPortfolioCallback,
shouldSkipTokenActionsScreen = true,
),
)
}
@AssistedFactory
interface Factory : SwapComponent.Factory {
override fun create(context: AppComponentContext, params: SwapComponent.Params): DefaultSwapComponent
}
private companion object {
const val BOTTOM_SHEET_SLOT_KEY = "bottomSheetSlot"
const val FEE_SELECTOR_SLOT_KEY = "feeSelectorSlot"
}
}

View file

@ -0,0 +1,26 @@
package com.tangem.feature.swap.converters
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.converter.Converter
internal class TokenMarketInfoToParamsConverter : Converter<TokenMarketInfo, TokenMarketParams> {
override fun convert(value: TokenMarketInfo): TokenMarketParams {
val tokenId = CryptoCurrency.RawID(value.id)
return TokenMarketParams(
id = tokenId,
name = value.name,
symbol = value.symbol,
tokenQuotes = TokenMarketParams.Quotes(
currentPrice = value.quotes.currentPrice,
h24Percent = value.quotes.h24ChangePercent,
weekPercent = value.quotes.weekChangePercent,
monthPercent = value.quotes.monthChangePercent,
),
imageUrl = getTokenIconUrlFromDefaultHost(tokenId),
)
}
}

View file

@ -6,10 +6,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import arrow.core.Either
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
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
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
@ -28,6 +33,7 @@ import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
@ -35,10 +41,12 @@ import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.GetTokenMarketInfoUseCase
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.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.promo.GetStoryContentUseCase
@ -58,9 +66,11 @@ 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.domain.wallets.usecase.GetWalletsUseCase
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.converters.TokenMarketInfoToParamsConverter
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.TransactionFeeResult
import com.tangem.feature.swap.domain.TxFeeSealedState
@ -69,6 +79,7 @@ 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.AddToPortfolioRoute
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.UiActions
@ -80,11 +91,14 @@ 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.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager
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.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP
import com.tangem.utils.coroutines.*
@ -100,6 +114,7 @@ import java.text.DecimalFormat
import java.text.NumberFormat
import java.util.Locale
import javax.inject.Inject
import kotlin.collections.filter
typealias SuccessLoadedSwapData = Map<SwapProvider, SwapState.QuotesLoadedState>
@ -137,7 +152,11 @@ internal class SwapModel @Inject constructor(
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val sendFeatureToggles: SendFeatureToggles,
private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
swapFeatureToggles: SwapFeatureToggles,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val excludedBlockchains: ExcludedBlockchains,
private val getUserWalletsUseCase: GetWalletsUseCase,
) : Model() {
private val params = paramsContainer.require<SwapComponent.Params>()
@ -214,6 +233,7 @@ internal class SwapModel @Inject constructor(
private val fromTokenBalanceJobHolder = JobHolder()
private val toTokenBalanceJobHolder = JobHolder()
private val addToPortfolioJobHolder = JobHolder()
private var isAmountChangedByUser: Boolean = false
private var lastPermissionNotificationTokens: Pair<String, String>? = null
@ -234,6 +254,24 @@ internal class SwapModel @Inject constructor(
val currentScreen: SwapNavScreen
get() = swapRouter.currentScreen
val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
override fun onSuccess(addedToken: CryptoCurrency) {
modelScope.launch {
bottomSheetNavigation.dismiss()
getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken)
.firstOrNull {
it.status.value is CryptoCurrencyStatus.Loaded
}?.let { (account, status) ->
applyAddedToken(status, account)
}
}
}
}
var addToPortfolioManager: AddToPortfolioManager? = null
init {
userCountry = getUserCountryUseCase.invokeSync().getOrNull()
?: UserCountry.Other(Locale.getDefault().country)
@ -318,7 +356,7 @@ internal class SwapModel @Inject constructor(
items = uiItems,
loadMore = { searchMarketsListManager.loadMore() },
onItemClick = { item ->
// TODO [REDACTED_TASK_KEY] Add currency to swap form market list
addToPortfolioItem(item)
},
visibleIdsChanged = { visibleMarketItemIds.value = it },
)
@ -424,21 +462,7 @@ internal class SwapModel @Inject constructor(
)
}
(dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = coin,
isFromCurrency = true,
)
}
(dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = coin,
isFromCurrency = false,
)
}
subscribeToCoinBalanceUpdatesIfNeeded()
}.onFailure { error ->
Timber.e(error)
@ -464,6 +488,47 @@ internal class SwapModel @Inject constructor(
}
}
private fun applyAddedToken(addedToken: CryptoCurrencyStatus, addedAccount: Account.CryptoPortfolio?) {
modelScope.launch {
runCatching(dispatchers.io) {
swapInteractor.getTokensDataState(initialCurrencyFrom)
}.onSuccess { state ->
updateTokensState(state)
applyInitialTokenChoice(
state = state,
selectedCurrency = addedToken,
selectedAccount = addedAccount,
isReverseFromTo = isOrderReversed,
)
subscribeToCoinBalanceUpdatesIfNeeded()
swapRouter.back()
}.onFailure { error ->
Timber.e(error)
}
}
}
private fun subscribeToCoinBalanceUpdatesIfNeeded() {
(dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = coin,
isFromCurrency = true,
)
}
(dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = coin,
isFromCurrency = false,
)
}
}
private fun initStories() {
modelScope.launch {
getStoryContentUseCase.invokeSync(StoryContentIds.STORY_FIRST_TIME_SWAP.id).fold(
@ -1933,6 +1998,42 @@ internal class SwapModel @Inject constructor(
}
}
private fun addToPortfolioItem(item: MarketsListItemUM) {
modelScope.launch {
val tokenInfo = getTokenMarketInfoUseCase(
selectedAppCurrencyFlow.value,
item.id,
item.currencySymbol,
).getOrNull() ?: return@launch
val converter = TokenMarketInfoToParamsConverter()
val param = converter.convert(tokenInfo)
val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot }
val networks = tokenInfo.networks?.filter { network ->
BlockchainUtils.isSupportedNetworkId(
blockchainId = network.networkId,
excludedBlockchains = excludedBlockchains,
hotExcludedBlockchains = hotWalletExcludedBlockchains,
hasOnlyHotWallets = hasOnlyHotWallets,
)
}.orEmpty()
addToPortfolioManager = addToPortfolioManagerFactory
.create(
scope = modelScope,
token = param,
analyticsParams = null,
).apply {
setTokenNetworks(networks)
}
addToPortfolioManager?.state
?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd }
?.run { bottomSheetNavigation.activate(AddToPortfolioRoute) }
}.saveIn(addToPortfolioJobHolder)
}
private fun CryptoCurrency.getNetworkInfo(): NetworkInfo {
return NetworkInfo(
name = this.network.name,

View file

@ -0,0 +1,7 @@
package com.tangem.feature.swap.models
import com.tangem.core.decompose.navigation.Route
import kotlinx.serialization.Serializable
@Serializable
internal data object AddToPortfolioRoute : Route