Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-11 15:48:18 +03:00
parent e49dc700ac
commit e3f6d16441
16 changed files with 597 additions and 118 deletions

View file

@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.*
internal class SwapMarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
private val order: TokenMarketListConfig.Order,
private val currentAppCurrency: Provider<AppCurrency>,
private val currentSearchText: Provider<String?>,
private val modelScope: CoroutineScope,
@ -183,7 +184,7 @@ internal class SwapMarketsListBatchFlowManager(
searchText ?: currentSearchText()
},
priceChangeInterval = TokenMarketListConfig.Interval.H24,
order = TokenMarketListConfig.Order.ByRating,
order = order,
shouldNetworks = true,
),
),

View file

@ -2,25 +2,40 @@ package com.tangem.features.onramp.swap.availablepairs.market.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class SwapMarketState {
abstract val marketsTitle: TextReference
abstract val shouldAssetsCount: Boolean
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val total: Int,
val loadMore: () -> Unit,
val onItemClick: (MarketsListItemUM) -> Unit,
val visibleIdsChanged: (List<CryptoCurrency.RawID>) -> Unit,
override val marketsTitle: TextReference,
override val shouldAssetsCount: Boolean,
) : SwapMarketState()
data object Loading : SwapMarketState()
data class Loading(
override val marketsTitle: TextReference,
override val shouldAssetsCount: Boolean,
) : SwapMarketState()
data class LoadingError(
val onRetryClicked: () -> Unit,
override val marketsTitle: TextReference,
override val shouldAssetsCount: Boolean,
) : SwapMarketState()
data object SearchNothingFound : SwapMarketState()
data object SearchNothingFound : SwapMarketState() {
override val marketsTitle: TextReference = TextReference.Res(R.string.markets_common_title)
override val shouldAssetsCount: Boolean = true
}
}

View file

@ -0,0 +1,44 @@
package com.tangem.features.onramp.swap.availablepairs.model
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
private const val SWAP_CATEGORY = "Swap"
internal sealed class AvailableSwapPairsAnalyticsEvent(
event: String,
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(SWAP_CATEGORY, event, params) {
class TokenSelected(
val token: String,
val source: String,
val isSearched: Boolean,
) : AvailableSwapPairsAnalyticsEvent(
event = "Token Selected",
params = mapOf(
TOKEN_PARAM to token,
SOURCE to source,
SEARCHED to if (isSearched) "True" else "False",
),
) {
companion object {
const val SOURCE = "Source"
const val SEARCHED = "Searched"
const val SOURCE_PORTFOLIO = "Portfolio"
const val SOURCE_MARKETS = "Markets"
}
}
class TokenAdded(
val token: String,
val blockchain: String,
) : AvailableSwapPairsAnalyticsEvent(
event = "Token Added",
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
),
)
}

View file

@ -1,8 +1,12 @@
package com.tangem.features.onramp.swap.availablepairs.model
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.Model
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.components.fields.InputManager
import com.tangem.core.ui.R as CoreUiR
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.capitalize
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
@ -22,6 +26,7 @@ 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.TokenMarketListConfig
import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountStatus
@ -66,6 +71,7 @@ import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.runSuspendCatching
import kotlinx.coroutines.ExperimentalCoroutinesApi
import com.tangem.utils.coroutines.saveIn
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@ -77,6 +83,7 @@ private typealias AvailablePairsState = Lce<Throwable, List<SwapPairLeast>>
internal class AvailableSwapPairsModel @Inject constructor(
paramsContainer: ParamsContainer,
override val dispatchers: CoroutineDispatcherProvider,
private val analyticsEventHandler: AnalyticsEventHandler,
private val getTokenListUseCase: GetTokenListUseCase,
private val tokenListUMController: TokenListUMController,
private val searchManager: InputManager,
@ -120,10 +127,23 @@ internal class AvailableSwapPairsModel @Inject constructor(
private val searchQueryStateForMarkets = MutableStateFlow("")
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val defaultMarketsListManager by lazy {
SwapMarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
order = TokenMarketListConfig.Order.Trending,
currentAppCurrency = Provider { selectedAppCurrencyFlow.value },
currentSearchText = Provider { null },
modelScope = modelScope,
dispatchers = dispatchers,
)
}
private val searchMarketsListManager by lazy {
SwapMarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
order = TokenMarketListConfig.Order.ByRating,
currentAppCurrency = Provider { selectedAppCurrencyFlow.value },
currentSearchText = Provider { searchQueryStateForMarkets.value },
modelScope = modelScope,
@ -131,6 +151,8 @@ internal class AvailableSwapPairsModel @Inject constructor(
)
}
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
init {
if (accountsFeatureToggles.isFeatureEnabled) {
subscribeOnUpdateStateV2()
@ -298,7 +320,7 @@ internal class AvailableSwapPairsModel @Inject constructor(
} else {
UpdateTokenItemsTransformer(
appCurrency = appCurrency,
onItemClick = params.onTokenClick,
onItemClick = ::onPortfolioTokenClick,
statuses = filterByQueryTokenList.filterByAvailability(availablePairs = availablePairs),
isBalanceHidden = isBalanceHidden,
unavailableTokensHeaderReference = resourceReference(
@ -357,7 +379,7 @@ internal class AvailableSwapPairsModel @Inject constructor(
} else {
UpdateAccountTokenListTransformer(
appCurrency = appCurrency,
onItemClick = params.onTokenClick,
onItemClick = ::onPortfolioTokenClick,
accountList = filterByQueryAccountList.filterByAvailability(availablePairs = availablePairs),
isBalanceHidden = isBalanceHidden,
unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header),
@ -543,6 +565,17 @@ internal class AvailableSwapPairsModel @Inject constructor(
}
}
private fun onPortfolioTokenClick(tokenItem: TokenItemState, status: CryptoCurrencyStatus) {
analyticsEventHandler.send(
AvailableSwapPairsAnalyticsEvent.TokenSelected(
token = status.currency.symbol,
source = AvailableSwapPairsAnalyticsEvent.TokenSelected.SOURCE_PORTFOLIO,
isSearched = state.value.searchBarUM.query.isNotEmpty(),
),
)
params.onTokenClick(tokenItem, status)
}
private fun CryptoCurrencyStatus.toLeastTokenInfo(): LeastTokenInfo {
return LeastTokenInfo(
contractAddress = (currency as? CryptoCurrency.Token)?.contractAddress ?: "0",
@ -550,50 +583,121 @@ internal class AvailableSwapPairsModel @Inject constructor(
)
}
@Suppress("LongMethod")
@OptIn(ExperimentalCoroutinesApi::class)
private fun subscribeOnMarketsUpdates() {
combine(
flow = searchQueryStateForMarkets
.onEach { searchQuery ->
if (searchQuery.isNotEmpty()) {
searchMarketsListManager.reload(searchQuery)
}
},
flow2 = searchMarketsListManager.uiItems,
flow3 = searchMarketsListManager.isInInitialLoadingErrorState,
flow4 = searchMarketsListManager.isSearchNotFoundState,
flow5 = searchMarketsListManager.totalCount,
) { searchQuery, uiItems, isError, isSearchNotFound, total ->
when {
searchQuery.isEmpty() -> {
searchQueryStateForMarkets
.map { it.isEmpty() }
.distinctUntilChanged()
.flatMapLatest { isDefaultMode ->
if (isDefaultMode) {
visibleMarketItemIds.value = emptyList()
null
createDefaultMarketsFlow()
} else {
visibleDefaultMarketItemIds.value = emptyList()
createSearchMarketsFlow()
}
}
.onEach { marketsState ->
tokenListUMController.update { it.copy(marketsState = marketsState) }
}
.flowOn(dispatchers.main)
.launchIn(modelScope)
searchQueryStateForMarkets
.onEach { searchQuery ->
if (searchQuery.isNotEmpty()) {
searchMarketsListManager.reload(searchQuery)
}
}
.launchIn(modelScope)
params.selectedStatus
.filterNotNull()
.take(1)
.onEach { defaultMarketsListManager.reload() }
.launchIn(modelScope)
}
private fun createDefaultMarketsFlow(): Flow<SwapMarketState> {
val marketsTitle = TextReference.Res(CoreUiR.string.feed_trending_now)
return combine(
defaultMarketsListManager.uiItems,
defaultMarketsListManager.isInInitialLoadingErrorState,
defaultMarketsListManager.totalCount,
) { uiItems, isError, total ->
when {
isError -> SwapMarketState.LoadingError(
onRetryClicked = { searchMarketsListManager.reload(searchQuery) },
onRetryClicked = { defaultMarketsListManager.reload() },
marketsTitle = marketsTitle,
shouldAssetsCount = false,
)
uiItems.isEmpty() -> SwapMarketState.Loading(
marketsTitle = marketsTitle,
shouldAssetsCount = false,
)
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { defaultMarketsListManager.loadMore() },
onItemClick = { item -> addToPortfolioItem(item) },
visibleIdsChanged = { visibleDefaultMarketItemIds.value = it },
total = total ?: uiItems.size,
marketsTitle = marketsTitle,
shouldAssetsCount = false,
)
}
}
}
private fun createSearchMarketsFlow(): Flow<SwapMarketState> {
val marketsTitle = TextReference.Res(CoreUiR.string.markets_common_title)
return combine(
flow = searchMarketsListManager.uiItems,
flow2 = searchMarketsListManager.isInInitialLoadingErrorState,
flow3 = searchMarketsListManager.isSearchNotFoundState,
flow4 = searchMarketsListManager.totalCount,
) { uiItems, isError, isSearchNotFound, total ->
when {
isError -> SwapMarketState.LoadingError(
onRetryClicked = {
searchMarketsListManager.reload(searchQueryStateForMarkets.value)
},
marketsTitle = marketsTitle,
shouldAssetsCount = true,
)
isSearchNotFound -> SwapMarketState.SearchNothingFound
uiItems.isEmpty() -> SwapMarketState.Loading
uiItems.isEmpty() -> SwapMarketState.Loading(
marketsTitle = marketsTitle,
shouldAssetsCount = true,
)
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { searchMarketsListManager.loadMore() },
onItemClick = { item -> addToPortfolioItem(item) },
visibleIdsChanged = { visibleMarketItemIds.value = it },
total = total ?: uiItems.size,
marketsTitle = marketsTitle,
shouldAssetsCount = true,
)
}
}
.distinctUntilChanged()
.onEach { marketsState ->
tokenListUMController.update { it.copy(marketsState = marketsState) }
}
.flowOn(dispatchers.main)
.launchIn(modelScope)
}
private fun onTokenAddedToPortfolio(addedToken: CryptoCurrency) {
modelScope.launch {
bottomSheetNavigation.dismiss()
analyticsEventHandler.send(
AvailableSwapPairsAnalyticsEvent.TokenAdded(
token = addedToken.symbol,
blockchain = addedToken.network.name,
),
)
analyticsEventHandler.send(
AvailableSwapPairsAnalyticsEvent.TokenSelected(
token = addedToken.symbol,
source = AvailableSwapPairsAnalyticsEvent.TokenSelected.SOURCE_MARKETS,
isSearched = state.value.searchBarUM.query.isNotEmpty(),
),
)
// Trigger re-fetch of available pairs (clears cache + re-enters collectLatest)
refreshPairsTrigger.emit(Unit)
@ -615,7 +719,9 @@ internal class AvailableSwapPairsModel @Inject constructor(
private fun addToPortfolioItem(item: MarketsListItemUM) {
modelScope.launch {
val tokenMarket = searchMarketsListManager.getTokenMarketById(item.id) ?: return@launch
val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id)
?: return@launch
val param = tokenMarket.toSerializableParam()
val hasOnlyHotWallets = allUserWallets.all { it is UserWallet.Hot }
@ -663,5 +769,16 @@ internal class AvailableSwapPairsModel @Inject constructor(
searchMarketsListManager.loadCharts(visibleBatchKeys)
}
}
modelScope.launch {
visibleDefaultMarketItemIds.mapNotNull { rawIds ->
if (rawIds.isNotEmpty()) {
defaultMarketsListManager.getBatchKeysByItemIds(rawIds)
} else {
null
}
}.distinctUntilChanged().collectLatest { visibleBatchKeys ->
defaultMarketsListManager.loadCharts(visibleBatchKeys)
}
}
}
}

View file

@ -17,6 +17,7 @@ import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.core.ui.R
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.features.onramp.swap.availablepairs.market.state.SwapMarketState
@ -28,7 +29,7 @@ internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) {
val totalCount = (state as? SwapMarketState.Content)?.total
Text(
text = buildAnnotatedString {
append(stringResourceSafe(R.string.markets_common_title))
append(state.marketsTitle.resolveReference())
if (totalCount != null) {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(" $totalCount")

View file

@ -50,8 +50,7 @@ import kotlinx.collections.immutable.ImmutableList
*
*/
internal fun LazyListScope.onrampSwapTokenList(state: TokenListUM) {
val isSearchMode = state.searchBarUM.query.isNotEmpty() && state.marketsState != null
if (isSearchMode) {
if (state.marketsState != null) {
onrampTokenListWithMarkets(state = state)
} else {
onrampTokenList(state = state)
@ -91,7 +90,10 @@ private fun LazyListScope.onrampTokenListWithMarkets(state: TokenListUM) {
state.tokensListData.totalTokensCount != 0
if (hasAssets) {
assetsTitle(count = state.tokensListData.totalTokensCount)
assetsTitle(
count = state.tokensListData.totalTokensCount,
showCount = state.marketsState?.shouldAssetsCount == true,
)
tokensList(items = state.availableItems, isBalanceHidden = state.isBalanceHidden)
@ -159,13 +161,15 @@ private fun LazyListScope.searchBarItem(searchBarUM: SearchBarUM, modifier: Modi
}
}
private fun LazyListScope.assetsTitle(count: Int) {
private fun LazyListScope.assetsTitle(count: Int, showCount: Boolean) {
item(key = "assets_title") {
Text(
text = buildAnnotatedString {
append(stringResourceSafe(R.string.swap_your_assets_title))
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(" $count")
if (showCount) {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(" $count")
}
}
},
style = TangemTheme.typography.h3,

View file

@ -25,7 +25,7 @@ sealed class SwapEvents(
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(SWAP_CATEGORY, event, params) {
data class SwapScreenOpened(
class SwapScreenOpened(
val token: String,
val blockchain: String,
) : SwapEvents(
@ -38,25 +38,47 @@ sealed class SwapEvents(
class SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked")
data class ChooseTokenScreenOpened(val hasAvailableTokens: Boolean) : SwapEvents(
class ChooseTokenScreenOpened(val hasAvailableTokens: Boolean) : SwapEvents(
event = "Choose Token Screen Opened",
params = mapOf("Available tokens" to if (hasAvailableTokens) "Yes" else "No"),
)
data class ChooseTokenScreenResult(val isTokenChosen: Boolean, val token: String? = null) : SwapEvents(
class ChooseTokenScreenResult(
val isTokenChosen: Boolean,
val token: String? = null,
val source: String? = null,
val isSearched: Boolean? = null,
) : SwapEvents(
event = "Choose Token Screen Result",
params = buildMap {
put("Token Chosen", if (isTokenChosen) "Yes" else "No")
token?.let { put("Token", it) }
source?.let { put(TOKEN_SELECTED_SOURCE, it) }
isSearched?.let { put(SEARCHED, if (it) "True" else "False") }
},
) {
companion object {
const val TOKEN_SELECTED_SOURCE = "Token Selected Source"
const val SEARCHED = "Searched"
const val SOURCE_PORTFOLIO = "Portfolio"
const val SOURCE_MARKETS = "Markets"
}
}
class TokenAdded(val token: String, val blockchain: String) : SwapEvents(
event = "Token Added",
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
),
)
data class ButtonSwapClicked(val sendToken: String, val receiveToken: String) : SwapEvents(
class ButtonSwapClicked(val sendToken: String, val receiveToken: String) : SwapEvents(
event = "Button - Swap",
params = mapOf("Send Token" to sendToken, "Receive Token" to receiveToken),
)
data class ButtonGivePermissionClicked(
class ButtonGivePermissionClicked(
val sendToken: String,
val receiveToken: String,
val provider: SwapProvider,
@ -69,7 +91,7 @@ sealed class SwapEvents(
),
)
data class ButtonPermissionApproveClicked(
class ButtonPermissionApproveClicked(
val sendToken: String,
val receiveToken: String,
val approveType: ApproveType,
@ -88,8 +110,8 @@ sealed class SwapEvents(
class ButtonSwipeClicked : SwapEvents(event = "Button - Swipe")
@Suppress("NullableToStringCall")
data class SwapInProgressScreen(
@Suppress("NullableToStringCall", "LongParameterList")
class SwapInProgressScreen(
val provider: SwapProvider,
val commission: FeeType, // Market / Fast
val sendBlockchain: String,
@ -118,24 +140,39 @@ sealed class SwapEvents(
class ProviderClicked : SwapEvents("Provider Clicked")
data class ProviderChosen(val provider: SwapProvider) : SwapEvents(
class ProviderChosen(val provider: SwapProvider) : SwapEvents(
event = "Provider Chosen",
params = mapOf("Provider" to provider.name),
)
data class ButtonStatus(val token: String) : SwapEvents(
class ButtonStatus(val token: String) : SwapEvents(
event = "Button - Status",
params = mapOf("Token" to token),
)
data class ButtonExplore(val token: String) : SwapEvents(
class ButtonExplore(val token: String) : SwapEvents(
event = "Button - Explore",
params = mapOf("Token" to token),
)
class NoticeNoAvailableTokensToSwap : SwapEvents("Notice - No Available Tokens To Swap")
data class NoticeNotEnoughFee(val token: String, val blockchain: String) : SwapEvents(
class NoticeUnavailableToSwapPair(
val sendToken: String,
val receiveToken: String,
val sendBlockchain: String,
val receiveBlockchain: String,
) : SwapEvents(
event = "Notice - Unavailable To Swap Pair",
params = mapOf(
SEND_TOKEN to sendToken,
RECEIVE_TOKEN to receiveToken,
"Send Blockchain" to sendBlockchain,
"Receive Blockchain" to receiveBlockchain,
),
)
class NoticeNotEnoughFee(val token: String, val blockchain: String) : SwapEvents(
event = "Notice - Not Enough Fee",
params = mapOf(
"Token" to token,
@ -143,7 +180,7 @@ sealed class SwapEvents(
),
)
data class NoticeProviderError(
class NoticeProviderError(
val sendToken: String,
val receiveToken: String,
val provider: SwapProvider,
@ -162,7 +199,7 @@ sealed class SwapEvents(
// TODO parameters
// region Promo activity
data class ChangellyActivity(
class ChangellyActivity(
val promoState: PromoState,
) : AnalyticsEvent(
category = PROMO_CATEGORY,
@ -177,7 +214,7 @@ sealed class SwapEvents(
}
}
data class NoticePermissionNeeded(
class NoticePermissionNeeded(
val sendToken: String,
val receiveToken: String,
val provider: SwapProvider,

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
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
@ -44,6 +45,7 @@ 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.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
@ -88,7 +90,7 @@ import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.market.SwapMarketsListBatchFlowManager
import com.tangem.feature.swap.models.market.state.SwapMarketState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import com.tangem.core.ui.R
import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.router.SwapRouter
import com.tangem.feature.swap.ui.StateBuilder
@ -105,6 +107,7 @@ import com.tangem.utils.Provider
import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP
import com.tangem.utils.coroutines.*
import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
@ -120,6 +123,7 @@ import javax.inject.Inject
typealias SuccessLoadedSwapData = Map<SwapProvider, SwapState.QuotesLoadedState>
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongParameterList", "LargeClass")
@Stable
@ModelScoped
@ -154,7 +158,7 @@ internal class SwapModel @Inject constructor(
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val sendFeatureToggles: SendFeatureToggles,
private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
swapFeatureToggles: SwapFeatureToggles,
private val swapFeatureToggles: SwapFeatureToggles,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val excludedBlockchains: ExcludedBlockchains,
private val getUserWalletsUseCase: GetWalletsUseCase,
@ -258,10 +262,26 @@ internal class SwapModel @Inject constructor(
private val searchQueryState = MutableStateFlow("")
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private var latestMarketsState: SwapMarketState? = null
private val defaultMarketsListManager by lazy {
SwapMarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
order = TokenMarketListConfig.Order.Trending,
currentAppCurrency = Provider { selectedAppCurrencyFlow.value },
currentSearchText = Provider { null },
modelScope = modelScope,
dispatchers = dispatchers,
)
}
private val searchMarketsListManager by lazy {
SwapMarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
order = TokenMarketListConfig.Order.ByRating,
currentAppCurrency = Provider { selectedAppCurrencyFlow.value },
currentSearchText = Provider { searchQueryState.value },
modelScope = modelScope,
@ -279,13 +299,21 @@ internal class SwapModel @Inject constructor(
override fun onSuccess(addedToken: CryptoCurrency) {
modelScope.launch {
bottomSheetNavigation.dismiss()
uiState.selectTokenState?.let { currentSelectState ->
uiState = uiState.copy(
selectTokenState = currentSelectState.copy(
marketsState = null,
),
)
}
analyticsEventHandler.send(
SwapEvents.ChooseTokenScreenResult(
isTokenChosen = true,
token = addedToken.symbol,
source = SwapEvents.ChooseTokenScreenResult.SOURCE_MARKETS,
isSearched = searchQueryState.value.isNotEmpty(),
),
)
analyticsEventHandler.send(
SwapEvents.TokenAdded(
token = addedToken.symbol,
blockchain = addedToken.network.name,
),
)
searchQueryState.value = ""
getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken)
.firstOrNull {
it.status.value is CryptoCurrencyStatus.Loaded
@ -362,50 +390,7 @@ internal class SwapModel @Inject constructor(
}
.launchIn(modelScope)
if (swapFeatureToggles.isMarketListFeatureEnabled) {
combine(
flow = searchQueryState
.onEach { searchQuery ->
searchMarketsListManager.reload(searchQuery)
},
flow2 = searchMarketsListManager.uiItems,
flow3 = searchMarketsListManager.isInInitialLoadingErrorState,
flow4 = searchMarketsListManager.isSearchNotFoundState,
flow5 = searchMarketsListManager.totalCount.filterNotNull(),
) { searchQuery, uiItems, isError, isSearchNotFound, total ->
when {
searchQuery.isEmpty() -> {
visibleMarketItemIds.value = emptyList()
null
}
isError -> SwapMarketState.LoadingError(
onRetryClicked = { searchMarketsListManager.reload(searchQuery) },
)
isSearchNotFound -> SwapMarketState.SearchNothingFound
uiItems.isEmpty() -> SwapMarketState.Loading
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { searchMarketsListManager.loadMore() },
onItemClick = { item ->
addToPortfolioItem(item)
},
visibleIdsChanged = { visibleMarketItemIds.value = it },
total = total,
)
}
}
.distinctUntilChanged()
.onEach { marketsState ->
uiState.selectTokenState?.let { currentSelectState ->
uiState = uiState.copy(
selectTokenState = currentSelectState.copy(
marketsState = marketsState,
),
)
}
}
.launchIn(modelScope)
}
subscribeMarketTokens()
modelScope.launch {
visibleMarketItemIds.mapNotNull { rawIDS ->
@ -418,6 +403,18 @@ internal class SwapModel @Inject constructor(
searchMarketsListManager.loadCharts(visibleBatchKeys)
}
}
modelScope.launch {
visibleDefaultMarketItemIds.mapNotNull { rawIds ->
if (rawIds.isNotEmpty()) {
defaultMarketsListManager.getBatchKeysByItemIds(rawIds)
} else {
null
}
}.distinctUntilChanged().collectLatest { visibleBatchKeys ->
defaultMarketsListManager.loadCharts(visibleBatchKeys)
}
}
}
fun onStart() {
@ -443,6 +440,41 @@ internal class SwapModel @Inject constructor(
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(hasAvailableTokens = isAnyAvailableTokens))
}
private fun subscribeMarketTokens() {
if (swapFeatureToggles.isMarketListFeatureEnabled) {
// Switch between default and search market flows
searchQueryState
.map { it.isEmpty() }
.distinctUntilChanged()
.flatMapLatest { isDefaultMode ->
if (isDefaultMode) {
visibleMarketItemIds.value = emptyList()
createDefaultMarketsFlow()
} else {
visibleDefaultMarketItemIds.value = emptyList()
createSearchMarketsFlow()
}
}
.onEach { marketsState ->
latestMarketsState = marketsState
applyMarketsState(marketsState)
}
.launchIn(modelScope)
// Reload search markets when query changes
searchQueryState
.onEach { searchQuery ->
if (searchQuery.isNotEmpty()) {
searchMarketsListManager.reload(searchQuery)
}
}
.launchIn(modelScope)
// Initial load of default markets
defaultMarketsListManager.reload()
}
}
@Suppress("LongMethod")
private fun initTokens(isReverseFromTo: Boolean) {
modelScope.launch(dispatchers.main) {
@ -613,6 +645,25 @@ internal class SwapModel @Inject constructor(
toAccount = toAccount,
tokensDataState = state,
)
if (!isTokenAvailableForSwap(state, selectedCurrency, isReverseFromTo)) {
analyticsEventHandler.send(
SwapEvents.NoticeUnavailableToSwapPair(
sendToken = fromCurrencyStatus.currency.symbol,
receiveToken = toCurrencyStatus.currency.symbol,
sendBlockchain = fromCurrencyStatus.currency.network.name,
receiveBlockchain = toCurrencyStatus.currency.network.name,
),
)
uiState = stateBuilder.createSwapNotSupportedState(
uiStateHolder = uiState,
fromToken = fromCurrencyStatus,
toToken = toCurrencyStatus,
toAccount = toAccount,
)
return
}
startLoadingQuotes(
fromToken = fromCurrencyStatus,
fromAccount = fromAccount,
@ -640,6 +691,17 @@ internal class SwapModel @Inject constructor(
fromToken = dataState.fromCryptoCurrency?.currency ?: initialCurrencyFrom,
)
}
latestMarketsState?.let(::applyMarketsState)
}
private fun applyMarketsState(marketsState: SwapMarketState) {
uiState.selectTokenState?.let { currentSelectState ->
uiState = uiState.copy(
selectTokenState = currentSelectState.copy(
marketsState = marketsState,
),
)
}
}
private fun startLoadingQuotes(
@ -1275,7 +1337,14 @@ internal class SwapModel @Inject constructor(
val (foundToken, foundAccount) = getSelectedTokenAndAccount(tokens, id)
foundToken?.currency?.symbol?.let { symbol ->
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = symbol))
analyticsEventHandler.send(
SwapEvents.ChooseTokenScreenResult(
isTokenChosen = true,
token = symbol,
source = SwapEvents.ChooseTokenScreenResult.SOURCE_PORTFOLIO,
isSearched = searchQueryState.value.isNotEmpty(),
),
)
}
if (foundToken != null) {
@ -1871,6 +1940,28 @@ internal class SwapModel @Inject constructor(
.orEmpty()
}
private fun isTokenAvailableForSwap(
state: TokensDataStateExpress,
selectedCurrency: CryptoCurrencyStatus,
isReverseFromTo: Boolean,
): Boolean {
val group = if (isReverseFromTo) state.fromGroup else state.toGroup
val idToFind = selectedCurrency.currency.id.value
return if (accountsFeatureToggles.isFeatureEnabled) {
group.accountCurrencyList.any { (_, currencyList) ->
currencyList.any { accountSwapCurrency ->
idToFind == accountSwapCurrency.cryptoCurrencyStatus.currency.id.value &&
accountSwapCurrency.isAvailable
}
}
} else {
group.available.any { swapAvailability ->
idToFind == swapAvailability.currencyStatus.currency.id.value
}
}
}
private fun List<SwapProvider>.filterForTangemPayWithdrawal(): List<SwapProvider> {
return if (tangemPayInput?.isWithdrawal == true) {
filter { it.type == ExchangeProviderType.CEX }
@ -2058,9 +2149,73 @@ internal class SwapModel @Inject constructor(
}
}
private fun createDefaultMarketsFlow(): Flow<SwapMarketState> {
val marketsTitle = TextReference.Res(R.string.feed_trending_now)
return combine(
defaultMarketsListManager.uiItems,
defaultMarketsListManager.isInInitialLoadingErrorState,
defaultMarketsListManager.totalCount,
) { uiItems, isError, total ->
when {
isError -> SwapMarketState.LoadingError(
onRetryClicked = { defaultMarketsListManager.reload() },
marketsTitle = marketsTitle,
shouldAssetsCount = false,
)
uiItems.isEmpty() -> SwapMarketState.Loading(
marketsTitle = marketsTitle,
shouldAssetsCount = false,
)
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { defaultMarketsListManager.loadMore() },
onItemClick = { item -> addToPortfolioItem(item) },
visibleIdsChanged = { visibleDefaultMarketItemIds.value = it },
total = total ?: uiItems.size,
marketsTitle = marketsTitle,
shouldAssetsCount = false,
)
}
}
}
private fun createSearchMarketsFlow(): Flow<SwapMarketState> {
val marketsTitle = TextReference.Res(R.string.markets_common_title)
return combine(
flow = searchMarketsListManager.uiItems,
flow2 = searchMarketsListManager.isInInitialLoadingErrorState,
flow3 = searchMarketsListManager.isSearchNotFoundState,
flow4 = searchMarketsListManager.totalCount,
) { uiItems, isError, isSearchNotFound, total ->
when {
isError -> SwapMarketState.LoadingError(
onRetryClicked = { searchMarketsListManager.reload(searchQueryState.value) },
marketsTitle = marketsTitle,
shouldAssetsCount = true,
)
isSearchNotFound -> SwapMarketState.SearchNothingFound
uiItems.isEmpty() -> SwapMarketState.Loading(
marketsTitle = marketsTitle,
shouldAssetsCount = true,
)
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { searchMarketsListManager.loadMore() },
onItemClick = { item -> addToPortfolioItem(item) },
visibleIdsChanged = { visibleMarketItemIds.value = it },
total = total ?: uiItems.size,
marketsTitle = marketsTitle,
shouldAssetsCount = true,
)
}
}
}
private fun addToPortfolioItem(item: MarketsListItemUM) {
modelScope.launch {
val tokenMarket = searchMarketsListManager.getTokenMarketById(item.id) ?: return@launch
val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id)
?: return@launch
val param = tokenMarket.toSerializableParam()
val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot }

View file

@ -64,6 +64,12 @@ internal class SwapNotificationsFactory(
)
}
fun getSwapNotSupportedNotifications(tokenName: String): ImmutableList<NotificationUM> {
return persistentListOf(
SwapNotificationUM.Warning.SwapNotSupported(tokenName),
)
}
fun getQuotesErrorStateNotifications(
expressDataError: ExpressDataError,
fromToken: CryptoCurrency,

View file

@ -22,12 +22,14 @@ import kotlinx.coroutines.flow.*
internal class SwapMarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
private val order: TokenMarketListConfig.Order,
private val currentAppCurrency: Provider<AppCurrency>,
private val currentSearchText: Provider<String?>,
private val modelScope: CoroutineScope,
private val dispatchers: CoroutineDispatcherProvider,
) {
private val actionsFlow = MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>()
private val actionsFlow =
MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>(replay = 1)
private val updateStateJob = JobHolder()
private val batchFlow = getMarketsTokenListFlowUseCase(
@ -183,7 +185,7 @@ internal class SwapMarketsListBatchFlowManager(
searchText ?: currentSearchText()
},
priceChangeInterval = TokenMarketListConfig.Interval.H24,
order = TokenMarketListConfig.Order.ByRating,
order = order,
shouldNetworks = true,
),
),

View file

@ -2,25 +2,40 @@ package com.tangem.feature.swap.models.market.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.core.ui.R
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class SwapMarketState {
abstract val marketsTitle: TextReference
abstract val shouldAssetsCount: Boolean
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val total: Int,
val loadMore: () -> Unit,
val onItemClick: (MarketsListItemUM) -> Unit,
val visibleIdsChanged: (List<CryptoCurrency.RawID>) -> Unit,
override val marketsTitle: TextReference,
override val shouldAssetsCount: Boolean,
) : SwapMarketState()
data object Loading : SwapMarketState()
data class Loading(
override val marketsTitle: TextReference,
override val shouldAssetsCount: Boolean,
) : SwapMarketState()
data class LoadingError(
val onRetryClicked: () -> Unit,
override val marketsTitle: TextReference,
override val shouldAssetsCount: Boolean,
) : SwapMarketState()
data object SearchNothingFound : SwapMarketState()
data object SearchNothingFound : SwapMarketState() {
override val marketsTitle: TextReference = TextReference.Res(R.string.markets_common_title)
override val shouldAssetsCount: Boolean = true
}
}

View file

@ -132,6 +132,18 @@ internal object SwapNotificationUM {
),
)
data class SwapNotSupported(
val tokenName: String,
) : Warning(
title = resourceReference(
id = com.tangem.feature.swap.presentation.R.string.express_swap_not_supported_title,
formatArgs = wrappedList(tokenName),
),
subtitle = resourceReference(
com.tangem.feature.swap.presentation.R.string.express_swap_not_supported_text,
),
)
data class NeedReserveToCreateAccount(
val receiveAmount: String,
val receiveToken: String,

View file

@ -200,6 +200,60 @@ internal class StateBuilder(
)
}
fun createSwapNotSupportedState(
uiStateHolder: SwapStateHolder,
fromToken: CryptoCurrencyStatus,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
return uiStateHolder.copy(
sendCardData = SwapCardState.SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable),
amountTextFieldValue = null,
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
token = fromToken,
tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl,
coinId = uiStateHolder.sendCardData.coinId,
isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken,
tokenCurrency = uiStateHolder.sendCardData.tokenCurrency,
canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken,
balance = fromToken.getFormattedAmount(isNeedSymbol = false),
networkIconRes = getActiveIconRes(fromToken.currency.network.rawId),
isBalanceHidden = isBalanceHiddenProvider(),
),
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(
accountTitleUM = getToCardAccountTitle(toAccount),
),
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
token = toToken,
tokenIconUrl = toToken.currency.iconUrl,
coinId = toToken.currency.network.backendId,
isNotNativeToken = toToken.currency is CryptoCurrency.Token,
tokenCurrency = toToken.currency.symbol,
canSelectAnotherToken = true,
balance = toToken.getFormattedAmount(isNeedSymbol = false),
networkIconRes = getActiveIconRes(toToken.currency.network.rawId),
isBalanceHidden = isBalanceHiddenProvider(),
),
notifications = notificationsFactory.getSwapNotSupportedNotifications(toToken.currency.name),
fee = FeeItemState.Empty,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
isEnabled = false,
isHoldToConfirm = isHoldToConfirmEnabled,
onClick = { },
),
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
providerState = ProviderState.Empty(),
priceImpact = PriceImpact.Empty(),
)
}
@Suppress("LongParameterList")
fun createQuotesLoadingState(
uiStateHolder: SwapStateHolder,
@ -438,6 +492,7 @@ internal class StateBuilder(
notification is SwapNotificationUM.Warning.ExpressError ||
notification is SwapNotificationUM.Warning.ExpressGeneralError ||
notification is SwapNotificationUM.Warning.NoAvailableTokensToSwap ||
notification is SwapNotificationUM.Warning.SwapNotSupported ||
notification is SwapNotificationUM.Warning.NeedReserveToCreateAccount ||
notification is SwapNotificationUM.Info.PermissionNeeded
}

View file

@ -189,7 +189,7 @@ private fun ListOfTokensWithMarkets(
state = lazyListState,
) {
if (state.tokensListData !is TokenListUMData.EmptyList) {
assetsTitle(count = state.tokensListData.totalTokensCount)
assetsTitle(count = state.tokensListData.totalTokensCount, showCount = marketsState.shouldAssetsCount)
}
tokensListItems(
@ -197,10 +197,18 @@ private fun ListOfTokensWithMarkets(
isBalanceHidden = state.isBalanceHidden,
)
if (state.tokensListData is TokenListUMData.EmptyList) {
tokensToSelectItems(state.availableTokens, state.onTokenSelected)
if (state.unavailableTokens.isNotEmpty()) {
item { SpacerH12() }
} else {
tokensToSelectItems(state.unavailableTokens, state.onTokenSelected)
}
val hasPortfolioContent = state.tokensListData !is TokenListUMData.EmptyList ||
state.availableTokens.isNotEmpty()
if (hasPortfolioContent) {
item { SpacerH32() }
} else {
item { SpacerH12() }
}
swapMarketsListItems(marketsState)
@ -242,13 +250,15 @@ private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapM
}
}
private fun LazyListScope.assetsTitle(count: Int) {
private fun LazyListScope.assetsTitle(count: Int, showCount: Boolean) {
item(key = "assets_title") {
Text(
text = buildAnnotatedString {
append(stringResourceSafe(R.string.swap_your_assets_title))
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(" $count")
if (showCount) {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(" $count")
}
}
},
style = TangemTheme.typography.h3,

View file

@ -14,6 +14,7 @@ import com.tangem.common.ui.markets.MarketsListItem
import com.tangem.common.ui.markets.MarketsListItemPlaceholder
import com.tangem.core.ui.components.UnableToLoadData
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.swap.models.market.state.SwapMarketState
@ -24,7 +25,7 @@ internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) {
val totalCount = (state as? SwapMarketState.Content)?.total
Text(
text = buildAnnotatedString {
append(stringResourceSafe(R.string.markets_common_title))
append(state.marketsTitle.resolveReference())
if (totalCount != null) {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(" $totalCount")

View file

@ -4,8 +4,10 @@ import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.core.ui.R
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenBalanceData
import com.tangem.feature.swap.models.TokenListUMData
@ -35,6 +37,8 @@ internal class SwapSelectTokenPreviewProvider {
onItemClick = { },
visibleIdsChanged = { },
total = TOTAL_ITEMS,
marketsTitle = TextReference.Res(R.string.feed_trending_now),
shouldAssetsCount = false,
)
private fun createPreviewMarketItems() = listOf(