Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-29 18:02:22 +04:00
parent 7095ceec0f
commit 82506a1c9c
29 changed files with 239 additions and 997 deletions

View file

@ -0,0 +1,88 @@
package com.tangem.features.commonfeatures.impl.choosetoken
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge.Settings
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
import com.tangem.features.commonfeatures.impl.choosetoken.model.PortfolioFullBlockDelegate
import com.tangem.features.commonfeatures.impl.choosetoken.model.PortfolioListBlockDelegate
import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel
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 DefaultChooseTokenBridge @AssistedInject constructor(
@Assisted private val modelScope: CoroutineScope,
portfolioListBlockDelegateFactory: PortfolioListBlockDelegate.Factory,
portfolioFullBlockDelegateFactory: PortfolioFullBlockDelegate.Factory,
@Assisted override val settings: Settings,
@Assisted override val analyticsPayload: Set<ChooseTokenAnalyticsPayload>,
) : ChooseTokenBridge {
override val onCurrencyChosen: Channel<ChooseTokenResult> = Channel()
override val onClose: Channel<Unit> = Channel()
private val onSearchQuery: Channel<SearchQuery> = Channel()
override val searchQueryState: StateFlow<SearchQuery> = onSearchQuery.receiveAsFlow()
.debounce(ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = SearchQuery.Empty)
private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create(
modelScope = modelScope,
searchQueryState = searchQueryState,
featureSettings = settings,
)
private val portfolioFullBlockDelegate: PortfolioFullBlockDelegate = portfolioFullBlockDelegateFactory.create(
modelScope = modelScope,
searchQueryState = searchQueryState,
portfolioListBlockDelegate = portfolioListBlockDelegate,
)
override val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean>
get() = portfolioListBlockDelegate.tokenFilter
override val fullPortfolioBlock: StateFlow<ChooseTokenPortfolioFullBlockUM?>
get() = portfolioFullBlockDelegate.fullPortfolioBlock
init {
portfolioListBlockDelegate.onTokenChosen.receiveAsFlow()
.onEach { chooseResult -> onCurrencyChosen(chooseResult) }
.launchIn(modelScope)
}
override fun selectWalletTab(walletId: UserWalletId) {
portfolioFullBlockDelegate.selectWalletTab(walletId)
}
override fun onSearchQuery(query: SearchQuery) {
onSearchQuery.trySend(query)
}
override fun onCurrencyChosen(result: ChooseTokenResult) {
onCurrencyChosen.trySend(result)
onSearchQuery(SearchQuery.Empty)
}
override fun onClose() {
onClose.trySend(Unit)
onSearchQuery(SearchQuery.Empty)
}
@AssistedFactory
interface Factory : ChooseTokenBridge.Factory {
override fun create(
modelScope: CoroutineScope,
settings: Settings,
analyticsPayload: Set<ChooseTokenAnalyticsPayload>,
): DefaultChooseTokenBridge
}
}

View file

@ -0,0 +1,72 @@
package com.tangem.features.commonfeatures.impl.choosetoken
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.childSlot
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.Route
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenScreen
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.serialization.Serializable
internal class DefaultChooseTokenComponent @AssistedInject constructor(
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: ChooseTokenComponent.Params,
) : AppComponentContext by appComponentContext, ChooseTokenComponent {
private val model: ChooseTokenModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = AddToPortfolioRoute.serializer(),
key = BOTTOM_SHEET_SLOT_KEY,
handleBackButton = false,
childFactory = { _, context -> bottomSheetChild(context) },
)
@Composable
override fun Content(modifier: Modifier) {
val bottomSheet by bottomSheetSlot.subscribeAsState()
val state by model.state.collectAsStateWithLifecycle()
ChooseTokenScreen(state = state)
bottomSheet.child?.instance?.BottomSheet()
}
@Suppress("UnsafeCallOnNullableType")
private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent {
return addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(
addToPortfolioManager = model.addToPortfolioManager,
),
)
}
@AssistedFactory
interface Factory : ChooseTokenComponent.Factory {
override fun create(
context: AppComponentContext,
params: ChooseTokenComponent.Params,
): DefaultChooseTokenComponent
}
private companion object {
const val BOTTOM_SHEET_SLOT_KEY = "choosePortfolioTokenBottomSheetSlot"
}
}
@Serializable
internal data object AddToPortfolioRoute : Route

View file

@ -0,0 +1,39 @@
package com.tangem.features.commonfeatures.impl.choosetoken
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import javax.inject.Inject
class SettingContextUseCase @Inject constructor(
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
) {
suspend fun invokeSync(): SettingContext = invoke().first()
operator fun invoke(): Flow<SettingContext> = combine(
flow = isAccountsModeEnabledUseCase.invoke(),
flow2 = getSelectedAppCurrencyUseCase.invokeOrDefault(),
flow3 = getBalanceHidingSettingsUseCase.isBalanceHidden(),
transform = { isAccountsModeEnabled, selectedAppCurrency, balanceHidingSettings ->
SettingContext(
isAccountsMode = isAccountsModeEnabled,
appCurrency = selectedAppCurrency,
isBalanceHidden = balanceHidingSettings,
)
},
).distinctUntilChanged()
}
data class SettingContext(
val isAccountsMode: Boolean,
val appCurrency: AppCurrency,
val isBalanceHidden: Boolean,
)

View file

@ -0,0 +1,230 @@
package com.tangem.features.commonfeatures.impl.choosetoken.converter
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
import com.tangem.common.ui.account.TokensListPortfolioItemConverter
import com.tangem.common.ui.account.toUM
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.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.icons.IconTint
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.token.state.TokenItemState.FiatAmountState
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.pluralReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
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.account.PaymentAccountStatusValue
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.tokenlist.TokenList
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
import com.tangem.features.commonfeatures.impl.choosetoken.model.ClickIntents
import com.tangem.features.commonfeatures.impl.R
import kotlinx.collections.immutable.toPersistentList
internal class ChooseTokenListItemConverter(
private val appCurrency: AppCurrency,
private val params: TokenConverterParams,
private val clickIntents: ClickIntents,
private val searchQuery: SearchQuery,
private val tokenFilter: (AccountStatus, CryptoCurrencyStatus) -> Boolean,
private val isShowPaymentAccount: Boolean,
) {
private val isSearchingState: Boolean get() = searchQuery.isSearchingState
private val onTokenClick: (account: AccountStatus, currencyStatus: CryptoCurrencyStatus) -> Unit =
{ account, currencyStatus ->
clickIntents.onTokenItemClick(account, currencyStatus)
}
private val onAccountItemClick: (Account, isExpanded: Boolean) -> Unit = { clickedAccount, isExpanded ->
if (isExpanded) {
clickIntents.onAccountCollapseClick(clickedAccount)
} else {
clickIntents.onAccountExpandClick(clickedAccount)
}
}
private val fiatAmountStateProvider: ((TotalFiatBalance, isExpanded: Boolean) -> FiatAmountState?) =
{ totalBalance, isExpanded ->
when {
isSearchingState -> FiatAmountState.Empty
!isExpanded -> FiatAmountState.Icon(R.drawable.ic_chewron_down_20, IconTint.Informative)
else -> AccountCryptoPortfolioItemStateConverter
.createFiatAmountState(totalBalance, appCurrency)
}
}
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(
account = params.mainAccount,
tokenConverter = tokenStatusConverter(params.mainAccount),
tokenListParam = params.tokenList,
)
}
}
private fun convertAccountList(params: TokenConverterParams.Account): TokenListUMData {
val accountList = params.accountList
val accountItems = accountList.accountStatuses
.mapNotNull { accountStatus ->
when (accountStatus) {
is AccountStatus.CryptoPortfolio -> accountStatus.toPortfolioItem(params)
is AccountStatus.Payment -> accountStatus.createPaymentAccountItem(params.expandedAccounts)
}
}
.filter { portfolio -> portfolio.tokens.isNotEmpty() }
if (accountItems.isEmpty()) {
return TokenListUMData.EmptyList
}
return TokenListUMData.AccountList(
tokensList = accountItems.toPersistentList(),
totalTokensCount = accountItems.sumOf { portfolio -> portfolio.tokensItemsList.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 ->
onAccountItemClick(clickedAccount, isExpanded)
}
val converter = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = account,
onItemClick = onItemClick.takeIf { !isSearchingState },
priceChangeLce = this.priceChangeLce,
fiatAmountStateProvider = { fiatBalance -> fiatAmountStateProvider(fiatBalance, isExpanded) },
subtitle2StateProvider = { _ -> null },
)
val accountItem = converter.convert(tokenList.totalFiatBalance)
val tokenConverter = tokenStatusConverter(this)
val tokensListState = convertTokenList(tokenConverter, tokenList, this)
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,
account: AccountStatus.CryptoPortfolio,
): TokenListUMData {
return when (val tokenList = filterTokenList(tokenListParam, account)) {
is TokenList.Empty -> TokenListUMData.EmptyList
is TokenList.GroupedByNetwork -> TokenListUMData.TokenList(
tokensList = tokenList.toGroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = tokenList.flattenCurrencies().size,
)
is TokenList.Ungrouped -> TokenListUMData.TokenList(
tokensList = tokenList.toUngroupedItems(tokenConverter).toPersistentList(),
totalTokensCount = tokenList.flattenCurrencies().size,
)
}
}
private fun List<CryptoCurrencyStatus>.filterCurrencies(account: AccountStatus): List<CryptoCurrencyStatus> =
filter { currency -> currency.filterByQuery() && tokenFilter(account, currency) }
private fun filterTokenList(tokenList: TokenList, account: AccountStatus.CryptoPortfolio): TokenList {
return when (tokenList) {
TokenList.Empty -> TokenList.Empty
is TokenList.Ungrouped -> {
val filtered = tokenList.currencies.filterCurrencies(account)
if (filtered.isEmpty()) TokenList.Empty else tokenList.copy(currencies = filtered)
}
is TokenList.GroupedByNetwork -> {
val filteredGroups = tokenList.groups
.map { group ->
val filteredCurrencies = group.currencies.filterCurrencies(account)
group.copy(currencies = filteredCurrencies)
}
.filter { group -> group.currencies.isNotEmpty() }
if (filteredGroups.isEmpty()) TokenList.Empty else tokenList.copy(groups = filteredGroups)
}
}
}
private fun CryptoCurrencyStatus.filterByQuery(): Boolean {
if (!isSearchingState) return true
val isSearchFilter = currency.name.contains(searchQuery.value, ignoreCase = true) ||
currency.symbol.contains(searchQuery.value, ignoreCase = true)
return isSearchFilter
}
private fun AccountStatus.Payment.createPaymentAccountItem(
expandedAccounts: Set<AccountId>,
): TokensListItemUM.Portfolio? {
if (!isShowPaymentAccount) return null
val paymentCurrency: CryptoCurrencyStatus = when (val status = this.value) {
is PaymentAccountStatusValue.Error,
is PaymentAccountStatusValue.IssuingCard,
PaymentAccountStatusValue.NotCreated,
is PaymentAccountStatusValue.UnderReview,
PaymentAccountStatusValue.Loading,
PaymentAccountStatusValue.Empty,
-> return null
is PaymentAccountStatusValue.Loaded -> status.cryptoCurrencyStatus
}
val account = this.account
val tokensCount = 1
val isExpanded = isSearchingState || expandedAccounts.contains(account.accountId)
val onItemClick: (TokenItemState) -> Unit = {
onAccountItemClick(account, isExpanded)
}
val fiatBalance: TotalFiatBalance = this.value.totalFiatBalance
val fiatAmountState = fiatAmountStateProvider(fiatBalance, isExpanded)
val paymentAccountItem = TokenItemState.Content(
id = account.accountId.value,
iconState = CurrencyIconState.PaymentAccount(),
titleState = TokenItemState.TitleState.Content(text = account.accountName.toUM().value),
subtitleState = TokenItemState.SubtitleState.TextContent(
value = pluralReference(
R.plurals.common_tokens_count,
count = tokensCount,
formatArgs = wrappedList(tokensCount),
),
isAvailable = false,
),
onItemClick = onItemClick.takeIf { !isSearchingState },
fiatAmountState = fiatAmountState,
subtitle2State = null,
onItemLongClick = null,
)
val tokenConverter = tokenStatusConverter(this)
val filtered = listOf(paymentCurrency)
.filterCurrencies(this)
.map { currency -> TokensListItemUM.Token(tokenConverter.convert(currency)) }
return TokensListPortfolioItemConverter(
tokenItemUM = paymentAccountItem,
isExpanded = isExpanded,
isCollapsable = !isSearchingState,
tokens = filtered.toPersistentList(),
).convert(Unit)
}
}

View file

@ -0,0 +1,13 @@
package com.tangem.features.commonfeatures.impl.choosetoken.converter
import com.tangem.features.commonfeatures.impl.choosetoken.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.features.commonfeatures.impl.choosetoken.converter
import com.tangem.features.commonfeatures.impl.choosetoken.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

@ -0,0 +1,39 @@
package com.tangem.features.commonfeatures.impl.choosetoken.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.commonfeatures.impl.choosetoken.DefaultChooseTokenBridge
import com.tangem.features.commonfeatures.impl.choosetoken.DefaultChooseTokenComponent
import com.tangem.features.commonfeatures.impl.choosetoken.model.ChooseTokenModel
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ClassKey
import dagger.multibindings.IntoMap
import javax.inject.Singleton
@Module
@InstallIn(ModelComponent::class)
internal interface ChooseTokenModelModule {
@Binds
@IntoMap
@ClassKey(ChooseTokenModel::class)
fun provideChooseTokenModel(model: ChooseTokenModel): Model
}
@Module
@InstallIn(SingletonComponent::class)
internal interface ChooseTokenFeatureModule {
@Binds
@Singleton
fun provideChooseTokenComponentFactory(impl: DefaultChooseTokenComponent.Factory): ChooseTokenComponent.Factory
@Binds
@Singleton
fun provideDefaultChooseTokenBridgeFactory(impl: DefaultChooseTokenBridge.Factory): ChooseTokenBridge.Factory
}

View file

@ -0,0 +1,270 @@
package com.tangem.features.commonfeatures.impl.choosetoken.market
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.*
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.features.commonfeatures.impl.choosetoken.market.converter.SwapMarketsTokenItemConverter
import com.tangem.pagination.Batch
import com.tangem.pagination.BatchAction
import com.tangem.pagination.PaginationStatus
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
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.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@Suppress("LongParameterList")
internal class MarketsListBatchFlowManager @AssistedInject constructor(
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val dispatchers: CoroutineDispatcherProvider,
@Assisted private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
@Assisted private val order: TokenMarketListConfig.Order,
@Assisted private val currentSearchText: Provider<String?>,
@Assisted private val modelScope: CoroutineScope,
) {
private val actionsFlow =
MutableSharedFlow<BatchAction<Int, TokenMarketListConfig, TokenMarketUpdateRequest>>(replay = 1)
private val updateStateJob = JobHolder()
private val batchFlow = getMarketsTokenListFlowUseCase(
batchingContext = TokenListBatchingContext(
actionsFlow = actionsFlow,
coroutineScope = modelScope,
),
batchFlowType = batchFlowType,
)
private val resultBatches = MutableStateFlow(ResultBatches())
private val uiBatches = resultBatches.map { it.uiBatches }
private val appCurrency: StateFlow<AppCurrency> = getSelectedAppCurrencyUseCase.invokeOrDefault()
.stateIn(modelScope, SharingStarted.Eagerly, AppCurrency.Default)
val uiItems: StateFlow<ImmutableList<MarketsListItemUM>>
get() = uiBatches
.map { batches ->
batches.asSequence()
.map { it.data }
.flatten()
.toImmutableList()
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = persistentListOf(),
)
val isInInitialLoadingErrorState = batchFlow.state
.map { it.status is PaginationStatus.InitialLoadingError }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
val isSearchNotFoundState = batchFlow.state
.map { batchListState ->
currentSearchText().isNullOrEmpty().not() &&
batchListState.status is PaginationStatus.EndOfPagination &&
batchListState.data.isEmpty()
}
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = false,
)
val totalCount: StateFlow<Int?> = batchFlow.state
.map { it.totalCount }
.distinctUntilChanged()
.stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = null,
)
init {
batchFlow.state
.map { it.data }
.distinctUntilChanged { a, b ->
a.size == b.size &&
a.map { it.key } == b.map { it.key } &&
a.map { it.data }.flatten() == b.map { it.data }.flatten()
}
.onEach {
coroutineScope {
launch {
updateState(it)
}.saveIn(updateStateJob)
}
}
.flowOn(dispatchers.default)
.launchIn(modelScope)
}
private suspend fun updateState(newList: List<Batch<Int, List<TokenMarket>>>, forceUpdate: Boolean = false) =
withContext(dispatchers.default) {
resultBatches.update { resultBatches ->
val items = resultBatches.uiBatches
val previousList = resultBatches.processedItems
val converter = SwapMarketsTokenItemConverter(appCurrency = appCurrency.value)
if (newList.isEmpty()) {
return@update ResultBatches(processedItems = emptyList())
}
val isInitialLoading =
forceUpdate || previousList.isNullOrEmpty() || newList.first().key != previousList.first().key
val outItems = if (isInitialLoading) {
newList.map { batch ->
Batch(
key = batch.key,
data = converter.convertList(batch.data),
)
}
} else {
if (previousList.size != newList.size) {
val keysToAdd = newList.map { it.key }.subtract(previousList.map { it.key }.toSet())
val newBatches = newList.filter { keysToAdd.contains(it.key) }
items + newBatches.map { batch ->
Batch(
key = batch.key,
data = converter.convertList(batch.data),
)
}
} else {
items.mapIndexed { batchIndex, batch ->
val prevBatch = previousList[batchIndex]
val newBatch = newList[batchIndex]
if (prevBatch == newBatch) return@mapIndexed batch
Batch(
key = batch.key,
data = batch.data.mapIndexed { index, marketsListItemUM ->
val prevItem = prevBatch.data.getOrNull(index)
val newItem = newBatch.data.getOrNull(index)
if (prevItem != null && newItem != null) {
converter.update(prevItem, marketsListItemUM, newItem)
} else {
newItem?.let { converter.convert(it) } ?: marketsListItemUM
}
},
)
}
}
}
currentCoroutineContext().ensureActive()
ResultBatches(
uiBatches = outItems,
processedItems = newList,
)
}
}
fun reload(searchText: String? = null) {
modelScope.launch {
resultBatches.value = ResultBatches()
actionsFlow.emit(
BatchAction.Reload(
requestParams = TokenMarketListConfig(
fiatPriceCurrency = appCurrency.value.code,
searchText = if (currentSearchText() == null) {
null
} else {
searchText ?: currentSearchText()
},
priceChangeInterval = TokenMarketListConfig.Interval.H24,
order = order,
shouldNetworks = true,
),
),
)
}
}
fun loadMore() {
modelScope.launch {
actionsFlow.emit(BatchAction.LoadMore())
}
}
fun loadCharts(batchKeys: Set<Int>) {
if (batchKeys.isEmpty()) return
modelScope.launch {
val currentData = batchFlow.state.value.data
val alreadyLoadedChartsBatchKeys = currentData
.filter { batch ->
val first = batch.data.firstOrNull() ?: return@filter false
first.tokenCharts.h24 != null
}
.map { it.key }
.toSet()
val batchesKeysToLoad = batchKeys.minus(alreadyLoadedChartsBatchKeys)
if (batchesKeysToLoad.isNotEmpty()) {
actionsFlow.emit(
BatchAction.UpdateBatches(
keys = batchesKeysToLoad,
updateRequest = TokenMarketUpdateRequest.UpdateChart(
interval = TokenMarketListConfig.Interval.H24,
currency = appCurrency.value.code,
),
async = true,
operationId = batchesKeysToLoad.toString() + "h24",
),
)
}
}
}
fun getBatchKeysByItemIds(ids: List<CryptoCurrency.RawID>): Set<Int> {
val currentData = batchFlow.state.value.data
return currentData
.filter { d -> d.data.any { ids.contains(it.id) } }
.map { it.key }
.toSet()
}
fun getTokenMarketById(id: CryptoCurrency.RawID): TokenMarket? {
return batchFlow.state.value.data
.asSequence()
.flatMap { it.data }
.firstOrNull { it.id == id }
}
private data class ResultBatches(
val uiBatches: List<Batch<Int, List<MarketsListItemUM>>> = emptyList(),
val processedItems: List<Batch<Int, List<TokenMarket>>>? = null,
)
@AssistedFactory
interface Factory {
fun create(
batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
order: TokenMarketListConfig.Order,
currentSearchText: Provider<String?>,
modelScope: CoroutineScope,
): MarketsListBatchFlowManager
}
}

View file

@ -0,0 +1,161 @@
package com.tangem.features.commonfeatures.impl.choosetoken.market.converter
import com.tangem.common.ui.charts.state.MarketChartData
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.charts.state.converter.PriceAndTimePointValuesConverter
import com.tangem.common.ui.charts.state.sorted
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.common.ui.markets.toMarketsListItemPriceAnnotated
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.format.bigdecimal.compact
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.percent
import com.tangem.core.ui.format.bigdecimal.price
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.markets.TokenMarket
import com.tangem.features.commonfeatures.impl.R
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
internal class SwapMarketsTokenItemConverter(
private val appCurrency: AppCurrency,
) : Converter<TokenMarket, MarketsListItemUM> {
private val priceAndTimePointValuesConverter = PriceAndTimePointValuesConverter(shouldFormatAxis = false)
override fun convert(value: TokenMarket): MarketsListItemUM {
return MarketsListItemUM(
id = value.id,
name = value.name,
currencySymbol = value.symbol,
ratingPosition = value.marketRating?.toString(),
marketCap = value.getMarketCap(),
iconUrl = value.imageUrlLarge,
price = value.getCurrentPrice(),
trendPercentText = value.getTrendPercent(),
trendType = value.getTrendType(),
chartData = value.getChartData(),
isUnder100kMarketCap = value.isUnderMarketCapLimit,
stakingRate = value.yieldRate?.format { percent() }?.let {
resourceReference(R.string.markets_apy_placeholder, wrappedList(it))
},
updateTimestamp = value.updateTimestamp,
networks = value.networks?.map { network ->
MarketsListItemUM.Network(
networkId = network.networkId,
contractAddress = network.contractAddress,
decimalCount = network.decimalCount,
)
},
)
}
fun update(prev: TokenMarket, prevUI: MarketsListItemUM, new: TokenMarket): MarketsListItemUM {
require(prev.id == new.id) {
"Ids is not the same during update TokenMarket item: previousItem[${prev.id}] != newItem[${new.id}]"
}
return prevUI.copy(
name = new.name,
currencySymbol = new.symbol,
ratingPosition = new.marketRating?.toString(),
marketCap = ifChanged(prev.marketCap, new.marketCap, prevUI.marketCap) { new.getMarketCap() },
iconUrl = new.imageUrlLarge,
price = ifChanged(prev = prev.tokenQuotesShort, new = new.tokenQuotesShort, prevR = prevUI.price) {
new.getCurrentPrice(
prev = prev,
)
},
trendPercentText = ifChanged(
prev.tokenQuotesShort,
new.tokenQuotesShort,
prevUI.trendPercentText,
) { new.getTrendPercent() },
trendType = ifChanged(prev.tokenQuotesShort, new.tokenQuotesShort, prevUI.trendType) { new.getTrendType() },
chartData = ifChanged(prev.tokenCharts, new.tokenCharts, prevUI.chartData) { new.getChartData() },
)
}
private inline fun <T, R> ifChanged(prev: T, new: T, prevR: R, force: Boolean = false, change: (T) -> R): R {
return if (force || prev != new) change(new) else prevR
}
private fun TokenMarket.getMarketCap(): String? {
val value = marketCap?.takeIf { marketCap != BigDecimal.ZERO } ?: return null
return value.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).compact(
threeDigitsMethod = true,
)
}
}
private fun TokenMarket.getCurrentPrice(prev: TokenMarket? = null): MarketsListItemUM.Price {
val prevPrice = prev?.tokenQuotesShort?.currentPrice
val priceText = tokenQuotesShort.currentPrice.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
).price()
}
val changeType = if (prevPrice != null) {
if (tokenQuotesShort.currentPrice > prevPrice) {
PriceChangeType.UP
} else {
PriceChangeType.DOWN
}
} else {
null
}
return MarketsListItemUM.Price(
text = priceText,
annotated = tokenQuotesShort.currentPrice.toMarketsListItemPriceAnnotated(
appCurrencyCode = appCurrency.code,
appCurrencySymbol = appCurrency.symbol,
),
changeType = changeType,
fiatPrice = tokenQuotesShort.currentPrice,
)
}
private fun TokenMarket.getChartData(): MarketChartRawData? {
val chart = tokenCharts.h24
return chart?.let { ct ->
priceAndTimePointValuesConverter.convert(
MarketChartData.Data(
y = ct.priceY.toImmutableList(),
x = ct.timeStamps.map { it.toBigDecimal() }.toImmutableList(),
).sorted(),
)
}
}
@Suppress("MagicNumber")
private fun TokenMarket.getTrendType(): PriceChangeType {
val percent = tokenQuotesShort.h24ChangePercent
val scaled = percent?.setScale(4, RoundingMode.HALF_UP)
return when {
scaled == null -> PriceChangeType.NEUTRAL
scaled > BigDecimal.ZERO -> PriceChangeType.UP
scaled < BigDecimal.ZERO -> PriceChangeType.DOWN
else -> PriceChangeType.NEUTRAL
}
}
private fun TokenMarket.getTrendPercent(): String {
val percent = tokenQuotesShort.h24ChangePercent
return percent.format { percent() }
}
}

View file

@ -0,0 +1,54 @@
package com.tangem.features.commonfeatures.impl.choosetoken.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 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() {
override val marketsTitle: TextReference = TextReference.Res(R.string.markets_common_title)
override val shouldAssetsCount: Boolean = true
}
companion object {
val DefaultLoading
get() = Loading(
marketsTitle = TextReference.Res(R.string.feed_trending_now),
shouldAssetsCount = false,
)
val SearchLoading
get() = Loading(
marketsTitle = TextReference.Res(R.string.markets_common_title),
shouldAssetsCount = true,
)
}
}

View file

@ -0,0 +1,121 @@
package com.tangem.features.commonfeatures.impl.choosetoken.model
import com.arkivanov.decompose.router.slot.dismiss
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.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarToggleTransformer
import com.tangem.features.commonfeatures.impl.choosetoken.converter.SearchBarUpdateQueryTransformer
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenFullUM
import com.tangem.features.commonfeatures.impl.choosetoken.ui.ChooseTokenInitialUM
import com.tangem.features.commonfeatures.api.R
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.*
import javax.inject.Inject
@Suppress("LongParameterList")
@ModelScoped
internal class ChooseTokenModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
marketBlockDelegateFactory: MarketBlockDelegate.Factory,
paramsContainer: ParamsContainer,
) : Model() {
private val params = paramsContainer.require<ChooseTokenComponent.Params>()
private val bridge: ChooseTokenBridge = params.bridge
private val searchQueryState: StateFlow<SearchQuery> = bridge.searchQueryState
private val isSearchingState: Boolean get() = bridge.searchQueryState.isSearchingState
private val marketBlockDelegate: MarketBlockDelegate = marketBlockDelegateFactory.create(
modelScope = modelScope,
searchQueryState = searchQueryState,
screensSourcesName = bridge.analyticsPayload
.filterIsInstance<ChooseTokenAnalyticsPayload.ScreensSources>()
.firstOrNull()?.value.orEmpty(),
)
val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot
val addToPortfolioManager get() = marketBlockDelegate.addToPortfolioManager
private val marketsStateFlow: Flow<SwapMarketState?> = if (bridge.settings.isShowMarketBlock) {
marketBlockDelegate.marketsStateFlow
} else {
flowOf(null)
}
private val initialState: MutableStateFlow<ChooseTokenInitialUM> = MutableStateFlow(getInitState())
val state: StateFlow<ChooseTokenFullUM> = combine(
flow = initialState,
flow2 = bridge.fullPortfolioBlock,
flow3 = marketsStateFlow,
transform = { initial, content, marketBlock ->
ChooseTokenFullUM(
initialUM = initial,
portfolioBlock = content,
marketsBlock = marketBlock,
)
},
).stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = ChooseTokenFullUM(
initialUM = initialState.value,
portfolioBlock = bridge.fullPortfolioBlock.value,
marketsBlock = null,
),
)
init {
addToPortfolioManager.onDismiss.receiveAsFlow()
.onEach { marketBlockDelegate.addToPortfolioSlot.dismiss() }
.launchIn(modelScope)
addToPortfolioManager.onSuccessAdded.receiveAsFlow()
.onEach { addedResult ->
val isSearched = ChooseTokenAnalyticsPayload.IsSearched(isSearchingState)
val chooseTokenResult = ChooseTokenResult(
currency = addedResult.addedCurrency,
account = addedResult.account,
wallet = addedResult.wallet,
analyticsPayload = setOf(isSearched),
)
bridge.onCurrencyChosen(chooseTokenResult)
marketBlockDelegate.addToPortfolioSlot.dismiss()
}
.launchIn(modelScope)
}
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 = bridge.settings.title,
onCloseClick = ::onBackClicked,
searchBar = getInitialSearchBar(),
)
companion object {
const val DEBOUNCE_SEARCH_DELAY = 500L
}
}

View file

@ -0,0 +1,223 @@
package com.tangem.features.commonfeatures.impl.choosetoken.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.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.addtoportfolio.AddToPortfolioManager
import com.tangem.features.commonfeatures.impl.choosetoken.AddToPortfolioRoute
import com.tangem.features.commonfeatures.impl.choosetoken.market.MarketsListBatchFlowManager
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlin.collections.filter
import kotlin.collections.map
import kotlin.collections.orEmpty
@Suppress("LongParameterList")
internal class MarketBlockDelegate @AssistedInject constructor(
private val marketsListBatchFlowManagerFactory: MarketsListBatchFlowManager.Factory,
private val excludedBlockchains: ExcludedBlockchains,
private val getUserWalletsUseCase: GetWalletsUseCase,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
@Assisted private val screensSourcesName: String,
) {
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
val addToPortfolioSlot: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
val addToPortfolioManager: AddToPortfolioManager = addToPortfolioManagerFactory.create(
scope = modelScope,
settings = AddToPortfolioManager.Settings.ChooseToken,
analyticsParams = AddToPortfolioManager.AnalyticsParams(source = screensSourcesName),
)
val marketsStateFlow: Flow<SwapMarketState> = searchQueryState
// Switch between default and search market flows
.map { it.value.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.value },
modelScope = modelScope,
)
}
init {
// Reload search markets when query changes
searchQueryState
.onEach { searchQuery ->
if (searchQuery.isSearchingState) {
searchMarketsListManager.reload(searchQuery.value)
}
}
.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.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) {
val tokenMarket = defaultMarketsListManager.getTokenMarketById(item.id)
?: searchMarketsListManager.getTokenMarketById(item.id) ?: return
val param = tokenMarket.toSerializableParam()
val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot }
val networks = tokenMarket.networks?.filter { network ->
BlockchainUtils.isSupportedNetworkId(
networkId = 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.setTokenNetworks(networks)
addToPortfolioManager.setTokenParams(param)
addToPortfolioSlot.activate(AddToPortfolioRoute)
}
@AssistedFactory
interface Factory {
fun create(
searchQueryState: StateFlow<SearchQuery>,
modelScope: CoroutineScope,
screensSourcesName: String,
): MarketBlockDelegate
}
}

View file

@ -0,0 +1,114 @@
package com.tangem.features.commonfeatures.impl.choosetoken.model
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isLocked
import com.tangem.domain.models.wallet.isMultiCurrency
import com.tangem.domain.wallets.usecase.GetSelectedWalletUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM
import com.tangem.features.commonfeatures.impl.choosetoken.SettingContextUseCase
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
@Suppress("LongParameterList")
internal class PortfolioFullBlockDelegate @AssistedInject constructor(
private val settingContextUseCase: SettingContextUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val dispatchers: CoroutineDispatcherProvider,
private val selectedWalletUseCase: GetSelectedWalletUseCase,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val portfolioListBlockDelegate: PortfolioListBlockDelegate,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
) {
private val isSearchingState: Boolean get() = searchQueryState.isSearchingState
private val onWalletSelected = Channel<UserWalletId>(capacity = Channel.BUFFERED)
val selectedWalletFlow: SharedFlow<UserWallet> = onWalletSelected.receiveAsFlow()
.distinctUntilChanged()
.mapNotNull { walletId -> getUserWalletUseCase.invoke(walletId).getOrNull() }
.shareIn(modelScope, SharingStarted.Eagerly, replay = 1)
val fullPortfolioBlock: StateFlow<ChooseTokenPortfolioFullBlockUM?> = buildFlow()
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = null)
init {
val globalSelectedWallet = selectedWalletUseCase.sync().getOrNull()
val allWallets = getWalletsUseCase.invokeSync().filter { it.isMultiCurrency }
val firstSelectedWallet = when {
globalSelectedWallet?.isMultiCurrency == true -> globalSelectedWallet
allWallets.isNotEmpty() -> allWallets.first()
else -> null
}
if (firstSelectedWallet != null) selectWalletTab(firstSelectedWallet.walletId)
}
private fun buildFlow() = flow {
val walletsFlow = getWalletsUseCase.invokeAsMap()
.map { wallets -> wallets.filterNot { (_, wallet) -> wallet.isLocked } }
val fullPortfolioBlockFlow = combine(
flow = walletsFlow,
flow2 = portfolioListBlockDelegate.portfolioList,
flow3 = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(),
flow4 = settingContextUseCase.invoke(),
transform = { allWallets, portfolioList, selectedWalletId, settings ->
val tokensListData = portfolioList[selectedWalletId] ?: return@combine null
val walletsUM = allWallets.entries
.map { (walletId, wallet) ->
val searchResultCount: TextReference? = portfolioList[walletId]?.totalTokensCount
?.toString()
?.let(::stringReference)
?.takeIf { isSearchingState }
WalletTabUM(
text = stringReference(wallet.name),
onClick = { selectWalletTab(walletId) },
isSelected = selectedWalletId == walletId,
count = searchResultCount,
)
}
val walletListUM = if (walletsUM.size != 1) {
WalletListUM(walletsUM.toPersistentList())
} else {
WalletListUM(persistentListOf())
}
ChooseTokenPortfolioFullBlockUM(
walletList = walletListUM,
isBalanceHidden = settings.isBalanceHidden,
isSearching = isSearchingState,
tokensListData = tokensListData,
)
},
)
emitAll(fullPortfolioBlockFlow)
}
fun selectWalletTab(walletId: UserWalletId) {
onWalletSelected.trySend(walletId)
}
@AssistedFactory
interface Factory {
fun create(
modelScope: CoroutineScope,
portfolioListBlockDelegate: PortfolioListBlockDelegate,
searchQueryState: StateFlow<SearchQuery>,
): PortfolioFullBlockDelegate
}
}

View file

@ -0,0 +1,153 @@
package com.tangem.features.commonfeatures.impl.choosetoken.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.ChooseTokenExpandedAccountsHolder
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.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridgeInternal.SearchQuery.Companion.isSearchingState
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
import com.tangem.features.commonfeatures.impl.choosetoken.SettingContextUseCase
import com.tangem.features.commonfeatures.impl.choosetoken.converter.ChooseTokenListItemConverter
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.*
@Suppress("LongParameterList")
internal class PortfolioListBlockDelegate @AssistedInject constructor(
private val expandedAccountsHolder: ChooseTokenExpandedAccountsHolder,
private val settingContext: SettingContextUseCase,
private val multiAccountStatusListSupplier: MultiAccountStatusListSupplier,
private val getWalletsUseCase: GetWalletsUseCase,
@Assisted private val modelScope: CoroutineScope,
@Assisted private val searchQueryState: StateFlow<SearchQuery>,
@Assisted private val featureSettings: ChooseTokenBridge.Settings,
) : ClickIntents {
private val onTokenItemClick: Channel<Pair<AccountStatus, CryptoCurrencyStatus>> = Channel()
val onTokenChosen: Channel<ChooseTokenResult> = Channel()
val tokenFilter: MutableStateFlow<(AccountStatus, CryptoCurrencyStatus) -> Boolean> =
MutableStateFlow { _, _ -> true }
val portfolioList: SharedFlow<Map<UserWalletId, TokenListUMData>> = buildDataFlow()
.distinctUntilChanged()
.shareIn(modelScope, SharingStarted.Eagerly, replay = 1)
private fun buildDataFlow(): Flow<Map<UserWalletId, TokenListUMData>> = channelFlow {
val allAccountsFlow: Flow<LinkedHashMap<UserWalletId, AccountStatusList>> =
multiAccountStatusListSupplier.invokeAsMap()
val allWalletsFlow: StateFlow<LinkedHashMap<UserWalletId, UserWallet>> =
getWalletsUseCase.invokeAsMap().stateIn(this)
onTokenItemClick.receiveAsFlow()
.onEach { (account, currencyStatus) ->
onTokenItemClick(
wallet = allWalletsFlow.value[account.accountId.userWalletId] ?: return@onEach,
account = account,
currencyStatus = currencyStatus,
)
}
.launchIn(this)
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,
flow5 = tokenFilter,
transform = { settings, allAccounts, expandedAccountsMap, searchQuery, tokenFilter ->
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,
tokenFilter = tokenFilter,
isShowPaymentAccount = featureSettings.isShowPaymentAccount,
).convert()
um
}
},
)
finalFlow.collectLatest { result -> channel.send(result) }
}
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() })
}
private fun onTokenItemClick(wallet: UserWallet, account: AccountStatus, currencyStatus: CryptoCurrencyStatus) {
val analyticsPayload = setOf(
ChooseTokenAnalyticsPayload.IsSearched(searchQueryState.isSearchingState),
)
val result = ChooseTokenResult(
account = account,
currency = currencyStatus,
wallet = wallet,
analyticsPayload = analyticsPayload,
)
onTokenChosen.trySend(result)
}
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<SearchQuery>,
modelScope: CoroutineScope,
featureSettings: ChooseTokenBridge.Settings,
): PortfolioListBlockDelegate
}
}
internal interface ClickIntents {
fun onTokenItemClick(account: AccountStatus, currencyStatus: CryptoCurrencyStatus)
fun onAccountExpandClick(account: Account)
fun onAccountCollapseClick(account: Account)
}

View file

@ -0,0 +1,498 @@
package com.tangem.features.commonfeatures.impl.choosetoken.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.tokens.portfolioTokensList
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.fields.SearchBar
import com.tangem.core.ui.components.fields.TangemSearchBarDefaults
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.components.token.AccountItemPreviewData
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.tokenlist.TokenListItem
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.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.utils.TangemSharedTransitionLayout
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
import com.tangem.features.commonfeatures.api.choosetoken.model.TokenListUMData
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletListUM
import com.tangem.features.commonfeatures.api.choosetoken.model.WalletTabUM
import com.tangem.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlin.random.Random
private const val LOAD_MORE_BUFFER = 25
private val ChooseTokenFullUM.isNotFoundState: Boolean
get() {
if (portfolioBlock == null) return false
if (marketsBlock == null) return false
return portfolioBlock.tokensListData.tokensList.isEmpty() &&
portfolioBlock.isSearching &&
marketsBlock !is SwapMarketState.Content &&
marketsBlock !is SwapMarketState.Loading
}
private val ChooseTokenFullUM.isEmptyState: Boolean
get() {
if (portfolioBlock == null) return false
if (marketsBlock == null) return false
return portfolioBlock.tokensListData.tokensList.isEmpty() &&
!portfolioBlock.isSearching &&
marketsBlock !is SwapMarketState.Content &&
marketsBlock !is SwapMarketState.Loading
}
@Composable
internal fun ChooseTokenScreen(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(color = TangemTheme.colors.background.secondary)
.fillMaxSize()
.imePadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBar(title = state.initialUM.screenTitle, onBackClick = state.initialUM.onCloseClick, Modifier)
Content(
state = state,
modifier = Modifier.weight(1f),
)
}
}
@Composable
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
.statusBarsPadding()
.height(TangemTheme.dimens.size56),
)
}
@Composable
private fun Content(state: ChooseTokenFullUM, modifier: Modifier = Modifier) {
val nestedScrollConnection = rememberHideKeyboardNestedScrollConnection()
val lazyListState = rememberLazyListState()
TangemSharedTransitionLayout(modifier) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection),
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()
if (state.portfolioBlock != null) {
walletListItem(state.portfolioBlock.walletList)
when {
state.isNotFoundState -> tokensNotFound()
state.isEmptyState -> emptyTokensList()
else -> {
tokensListItems(
tokensListData = state.portfolioBlock.tokensListData,
isBalanceHidden = state.portfolioBlock.isBalanceHidden,
)
if (state.marketsBlock != null) {
item("markets_title_spacer") { SpacerH(height = 20.dp) }
swapMarketsListItems(state.marketsBlock)
}
}
}
}
}
}
if (state.marketsBlock != null && !state.isNotFoundState && !state.isEmptyState) {
SetupMarketScrollTracker(state.marketsBlock, lazyListState)
}
}
@Composable
private fun SetupMarketScrollTracker(marketsState: SwapMarketState, lazyListState: LazyListState) {
if (marketsState !is SwapMarketState.Content) return
val onLoadMore = remember(marketsState) {
{
marketsState.loadMore()
true
}
}
VisibleItemsTracker(
lazyListState = lazyListState,
marketState = marketsState,
)
InfiniteListHandler(
listState = lazyListState,
buffer = LOAD_MORE_BUFFER,
triggerLoadMoreCheckOnItemsCountChange = true,
onLoadMore = onLoadMore,
)
}
@Composable
private fun VisibleItemsTracker(lazyListState: LazyListState, marketState: SwapMarketState.Content) {
val visibleItems by remember {
derivedStateOf {
lazyListState.layoutInfo.visibleItemsInfo.mapNotNull { itemInfo ->
marketState.items.find { it.getComposeKey() == itemInfo.key }?.id
}
}
}
LaunchedEffect(visibleItems) {
marketState.visibleIdsChanged(visibleItems)
}
}
private fun LazyListScope.assetsTitle() {
item(key = "assets_title") {
Text(
text = stringResourceSafe(R.string.swap_your_assets_title),
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.fillMaxWidth()
.padding(
top = TangemTheme.dimens.spacing20,
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
),
)
}
}
private fun LazyListScope.walletListItem(walletList: WalletListUM) {
if (walletList.items.isEmpty()) return
item("wallet_list") {
LazyRow(
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(walletList.items) { um ->
WalletTabItem(um)
}
}
}
}
@Composable
private fun WalletTabItem(state: WalletTabUM, modifier: Modifier = Modifier) {
val isSelected = state.isSelected
val backgroundColor = if (isSelected) TangemTheme.colors.button.primary else TangemTheme.colors.button.secondary
val buttonTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.primary1
val countTextColor = if (isSelected) TangemTheme.colors.text.primary2 else TangemTheme.colors.text.secondary
val countBackground = if (isSelected) {
TangemTheme.colors.button.secondary.copy(alpha = 0.2f)
} else {
TangemTheme.colors.button.primary.copy(alpha = 0.1f)
}
Row(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(backgroundColor)
.clickable(onClick = state.onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = state.text.resolveReference(),
color = buttonTextColor,
style = TangemTheme.typography.button,
)
val count = state.count
if (count != null) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.background(countBackground, shape = CircleShape)
.defaultMinSize(minWidth = 20.dp)
.padding(horizontal = 4.dp, vertical = 2.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = count.resolveReference(),
color = countTextColor,
style = TangemTheme.typography.caption1,
)
}
}
}
}
private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBalanceHidden: Boolean) {
when (tokensListData) {
is TokenListUMData.AccountList -> {
tokensListData.tokensList.forEachIndexed { index, item ->
portfolioTokensList(
portfolio = item,
portfolioIndex = index,
isBalanceHidden = isBalanceHidden,
testTag = BuyTokenScreenTestTags.LAZY_LIST_ITEM,
)
}
}
is TokenListUMData.TokenList -> {
tokensList(
items = tokensListData.tokensList,
isBalanceHidden = isBalanceHidden,
)
}
TokenListUMData.EmptyList -> Unit
}
}
private fun LazyListScope.tokensList(items: ImmutableList<TokensListItemUM>, isBalanceHidden: Boolean) {
itemsIndexed(
items = items,
key = { _, item -> item.id },
contentType = { _, item -> item::class.java },
itemContent = { index, item ->
TokenListItem(
state = item,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = items.lastIndex,
backgroundColor = TangemTheme.colors.background.primary,
)
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
.semantics { lazyListItemPosition = index },
)
},
)
}
private fun LazyListScope.emptyTokensList(modifier: Modifier = Modifier) {
item("EmptyTokensList") {
Box(
modifier = modifier
.background(TangemTheme.colors.background.secondary)
.fillParentMaxSize(),
) {
Column(modifier = Modifier.align(Alignment.Center)) {
Image(
modifier = Modifier
.size(TangemTheme.dimens.size64)
.align(Alignment.CenterHorizontally),
painter = painterResource(id = R.drawable.ic_no_token_44),
colorFilter = ColorFilter.tint(TangemTheme.colors.icon.inactive),
contentDescription = null,
)
Text(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing16)
.padding(horizontal = TangemTheme.dimens.spacing30)
.align(Alignment.CenterHorizontally),
text = stringResourceSafe(id = R.string.exchange_tokens_empty_tokens),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
}
}
}
}
private fun LazyListScope.tokensNotFound(modifier: Modifier = Modifier) {
item("TokensNotFound") {
Box(
modifier = modifier
.background(TangemTheme.colors.background.secondary)
.fillParentMaxSize(),
) {
Text(
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing32)
.padding(horizontal = TangemTheme.dimens.spacing30)
.align(Alignment.TopCenter),
text = stringResourceSafe(id = R.string.express_token_list_empty_search),
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.tertiary,
textAlign = TextAlign.Center,
)
}
}
}
@Preview
@Composable
private fun TokenScreenPreview(@PreviewParameter(ChooseTokenScreenPreviewProvider::class) state: ChooseTokenFullUM) {
TangemThemePreview {
ChooseTokenScreen(
state = state,
modifier = Modifier,
)
}
}
private val searchBar
get() = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
onQueryChange = {},
isActive = false,
onActiveChange = {},
)
private val tokenItem
get() = TokenItemState.Content(
id = Random.nextInt().toString(),
iconState = CurrencyIconState.Locked,
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Bitcoin")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "12 368,14 \$"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "0,35853044 BTC"),
subtitleState = TokenItemState.SubtitleState.CryptoPriceContent(
price = "34 496,75 \$",
priceChangePercent = "0,43 %",
type = PriceChangeType.DOWN,
),
onItemClick = {},
onItemLongClick = {},
)
private val tokens
get() = persistentListOf(
TokensListItemUM.GroupTitle(id = 111, text = stringReference("Network Bitcoin")),
TokensListItemUM.Token(state = tokenItem),
TokensListItemUM.GroupTitle(id = 222, text = stringReference("Network Ethereum")),
TokensListItemUM.Token(state = tokenItem),
TokensListItemUM.Token(state = tokenItem),
TokensListItemUM.Token(state = tokenItem),
)
private val accounts
get() = persistentListOf(
TokensListItemUM.Portfolio(
content = PortfolioItemContentUM.Tokens(
tokens = tokens.filterIsInstance<PortfolioTokensListItemUM>().toPersistentList(),
),
isExpanded = false,
isCollapsable = true,
tokenItemUM = AccountItemPreviewData.accountItem.copy(iconState = AccountItemPreviewData.accountLetterIcon),
),
TokensListItemUM.Portfolio(
content = PortfolioItemContentUM.Tokens(
tokens = tokens.filterIsInstance<PortfolioTokensListItemUM>().toPersistentList(),
),
isExpanded = true,
isCollapsable = true,
tokenItemUM = AccountItemPreviewData.accountItem,
),
)
private val wallets
get() = persistentListOf(
WalletTabUM(
text = TextReference.Str(value = "Wallet 1"),
isSelected = true,
onClick = {},
count = null,
),
WalletTabUM(
text = TextReference.Str(value = "Wallet 1"),
isSelected = true,
onClick = {},
count = stringReference("3"),
),
WalletTabUM(
text = TextReference.Str(value = "Wallet 2"),
isSelected = false,
onClick = {},
count = stringReference("333"),
),
WalletTabUM(
text = TextReference.Str(value = "Wallet 3"),
isSelected = false,
onClick = {},
count = null,
),
)
private val initialUM = ChooseTokenInitialUM(
screenTitle = stringReference("Choose token"),
onCloseClick = {},
searchBar = searchBar,
)
private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider<ChooseTokenFullUM> {
override val values: Sequence<ChooseTokenFullUM> = sequenceOf(
ChooseTokenFullUM(
initialUM = initialUM,
portfolioBlock = ChooseTokenPortfolioFullBlockUM(
walletList = WalletListUM(wallets),
isBalanceHidden = false,
isSearching = false,
tokensListData = TokenListUMData.AccountList(
tokensList = accounts,
accounts.size,
),
),
marketsBlock = SwapSelectTokenPreviewProvider.marketState,
),
ChooseTokenFullUM(
initialUM = initialUM,
portfolioBlock = ChooseTokenPortfolioFullBlockUM(
walletList = WalletListUM(wallets),
isBalanceHidden = false,
isSearching = false,
tokensListData = TokenListUMData.EmptyList,
),
marketsBlock = null,
),
)
}

View file

@ -0,0 +1,18 @@
package com.tangem.features.commonfeatures.impl.choosetoken.ui
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.features.commonfeatures.api.choosetoken.model.ChooseTokenPortfolioFullBlockUM
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
internal data class ChooseTokenFullUM(
val initialUM: ChooseTokenInitialUM,
val portfolioBlock: ChooseTokenPortfolioFullBlockUM?,
val marketsBlock: SwapMarketState?,
)
internal data class ChooseTokenInitialUM(
val screenTitle: TextReference,
val onCloseClick: () -> Unit,
val searchBar: SearchBarUM,
)

View file

@ -0,0 +1,106 @@
package com.tangem.features.commonfeatures.impl.choosetoken.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
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.features.commonfeatures.impl.R
import com.tangem.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
internal fun LazyListScope.swapMarketsListItems(state: SwapMarketState) {
item {
val totalCount = (state as? SwapMarketState.Content)?.total
Text(
text = buildAnnotatedString {
append(state.marketsTitle.resolveReference())
if (totalCount != null) {
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(" $totalCount")
}
}
},
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.fillMaxWidth().padding(horizontal = TangemTheme.dimens.spacing16),
)
}
when (state) {
is SwapMarketState.Loading -> {
items(count = 100, key = { "market_placeholder_$it" }) {
MarketsListItemPlaceholder()
}
}
is SwapMarketState.LoadingError -> {
item(key = "market_loading_error") {
LoadingErrorItem(
modifier = Modifier.fillParentMaxWidth(),
onTryAgain = state.onRetryClicked,
)
}
}
SwapMarketState.SearchNothingFound -> {
item(key = "market_not_found") {
SearchNothingFoundText(
modifier = Modifier.fillParentMaxWidth(),
)
}
}
is SwapMarketState.Content -> {
itemsIndexed(
items = state.items,
key = { _, item -> item.getComposeKey() },
) { index, item ->
MarketsListItem(
model = item,
onClick = { state.onItemClick(item) },
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = state.items.lastIndex,
backgroundColor = TangemTheme.colors.background.action,
),
)
}
}
}
}
@Composable
private fun LoadingErrorItem(onTryAgain: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier
.padding(
horizontal = TangemTheme.dimens.spacing16,
vertical = TangemTheme.dimens.spacing12,
),
contentAlignment = Alignment.Center,
) {
UnableToLoadData(onRetryClick = onTryAgain)
}
}
@Composable
private fun SearchNothingFoundText(modifier: Modifier = Modifier) {
Box(
modifier = modifier.padding(TangemTheme.dimens.spacing16),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResourceSafe(R.string.markets_search_token_no_result_title),
style = TangemTheme.typography.caption1,
color = TangemTheme.colors.text.tertiary,
)
}
}

View file

@ -0,0 +1,125 @@
package com.tangem.features.commonfeatures.impl.choosetoken.ui
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.R
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.features.commonfeatures.impl.choosetoken.market.state.SwapMarketState
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
internal object SwapSelectTokenPreviewProvider {
private const val CHART_VALUE_1 = 0.4
private const val CHART_VALUE_2 = 0.2
private const val CHART_VALUE_3 = 0.1
private const val CHART_VALUE_4 = 2.0
private const val CHART_VALUE_5 = 5.0
private const val CHART_VALUE_6 = 3.0
private const val TOTAL_ITEMS = 322
private val PREVIEW_CHART_DATA = MarketChartRawData(
y = persistentListOf(
CHART_VALUE_1,
CHART_VALUE_2,
CHART_VALUE_1,
CHART_VALUE_3,
CHART_VALUE_1,
CHART_VALUE_4,
CHART_VALUE_5,
CHART_VALUE_3,
CHART_VALUE_4,
CHART_VALUE_4,
CHART_VALUE_6,
),
)
val marketState = SwapMarketState.Content(
items = createPreviewMarketItems(),
loadMore = { },
onItemClick = { },
visibleIdsChanged = { },
total = TOTAL_ITEMS,
marketsTitle = TextReference.Res(R.string.feed_trending_now),
shouldAssetsCount = false,
)
private fun createPreviewMarketItems() = listOf(
createMarketItem(
id = "1",
iconUrl = "",
ratingPosition = "10",
marketCap = "$6.233 B",
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "2",
ratingPosition = "10",
marketCap = "$6.233 B",
trendType = PriceChangeType.NEUTRAL,
chartData = null,
),
createMarketItem(
id = "3",
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
ratingPosition = "10",
marketCap = "$6.23348172384781234 B",
trendType = PriceChangeType.DOWN,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "4",
ratingPosition = "10",
marketCap = null,
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "5",
ratingPosition = null,
marketCap = "$6.233 B",
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "6",
ratingPosition = null,
marketCap = null,
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
).toImmutableList()
private fun createMarketItem(
id: String,
name: String = "Bitcoin",
iconUrl: String? = null,
ratingPosition: String?,
marketCap: String?,
trendType: PriceChangeType,
chartData: MarketChartRawData?,
) = MarketsListItemUM(
id = CryptoCurrency.RawID(id),
name = name,
currencySymbol = "BTC",
iconUrl = iconUrl,
ratingPosition = ratingPosition,
marketCap = marketCap,
price = MarketsListItemUM.Price(
text = "31 285.72$",
annotated = stringReference("31 285.72$"),
fiatPrice = BigDecimal("123123"),
),
trendPercentText = "12.43%",
trendType = trendType,
chartData = chartData,
isUnder100kMarketCap = false,
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
)
}