Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-11 14:07:02 +03:00
parent 1d9d74b6a9
commit fe2bade1e4
7 changed files with 161 additions and 4 deletions

View file

@ -19,6 +19,7 @@ dependencies {
implementation(projects.features.swap.domain)
implementation(projects.features.swap.domain.api)
implementation(projects.features.swap.domain.models)
implementation(projects.features.feed.api)
/** Project - Core */
implementation(projects.core.analytics)

View file

@ -5,13 +5,20 @@ import androidx.compose.runtime.Stable
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.childSlot
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.event.MainScreenAnalyticsEvent
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.onramp.component.SwapSelectTokensComponent
import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent
import com.tangem.features.onramp.swap.availablepairs.model.AddToPortfolioRoute
import com.tangem.features.onramp.swap.model.SwapSelectTokensModel
import com.tangem.features.onramp.swap.ui.SwapSelectTokens
import com.tangem.features.onramp.tokenlist.OnrampTokenListComponent
@ -25,6 +32,7 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor(
tokenListComponentFactory: OnrampTokenListComponent.Factory,
availableSwapPairsComponentFactory: AvailableSwapPairsComponent.Factory,
analyticsEventHandler: AnalyticsEventHandler,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
@Assisted private val appComponentContext: AppComponentContext,
@Assisted private val params: SwapSelectTokensComponent.Params,
) : AppComponentContext by appComponentContext, SwapSelectTokensComponent {
@ -49,15 +57,36 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor(
),
)
private val bottomSheetSlot = childSlot(
source = selectToTokenListComponent.bottomSheetNavigation,
serializer = AddToPortfolioRoute.serializer(),
key = "add_to_portfolio_bottom_sheet",
handleBackButton = false,
childFactory = { _, context -> bottomSheetChild(context) },
)
init {
analyticsEventHandler.send(event = MainScreenAnalyticsEvent.SwapScreenOpened())
}
@Suppress("UnsafeCallOnNullableType")
private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent {
return addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(
addToPortfolioManager = selectToTokenListComponent.addToPortfolioManager!!,
callback = selectToTokenListComponent.addToPortfolioCallback,
shouldSkipTokenActionsScreen = true,
),
)
}
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
val fromTokensState by selectFromTokenListComponent.uiState.collectAsStateWithLifecycle()
val toTokensState by selectToTokenListComponent.uiState.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
SwapSelectTokens(
state = state,
@ -67,6 +96,8 @@ internal class DefaultSwapSelectTokensComponent @AssistedInject constructor(
selectToTokenListState = toTokensState,
modifier = modifier,
)
bottomSheet.child?.instance?.BottomSheet()
}
@AssistedFactory

View file

@ -1,11 +1,15 @@
package com.tangem.features.onramp.swap.availablepairs
import androidx.compose.runtime.Stable
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.decompose.ComposableListContentComponent
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
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.onramp.swap.availablepairs.model.AddToPortfolioRoute
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import kotlinx.coroutines.flow.StateFlow
@ -13,6 +17,10 @@ import kotlinx.coroutines.flow.StateFlow
@Stable
internal interface AvailableSwapPairsComponent : ComposableListContentComponent<TokenListUM> {
val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute>
val addToPortfolioManager: AddToPortfolioManager?
val addToPortfolioCallback: AddToPortfolioComponent.Callback
/** Component factory */
interface Factory : ComponentFactory<Params, AvailableSwapPairsComponent>

View file

@ -3,8 +3,12 @@ package com.tangem.features.onramp.swap.availablepairs
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.model.getOrCreateModel
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.onramp.swap.availablepairs.model.AddToPortfolioRoute
import com.tangem.features.onramp.swap.availablepairs.model.AvailableSwapPairsModel
import com.tangem.features.onramp.tokenlist.entity.TokenListUM
import com.tangem.features.onramp.tokenlist.ui.onrampSwapTokenList
@ -21,6 +25,10 @@ internal class DefaultAvailableSwapPairsComponent @AssistedInject constructor(
private val model: AvailableSwapPairsModel = getOrCreateModel(params)
override val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute> get() = model.bottomSheetNavigation
override val addToPortfolioManager: AddToPortfolioManager? get() = model.addToPortfolioManager
override val addToPortfolioCallback: AddToPortfolioComponent.Callback get() = model.addToPortfolioCallback
override val uiState: StateFlow<TokenListUM>
get() = model.state

View file

@ -228,6 +228,13 @@ internal class SwapMarketsListBatchFlowManager(
}
}
fun getTokenMarketById(id: CryptoCurrency.RawID): TokenMarket? {
return batchFlow.state.value.data
.asSequence()
.flatMap { it.data }
.firstOrNull { it.id == id }
}
fun getBatchKeysByItemIds(ids: List<CryptoCurrency.RawID>): Set<Int> {
val currentData = batchFlow.state.value.data

View file

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

View file

@ -9,28 +9,36 @@ import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.status.producer.SingleAccountStatusListProducer
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
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.core.lce.Lce
import com.tangem.domain.core.utils.getOrElse
import com.tangem.domain.core.utils.lceContent
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.core.utils.lceLoading
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.account.filterCryptoPortfolio
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.tokens.GetTokenListUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.feature.swap.domain.GetAvailablePairsUseCase
import com.tangem.feature.swap.domain.models.domain.LeastTokenInfo
import com.tangem.feature.swap.domain.models.domain.SwapPairLeast
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.onramp.impl.R
import com.tangem.features.onramp.tokenlist.entity.utils.OnrampTokenItemStateConverterFactory
import com.tangem.features.onramp.swap.availablepairs.AvailableSwapPairsComponent
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.features.onramp.swap.availablepairs.entity.transformers.SetErrorWarningTransformer
@ -48,9 +56,17 @@ import com.tangem.features.onramp.tokenlist.entity.transformer.*
import com.tangem.features.onramp.utils.UpdateSearchBarActiveStateTransformer
import com.tangem.features.onramp.utils.UpdateSearchBarCallbacksTransformer
import com.tangem.features.onramp.utils.UpdateSearchQueryTransformer
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.lib.crypto.BlockchainUtils
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.runSuspendCatching
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -71,7 +87,10 @@ internal class AvailableSwapPairsModel @Inject constructor(
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val accountsFeatureToggles: AccountsFeatureToggles,
private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val excludedBlockchains: ExcludedBlockchains,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
swapFeatureToggles: SwapFeatureToggles,
getWalletsUseCase: GetWalletsUseCase,
) : Model() {
@ -79,6 +98,17 @@ internal class AvailableSwapPairsModel @Inject constructor(
private val params: AvailableSwapPairsComponent.Params = paramsContainer.require()
private val userWallet = getWalletsUseCase.invokeSync().first { it.walletId == params.userWalletId }
private val allUserWallets = getWalletsUseCase.invokeSync()
val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
var addToPortfolioManager: AddToPortfolioManager? = null
val addToPortfolioCallback: AddToPortfolioComponent.Callback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
override fun onSuccess(addedToken: CryptoCurrency) {
onTokenAddedToPortfolio(addedToken)
}
}
private val addToPortfolioJobHolder = JobHolder()
private val tokenListFlow = getTokenListUseCaseFlow()
private val accountListFlow = getAccountListUseCaseFlow()
@ -86,6 +116,7 @@ internal class AvailableSwapPairsModel @Inject constructor(
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = getSelectedAppCurrencyUseCase.invokeOrDefault()
.stateIn(scope = modelScope, started = SharingStarted.Eagerly, initialValue = AppCurrency.Default)
private val refreshPairsTrigger = MutableSharedFlow<Unit>()
private val searchQueryStateForMarkets = MutableStateFlow("")
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
@ -374,8 +405,12 @@ internal class AvailableSwapPairsModel @Inject constructor(
private fun subscribeOnAvailablePairsUpdates() {
modelScope.launch {
params.selectedStatus
.filterNotNull()
combine(
params.selectedStatus.filterNotNull(),
refreshPairsTrigger
.onEach { availablePairsByNetworkFlow.value = emptyMap() }
.onStart { emit(Unit) },
) { status, _ -> status }
.collectLatest { selectedStatus ->
val networkInfo = selectedStatus.toLeastTokenInfo()
@ -542,7 +577,7 @@ internal class AvailableSwapPairsModel @Inject constructor(
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { searchMarketsListManager.loadMore() },
onItemClick = {},
onItemClick = { item -> addToPortfolioItem(item) },
visibleIdsChanged = { visibleMarketItemIds.value = it },
total = total ?: uiItems.size,
)
@ -556,6 +591,66 @@ internal class AvailableSwapPairsModel @Inject constructor(
.launchIn(modelScope)
}
private fun onTokenAddedToPortfolio(addedToken: CryptoCurrency) {
modelScope.launch {
bottomSheetNavigation.dismiss()
// Trigger re-fetch of available pairs (clears cache + re-enters collectLatest)
refreshPairsTrigger.emit(Unit)
// Wait for the added token status to become Loaded
val addedTokenStatus = getAccountCurrencyStatusUseCase(params.userWalletId, addedToken)
.firstOrNull { it.status.value is CryptoCurrencyStatus.Loaded }
?.status
?: return@launch
// Convert to TokenItemState and trigger token selection → navigates to swap
val converter = OnrampTokenItemStateConverterFactory.createAvailableItemConverter(
appCurrency = selectedAppCurrencyFlow.value,
onItemClick = params.onTokenClick,
)
params.onTokenClick(converter.convert(addedTokenStatus), addedTokenStatus)
}
}
private fun addToPortfolioItem(item: MarketsListItemUM) {
modelScope.launch {
val tokenMarket = searchMarketsListManager.getTokenMarketById(item.id) ?: return@launch
val param = tokenMarket.toSerializableParam()
val hasOnlyHotWallets = allUserWallets.all { it is UserWallet.Hot }
val networks = tokenMarket.networks?.filter { network ->
BlockchainUtils.isSupportedNetworkId(
blockchainId = network.networkId,
excludedBlockchains = excludedBlockchains,
hotExcludedBlockchains = hotWalletExcludedBlockchains,
hasOnlyHotWallets = hasOnlyHotWallets,
)
}?.map { network ->
TokenMarketInfo.Network(
networkId = network.networkId,
isExchangeable = false,
contractAddress = network.contractAddress,
decimalCount = network.decimalCount,
)
}.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 subscribeOnVisibleMarketItems() {
modelScope.launch {
visibleMarketItemIds.mapNotNull { rawIds ->