From 155b6516d99779e876133a0b7dbd1e5128caa4a6 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 16 Jan 2026 13:36:43 +0300 Subject: [PATCH] Updated on 2026-08-14 --- .../com/tangem/features/swap/SwapComponent.kt | 4 + features/swap/impl/build.gradle.kts | 4 + .../feature/swap/DefaultSwapComponent.kt | 34 +++++ .../TokenMarketInfoToParamsConverter.kt | 26 ++++ .../tangem/feature/swap/model/SwapModel.kt | 135 +++++++++++++++--- .../swap/models/AddToPortfolioRoute.kt | 7 + 6 files changed, 193 insertions(+), 17 deletions(-) create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokenMarketInfoToParamsConverter.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt diff --git a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt index 2d0f54f7b2..d2ef17de45 100644 --- a/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt +++ b/features/swap/api/src/main/kotlin/com/tangem/features/swap/SwapComponent.kt @@ -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, diff --git a/features/swap/impl/build.gradle.kts b/features/swap/impl/build.gradle.kts index c31fc810fd..488c083c0e 100644 --- a/features/swap/impl/build.gradle.kts +++ b/features/swap/impl/build.gradle.kts @@ -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) diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt index b391c87b53..3cde40dc0c 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/DefaultSwapComponent.kt @@ -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" + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokenMarketInfoToParamsConverter.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokenMarketInfoToParamsConverter.kt new file mode 100644 index 0000000000..992ad0521b --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/converters/TokenMarketInfoToParamsConverter.kt @@ -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 { + + 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), + ) + } +} \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 66242b645e..ce909de7e6 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -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 @@ -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() @@ -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? = null @@ -234,6 +254,24 @@ internal class SwapModel @Inject constructor( val currentScreen: SwapNavScreen get() = swapRouter.currentScreen + val bottomSheetNavigation: SlotNavigation = 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, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt new file mode 100644 index 0000000000..afebcd52cf --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/AddToPortfolioRoute.kt @@ -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 \ No newline at end of file