Updated on 2026-08-14

This commit is contained in:
Tangem 2026-03-27 06:24:04 +03:00
parent 7a7af53464
commit 9ee1319e40
18 changed files with 786 additions and 713 deletions

View file

@ -10,6 +10,7 @@ import kotlinx.serialization.Serializable
interface AddToPortfolioManager {
// todo swap make updatable
val token: TokenMarketParams
val analyticsParams: AnalyticsParams?
val portfolioFetcher: PortfolioFetcher

View file

@ -5,7 +5,6 @@ import androidx.compose.foundation.background
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.extensions.compose.subscribeAsState
import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
@ -14,23 +13,21 @@ 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.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.ui.R
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
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.ChooseTokenComponent
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.model.SwapModel
import com.tangem.feature.swap.models.AddToPortfolioRoute
import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.ui.SwapScreen
import com.tangem.feature.swap.ui.SwapSelectTokenScreen
import com.tangem.feature.swap.ui.SwapSuccessScreen
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.swap.SwapComponent
import com.tangem.utils.extensions.isZero
@ -45,19 +42,19 @@ internal class DefaultSwapComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SwapComponent.Params,
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
private val giveApprovalComponentFactory: GiveApprovalComponent.Factory,
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
) : SwapComponent, AppComponentContext by appComponentContext {
private val model: SwapModel = getOrCreateModel(params)
private val bottomSheetSlot = childSlot(
source = model.bottomSheetNavigation,
serializer = AddToPortfolioRoute.serializer(),
key = BOTTOM_SHEET_SLOT_KEY,
handleBackButton = false,
childFactory = { _, context -> bottomSheetChild(context) },
)
// todo swap create InnerRouter
private val chooseTokenComponent by lazy {
chooseTokenComponentFactory.create(
context = child("chooseTokenComponent"),
params = ChooseTokenComponent.Params(model.chooseTokenBridge),
)
}
private val approvalSlot = childSlot(
key = APPROVAL_SLOT_KEY,
@ -161,7 +158,6 @@ internal class DefaultSwapComponent @AssistedInject constructor(
val feeSelectorChildStackState by childSlot.subscribeAsState()
val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance
val bottomSheet by bottomSheetSlot.subscribeAsState()
Crossfade(
modifier = Modifier.background(TangemTheme.colors.background.secondary),
@ -189,38 +185,14 @@ internal class DefaultSwapComponent @AssistedInject constructor(
)
}
}
SwapNavScreen.SelectToken -> {
val tokenState = model.uiState.selectTokenState
if (tokenState != null) {
SwapSelectTokenScreen(state = tokenState, onBack = model.uiState.onBackClicked)
} else {
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
SwapNavScreen.SelectToken -> chooseTokenComponent.Content(Modifier)
}
}
bottomSheet.child?.instance?.BottomSheet()
val approvalSlotState by approvalSlot.subscribeAsState()
approvalSlotState.child?.instance?.BottomSheet()
}
@Suppress("UnsafeCallOnNullableType")
private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent {
return addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(
addToPortfolioManager = model.addToPortfolioManager!!,
callback = model.addToPortfolioCallback,
shouldSkipTokenActionsScreen = true,
),
)
}
fun getApprovalParams(): GiveApprovalComponent.Params? {
val permissionState = model.uiState.permissionState as? GiveTxPermissionState.ReadyForRequest
?: return null

View file

@ -0,0 +1,82 @@
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.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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
// todo swap make universal, encapsulate, move to some common module
internal interface ChooseTokenBridge {
// todo swap new api
val onCurrencyChosen: Channel<ChooseTokenResult>
val onClose: Channel<Unit>
// todo swap legacy api, remove
val onTokenSelected: Channel<Pair<String, ChooseTokenAnalyticsPayload.IsSearched>>
val onNewTokenAdded: Channel<Pair<CryptoCurrency, ChooseTokenAnalyticsPayload.IsSearched>>
val searchQueryState: StateFlow<String>
val currenciesGroup: Flow<CurrenciesGroup>
fun onTokenSelected(tokenId: Pair<String, ChooseTokenAnalyticsPayload.IsSearched>) {
onTokenSelected.trySend(tokenId)
onSearchQuery("")
}
fun onNewTokenAdded(addedToken: Pair<CryptoCurrency, ChooseTokenAnalyticsPayload.IsSearched>) {
onNewTokenAdded.trySend(addedToken)
onSearchQuery("")
}
fun onSearchQuery(query: String)
fun updateCurrenciesGroup(currenciesGroup: CurrenciesGroup)
fun onCurrencyChosen(result: ChooseTokenResult) {
onCurrencyChosen.trySend(result)
}
fun onClose() {
onClose.trySend(Unit)
onSearchQuery("")
}
interface Factory {
fun create(modelScope: CoroutineScope): ChooseTokenBridge
}
}
data class ChooseTokenResult(
val addedCurrency: AccountCryptoCurrencyStatus,
val userWallet: UserWallet,
val analyticsPayload: Set<ChooseTokenAnalyticsPayload> = emptySet(),
) {
val status: CryptoCurrencyStatus get() = addedCurrency.status
val account: Account.CryptoPortfolio get() = addedCurrency.account
val walletId get() = userWallet.walletId
}
sealed interface ChooseTokenAnalyticsPayload {
@Suppress("BooleanPropertyNaming")
@JvmInline
value class IsSearched(val value: Boolean) : ChooseTokenAnalyticsPayload
}
internal interface ChooseTokenComponent : ComposableContentComponent {
data class Params(
val bridge: ChooseTokenBridge,
)
interface Factory : ComponentFactory<Params, ChooseTokenComponent>
}

View file

@ -0,0 +1,40 @@
package com.tangem.feature.swap.choosetoken.api
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import javax.inject.Inject
// todo swap move to some common module
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,46 @@
package com.tangem.feature.swap.choosetoken.impl
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.swap.choosetoken.api.ChooseTokenAnalyticsPayload
import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge
import com.tangem.feature.swap.choosetoken.api.ChooseTokenResult
import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel.Companion.DEBOUNCE_SEARCH_DELAY
import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup
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,
) : ChooseTokenBridge {
override val onCurrencyChosen: Channel<ChooseTokenResult> = Channel()
override val onTokenSelected: Channel<Pair<String, ChooseTokenAnalyticsPayload.IsSearched>> = Channel()
override val onNewTokenAdded: Channel<Pair<CryptoCurrency, ChooseTokenAnalyticsPayload.IsSearched>> = Channel()
override val onClose: Channel<Unit> = Channel()
private val onSearchQuery: Channel<String> = Channel()
override val searchQueryState: StateFlow<String> = onSearchQuery.receiveAsFlow()
.debounce(DEBOUNCE_SEARCH_DELAY)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = "")
private val _currenciesGroupFlow = MutableStateFlow<CurrenciesGroup?>(null)
override val currenciesGroup: Flow<CurrenciesGroup> = _currenciesGroupFlow.filterNotNull()
override fun onSearchQuery(query: String) {
onSearchQuery.trySend(query)
}
override fun updateCurrenciesGroup(currenciesGroup: CurrenciesGroup) {
_currenciesGroupFlow.update { currenciesGroup }
}
@AssistedFactory
interface Factory : ChooseTokenBridge.Factory {
override fun create(modelScope: CoroutineScope): DefaultChooseTokenBridge
}
}

View file

@ -0,0 +1,72 @@
package com.tangem.feature.swap.choosetoken.impl
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.ui.decompose.ComposableBottomSheetComponent
import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent
import com.tangem.feature.swap.choosetoken.impl.model.ChooseTokenModel
import com.tangem.feature.swap.models.AddToPortfolioRoute
import com.tangem.feature.swap.ui.SwapSelectTokenScreen
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
internal class DefaultChooseTokenComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: ChooseTokenComponent.Params,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
) : 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 state by model.state.collectAsStateWithLifecycle()
val bottomSheet by bottomSheetSlot.subscribeAsState()
state?.let { stateHolder ->
SwapSelectTokenScreen(state = stateHolder, onBack = { model.onBackClicked() })
}
bottomSheet.child?.instance?.BottomSheet()
}
@Suppress("UnsafeCallOnNullableType")
private fun bottomSheetChild(componentContext: ComponentContext): ComposableBottomSheetComponent {
return addToPortfolioComponentFactory.create(
context = childByContext(componentContext),
params = AddToPortfolioComponent.Params(
addToPortfolioManager = model.addToPortfolioManager!!,
callback = model.addToPortfolioCallback,
shouldSkipTokenActionsScreen = true,
),
)
}
@AssistedFactory
interface Factory : ChooseTokenComponent.Factory {
override fun create(
context: AppComponentContext,
params: ChooseTokenComponent.Params,
): DefaultChooseTokenComponent
}
private companion object {
const val BOTTOM_SHEET_SLOT_KEY = "choosePortfolioTokenBottomSheetSlot"
}
}

View file

@ -0,0 +1,39 @@
package com.tangem.feature.swap.choosetoken.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge
import com.tangem.feature.swap.choosetoken.api.ChooseTokenComponent
import com.tangem.feature.swap.choosetoken.impl.DefaultChooseTokenBridge
import com.tangem.feature.swap.choosetoken.impl.DefaultChooseTokenComponent
import com.tangem.feature.swap.choosetoken.impl.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,289 @@
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.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.wallet.UserWallet
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.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.market.state.SwapMarketState
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.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@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,
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()
val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
var addToPortfolioManager: AddToPortfolioManager? = null
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
override fun onSuccess(addedToken: CryptoCurrency) {
modelScope.launch {
val newToken = addedToken to ChooseTokenAnalyticsPayload
.IsSearched(searchQueryState.value.isNotEmpty())
bridge.onNewTokenAdded(newToken)
bottomSheetNavigation.dismiss()
}
}
}
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,
)
}
val state: StateFlow<SwapSelectTokenStateHolder?> = combineUI()
init {
subscribeMarketTokens()
}
private fun combineUI(): StateFlow<SwapSelectTokenStateHolder?> = combine(
flow = bridge.currenciesGroup,
flow2 = settingContextUseCase.invoke(),
flow3 = marketsStateFlow(),
transform = { currenciesGroup, settingContext, marketState ->
val isAccountsMode = settingContext.isAccountsMode
val appCurrency = settingContext.appCurrency
val isBalanceHidden = settingContext.isBalanceHidden
TokensDataConverter(
onSearchEntered = { query -> bridge.onSearchQuery(query) },
onTokenSelected = { tokenId ->
val selected = tokenId to ChooseTokenAnalyticsPayload
.IsSearched(searchQueryState.value.isNotEmpty())
bridge.onTokenSelected(selected)
},
tokensDataState = currenciesGroup,
isBalanceHidden = isBalanceHidden,
isAccountsMode = isAccountsMode,
appCurrency = appCurrency,
marketState = 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()
}
}
private fun subscribeMarketTokens() {
// 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 = ScreensSources.Swap.value),
).apply {
setTokenNetworks(networks)
}
addToPortfolioManager?.state
?.firstOrNull { it is AddToPortfolioManager.State.AvailableToAdd }
?.run { bottomSheetNavigation.activate(AddToPortfolioRoute) }
}.saveIn(addToPortfolioJobHolder)
}
fun onBackClicked() {
bridge.onClose()
}
companion object {
const val DEBOUNCE_SEARCH_DELAY = 500L
}
}

View file

@ -5,76 +5,70 @@ import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.SwapStateHolder
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.utils.Provider
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.plus
import kotlinx.collections.immutable.toPersistentList
@Suppress("LongParameterList")
internal class TokensDataConverter(
private val onSearchEntered: (String) -> Unit,
private val onTokenSelected: (String) -> Unit,
private val tokensDataState: CurrenciesGroup,
private val isBalanceHidden: Boolean,
private val isAccountsMode: Boolean,
appCurrencyProvider: Provider<AppCurrency>,
) : Transformer<SwapStateHolder> {
private val appCurrency: AppCurrency,
private val marketState: SwapMarketState,
) {
private val accountListItemConverter = AccountTokenItemConverter(
appCurrency = appCurrencyProvider(),
appCurrency = appCurrency,
unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header),
onItemClick = onTokenSelected,
)
override fun transform(prevState: SwapStateHolder): SwapStateHolder {
fun transform(): SwapSelectTokenStateHolder {
val accountList = tokensDataState.accountCurrencyList
val currentMarketsState = prevState.selectTokenState?.marketsState
return prevState.copy(
selectTokenState = SwapSelectTokenStateHolder(
availableTokens = persistentListOf(),
unavailableTokens = persistentListOf(),
tokensListData = if (isAccountsMode) {
val portfolioList = accountListItemConverter.convertList(accountList).toPersistentList()
val totalTokensCount = portfolioList.sumOf { it.tokens.size }
if (totalTokensCount > 0) {
TokenListUMData.AccountList(
tokensList = portfolioList,
totalTokensCount = totalTokensCount,
)
} else {
TokenListUMData.EmptyList
}
return SwapSelectTokenStateHolder(
tokensListData = if (isAccountsMode) {
val portfolioList = accountListItemConverter.convertList(accountList).toPersistentList()
val totalTokensCount = portfolioList.sumOf { it.tokens.size }
if (totalTokensCount > 0) {
TokenListUMData.AccountList(
tokensList = portfolioList,
totalTokensCount = totalTokensCount,
)
} else {
val tokensList = accountList.flatMap { (_, currencyList) ->
currencyList.asSequence().map { accountSwapCurrency ->
accountListItemConverter.createAvailableItemConverter()
.convert(accountSwapCurrency.cryptoCurrencyStatus)
}.map(TokensListItemUM::Token).toPersistentList()
}.toPersistentList()
TokenListUMData.EmptyList
}
} else {
val tokensList = accountList.flatMap { (_, currencyList) ->
currencyList.asSequence().map { accountSwapCurrency ->
accountListItemConverter.createAvailableItemConverter()
.convert(accountSwapCurrency.cryptoCurrencyStatus)
}.map(TokensListItemUM::Token).toPersistentList()
}.toPersistentList()
if (tokensList.isNotEmpty()) {
TokenListUMData.TokenList(
tokensList = persistentListOf(
TokensListItemUM.GroupTitle(
id = "available_tokens_title",
text = resourceReference(R.string.exchange_tokens_available_tokens_header),
),
) + tokensList,
totalTokensCount = tokensList.size,
)
} else {
TokenListUMData.EmptyList
}
},
marketsState = currentMarketsState,
onSearchEntered = onSearchEntered,
onTokenSelected = onTokenSelected,
isBalanceHidden = isBalanceHidden,
isAfterSearch = tokensDataState.isAfterSearch,
),
if (tokensList.isNotEmpty()) {
TokenListUMData.TokenList(
tokensList = persistentListOf(
TokensListItemUM.GroupTitle(
id = "available_tokens_title",
text = resourceReference(R.string.exchange_tokens_available_tokens_header),
),
) + tokensList,
totalTokensCount = tokensList.size,
)
} else {
TokenListUMData.EmptyList
}
},
marketsState = marketState,
onSearchEntered = onSearchEntered,
isBalanceHidden = isBalanceHidden,
isAfterSearch = tokensDataState.isAfterSearch,
)
}
}

View file

@ -10,12 +10,10 @@ import com.arkivanov.decompose.router.slot.SlotNavigation
import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.dismiss
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
@ -38,29 +36,25 @@ import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
import com.tangem.domain.account.status.supplier.SingleAccountStatusListSupplier
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.account.status.utils.CryptoCurrencyStatusOperations.getCryptoCurrencyStatus
import com.tangem.domain.account.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.card.common.extensions.hotWalletExcludedBlockchains
import com.tangem.domain.express.models.ExpressOperationType
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.feedback.models.BlockchainErrorInfo
import com.tangem.domain.feedback.models.FeedbackEmailType
import com.tangem.domain.markets.GetMarketsTokenListFlowUseCase
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketListConfig
import com.tangem.domain.markets.toSerializableParam
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.promo.ShouldShowStoriesUseCase
import com.tangem.domain.promo.models.StoryContentIds
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
@ -68,8 +62,6 @@ import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.promo.ShouldShowStoriesUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.transaction.error.GetFeeError
@ -77,8 +69,8 @@ import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.choosetoken.api.ChooseTokenBridge
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter
import com.tangem.feature.swap.domain.SwapInteractor
@ -89,12 +81,9 @@ import com.tangem.feature.swap.domain.models.ExpressException
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.AddToPortfolioRoute
import com.tangem.feature.swap.models.SwapAlertUM
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.market.SwapMarketsListBatchFlowManager
import com.tangem.feature.swap.models.market.state.SwapMarketState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.router.SwapRouter
@ -102,20 +91,16 @@ import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalFeatureToggles
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioManager
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.swap.SwapComponent
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.lib.crypto.BlockchainUtils
import com.tangem.utils.Provider
import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP
import com.tangem.utils.coroutines.*
import com.tangem.utils.isNullOrZero
import com.tangem.utils.logging.TangemLogger
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import com.tangem.utils.logging.TangemLogger
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
@ -156,15 +141,11 @@ internal class SwapModel @Inject constructor(
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
private val swapFeatureToggles: SwapFeatureToggles,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val excludedBlockchains: ExcludedBlockchains,
private val getUserWalletsUseCase: GetWalletsUseCase,
private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase,
private val appsFlyerStore: AppsFlyerStore,
private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles,
private val messageSender: UiMessageSender,
chooseTokenBridgeFactory: ChooseTokenBridge.Factory,
giveApprovalFeatureToggles: GiveApprovalFeatureToggles,
) : Model() {
@ -191,6 +172,8 @@ internal class SwapModel @Inject constructor(
private val selectedAppCurrencyFlow: StateFlow<AppCurrency> = createSelectedAppCurrencyFlow()
val chooseTokenBridge: ChooseTokenBridge = chooseTokenBridgeFactory.create(modelScope)
private val stateBuilder = StateBuilder(
userWalletProvider = Provider { userWallet },
actions = createUiActions(),
@ -207,7 +190,6 @@ internal class SwapModel @Inject constructor(
?: error("NumberFormat is not DecimalFormat"),
)
private val amountDebouncer = Debouncer()
private val searchDebouncer = Debouncer()
private val singleTaskScheduler = SingleTaskScheduler<Map<SwapProvider, SwapState>>()
val dataStateStateFlow = MutableStateFlow(SwapProcessDataState())
@ -256,44 +238,13 @@ internal class SwapModel @Inject constructor(
private val fromTokenBalanceJobHolder = JobHolder()
private val toTokenBalanceJobHolder = JobHolder()
private val addToPortfolioJobHolder = JobHolder()
private var isAmountChangedByUser: Boolean = false
private var lastPermissionNotificationTokens: Pair<String, String>? = null
private val searchQueryState = MutableStateFlow("")
private val visibleMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private val visibleDefaultMarketItemIds = MutableStateFlow<List<CryptoCurrency.RawID>>(emptyList())
private var latestMarketsState: SwapMarketState? = null
private val defaultMarketsListManager by lazy {
SwapMarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Main,
order = TokenMarketListConfig.Order.Trending,
currentAppCurrency = Provider { selectedAppCurrencyFlow.value },
currentSearchText = Provider { null },
modelScope = modelScope,
dispatchers = dispatchers,
)
}
private val searchMarketsListManager by lazy {
SwapMarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
order = TokenMarketListConfig.Order.ByRating,
currentAppCurrency = Provider { selectedAppCurrencyFlow.value },
currentSearchText = Provider { searchQueryState.value },
modelScope = modelScope,
dispatchers = dispatchers,
)
}
val currentScreen: SwapNavScreen
get() = swapRouter.currentScreen
val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
val approvalSlotNavigation = SlotNavigation<Unit>()
private val shouldUseGaslessApproval: Boolean = giveApprovalFeatureToggles.isGaslessApprovalEnabled
@ -321,35 +272,30 @@ internal class SwapModel @Inject constructor(
}
}
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
override fun onSuccess(addedToken: CryptoCurrency) {
modelScope.launch {
bottomSheetNavigation.dismiss()
analyticsEventHandler.send(
SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = addedToken.symbol),
)
analyticsEventHandler.send(
SwapAnalyticsEvent.TokenSelected(
token = addedToken.symbol,
source = ScreensSources.Markets,
isSearched = searchQueryState.value.isNotEmpty(),
),
)
searchQueryState.value = ""
getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken)
.firstOrNull {
it.status.value is CryptoCurrencyStatus.Loaded
}?.let { (account, status) ->
applyAddedToken(status, account)
}
}
}
}
var addToPortfolioManager: AddToPortfolioManager? = null
init {
chooseTokenBridge.searchQueryState
.onEach { query -> onSearchEntered(query) }
.launchIn(modelScope)
chooseTokenBridge.onNewTokenAdded.receiveAsFlow()
.onEach { (addedToken, isSearched) ->
applyAddedToken(addedToken, isSearched.value)
}
.launchIn(modelScope)
chooseTokenBridge.onTokenSelected.receiveAsFlow()
.onEach { (addedToken, isSearched) ->
onTokenSelect(addedToken, isSearched.value)
}
.launchIn(modelScope)
chooseTokenBridge.onClose.receiveAsFlow()
.onEach {
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false))
swapRouter.back()
}
.launchIn(modelScope)
modelScope.launch {
val storyId = StoryContentIds.STORY_FIRST_TIME_SWAP.id
if (shouldShowStoriesUseCase.invokeSync(storyId)) {
@ -423,32 +369,6 @@ internal class SwapModel @Inject constructor(
uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden)
}
.launchIn(modelScope)
subscribeMarketTokens()
modelScope.launch {
visibleMarketItemIds.mapNotNull { rawIDS ->
if (rawIDS.isNotEmpty()) {
searchMarketsListManager.getBatchKeysByItemIds(rawIDS)
} else {
null
}
}.distinctUntilChanged().collectLatest { visibleBatchKeys ->
searchMarketsListManager.loadCharts(visibleBatchKeys)
}
}
modelScope.launch {
visibleDefaultMarketItemIds.mapNotNull { rawIds ->
if (rawIds.isNotEmpty()) {
defaultMarketsListManager.getBatchKeysByItemIds(rawIds)
} else {
null
}
}.distinctUntilChanged().collectLatest { visibleBatchKeys ->
defaultMarketsListManager.loadCharts(visibleBatchKeys)
}
}
}
fun onStart() {
@ -474,41 +394,6 @@ internal class SwapModel @Inject constructor(
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(hasAvailableTokens = isAnyAvailableTokens))
}
private fun subscribeMarketTokens() {
if (swapFeatureToggles.isMarketListFeatureEnabled) {
// Switch between default and search market flows
searchQueryState
.map { it.isEmpty() }
.distinctUntilChanged()
.flatMapLatest { isDefaultMode ->
if (isDefaultMode) {
visibleMarketItemIds.value = emptyList()
createDefaultMarketsFlow()
} else {
visibleDefaultMarketItemIds.value = emptyList()
createSearchMarketsFlow()
}
}
.onEach { marketsState ->
latestMarketsState = marketsState
applyMarketsState(marketsState)
}
.launchIn(modelScope)
// Reload search markets when query changes
searchQueryState
.onEach { searchQuery ->
if (searchQuery.isNotEmpty()) {
searchMarketsListManager.reload(searchQuery)
}
}
.launchIn(modelScope)
// Initial load of default markets
defaultMarketsListManager.reload()
}
}
@Suppress("LongMethod")
private fun initTokens(isReverseFromTo: Boolean) {
modelScope.launch(dispatchers.main) {
@ -583,26 +468,40 @@ internal class SwapModel @Inject constructor(
}
}
private fun applyAddedToken(addedToken: CryptoCurrencyStatus, addedAccount: Account.CryptoPortfolio?) {
modelScope.launch {
runCatching(dispatchers.io) {
swapInteractor.getTokensDataState(initialCurrencyFrom)
}.onSuccess { state ->
updateTokensState(state)
private suspend fun applyAddedToken(addedToken: CryptoCurrency, isSearched: Boolean) {
analyticsEventHandler.send(
SwapEvents.ChooseTokenScreenResult(isTokenChosen = true, token = addedToken.symbol),
)
analyticsEventHandler.send(
SwapAnalyticsEvent.TokenSelected(
token = addedToken.symbol,
source = ScreensSources.Markets,
isSearched = isSearched,
),
)
val status = getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken)
// todo swap are sure?? about status.value is CryptoCurrencyStatus.Loaded
.firstOrNull { it.status.value is CryptoCurrencyStatus.Loaded }
?: return
val (selectedAccount, selectedCurrency) = status
applyInitialTokenChoice(
state = state,
selectedCurrency = addedToken,
selectedAccount = addedAccount,
isReverseFromTo = isOrderReversed,
)
runCatching(dispatchers.io) {
swapInteractor.getTokensDataState(initialCurrencyFrom)
}.onSuccess { state ->
updateTokensState(state)
subscribeToCoinBalanceUpdatesIfNeeded()
applyInitialTokenChoice(
state = state,
selectedCurrency = selectedCurrency,
selectedAccount = selectedAccount,
isReverseFromTo = isOrderReversed,
)
swapRouter.back()
}.onFailure { error ->
TangemLogger.e("Error", error)
}
subscribeToCoinBalanceUpdatesIfNeeded()
swapRouter.back()
}.onFailure { error ->
TangemLogger.e("Error", error)
}
}
@ -686,23 +585,7 @@ internal class SwapModel @Inject constructor(
private fun updateTokensState(tokenDataState: TokensDataStateExpress) {
val tokensDataState = if (isOrderReversed) tokenDataState.fromGroup else tokenDataState.toGroup
uiState = stateBuilder.addTokensToStateV2(
uiState = uiState,
tokensDataState = tokensDataState,
isAccountsMode = isAccountsMode,
)
latestMarketsState?.let(::applyMarketsState)
}
private fun applyMarketsState(marketsState: SwapMarketState) {
uiState.selectTokenState?.let { currentSelectState ->
uiState = uiState.copy(
selectTokenState = currentSelectState.copy(
marketsState = marketsState,
),
)
}
chooseTokenBridge.updateCurrenciesGroup(tokensDataState)
}
private fun startLoadingQuotes(
@ -1274,65 +1157,61 @@ internal class SwapModel @Inject constructor(
}
private fun onSearchEntered(searchQuery: String) {
searchDebouncer.debounce(modelScope, DEBOUNCE_SEARCH_DELAY) {
searchQueryState.value = searchQuery
val tokenDataState = dataState.tokensDataState ?: return@debounce
val group = if (isOrderReversed) {
tokenDataState.fromGroup
} else {
tokenDataState.toGroup
}
val available = group.available.filter { swapAvailability ->
swapAvailability.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) ||
swapAvailability.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true)
}
val unavailable = group.unavailable.filter { swapAvailability ->
swapAvailability.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) ||
swapAvailability.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true)
}
val accountCurrencyList = group.accountCurrencyList.mapNotNull { accountSwapAvailability ->
val filteredCurrencies = accountSwapAvailability.currencyList.filter { accountSwapCurrency ->
val currency = accountSwapCurrency.cryptoCurrencyStatus.currency
currency.name.contains(searchQuery, ignoreCase = true) ||
currency.symbol.contains(searchQuery, ignoreCase = true)
}
if (filteredCurrencies.isEmpty()) {
return@mapNotNull null
}
accountSwapAvailability.copy(
currencyList = filteredCurrencies,
)
}
val filteredTokenDataState = if (isOrderReversed) {
tokenDataState.copy(
fromGroup = tokenDataState.fromGroup.copy(
available = available,
unavailable = unavailable,
accountCurrencyList = accountCurrencyList,
isAfterSearch = true,
),
)
} else {
tokenDataState.copy(
toGroup = tokenDataState.toGroup.copy(
available = available,
unavailable = unavailable,
accountCurrencyList = accountCurrencyList,
isAfterSearch = true,
),
)
}
updateTokensState(filteredTokenDataState)
val tokenDataState = dataState.tokensDataState ?: return
val group = if (isOrderReversed) {
tokenDataState.fromGroup
} else {
tokenDataState.toGroup
}
val available = group.available.filter { swapAvailability ->
swapAvailability.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) ||
swapAvailability.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true)
}
val unavailable = group.unavailable.filter { swapAvailability ->
swapAvailability.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) ||
swapAvailability.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true)
}
val accountCurrencyList = group.accountCurrencyList.mapNotNull { accountSwapAvailability ->
val filteredCurrencies = accountSwapAvailability.currencyList.filter { accountSwapCurrency ->
val currency = accountSwapCurrency.cryptoCurrencyStatus.currency
currency.name.contains(searchQuery, ignoreCase = true) ||
currency.symbol.contains(searchQuery, ignoreCase = true)
}
if (filteredCurrencies.isEmpty()) {
return@mapNotNull null
}
accountSwapAvailability.copy(
currencyList = filteredCurrencies,
)
}
val filteredTokenDataState = if (isOrderReversed) {
tokenDataState.copy(
fromGroup = tokenDataState.fromGroup.copy(
available = available,
unavailable = unavailable,
accountCurrencyList = accountCurrencyList,
isAfterSearch = true,
),
)
} else {
tokenDataState.copy(
toGroup = tokenDataState.toGroup.copy(
available = available,
unavailable = unavailable,
accountCurrencyList = accountCurrencyList,
isAfterSearch = true,
),
)
}
updateTokensState(filteredTokenDataState)
}
@Suppress("LongMethod")
private fun onTokenSelect(id: String) {
private fun onTokenSelect(id: String, isSearched: Boolean) {
val tokens = dataState.tokensDataState ?: return
val (foundToken, foundAccount) = getSelectedTokenAndAccount(tokens, id)
@ -1345,7 +1224,7 @@ internal class SwapModel @Inject constructor(
SwapAnalyticsEvent.TokenSelected(
token = symbol,
source = ScreensSources.Portfolio,
isSearched = searchQueryState.value.isNotEmpty(),
isSearched = isSearched,
),
)
}
@ -1709,8 +1588,6 @@ internal class SwapModel @Inject constructor(
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun createUiActions(): UiActions {
return UiActions(
onSearchEntered = { onSearchEntered(it) },
onTokenSelected = { onTokenSelect(it) },
onAmountChanged = { onAmountChanged(it) },
onSwapClick = {
onSwapClick()
@ -1738,9 +1615,6 @@ internal class SwapModel @Inject constructor(
if (bottomSheet != null && bottomSheet.isShown) {
uiState = stateBuilder.dismissBottomSheet(uiState)
} else {
if (swapRouter.currentScreen == SwapNavScreen.SelectToken) {
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(isTokenChosen = false))
}
swapRouter.back()
}
onSearchEntered("")
@ -2180,110 +2054,6 @@ internal class SwapModel @Inject constructor(
}
}
private fun createDefaultMarketsFlow(): Flow<SwapMarketState> {
val marketsTitle = TextReference.Res(R.string.feed_trending_now)
return combine(
defaultMarketsListManager.uiItems,
defaultMarketsListManager.isInInitialLoadingErrorState,
defaultMarketsListManager.totalCount,
) { uiItems, isError, total ->
when {
isError -> SwapMarketState.LoadingError(
onRetryClicked = { defaultMarketsListManager.reload() },
marketsTitle = marketsTitle,
shouldAssetsCount = false,
)
uiItems.isEmpty() -> SwapMarketState.Loading(
marketsTitle = marketsTitle,
shouldAssetsCount = false,
)
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { defaultMarketsListManager.loadMore() },
onItemClick = { item -> addToPortfolioItem(item) },
visibleIdsChanged = { visibleDefaultMarketItemIds.value = it },
total = total ?: uiItems.size,
marketsTitle = marketsTitle,
shouldAssetsCount = false,
)
}
}
}
private fun createSearchMarketsFlow(): Flow<SwapMarketState> {
val marketsTitle = TextReference.Res(R.string.markets_common_title)
return combine(
flow = searchMarketsListManager.uiItems,
flow2 = searchMarketsListManager.isInInitialLoadingErrorState,
flow3 = searchMarketsListManager.isSearchNotFoundState,
flow4 = searchMarketsListManager.totalCount,
) { uiItems, isError, isSearchNotFound, total ->
when {
isError -> SwapMarketState.LoadingError(
onRetryClicked = { searchMarketsListManager.reload(searchQueryState.value) },
marketsTitle = marketsTitle,
shouldAssetsCount = true,
)
isSearchNotFound -> SwapMarketState.SearchNothingFound
uiItems.isEmpty() -> SwapMarketState.Loading(
marketsTitle = marketsTitle,
shouldAssetsCount = true,
)
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { searchMarketsListManager.loadMore() },
onItemClick = { item -> addToPortfolioItem(item) },
visibleIdsChanged = { visibleMarketItemIds.value = it },
total = total ?: uiItems.size,
marketsTitle = marketsTitle,
shouldAssetsCount = true,
)
}
}
}
private fun addToPortfolioItem(item: MarketsListItemUM) {
modelScope.launch {
val tokenMarket = 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 CryptoCurrency.getNetworkInfo(): NetworkInfo {
return NetworkInfo(
name = this.network.name,
@ -2480,7 +2250,6 @@ internal class SwapModel @Inject constructor(
const val INITIAL_AMOUNT = ""
const val UPDATE_DELAY = 10000L
const val DEBOUNCE_AMOUNT_DELAY = 1000L
const val DEBOUNCE_SEARCH_DELAY = 500L
const val UPDATE_BALANCE_DELAY_MILLIS = 11000L
const val SWAP_IN_PROGRESS_DELAY = 200L
const val CHANGELLY_PROVIDER_ID = "changelly"

View file

@ -1,43 +1,20 @@
package com.tangem.feature.swap.models
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import androidx.compose.runtime.Immutable
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.feature.swap.models.market.state.SwapMarketState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
internal data class SwapSelectTokenStateHolder(
val availableTokens: ImmutableList<TokenToSelectState>,
val unavailableTokens: ImmutableList<TokenToSelectState>,
val marketsState: SwapMarketState? = null,
val marketsState: SwapMarketState,
val tokensListData: TokenListUMData,
val isBalanceHidden: Boolean,
val isAfterSearch: Boolean,
val onSearchEntered: (String) -> Unit,
val onTokenSelected: (String) -> Unit,
)
internal sealed class TokenToSelectState {
data class Title(val title: TextReference) : TokenToSelectState()
data class TokenToSelect(
val id: String,
val name: String,
val symbol: String,
val tokenIcon: CurrencyIconState,
val isAvailable: Boolean = true,
val addedTokenBalanceData: TokenBalanceData? = null,
) : TokenToSelectState()
}
internal data class TokenBalanceData(
val amount: String?,
val amountEquivalent: String?,
val isBalanceHidden: Boolean,
)
@Immutable
internal sealed interface TokenListUMData {
val tokensList: ImmutableList<TokensListItemUM>
@ -64,11 +41,11 @@ internal sealed interface TokenListUMData {
}
internal val SwapSelectTokenStateHolder.isNotFoundState: Boolean
get() = availableTokens.isEmpty() && unavailableTokens.isEmpty() &&
get() =
tokensListData.tokensList.isEmpty() && isAfterSearch &&
marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading
marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading
internal val SwapSelectTokenStateHolder.isEmptyState: Boolean
get() = availableTokens.isEmpty() && unavailableTokens.isEmpty() &&
get() =
tokensListData.tokensList.isEmpty() && !isAfterSearch &&
marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading
marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading

View file

@ -28,7 +28,6 @@ internal data class SwapStateHolder(
val priceImpact: PriceImpact,
val successState: SwapSuccessStateHolder? = null,
val selectTokenState: SwapSelectTokenStateHolder? = null,
val bottomSheetConfig: TangemBottomSheetConfig? = null,
val swapButton: SwapButton,
val shouldShowMaxAmount: Boolean,

View file

@ -7,8 +7,6 @@ import com.tangem.feature.swap.domain.models.ui.TxFee
import java.math.BigDecimal
data class UiActions(
val onSearchEntered: (String) -> Unit,
val onTokenSelected: (String) -> Unit,
val onAmountChanged: (String) -> Unit,
val onAmountSelected: (Boolean) -> Unit,
val onSwapClick: () -> Unit,

View file

@ -1,6 +1,7 @@
package com.tangem.feature.swap.models.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
@ -12,6 +13,9 @@ 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
@ -19,14 +23,14 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@Suppress("LongParameterList")
internal class SwapMarketsListBatchFlowManager(
internal class MarketsListBatchFlowManager @AssistedInject constructor(
getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
private val batchFlowType: GetMarketsTokenListFlowUseCase.BatchFlowType,
private val order: TokenMarketListConfig.Order,
private val currentAppCurrency: Provider<AppCurrency>,
private val currentSearchText: Provider<String?>,
private val modelScope: CoroutineScope,
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)
@ -43,6 +47,9 @@ internal class SwapMarketsListBatchFlowManager(
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 ->
@ -114,7 +121,7 @@ internal class SwapMarketsListBatchFlowManager(
val items = resultBatches.uiBatches
val previousList = resultBatches.processedItems
val converter = SwapMarketsTokenItemConverter(appCurrency = currentAppCurrency())
val converter = SwapMarketsTokenItemConverter(appCurrency = appCurrency.value)
if (newList.isEmpty()) {
return@update ResultBatches(processedItems = emptyList())
@ -178,7 +185,7 @@ internal class SwapMarketsListBatchFlowManager(
actionsFlow.emit(
BatchAction.Reload(
requestParams = TokenMarketListConfig(
fiatPriceCurrency = currentAppCurrency().code,
fiatPriceCurrency = appCurrency.value.code,
searchText = if (currentSearchText() == null) {
null
} else {
@ -220,7 +227,7 @@ internal class SwapMarketsListBatchFlowManager(
keys = batchesKeysToLoad,
updateRequest = TokenMarketUpdateRequest.UpdateChart(
interval = TokenMarketListConfig.Interval.H24,
currency = currentAppCurrency().code,
currency = appCurrency.value.code,
),
async = true,
operationId = batchesKeysToLoad.toString() + "h24",
@ -250,4 +257,14 @@ internal class SwapMarketsListBatchFlowManager(
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

@ -2,9 +2,9 @@ package com.tangem.feature.swap.models.market.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.core.ui.R
import kotlinx.collections.immutable.ImmutableList
@Immutable
@ -38,4 +38,17 @@ internal sealed class 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

@ -23,7 +23,6 @@ import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
@ -658,21 +657,6 @@ internal class StateBuilder(
)
}
fun addTokensToStateV2(
uiState: SwapStateHolder,
tokensDataState: CurrenciesGroup,
isAccountsMode: Boolean,
): SwapStateHolder {
return TokensDataConverter(
onSearchEntered = actions.onSearchEntered,
onTokenSelected = actions.onTokenSelected,
appCurrencyProvider = appCurrencyProvider,
tokensDataState = tokensDataState,
isBalanceHidden = isBalanceHiddenProvider(),
isAccountsMode = isAccountsMode,
).transform(uiState)
}
fun createSilentLoadState(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS,
@ -757,28 +741,10 @@ internal class StateBuilder(
val patchedReceiveCardData = uiState.receiveCardData.copy(
isBalanceHidden = isBalanceHidden,
)
val selectTokenState = uiState.selectTokenState?.copy(
isBalanceHidden = isBalanceHidden,
availableTokens = uiState.selectTokenState.availableTokens.map { tokenState ->
when (tokenState) {
is TokenToSelectState.TokenToSelect -> {
tokenState.copy(
addedTokenBalanceData = tokenState.addedTokenBalanceData?.copy(
isBalanceHidden = isBalanceHidden,
),
)
}
is TokenToSelectState.Title -> {
tokenState
}
}
}.toImmutableList(),
)
return uiState.copy(
sendCardData = patchedSendCardData,
receiveCardData = patchedReceiveCardData,
selectTokenState = selectTokenState,
)
}

View file

@ -3,7 +3,6 @@ package com.tangem.feature.swap.ui
import androidx.activity.compose.BackHandler
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.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
@ -26,19 +25,13 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
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.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.SpacerW2
import com.tangem.core.ui.components.appbar.ExpandableSearchView
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.tokenlist.PortfolioListItem
import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem
import com.tangem.core.ui.components.tokenlist.TokenListItem
@ -51,7 +44,6 @@ import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.feature.swap.models.TokenToSelectState
import com.tangem.feature.swap.models.isEmptyState
import com.tangem.feature.swap.models.isNotFoundState
import com.tangem.feature.swap.models.market.state.SwapMarketState
@ -59,7 +51,6 @@ import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.ui.market.swapMarketsListItems
import com.tangem.feature.swap.ui.preview.SwapSelectTokenPreviewProvider
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
private const val LOAD_MORE_BUFFER = 25
@ -162,12 +153,6 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier =
tokensListData = state.tokensListData,
isBalanceHidden = state.isBalanceHidden,
)
tokensToSelectItems(state.availableTokens, state.onTokenSelected)
item { SpacerH12() }
tokensToSelectItems(state.unavailableTokens, state.onTokenSelected)
}
}
@ -197,20 +182,6 @@ private fun ListOfTokensWithMarkets(
isBalanceHidden = state.isBalanceHidden,
)
tokensToSelectItems(state.availableTokens, state.onTokenSelected)
if (state.unavailableTokens.isNotEmpty()) {
item { SpacerH12() }
tokensToSelectItems(state.unavailableTokens, state.onTokenSelected)
}
val hasPortfolioContent = state.tokensListData !is TokenListUMData.EmptyList ||
state.availableTokens.isNotEmpty()
if (hasPortfolioContent) {
item { SpacerH32() }
} else {
item { SpacerH12() }
}
swapMarketsListItems(marketsState)
}
@ -374,170 +345,25 @@ private fun LazyListScope.portfolioItem(
}
}
private fun LazyListScope.tokensToSelectItems(
items: ImmutableList<TokenToSelectState>,
onTokenClick: (String) -> Unit,
) {
itemsIndexed(items = items) { index, item ->
when (item) {
is TokenToSelectState.Title -> {
TitleHeader(
item = item,
modifier = Modifier.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = items.lastIndex,
),
)
}
is TokenToSelectState.TokenToSelect -> {
TokenItem(
token = item,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = items.lastIndex,
)
.background(TangemTheme.colors.background.action),
onTokenClick = {
onTokenClick(item.id)
},
)
}
}
}
}
@Composable
private fun TitleHeader(item: TokenToSelectState.Title, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.background(TangemTheme.colors.background.action),
) {
Text(
text = item.title.resolveReference().uppercase(),
style = TangemTheme.typography.overline,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier
.padding(
top = TangemTheme.dimens.spacing16,
start = TangemTheme.dimens.spacing16,
),
)
}
}
@Suppress("LongMethod")
@Composable
private fun TokenItem(
token: TokenToSelectState.TokenToSelect,
onTokenClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.height(TangemTheme.dimens.size72)
.clickable(
enabled = token.isAvailable,
onClick = onTokenClick,
)
.padding(
vertical = TangemTheme.dimens.spacing14,
horizontal = TangemTheme.dimens.spacing16,
),
verticalAlignment = Alignment.CenterVertically,
) {
CurrencyIcon(
state = token.tokenIcon,
shouldDisplayNetwork = true,
)
Column(
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
.padding(start = TangemTheme.dimens.spacing12),
) {
EllipsisText(
text = token.name,
style = TangemTheme.typography.subtitle1,
color = if (token.isAvailable) {
TangemTheme.colors.text.primary1
} else {
TangemTheme.colors.text.tertiary
},
)
SpacerW2()
EllipsisText(
text = token.symbol,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
if (token.addedTokenBalanceData != null) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing8),
) {
Text(
text = token.addedTokenBalanceData.amountEquivalent.orEmpty().orMaskWithStars(
maskWithStars = token.addedTokenBalanceData.isBalanceHidden &&
!token.addedTokenBalanceData.amountEquivalent.isNullOrEmpty(),
),
style = TangemTheme.typography.subtitle1,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Visible,
color = if (token.isAvailable) {
TangemTheme.colors.text.primary1
} else {
TangemTheme.colors.text.tertiary
},
)
SpacerW2()
Text(
text = token.addedTokenBalanceData.amount.orEmpty().orMaskWithStars(
maskWithStars = token.addedTokenBalanceData.isBalanceHidden &&
!token.addedTokenBalanceData.amount.isNullOrEmpty(),
),
maxLines = 1,
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
}
private class SwapSelectTokenScreenPreviewProvider : PreviewParameterProvider<SwapSelectTokenStateHolder> {
override val values: Sequence<SwapSelectTokenStateHolder> = sequenceOf(
// Content state with tokens and markets
SwapSelectTokenPreviewProvider().provideSwapSelectTokenState(),
// Empty state
SwapSelectTokenStateHolder(
availableTokens = persistentListOf(),
unavailableTokens = persistentListOf(),
tokensListData = TokenListUMData.EmptyList,
marketsState = null,
marketsState = SwapMarketState.DefaultLoading,
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
onTokenSelected = {},
),
// Not found state
SwapSelectTokenStateHolder(
availableTokens = persistentListOf(),
unavailableTokens = persistentListOf(),
tokensListData = TokenListUMData.EmptyList,
marketsState = null,
marketsState = SwapMarketState.SearchLoading,
isAfterSearch = true,
isBalanceHidden = false,
onSearchEntered = {},
onTokenSelected = {},
),
)
}

View file

@ -2,16 +2,13 @@ package com.tangem.feature.swap.ui.preview
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.core.ui.R
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenBalanceData
import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.feature.swap.models.TokenToSelectState
import com.tangem.feature.swap.models.market.state.SwapMarketState
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -20,13 +17,10 @@ internal class SwapSelectTokenPreviewProvider {
fun provideSwapSelectTokenState(): SwapSelectTokenStateHolder {
return SwapSelectTokenStateHolder(
availableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(),
unavailableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(),
tokensListData = TokenListUMData.EmptyList,
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
onTokenSelected = {},
marketsState = createPreviewMarketsState(),
)
}
@ -136,26 +130,5 @@ internal class SwapSelectTokenPreviewProvider {
CHART_VALUE_6,
),
)
private val previewToken = TokenToSelectState.TokenToSelect(
tokenIcon = CurrencyIconState.CoinIcon(
url = "",
fallbackResId = 0,
isGrayscale = false,
shouldShowCustomBadge = false,
),
id = "",
name = "Optimistic Ethereum (ETH)",
symbol = "USDC",
addedTokenBalanceData = TokenBalanceData(
amount = "15 000 $",
amountEquivalent = "15 000 USDT",
isBalanceHidden = false,
),
)
private val previewTitle = TokenToSelectState.Title(
title = stringReference("MY TOKENS"),
)
}
}