Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-06 12:26:33 +04:00
parent 7a197ec6c9
commit c662136303
26 changed files with 906 additions and 346 deletions

View file

@ -37,6 +37,7 @@ dependencies {
implementation(projects.domain.card)
implementation(projects.domain.staking.models)
implementation(projects.domain.staking)
implementation(projects.domain.account)
implementation(projects.domain.tokens.models)
implementation(projects.domain.transaction.models)
implementation(projects.domain.wallets.models)

View file

@ -1,13 +1,14 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers
package com.tangem.common.ui.tokens
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.tokenlist.TokenList
sealed interface TokenConverterParams {
/** Wallet mode; list of tokens for main account */
data class Wallet(
val accountId: AccountId,
val mainAccount: AccountStatus,
val tokenList: TokenList,
) : TokenConverterParams

View file

@ -0,0 +1,53 @@
package com.tangem.common.ui.tokens
import com.tangem.common.ui.R
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.models.tokenlist.TokenList.GroupedByNetwork.NetworkGroup
object TokenItemGrouping {
fun TokenList.GroupedByNetwork.toGroupedItems(tokenConverter: TokenItemStateConverter): List<TokensListItemUM> {
return groups.fold(initial = mutableListOf()) { acc, group ->
acc.addGroup(tokenConverter, group)
}
}
fun TokenList.Ungrouped.toUngroupedItems(tokenConverter: TokenItemStateConverter): List<TokensListItemUM> {
return currencies.fold(initial = mutableListOf()) { acc, token ->
acc.addToken(tokenConverter, token)
}
}
fun MutableList<TokensListItemUM>.addGroup(
tokenConverter: TokenItemStateConverter,
group: NetworkGroup,
): MutableList<TokensListItemUM> {
val groupTitle = TokensListItemUM.GroupTitle(
id = group.network.hashCode(),
text = resourceReference(
id = R.string.wallet_network_group_title,
formatArgs = wrappedList(group.network.name),
),
)
add(groupTitle)
group.currencies.forEach { token -> addToken(tokenConverter, token) }
return this
}
fun MutableList<TokensListItemUM>.addToken(
tokenConverter: TokenItemStateConverter,
token: CryptoCurrencyStatus,
): MutableList<TokensListItemUM> {
val tokenItemState = tokenConverter.convert(token)
add(TokensListItemUM.Token(tokenItemState))
return this
}
}

View file

@ -1,11 +1,10 @@
package com.tangem.utils.extensions
fun <K, V, R> Map<K, V>.mapNotNullValues(transform: (Map.Entry<K, V>) -> R?): Map<K, R> {
return this
.mapNotNull { entry ->
val newValue = transform(entry) ?: return@mapNotNull null
entry.key to newValue
}
.toMap()
inline fun <K, V, R> Map<K, V>.mapNotNullValues(transform: (Map.Entry<K, V>) -> R?): Map<K, R> {
val result = linkedMapOf<K, R>()
this.forEach { entry ->
val newValue = transform(entry) ?: return@forEach
result[entry.key] = newValue
}
return result
}

View file

@ -3,7 +3,9 @@ package com.tangem.domain.account.status.supplier
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.producer.MultiAccountStatusListProducer
import com.tangem.domain.core.flow.FlowCachingSupplier
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Supplier that provides a list of [AccountStatusList]s for all user wallets.
@ -18,4 +20,12 @@ abstract class MultiAccountStatusListSupplier(
operator fun invoke(): Flow<List<AccountStatusList>> {
return super.invoke(params = Unit)
}
fun invokeAsMap(): Flow<LinkedHashMap<UserWalletId, AccountStatusList>> = invoke()
.map { accountLists ->
accountLists.associateByTo(
destination = linkedMapOf(),
keySelector = { accountList -> accountList.userWalletId },
)
}
}

View file

@ -1,6 +1,5 @@
package com.tangem.feature.wallet.presentation.account
package com.tangem.domain.account.status.utils
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.account.models.AccountExpandedState
import com.tangem.domain.account.models.AccountList
import com.tangem.domain.account.repository.AccountsExpandedRepository
@ -16,9 +15,11 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@ModelScoped
internal class ExpandedAccountsHolder @Inject constructor(
// todo swap separate for main and swap
@Singleton
class ExpandedAccountsHolder @Inject constructor(
private val singleAccountListSupplier: SingleAccountListSupplier,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val accountsExpandedRepository: AccountsExpandedRepository,
@ -30,9 +31,9 @@ internal class ExpandedAccountsHolder @Inject constructor(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
fun expandedAccounts(userWallet: UserWallet): Flow<Set<AccountId>> = channelFlow {
val walletId = userWallet.walletId
fun expandedAccounts(userWallet: UserWallet): Flow<Set<AccountId>> = expandedAccounts(userWallet.walletId)
fun expandedAccounts(walletId: UserWalletId): Flow<Set<AccountId>> = channelFlow {
val storedState = accountsExpandedRepository.expandedAccounts
.map { it[walletId].orEmpty() }
.stateIn(this)
@ -65,7 +66,7 @@ internal class ExpandedAccountsHolder @Inject constructor(
walletAccounts(walletId).onEach { accountList ->
if (!isAccountsModeEnabledUseCase.invokeSync()) {
accountsExpandedRepository.clearStore()
expandedAccounts.update { setOf() }
expandedAccounts.update { emptySet() }
return@onEach
}
val idsSet = accountList.accounts.mapTo(mutableSetOf()) { it.accountId }
@ -88,7 +89,7 @@ internal class ExpandedAccountsHolder @Inject constructor(
if (isAccountMode) {
channel.send(expanded)
} else {
channel.send(setOf())
channel.send(emptySet())
}
},
).collect()

View file

@ -2,8 +2,10 @@ package com.tangem.domain.wallets.usecase
import com.tangem.domain.common.wallets.UserWalletsListRepository
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.util.LinkedHashMap
/**
* Use case for getting list of user wallets
@ -19,6 +21,16 @@ class GetWalletsUseCase(
@Throws(IllegalArgumentException::class)
operator fun invoke(): Flow<List<UserWallet>> = userWalletsListRepository.userWallets.map { requireNotNull(it) }
@Throws(IllegalArgumentException::class)
fun invokeAsMap(): Flow<LinkedHashMap<UserWalletId, UserWallet>> = userWalletsListRepository.userWallets
.map { requireNotNull(it) }
.map { wallets ->
wallets.associateByTo(
destination = linkedMapOf(),
keySelector = { wallet -> wallet.walletId },
)
}
@Throws(IllegalArgumentException::class)
fun invokeSync(): List<UserWallet> = userWalletsListRepository.userWallets.value!!
}

View file

@ -14,6 +14,7 @@ interface AddToPortfolioComponent : ComposableBottomSheetComponent {
interface Callback {
fun onDismiss()
// todo swap add new onSuccess with full data of added token
fun onSuccess(addedToken: CryptoCurrency)
}

View file

@ -32,6 +32,7 @@ dependencies {
/** Domain modules **/
implementation(projects.domain.models)
implementation(projects.domain.account)
implementation(projects.domain.appCurrency)
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.balanceHiding)

View file

@ -12,6 +12,7 @@ import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
@ -21,6 +22,7 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload
import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.model.SwapModel
@ -52,7 +54,13 @@ internal class DefaultSwapComponent @AssistedInject constructor(
private val chooseTokenComponent by lazy {
chooseTokenComponentFactory.create(
context = child("chooseTokenComponent"),
params = ChooseTokenComponent.Params(model.chooseTokenBridge),
params = ChooseTokenComponent.Params(
bridge = model.chooseTokenBridge,
settings = ChooseTokenComponent.Settings.SwapTo,
analyticsPayload = setOf(
ChooseTokenAnalyticsPayload.ScreensSources(ScreensSources.Swap.value),
),
),
)
}

View file

@ -2,12 +2,14 @@ package com.tangem.feature.swap.choosetoken.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
import com.tangem.domain.models.account.Account
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup
import com.tangem.feature.swap.presentation.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
@ -56,13 +58,12 @@ internal interface ChooseTokenBridge {
}
data class ChooseTokenResult(
val addedCurrency: AccountCryptoCurrencyStatus,
val userWallet: UserWallet,
val currency: CryptoCurrencyStatus,
val account: AccountStatus,
val wallet: UserWallet,
val analyticsPayload: Set<ChooseTokenAnalyticsPayload> = emptySet(),
) {
val status: CryptoCurrencyStatus get() = addedCurrency.status
val account: Account.CryptoPortfolio get() = addedCurrency.account
val walletId get() = userWallet.walletId
val walletId get() = wallet.walletId
}
sealed interface ChooseTokenAnalyticsPayload {
@ -70,13 +71,34 @@ sealed interface ChooseTokenAnalyticsPayload {
@Suppress("BooleanPropertyNaming")
@JvmInline
value class IsSearched(val value: Boolean) : ChooseTokenAnalyticsPayload
@JvmInline
value class ScreensSources(val value: String) : ChooseTokenAnalyticsPayload
}
internal interface ChooseTokenComponent : ComposableContentComponent {
data class Params(
val bridge: ChooseTokenBridge,
val settings: Settings,
val analyticsPayload: Set<ChooseTokenAnalyticsPayload> = emptySet(),
)
data class Settings(
val title: TextReference,
val isShowMarketBlock: Boolean,
) {
companion object {
val SwapFrom = Settings(
title = resourceReference(R.string.swapping_from_title),
isShowMarketBlock = false,
)
val SwapTo = Settings(
title = resourceReference(R.string.swapping_to_title),
isShowMarketBlock = true,
)
}
}
interface Factory : ComponentFactory<Params, ChooseTokenComponent>
}

View file

@ -38,11 +38,14 @@ internal class DefaultChooseTokenComponent @AssistedInject constructor(
@Composable
override fun Content(modifier: Modifier) {
val state by model.state.collectAsStateWithLifecycle()
val stateOld by model.stateOld.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
state?.let { stateHolder ->
stateOld?.let { stateHolder ->
SwapSelectTokenScreen(state = stateHolder, onBack = { model.onBackClicked() })
}
// todo swap uncomment
// val state by model.state.collectAsStateWithLifecycle()
// ChooseTokenScreen(state = state)
bottomSheet.child?.instance?.BottomSheet()
}

View file

@ -0,0 +1,140 @@
package com.tangem.feature.swap.choosetoken.impl.converter
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
import com.tangem.common.ui.account.TokensListPortfolioItemConverter
import com.tangem.common.ui.tokens.TokenConverterParams
import com.tangem.common.ui.tokens.TokenItemGrouping.toGroupedItems
import com.tangem.common.ui.tokens.TokenItemGrouping.toUngroupedItems
import com.tangem.common.ui.tokens.TokenItemStateConverter
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.domain.appcurrency.model.AppCurrency
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.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.feature.swap.choosetoken.impl.model.ClickIntents
import com.tangem.feature.swap.choosetoken.impl.model.isSearchingState
import com.tangem.feature.swap.models.TokenListUMData
import kotlinx.collections.immutable.toPersistentList
internal class ChooseTokenListItemConverter(
private val appCurrency: AppCurrency,
private val params: TokenConverterParams,
private val clickIntents: ClickIntents,
private val searchQuery: String,
) {
private val isSearchingState: Boolean get() = searchQuery.isSearchingState
private val onTokenClick: (account: AccountStatus, currencyStatus: CryptoCurrencyStatus) -> Unit =
{ account, currencyStatus ->
clickIntents.onTokenItemClick(account, currencyStatus)
}
private fun tokenStatusConverter(account: AccountStatus) = TokenItemStateConverter(
appCurrency = appCurrency,
onItemClick = { _, status -> onTokenClick(account, status) },
)
fun convert(): TokenListUMData {
return when (params) {
is TokenConverterParams.Account -> convertAccountList(params)
is TokenConverterParams.Wallet -> convertTokenList(
tokenConverter = tokenStatusConverter(params.mainAccount),
tokenListParam = params.tokenList,
)
}
}
private fun convertAccountList(params: TokenConverterParams.Account): TokenListUMData {
val accountList = params.accountList
val accountItems = accountList.accountStatuses
.filterCryptoPortfolio()
.map { accountStatus -> accountStatus.toPortfolioItem(params) }
.filter { portfolio -> portfolio.tokens.isNotEmpty() }
if (accountItems.isEmpty()) {
return TokenListUMData.EmptyList
}
val accountsList = accountItems.toPersistentList()
return TokenListUMData.AccountList(
tokensList = accountsList,
totalTokensCount = accountsList.size,
)
}
private fun AccountStatus.CryptoPortfolio.toPortfolioItem(
params: TokenConverterParams.Account,
): TokensListItemUM.Portfolio {
val tokenList: TokenList = this.tokenList
val account: Account.CryptoPortfolio = this.account
val isExpanded = isSearchingState || params.expandedAccounts.contains(account.accountId)
val onItemClick: (Account.CryptoPortfolio) -> Unit = { clickedAccount ->
if (isExpanded) {
clickIntents.onAccountCollapseClick(clickedAccount)
} else {
clickIntents.onAccountExpandClick(clickedAccount)
}
}
val converter = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = account,
onItemClick = onItemClick.takeIf { !isSearchingState },
priceChangeLce = this.priceChangeLce,
)
val accountItem = converter.convert(tokenList.totalFiatBalance)
val tokenConverter = tokenStatusConverter(this)
val tokensListState = convertTokenList(tokenConverter, tokenList)
val items = tokensListState.tokensList
return TokensListPortfolioItemConverter(
tokenItemUM = accountItem,
isExpanded = isExpanded,
isCollapsable = !isSearchingState,
tokens = items.filterIsInstance<PortfolioTokensListItemUM>().toPersistentList(),
).convert(Unit)
}
private fun convertTokenList(tokenConverter: TokenItemStateConverter, tokenListParam: TokenList): TokenListUMData {
val tokenList = if (isSearchingState) filterByQuery(tokenListParam) else tokenListParam
return when (tokenList) {
is TokenList.Empty -> TokenListUMData.EmptyList
is TokenList.GroupedByNetwork -> tokenList.toGroupedItems(tokenConverter).let { grouped ->
TokenListUMData.TokenList(
tokensList = grouped.toPersistentList(),
totalTokensCount = grouped.size,
)
}
is TokenList.Ungrouped -> tokenList.toUngroupedItems(tokenConverter).let { ungrouped ->
TokenListUMData.TokenList(
tokensList = ungrouped.toPersistentList(),
totalTokensCount = ungrouped.size,
)
}
}
}
private fun filterByQuery(tokenList: TokenList): TokenList {
fun List<CryptoCurrencyStatus>.filterByQuery(): List<CryptoCurrencyStatus> = filter { currency ->
currency.currency.name.contains(searchQuery, ignoreCase = true) ||
currency.currency.symbol.contains(searchQuery, ignoreCase = true)
}
return when (tokenList) {
TokenList.Empty -> TokenList.Empty
is TokenList.Ungrouped -> {
val filtered = tokenList.currencies.filterByQuery()
if (filtered.isEmpty()) TokenList.Empty else tokenList.copy(currencies = filtered)
}
is TokenList.GroupedByNetwork -> {
val filteredGroups = tokenList.groups
.map { group ->
val filteredCurrencies = group.currencies.filterByQuery()
group.copy(currencies = filteredCurrencies)
}
.filter { group -> group.currencies.isNotEmpty() }
if (filteredGroups.isEmpty()) TokenList.Empty else tokenList.copy(groups = filteredGroups)
}
}
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.feature.swap.choosetoken.impl.converter
import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM
import com.tangem.utils.transformer.Transformer
internal class SearchBarToggleTransformer(private val isActive: Boolean) : Transformer<ChooseTokenInitialUM> {
override fun transform(prevState: ChooseTokenInitialUM): ChooseTokenInitialUM {
return prevState.copy(
searchBar = prevState.searchBar.copy(isActive = isActive),
)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.feature.swap.choosetoken.impl.converter
import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM
import com.tangem.utils.transformer.Transformer
internal class SearchBarUpdateQueryTransformer(private val newQuery: String) : Transformer<ChooseTokenInitialUM> {
override fun transform(prevState: ChooseTokenInitialUM): ChooseTokenInitialUM {
return prevState.copy(
searchBar = prevState.searchBar.copy(query = newQuery),
)
}
}

View file

@ -1,112 +1,118 @@
package com.tangem.feature.swap.choosetoken.impl.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
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.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload
import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge
import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent
import com.tangem.feature.swap.choosetoken.api.SettingContextUseCase
import com.tangem.feature.swap.choosetoken.api.*
import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarToggleTransformer
import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarUpdateQueryTransformer
import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenFullUM
import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM
import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenUM
import com.tangem.feature.swap.choosetoken.impl.ui.WalletListUM
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.models.AddToPortfolioRoute
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.market.MarketsListBatchFlowManager
import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.feature.swap.models.market.state.SwapMarketState
import com.tangem.feature.swap.presentation.R
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.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
internal val String.isSearchingState: Boolean get() = this.isNotBlank()
internal val StateFlow<String>.isSearchingState: Boolean get() = this.value.isSearchingState
@Suppress("LongParameterList")
@ModelScoped
internal class ChooseTokenModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val excludedBlockchains: ExcludedBlockchains,
private val getUserWalletsUseCase: GetWalletsUseCase,
private val settingContextUseCase: SettingContextUseCase,
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory,
private val getWalletsUseCase: GetWalletsUseCase,
portfolioListBlockDelegateFactory: PortfolioListBlockDelegate.Factory,
marketBlockDelegateFactory: MarketBlockDelegate.Factory,
paramsContainer: ParamsContainer,
) : Model() {
private val params = paramsContainer.require<ChooseTokenComponent.Params>()
private val bridge: ChooseTokenBridge = params.bridge
private val searchQueryState = bridge.searchQueryState
private val addToPortfolioJobHolder = JobHolder()
private val searchQueryState: StateFlow<String> = bridge.searchQueryState
private val isSearchingState: Boolean get() = bridge.searchQueryState.isSearchingState
private val marketBlockDelegate: MarketBlockDelegate = marketBlockDelegateFactory.create(
modelScope = modelScope,
searchQueryState = searchQueryState,
screensSourcesName = params.analyticsPayload
.filterIsInstance<ChooseTokenAnalyticsPayload.ScreensSources>()
.firstOrNull()?.value.orEmpty(),
)
private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create(
modelScope = modelScope,
searchQueryState = searchQueryState,
)
val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot
val addToPortfolioManager get() = marketBlockDelegate.addToPortfolioManager
private val marketsStateFlow: Flow<SwapMarketState?> = if (params.settings.isShowMarketBlock) {
marketBlockDelegate.marketsStateFlow
} else {
flowOf(null)
}
private val expandedAccountsFlow: MutableStateFlow<Map<AccountId, Boolean>> = MutableStateFlow(emptyMap())
private val onWalletSelected = Channel<UserWalletId>()
var addToPortfolioManager: AddToPortfolioManager? = null
// todo swap call new api result
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
override fun onDismiss() = marketBlockDelegate.addToPortfolioSlot.dismiss()
override fun onSuccess(addedToken: CryptoCurrency) {
modelScope.launch {
val newToken = addedToken to ChooseTokenAnalyticsPayload
.IsSearched(searchQueryState.value.isNotEmpty())
bridge.onNewTokenAdded(newToken)
bottomSheetNavigation.dismiss()
}
val newToken = addedToken to ChooseTokenAnalyticsPayload.IsSearched(isSearchingState)
bridge.onNewTokenAdded(newToken)
marketBlockDelegate.addToPortfolioSlot.dismiss()
}
}
private val defaultMarketsListManager by lazy {
marketsListBatchFlowManagerFactory.create(
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
order = TokenMarketListConfig.Order.Trending,
currentSearchText = Provider { null },
modelScope = modelScope,
)
}
val stateOld: StateFlow<SwapSelectTokenStateHolder?> = combineUIOld()
private val searchMarketsListManager by lazy {
marketsListBatchFlowManagerFactory.create(
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
order = TokenMarketListConfig.Order.ByRating,
currentSearchText = Provider { searchQueryState.value },
modelScope = modelScope,
)
}
private val contentState: StateFlow<ChooseTokenUM?> = combineUI()
private val initialState: MutableStateFlow<ChooseTokenInitialUM> = MutableStateFlow(getInitState())
val state: StateFlow<ChooseTokenFullUM> = combine(
flow = initialState,
flow2 = contentState,
transform = { initial, content ->
ChooseTokenFullUM(
initialUM = initial,
contentUM = content,
)
},
).stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = ChooseTokenFullUM(initialState.value, contentState.value),
)
val state: StateFlow<SwapSelectTokenStateHolder?> = combineUI()
init {
subscribeMarketTokens()
}
private fun combineUI(): StateFlow<SwapSelectTokenStateHolder?> = combine(
@Suppress("UnusedPrivateMember")
private fun combineUIOld(): StateFlow<SwapSelectTokenStateHolder?> = combine(
flow = bridge.currenciesGroup,
flow2 = settingContextUseCase.invoke(),
flow3 = marketsStateFlow(),
flow3 = marketsStateFlow,
flow4 = expandedAccountsFlow,
transform = { currenciesGroup, settingContext, marketState, expandedAccounts ->
val isAccountsMode = settingContext.isAccountsMode
@ -117,7 +123,7 @@ internal class ChooseTokenModel @Inject constructor(
onSearchEntered = { query -> bridge.onSearchQuery(query) },
onTokenClick = { tokenId ->
val selected = tokenId to ChooseTokenAnalyticsPayload
.IsSearched(searchQueryState.value.isNotEmpty())
.IsSearched(isSearchingState)
bridge.onTokenSelected(selected)
},
onAccountClick = { account ->
@ -132,170 +138,124 @@ internal class ChooseTokenModel @Inject constructor(
isBalanceHidden = isBalanceHidden,
isAccountsMode = isAccountsMode,
appCurrency = appCurrency,
marketState = marketState,
marketState = requireNotNull(marketState),
).transform()
},
)
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = null)
private fun marketsStateFlow() = searchQueryState
// Switch between default and search market flows
.map { it.isEmpty() }
.distinctUntilChanged()
.flatMapLatest { isDefaultMode ->
if (isDefaultMode) {
visibleMarketItemIds.value = emptyList()
createDefaultMarketsFlow()
} else {
visibleDefaultMarketItemIds.value = emptyList()
createSearchMarketsFlow()
}
}
@Suppress("LongMethod")
private fun combineUI(): StateFlow<ChooseTokenUM?> = channelFlow {
val allWalletsFlow: StateFlow<LinkedHashMap<UserWalletId, UserWallet>> =
getWalletsUseCase.invokeAsMap().stateIn(this)
private fun subscribeMarketTokens() {
// Reload search markets when query changes
searchQueryState
.onEach { searchQuery ->
if (searchQuery.isNotEmpty()) {
searchMarketsListManager.reload(searchQuery)
}
}
.launchIn(modelScope)
val selectedWalletFlow: StateFlow<UserWallet> =
onWalletSelected.receiveAsFlow()
.mapNotNull { walletId -> allWalletsFlow.value[walletId] }
.stateIn(this, SharingStarted.Eagerly, allWalletsFlow.value.values.first())
// Initial load of default markets
defaultMarketsListManager.reload()
visibleMarketItemIds
.mapNotNull { rawIDS ->
if (rawIDS.isNotEmpty()) {
searchMarketsListManager.getBatchKeysByItemIds(rawIDS)
} else {
null
}
}
val selectedWalletTokensData: Flow<TokenListUMData> = combine(
flow = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(),
flow2 = portfolioListBlockDelegate.portfolioList,
transform = { selectedWalletId, allPortfoliosData -> allPortfoliosData[selectedWalletId] },
)
.filterNotNull()
.distinctUntilChanged()
.transformLatest<Set<Int>, Unit> { visibleBatchKeys ->
searchMarketsListManager.loadCharts(visibleBatchKeys)
}
.launchIn(modelScope)
visibleDefaultMarketItemIds
.mapNotNull { rawIds ->
if (rawIds.isNotEmpty()) {
defaultMarketsListManager.getBatchKeysByItemIds(rawIds)
val walletListUmFlow = combine(
flow = selectedWalletFlow,
flow2 = allWalletsFlow,
transform = { selectedWallet, allWallets ->
allWallets.entries
.map { (walletId, wallet) ->
val type = if (selectedWallet.walletId == walletId) {
TangemButtonType.Primary
} else {
TangemButtonType.Secondary
}
TangemButtonUM(
text = stringReference(wallet.name),
onClick = { onWalletSelected.trySend(walletId) },
type = type,
)
}
},
)
.distinctUntilChanged()
portfolioListBlockDelegate.onTokenItemClick.receiveAsFlow()
.onEach { (account, currencyStatus) ->
onTokenItemClick(
wallet = allWalletsFlow.value[account.accountId.userWalletId] ?: return@onEach,
account = account,
currencyStatus = currencyStatus,
)
}
.launchIn(this)
combine(
flow = selectedWalletTokensData,
flow2 = settingContextUseCase.invoke(),
flow3 = marketsStateFlow,
flow4 = walletListUmFlow,
transform = { tokensData, settings, marketsData, walletList ->
val walletsUM = if (walletList.size != 1) {
WalletListUM(walletList.toPersistentList())
} else {
null
WalletListUM(persistentListOf())
}
}.distinctUntilChanged()
.transformLatest<Set<Int>, Unit> { visibleBatchKeys ->
defaultMarketsListManager.loadCharts(visibleBatchKeys)
}
.launchIn(modelScope)
ChooseTokenUM(
walletList = walletsUM,
isBalanceHidden = settings.isBalanceHidden,
isSearching = isSearchingState,
tokensListData = tokensData,
marketsState = marketsData,
)
},
)
.distinctUntilChanged()
.collect { newUM -> channel.send(newUM) }
}
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = null)
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.DefaultLoading
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.SearchLoading
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 = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id)
?: return@launch
val param = tokenMarket.toSerializableParam()
val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot }
val networks = tokenMarket.networks?.filter { network ->
BlockchainUtils.isSupportedNetworkId(
blockchainId = network.networkId,
coinId = tokenMarket.id.value,
contractAddress = network.contractAddress,
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 = AddToPortfolioManager.AnalyticsParams(source = ScreensSources.Swap.value),
).apply {
setTokenNetworks(networks)
}
addToPortfolioManager?.state
?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd }
?.run { bottomSheetNavigation.activate(AddToPortfolioRoute) }
}.saveIn(addToPortfolioJobHolder)
private fun onTokenItemClick(wallet: UserWallet, account: AccountStatus, currencyStatus: CryptoCurrencyStatus) {
val analyticsPayload = setOf(
ChooseTokenAnalyticsPayload.IsSearched(isSearchingState),
)
val result = ChooseTokenResult(
account = account,
currency = currencyStatus,
wallet = wallet,
analyticsPayload = analyticsPayload,
)
bridge.onCurrencyChosen(result)
}
fun onBackClicked() {
bridge.onClose()
}
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
isActive = false,
onQueryChange = { query ->
initialState.update { prevState -> SearchBarUpdateQueryTransformer(query).transform(prevState) }
bridge.onSearchQuery(query)
},
onActiveChange = { isActive ->
initialState.update { prevState -> SearchBarToggleTransformer(isActive).transform(prevState) }
},
)
private fun getInitState() = ChooseTokenInitialUM(
screenTitle = params.settings.title,
onCloseClick = ::onBackClicked,
searchBar = getInitialSearchBar(),
)
companion object {
const val DEBOUNCE_SEARCH_DELAY = 500L
}

View file

@ -0,0 +1,229 @@
package com.tangem.feature.swap.choosetoken.impl.model
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
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.card.common.extensions.hotWalletExcludedBlockchains
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.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.feature.swap.models.AddToPortfolioRoute
import com.tangem.feature.swap.models.market.MarketsListBatchFlowManager
import com.tangem.feature.swap.models.market.state.SwapMarketState
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.JobHolder
import com.tangem.utils.coroutines.saveIn
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
@Suppress("LongParameterList")
internal class MarketBlockDelegate @AssistedInject constructor(
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val excludedBlockchains: ExcludedBlockchains,
private val getUserWalletsUseCase: GetWalletsUseCase,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val searchQueryState: StateFlow<String>,
@Assisted private val screensSourcesName: String,
) {
private val addToPortfolioJobHolder = JobHolder()
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
var addToPortfolioManager: AddToPortfolioManager? = null
val marketsStateFlow: Flow<SwapMarketState> = searchQueryState
// Switch between default and search market flows
.map { it.isEmpty() }
.distinctUntilChanged()
.flatMapLatest { isDefaultMode ->
if (isDefaultMode) {
visibleMarketItemIds.value = emptyList()
createDefaultMarketsFlow()
} else {
visibleDefaultMarketItemIds.value = emptyList()
createSearchMarketsFlow()
}
}
private val defaultMarketsListManager by lazy {
marketsListBatchFlowManagerFactory.create(
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
order = TokenMarketListConfig.Order.Trending,
currentSearchText = Provider { null },
modelScope = modelScope,
)
}
private val searchMarketsListManager by lazy {
marketsListBatchFlowManagerFactory.create(
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
order = TokenMarketListConfig.Order.ByRating,
currentSearchText = Provider { searchQueryState.value },
modelScope = modelScope,
)
}
init {
// Reload search markets when query changes
searchQueryState
.onEach { searchQuery ->
if (searchQuery.isNotEmpty()) {
searchMarketsListManager.reload(searchQuery)
}
}
.launchIn(modelScope)
// Initial load of default markets
defaultMarketsListManager.reload()
visibleMarketItemIds
.mapNotNull { rawIDS ->
if (rawIDS.isNotEmpty()) {
searchMarketsListManager.getBatchKeysByItemIds(rawIDS)
} else {
null
}
}
.distinctUntilChanged()
.transformLatest<Set<Int>, Unit> { visibleBatchKeys ->
searchMarketsListManager.loadCharts(visibleBatchKeys)
}
.launchIn(modelScope)
visibleDefaultMarketItemIds
.mapNotNull { rawIds ->
if (rawIds.isNotEmpty()) {
defaultMarketsListManager.getBatchKeysByItemIds(rawIds)
} else {
null
}
}.distinctUntilChanged()
.transformLatest<Set<Int>, Unit> { visibleBatchKeys ->
defaultMarketsListManager.loadCharts(visibleBatchKeys)
}
.launchIn(modelScope)
}
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.DefaultLoading
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.SearchLoading
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 = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id)
?: return@launch
val param = tokenMarket.toSerializableParam()
val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot }
val networks = tokenMarket.networks?.filter { network ->
BlockchainUtils.isSupportedNetworkId(
blockchainId = network.networkId,
coinId = tokenMarket.id.value,
contractAddress = network.contractAddress,
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 = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName),
).apply {
setTokenNetworks(networks)
}
addToPortfolioManager?.state
?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd }
?.run { addToPortfolioSlot.activate(AddToPortfolioRoute) }
}.saveIn(addToPortfolioJobHolder)
}
@AssistedFactory
interface Factory {
fun create(
searchQueryState: StateFlow<String>,
modelScope: CoroutineScope,
screensSourcesName: String,
): MarketBlockDelegate
}
}

View file

@ -0,0 +1,110 @@
package com.tangem.feature.swap.choosetoken.impl.model
import com.tangem.common.ui.tokens.TokenConverterParams
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.account.status.supplier.MultiAccountStatusListSupplier
import com.tangem.domain.account.status.utils.ExpandedAccountsHolder
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.feature.swap.choosetoken.api.SettingContextUseCase
import com.tangem.feature.swap.choosetoken.impl.converter.ChooseTokenListItemConverter
import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.utils.extensions.mapNotNullValues
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
internal class PortfolioListBlockDelegate @AssistedInject constructor(
private val expandedAccountsHolder: ExpandedAccountsHolder,
private val settingContext: SettingContextUseCase,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val getWalletsUseCase: GetWalletsUseCase,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val searchQueryState: StateFlow<String>,
) : ClickIntents {
val onTokenItemClick: Channel<Pair<AccountStatus, CryptoCurrencyStatus>> = Channel()
val portfolioList: Flow<Map<UserWalletId, TokenListUMData>> = flow {
val allAccountsFlow: Flow<LinkedHashMap<UserWalletId, AccountStatusList>> =
multiAccountStatusListSupplier.invokeAsMap()
val allWalletsFlow: Flow<LinkedHashMap<UserWalletId, UserWallet>> =
getWalletsUseCase.invokeAsMap()
val expandedAccountsMapFlow = allWalletsFlow
.map { allWallets -> allWallets.values.map { wallet -> wallet.walletId } }
.distinctUntilChanged()
.flatMapLatest { walletIds -> walletIds.toExpandedAccountsMap() }
.distinctUntilChanged()
val finalFlow = combine(
flow = settingContext.invoke(),
flow2 = allAccountsFlow,
flow3 = expandedAccountsMapFlow,
flow4 = searchQueryState,
transform = { settings, allAccounts, expandedAccountsMap, searchQuery ->
allAccounts.mapNotNullValues { (walletId, statusList) ->
val expandedAccounts = expandedAccountsMap[walletId].orEmpty()
val converterParams = if (settings.isAccountsMode) {
TokenConverterParams.Account(statusList, expandedAccounts)
} else {
TokenConverterParams.Wallet(statusList.mainAccount, statusList.mainAccount.tokenList)
}
val um = ChooseTokenListItemConverter(
appCurrency = settings.appCurrency,
params = converterParams,
clickIntents = this@PortfolioListBlockDelegate,
searchQuery = searchQuery,
).convert()
um
}
},
)
emitAll(finalFlow)
}
.distinctUntilChanged()
.shareIn(modelScope, SharingStarted.Eagerly, replay = 1)
private fun List<UserWalletId>.toExpandedAccountsMap(): Flow<Map<UserWalletId, Set<AccountId>>> {
if (isEmpty()) return flowOf(emptyMap())
val flows: List<Flow<Pair<UserWalletId, Set<AccountId>>>> =
map { walletId -> expandedAccountsHolder.expandedAccounts(walletId).map { set -> walletId to set } }
return combine(flows, { pairs -> pairs.toMap() })
}
override fun onTokenItemClick(account: AccountStatus, currencyStatus: CryptoCurrencyStatus) {
onTokenItemClick.trySend(account to currencyStatus)
}
override fun onAccountExpandClick(account: Account) {
expandedAccountsHolder.expandAccount(account.accountId)
}
override fun onAccountCollapseClick(account: Account) {
expandedAccountsHolder.collapseAccount(account.accountId)
}
@AssistedFactory
interface Factory {
fun create(searchQueryState: StateFlow<String>, modelScope: CoroutineScope): PortfolioListBlockDelegate
}
}
internal interface ClickIntents {
fun onTokenItemClick(account: AccountStatus, currencyStatus: CryptoCurrencyStatus)
fun onAccountExpandClick(account: Account)
fun onAccountCollapseClick(account: Account)
}

View file

@ -55,21 +55,15 @@ import kotlin.random.Random
private const val LOAD_MORE_BUFFER = 25
@Composable
internal fun ChooseTokenScreen(state: ChooseTokenUM, modifier: Modifier = Modifier) {
internal fun ChooseTokenScreen(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.tertiary)
.background(color = TangemTheme.colors.background.secondary)
.fillMaxSize()
.systemBarsPadding()
.imePadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBar(title = state.screenTitle, onBackClick = state.onCloseClick)
SearchBar(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
state = state.searchBar,
colors = TangemSearchBarDefaults.secondaryTextFieldColors,
)
AppBar(title = state.initialUM.screenTitle, onBackClick = state.initialUM.onCloseClick, Modifier)
Content(
state = state,
@ -79,17 +73,19 @@ internal fun ChooseTokenScreen(state: ChooseTokenUM, modifier: Modifier = Modifi
}
@Composable
private fun AppBar(title: TextReference, onBackClick: () -> Unit) {
private fun AppBar(title: TextReference, onBackClick: () -> Unit, modifier: Modifier = Modifier) {
AppBarWithBackButton(
text = title.resolveReference(),
onBackClick = onBackClick,
iconRes = com.tangem.common.ui.R.drawable.ic_back_24,
modifier = Modifier.height(TangemTheme.dimens.size56),
modifier = modifier
.statusBarsPadding()
.height(TangemTheme.dimens.size56),
)
}
@Composable
private fun Content(state: ChooseTokenUM, modifier: Modifier = Modifier) {
private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
val nestedScrollConnection = rememberHideKeyboardNestedScrollConnection()
val lazyListState = rememberLazyListState()
@ -99,23 +95,34 @@ private fun Content(state: ChooseTokenUM, modifier: Modifier = Modifier) {
.nestedScroll(nestedScrollConnection),
horizontalAlignment = Alignment.CenterHorizontally,
state = lazyListState,
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) {
item(key = "search_bar") {
SearchBar(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
state = state.initialUM.searchBar,
colors = TangemSearchBarDefaults.secondaryTextFieldColors,
)
}
assetsTitle()
walletListItem(state.walletList)
if (state.contentUM != null) {
walletListItem(state.contentUM.walletList)
tokensListItems(
tokensListData = state.tokensListData,
isBalanceHidden = state.isBalanceHidden,
)
tokensListItems(
tokensListData = state.contentUM.tokensListData,
isBalanceHidden = state.contentUM.isBalanceHidden,
)
if (state.marketsState != null) {
item("markets_title_spacer") { SpacerH(height = 20.dp) }
swapMarketsListItems(state.marketsState)
if (state.contentUM.marketsState != null) {
item("markets_title_spacer") { SpacerH(height = 20.dp) }
swapMarketsListItems(state.contentUM.marketsState)
}
}
}
if (state.marketsState != null) {
SetupMarketScrollTracker(state.marketsState, lazyListState)
if (state.contentUM?.marketsState != null) {
SetupMarketScrollTracker(state.contentUM.marketsState, lazyListState)
}
}
@ -248,7 +255,7 @@ private fun LazyListScope.tokensList(items: ImmutableList<TokensListItemUM>, isB
@Preview
@Composable
private fun TokenScreenPreview(@PreviewParameter(ChooseTokenScreenPreviewProvider::class) state: ChooseTokenUM) {
private fun TokenScreenPreview(@PreviewParameter(ChooseTokenScreenPreviewProvider::class) state: ChooseTokenFullUM) {
TangemThemePreview {
ChooseTokenScreen(
state = state,
@ -331,20 +338,24 @@ private val wallets
),
)
private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider<ChooseTokenUM> {
override val values: Sequence<ChooseTokenUM> = sequenceOf(
ChooseTokenUM(
screenTitle = stringReference("Choose token"),
onCloseClick = {},
walletList = WalletListUM(wallets),
searchBar = searchBar,
isBalanceHidden = false,
isAfterSearch = false,
tokensListData = TokenListUMData.AccountList(
tokensList = accounts,
accounts.size,
private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider<ChooseTokenFullUM> {
override val values: Sequence<ChooseTokenFullUM> = sequenceOf(
ChooseTokenFullUM(
initialUM = ChooseTokenInitialUM(
screenTitle = stringReference("Choose token"),
onCloseClick = {},
searchBar = searchBar,
),
contentUM = ChooseTokenUM(
walletList = WalletListUM(wallets),
isBalanceHidden = false,
isSearching = false,
tokensListData = TokenListUMData.AccountList(
tokensList = accounts,
accounts.size,
),
marketsState = SwapSelectTokenPreviewProvider.defaultState.marketsState,
),
marketsState = SwapSelectTokenPreviewProvider.defaultState.marketsState,
),
)
}

View file

@ -7,17 +7,25 @@ import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.feature.swap.models.market.state.SwapMarketState
import kotlinx.collections.immutable.ImmutableList
internal data class ChooseTokenFullUM(
val initialUM: ChooseTokenInitialUM,
val contentUM: ChooseTokenUM?,
)
internal data class ChooseTokenUM(
val screenTitle: TextReference,
val onCloseClick: () -> Unit,
val walletList: WalletListUM,
val searchBar: SearchBarUM,
val isBalanceHidden: Boolean,
val isAfterSearch: Boolean,
val isSearching: Boolean,
val tokensListData: TokenListUMData,
val marketsState: SwapMarketState?,
)
internal data class ChooseTokenInitialUM(
val screenTitle: TextReference,
val onCloseClick: () -> Unit,
val searchBar: SearchBarUM,
)
internal data class WalletListUM(
val items: ImmutableList<TangemButtonUM>,
)

View file

@ -3,6 +3,7 @@ package com.tangem.feature.wallet.presentation.account
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.supplier.SingleAccountStatusSupplier
import com.tangem.domain.account.status.utils.ExpandedAccountsHolder
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import javax.inject.Inject

View file

@ -1,5 +1,6 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers
import com.tangem.common.ui.tokens.TokenConverterParams
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency

View file

@ -7,7 +7,6 @@ import com.tangem.core.ui.components.tokenlist.state.PortfolioItemContentUM
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.card.common.util.cardTypesResolver
@ -25,11 +24,10 @@ import com.tangem.domain.staking.model.StakingAvailability
import com.tangem.feature.wallet.child.wallet.model.intents.WalletClickIntents
import com.tangem.feature.wallet.impl.R
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState
import com.tangem.feature.wallet.presentation.wallet.state.transformers.TokenConverterParams
import com.tangem.common.ui.tokens.TokenConverterParams
import com.tangem.common.ui.tokens.TokenItemGrouping.toGroupedItems
import com.tangem.common.ui.tokens.TokenItemGrouping.toUngroupedItems
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
import com.tangem.feature.wallet.presentation.wallet.state.model.WalletTokensListState.OrganizeTokensButtonConfig as WalletOrganizeTokensButtonConfig
@ -92,7 +90,7 @@ internal class TokenListStateConverter(
return when (params) {
is TokenConverterParams.Account -> convertAccountList(params)
is TokenConverterParams.Wallet -> convertTokenList(
tokenConverter = tokenStatusConverter(params.accountId),
tokenConverter = tokenStatusConverter(params.mainAccount.accountId),
tokenList = params.tokenList,
)
}
@ -102,11 +100,11 @@ internal class TokenListStateConverter(
when (tokenList) {
is TokenList.Empty -> WalletTokensListState.Empty
is TokenList.GroupedByNetwork -> WalletTokensListState.ContentState.Content(
items = tokenList.toGroupedItems(tokenConverter),
items = tokenList.toGroupedItems(tokenConverter).toPersistentList(),
organizeTokensButtonConfig = getOrganizeTokensButtonState(tokenList = tokenList),
)
is TokenList.Ungrouped -> WalletTokensListState.ContentState.Content(
items = tokenList.toUngroupedItems(tokenConverter),
items = tokenList.toUngroupedItems(tokenConverter).toPersistentList(),
organizeTokensButtonConfig = getOrganizeTokensButtonState(tokenList = tokenList),
)
}
@ -167,51 +165,6 @@ internal class TokenListStateConverter(
)
}
private fun TokenList.GroupedByNetwork.toGroupedItems(
tokenConverter: TokenItemStateConverter,
): PersistentList<TokensListItemUM> {
return groups.fold(initial = persistentListOf()) { acc, group ->
acc.mutate { it.addGroup(tokenConverter, group) }
}
}
private fun TokenList.Ungrouped.toUngroupedItems(
tokenConverter: TokenItemStateConverter,
): PersistentList<TokensListItemUM> {
return currencies.fold(initial = persistentListOf()) { acc, token ->
acc.mutate { it.addToken(tokenConverter, token) }
}
}
private fun MutableList<TokensListItemUM>.addGroup(
tokenConverter: TokenItemStateConverter,
group: NetworkGroup,
): List<TokensListItemUM> {
val groupTitle = TokensListItemUM.GroupTitle(
id = group.network.hashCode(),
text = resourceReference(
id = R.string.wallet_network_group_title,
formatArgs = wrappedList(group.network.name),
),
)
add(groupTitle)
group.currencies.forEach { token -> addToken(tokenConverter, token) }
return this
}
private fun MutableList<TokensListItemUM>.addToken(
tokenConverter: TokenItemStateConverter,
token: CryptoCurrencyStatus,
): List<TokensListItemUM> {
val tokenItemState = tokenConverter.convert(token)
add(TokensListItemUM.Token(tokenItemState))
return this
}
private fun getOrganizeTokensButtonState(tokenList: TokenList): WalletOrganizeTokensButtonConfig? {
val currenciesSize = when (tokenList) {
TokenList.Empty -> return null

View file

@ -4,7 +4,7 @@ import com.tangem.domain.account.models.AccountStatusList
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.currency.yieldSupplyKey
import com.tangem.feature.wallet.presentation.wallet.state.transformers.TokenConverterParams
import com.tangem.common.ui.tokens.TokenConverterParams
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.converter.Converter
import java.math.BigDecimal

View file

@ -16,7 +16,8 @@ import com.tangem.feature.wallet.presentation.account.AccountDependencies
import com.tangem.feature.wallet.presentation.wallet.state.WalletStateController
import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTokenListErrorTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.SetTokenListTransformer
import com.tangem.feature.wallet.presentation.wallet.state.transformers.TokenConverterParams
import com.tangem.common.ui.tokens.TokenConverterParams
import com.tangem.domain.models.account.AccountStatus
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
@ -65,7 +66,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() {
singleAccountTransform(
maybeTokenList = maybeTokenList,
appCurrency = appCurrency,
accountId = mainAccount.accountId,
mainAccount = mainAccount,
yieldSupplyApyMap = yieldSupplyApyMap,
stakingAvailabilityMap = stakingAvailabilityMap,
shouldShowMainPromo = shouldShowMainPromo,
@ -111,7 +112,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() {
private fun singleAccountTransform(
maybeTokenList: Lce<TokenListError, TokenList>,
appCurrency: AppCurrency,
accountId: AccountId,
mainAccount: AccountStatus.CryptoPortfolio,
yieldSupplyApyMap: Map<String, BigDecimal> = emptyMap(),
stakingAvailabilityMap: Map<CryptoCurrency, StakingAvailability> = emptyMap(),
shouldShowMainPromo: Boolean,
@ -141,7 +142,7 @@ internal abstract class BasicAccountListSubscriber : BasicWalletSubscriber() {
)
updateContent(
params = TokenConverterParams.Wallet(accountId, tokenList),
params = TokenConverterParams.Wallet(mainAccount, tokenList),
appCurrency = appCurrency,
yieldSupplyApyMap = yieldSupplyApyMap,
stakingAvailabilityMap = stakingAvailabilityMap,

View file

@ -1,9 +1,11 @@
package com.tangem.feature.wallet.presentation.wallet.state.transformers.converter
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.PortfolioId
import com.tangem.common.ui.tokens.TokenConverterParams
import com.tangem.domain.core.utils.lceError
import com.tangem.domain.models.StatusSource
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.Account.CryptoPortfolio.Companion.createMainAccount
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -11,19 +13,25 @@ import com.tangem.domain.models.network.NetworkAddress
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.yield.supply.YieldSupplyStatus
import com.tangem.feature.wallet.presentation.wallet.state.transformers.TokenConverterParams
import org.junit.Test
import java.math.BigDecimal
class YieldSupplyPromoBannerConverterTest {
private val account
get() = AccountStatus.CryptoPortfolio(
tokenList = TokenList.Empty,
priceChangeLce = Unit.lceError(),
account = createMainAccount(UserWalletId("00"))
)
@Test
fun `GIVEN promo disabled WHEN convert THEN return null`() {
val token = createToken(networkId = "ethereum", backendId = "ethereum", contract = "0xABCDEF")
val status = createLoadedStatus(token = token, amount = BigDecimal.ONE, isYieldActive = false)
val tokenList = ungroupedTokenList(status)
val params = TokenConverterParams.Wallet(
accountId = AccountId.forMainCryptoPortfolio(UserWalletId("00")),
mainAccount = account,
tokenList = tokenList,
)
val converter = YieldSupplyPromoBannerConverter(
@ -41,7 +49,7 @@ class YieldSupplyPromoBannerConverterTest {
val token = createToken(networkId = "ethereum", backendId = "ethereum", contract = "0xA1")
val status = createLoadedStatus(token = token, amount = BigDecimal("2.0"), isYieldActive = false)
val params = TokenConverterParams.Wallet(
accountId = AccountId.forMainCryptoPortfolio(UserWalletId("00")),
mainAccount = account,
tokenList = ungroupedTokenList(status),
)
val converter = YieldSupplyPromoBannerConverter(
@ -59,7 +67,7 @@ class YieldSupplyPromoBannerConverterTest {
val token = createToken(networkId = "ethereum", backendId = "ethereum", contract = "0xAA")
val statusActive = createLoadedStatus(token = token, amount = BigDecimal("5"), isYieldActive = true)
val params = TokenConverterParams.Wallet(
accountId = AccountId.forMainCryptoPortfolio(UserWalletId("00")),
mainAccount = account,
tokenList = ungroupedTokenList(statusActive),
)
val converter = YieldSupplyPromoBannerConverter(
@ -87,7 +95,7 @@ class YieldSupplyPromoBannerConverterTest {
)
val params = TokenConverterParams.Wallet(
accountId = AccountId.forMainCryptoPortfolio(UserWalletId("00")),
mainAccount = account,
tokenList = ungroupedTokenList(statusSmall, statusBig),
)
val converter = YieldSupplyPromoBannerConverter(
@ -110,7 +118,7 @@ class YieldSupplyPromoBannerConverterTest {
val apyMap = mapOf(mismatchedKey to BigDecimal("0.07"))
val params = TokenConverterParams.Wallet(
accountId = AccountId.forMainCryptoPortfolio(UserWalletId("00")),
mainAccount = account,
tokenList = ungroupedTokenList(status),
)
val converter = YieldSupplyPromoBannerConverter(
@ -128,7 +136,7 @@ class YieldSupplyPromoBannerConverterTest {
val token = createToken(networkId = "ethereum", backendId = "ethereum", contract = "0xCUSTOM")
val status = createCustomStatus(token = token, amount = BigDecimal("5.0"), isYieldActive = false)
val params = TokenConverterParams.Wallet(
accountId = AccountId.forMainCryptoPortfolio(UserWalletId("00")),
mainAccount = account,
tokenList = ungroupedTokenList(status),
)
val converter = YieldSupplyPromoBannerConverter(