Updated on 2026-08-14

This commit is contained in:
Tangem 2026-04-06 20:37:03 +07:00
commit e52f9de341
987 changed files with 20247 additions and 8171 deletions

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
@ -13,31 +12,31 @@ import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.core.analytics.models.AnalyticsParam.ScreensSources
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.context.childByContext
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.ChooseTokenAnalyticsPayload
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
import com.tangem.utils.logging.TangemLogger
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import timber.log.Timber
import java.math.BigDecimal
@Suppress("UnusedPrivateMember")
@ -45,19 +44,25 @@ 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(
bridge = model.chooseTokenBridge,
settings = ChooseTokenComponent.Settings.SwapTo,
analyticsPayload = setOf(
ChooseTokenAnalyticsPayload.ScreensSources(ScreensSources.Swap.value),
),
),
)
}
private val approvalSlot = childSlot(
key = APPROVAL_SLOT_KEY,
@ -130,7 +135,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock) {
if (shouldHideBlock) {
Timber.e(
TangemLogger.e(
"Dismissing fee selector: " +
"shouldHideBlock = $shouldHideBlock, amount = ${dataState.amount}, " +
"isInsufficientFunds = ${model.uiState.isInsufficientFunds}",
@ -140,13 +145,13 @@ internal class DefaultSwapComponent @AssistedInject constructor(
}
val sendingCryptoCurrencyStatus = fromCryptoCurrency ?: run {
Timber.e("Dismissing fee selector: fromCryptoCurrency is null")
TangemLogger.e("Dismissing fee selector: fromCryptoCurrency is null")
slotNavigation.dismiss()
return@LaunchedEffect
}
val feeCurrencyStatus = feePaidCryptoCurrency ?: run {
Timber.e("Dismissing fee selector: feePaidCryptoCurrency is null")
TangemLogger.e("Dismissing fee selector: feePaidCryptoCurrency is null")
slotNavigation.dismiss()
return@LaunchedEffect
}
@ -161,7 +166,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 +193,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
@ -232,12 +212,19 @@ internal class DefaultSwapComponent @AssistedInject constructor(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = fromCryptoCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrency,
amount = permissionState.amount,
amount = model.dataState.amount.orEmpty(),
spenderAddress = requireNotNull(model.dataState.approveDataModel).spenderAddress,
subtitle = resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, permissionState.currency),
),
amountFooter = if (permissionState.isResetApproval) {
resourceReference(R.string.update_approval_permission_subtitle)
} else {
resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, permissionState.currency),
)
},
feeFooter = resourceReference(R.string.swap_give_permission_fee_footer),
isResetApproval = permissionState.isResetApproval,
isHoldToConfirm = model.isHoldToConfirmEnabled,
callback = model.approvalCallback,
)
}

View file

@ -0,0 +1,104 @@
package com.tangem.feature.swap.choosetoken.api
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup
import com.tangem.feature.swap.presentation.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
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 currency: CryptoCurrencyStatus,
val account: AccountStatus,
val wallet: UserWallet,
val analyticsPayload: Set<ChooseTokenAnalyticsPayload> = emptySet(),
) {
val walletId get() = wallet.walletId
}
sealed interface ChooseTokenAnalyticsPayload {
@Suppress("BooleanPropertyNaming")
@JvmInline
value class IsSearched(val value: Boolean) : ChooseTokenAnalyticsPayload
@JvmInline
value class ScreensSources(val value: String) : ChooseTokenAnalyticsPayload
}
internal interface ChooseTokenComponent : ComposableContentComponent {
data class Params(
val bridge: ChooseTokenBridge,
val settings: Settings,
val analyticsPayload: Set<ChooseTokenAnalyticsPayload> = emptySet(),
)
data class Settings(
val title: TextReference,
val isShowMarketBlock: Boolean,
) {
companion object {
val SwapFrom = Settings(
title = resourceReference(R.string.swapping_from_title),
isShowMarketBlock = false,
)
val SwapTo = Settings(
title = resourceReference(R.string.swapping_to_title),
isShowMarketBlock = true,
)
}
}
interface Factory : ComponentFactory<Params, ChooseTokenComponent>
}

View file

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

View file

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

View file

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

View file

@ -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,262 @@
package com.tangem.feature.swap.choosetoken.impl.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.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.AccountId
import com.tangem.domain.models.account.AccountStatus
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.wallets.usecase.GetWalletsUseCase
import com.tangem.feature.swap.choosetoken.api.*
import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarToggleTransformer
import com.tangem.feature.swap.choosetoken.impl.converter.SearchBarUpdateQueryTransformer
import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenFullUM
import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenInitialUM
import com.tangem.feature.swap.choosetoken.impl.ui.ChooseTokenUM
import com.tangem.feature.swap.choosetoken.impl.ui.WalletListUM
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.feature.swap.models.market.state.SwapMarketState
import com.tangem.feature.swap.presentation.R
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import javax.inject.Inject
internal val String.isSearchingState: Boolean get() = this.isNotBlank()
internal val StateFlow<String>.isSearchingState: Boolean get() = this.value.isSearchingState
@Suppress("LongParameterList")
@ModelScoped
internal class ChooseTokenModel @Inject constructor(
override val dispatchers: CoroutineDispatcherProvider,
private val settingContextUseCase: SettingContextUseCase,
private val getWalletsUseCase: GetWalletsUseCase,
portfolioListBlockDelegateFactory: PortfolioListBlockDelegate.Factory,
marketBlockDelegateFactory: MarketBlockDelegate.Factory,
paramsContainer: ParamsContainer,
) : Model() {
private val params = paramsContainer.require<ChooseTokenComponent.Params>()
private val bridge: ChooseTokenBridge = params.bridge
private val searchQueryState: StateFlow<String> = bridge.searchQueryState
private val isSearchingState: Boolean get() = bridge.searchQueryState.isSearchingState
private val marketBlockDelegate: MarketBlockDelegate = marketBlockDelegateFactory.create(
modelScope = modelScope,
searchQueryState = searchQueryState,
screensSourcesName = params.analyticsPayload
.filterIsInstance<ChooseTokenAnalyticsPayload.ScreensSources>()
.firstOrNull()?.value.orEmpty(),
)
private val portfolioListBlockDelegate: PortfolioListBlockDelegate = portfolioListBlockDelegateFactory.create(
modelScope = modelScope,
searchQueryState = searchQueryState,
)
val bottomSheetNavigation get() = marketBlockDelegate.addToPortfolioSlot
val addToPortfolioManager get() = marketBlockDelegate.addToPortfolioManager
private val marketsStateFlow: Flow<SwapMarketState?> = if (params.settings.isShowMarketBlock) {
marketBlockDelegate.marketsStateFlow
} else {
flowOf(null)
}
private val expandedAccountsFlow: MutableStateFlow<Map<AccountId, Boolean>> = MutableStateFlow(emptyMap())
private val onWalletSelected = Channel<UserWalletId>()
// todo swap call new api result
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = marketBlockDelegate.addToPortfolioSlot.dismiss()
override fun onSuccess(addedToken: CryptoCurrency) {
val newToken = addedToken to ChooseTokenAnalyticsPayload.IsSearched(isSearchingState)
bridge.onNewTokenAdded(newToken)
marketBlockDelegate.addToPortfolioSlot.dismiss()
}
}
val stateOld: StateFlow<SwapSelectTokenStateHolder?> = combineUIOld()
private val contentState: StateFlow<ChooseTokenUM?> = combineUI()
private val initialState: MutableStateFlow<ChooseTokenInitialUM> = MutableStateFlow(getInitState())
val state: StateFlow<ChooseTokenFullUM> = combine(
flow = initialState,
flow2 = contentState,
transform = { initial, content ->
ChooseTokenFullUM(
initialUM = initial,
contentUM = content,
)
},
).stateIn(
scope = modelScope,
started = SharingStarted.Eagerly,
initialValue = ChooseTokenFullUM(initialState.value, contentState.value),
)
@Suppress("UnusedPrivateMember")
private fun combineUIOld(): StateFlow<SwapSelectTokenStateHolder?> = combine(
flow = bridge.currenciesGroup,
flow2 = settingContextUseCase.invoke(),
flow3 = marketsStateFlow,
flow4 = expandedAccountsFlow,
transform = { currenciesGroup, settingContext, marketState, expandedAccounts ->
val isAccountsMode = settingContext.isAccountsMode
val appCurrency = settingContext.appCurrency
val isBalanceHidden = settingContext.isBalanceHidden
TokensDataConverter(
onSearchEntered = { query -> bridge.onSearchQuery(query) },
onTokenClick = { tokenId ->
val selected = tokenId to ChooseTokenAnalyticsPayload
.IsSearched(isSearchingState)
bridge.onTokenSelected(selected)
},
onAccountClick = { account ->
expandedAccountsFlow.update { expandedList ->
val hasSavedAccount = expandedList[account.accountId]
val isExpanded = hasSavedAccount == true
expandedList + (account.accountId to !isExpanded)
}
},
expandedAccounts = expandedAccounts,
tokensDataState = currenciesGroup,
isBalanceHidden = isBalanceHidden,
isAccountsMode = isAccountsMode,
appCurrency = appCurrency,
marketState = requireNotNull(marketState),
).transform()
},
)
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = null)
@Suppress("LongMethod")
private fun combineUI(): StateFlow<ChooseTokenUM?> = channelFlow {
val allWalletsFlow: StateFlow<LinkedHashMap<UserWalletId, UserWallet>> =
getWalletsUseCase.invokeAsMap().stateIn(this)
val selectedWalletFlow: StateFlow<UserWallet> =
onWalletSelected.receiveAsFlow()
.mapNotNull { walletId -> allWalletsFlow.value[walletId] }
.stateIn(this, SharingStarted.Eagerly, allWalletsFlow.value.values.first())
val selectedWalletTokensData: Flow<TokenListUMData> = combine(
flow = selectedWalletFlow.map { wallet -> wallet.walletId }.distinctUntilChanged(),
flow2 = portfolioListBlockDelegate.portfolioList,
transform = { selectedWalletId, allPortfoliosData -> allPortfoliosData[selectedWalletId] },
)
.filterNotNull()
.distinctUntilChanged()
val walletListUmFlow = combine(
flow = selectedWalletFlow,
flow2 = allWalletsFlow,
transform = { selectedWallet, allWallets ->
allWallets.entries
.map { (walletId, wallet) ->
val type = if (selectedWallet.walletId == walletId) {
TangemButtonType.Primary
} else {
TangemButtonType.Secondary
}
TangemButtonUM(
text = stringReference(wallet.name),
onClick = { onWalletSelected.trySend(walletId) },
type = type,
)
}
},
)
.distinctUntilChanged()
portfolioListBlockDelegate.onTokenItemClick.receiveAsFlow()
.onEach { (account, currencyStatus) ->
onTokenItemClick(
wallet = allWalletsFlow.value[account.accountId.userWalletId] ?: return@onEach,
account = account,
currencyStatus = currencyStatus,
)
}
.launchIn(this)
combine(
flow = selectedWalletTokensData,
flow2 = settingContextUseCase.invoke(),
flow3 = marketsStateFlow,
flow4 = walletListUmFlow,
transform = { tokensData, settings, marketsData, walletList ->
val walletsUM = if (walletList.size != 1) {
WalletListUM(walletList.toPersistentList())
} else {
WalletListUM(persistentListOf())
}
ChooseTokenUM(
walletList = walletsUM,
isBalanceHidden = settings.isBalanceHidden,
isSearching = isSearchingState,
tokensListData = tokensData,
marketsState = marketsData,
)
},
)
.distinctUntilChanged()
.collect { newUM -> channel.send(newUM) }
}
.flowOn(dispatchers.default)
.stateIn(modelScope, SharingStarted.Eagerly, initialValue = null)
private fun onTokenItemClick(wallet: UserWallet, account: AccountStatus, currencyStatus: CryptoCurrencyStatus) {
val analyticsPayload = setOf(
ChooseTokenAnalyticsPayload.IsSearched(isSearchingState),
)
val result = ChooseTokenResult(
account = account,
currency = currencyStatus,
wallet = wallet,
analyticsPayload = analyticsPayload,
)
bridge.onCurrencyChosen(result)
}
fun onBackClicked() {
bridge.onClose()
}
private fun getInitialSearchBar(): SearchBarUM = SearchBarUM(
placeholderText = resourceReference(R.string.common_search),
query = "",
isActive = false,
onQueryChange = { query ->
initialState.update { prevState -> SearchBarUpdateQueryTransformer(query).transform(prevState) }
bridge.onSearchQuery(query)
},
onActiveChange = { isActive ->
initialState.update { prevState -> SearchBarToggleTransformer(isActive).transform(prevState) }
},
)
private fun getInitState() = ChooseTokenInitialUM(
screenTitle = params.settings.title,
onCloseClick = ::onBackClicked,
searchBar = getInitialSearchBar(),
)
companion object {
const val DEBOUNCE_SEARCH_DELAY = 500L
}
}

View file

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

View file

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

View file

@ -0,0 +1,361 @@
package com.tangem.feature.swap.choosetoken.impl.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.semantics
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.buttons.common.TangemButton
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.components.buttons.common.TangemButtonSize
import com.tangem.core.ui.components.buttons.common.TangemButtonsDefaults
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.ds.button.TangemButtonType
import com.tangem.core.ui.ds.button.TangemButtonUM
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.lazyListItemPosition
import com.tangem.core.ui.utils.rememberHideKeyboardNestedScrollConnection
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.feature.swap.ui.market.swapMarketsListItems
import com.tangem.feature.swap.ui.preview.SwapSelectTokenPreviewProvider
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
@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()
LazyColumn(
modifier = modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection),
horizontalAlignment = Alignment.CenterHorizontally,
state = lazyListState,
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) {
item(key = "search_bar") {
SearchBar(
modifier = Modifier.padding(horizontal = TangemTheme.dimens.spacing16),
state = state.initialUM.searchBar,
colors = TangemSearchBarDefaults.secondaryTextFieldColors,
)
}
assetsTitle()
if (state.contentUM != null) {
walletListItem(state.contentUM.walletList)
tokensListItems(
tokensListData = state.contentUM.tokensListData,
isBalanceHidden = state.contentUM.isBalanceHidden,
)
if (state.contentUM.marketsState != null) {
item("markets_title_spacer") { SpacerH(height = 20.dp) }
swapMarketsListItems(state.contentUM.marketsState)
}
}
}
if (state.contentUM?.marketsState != null) {
SetupMarketScrollTracker(state.contentUM.marketsState, 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") {
Row(
modifier = Modifier
.padding(horizontal = TangemTheme.dimens.spacing16)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(space = TangemTheme.dimens.spacing8),
verticalAlignment = Alignment.CenterVertically,
) {
walletList.items.forEach { um ->
val colors = when (um.type) {
TangemButtonType.Primary -> TangemButtonsDefaults.primaryButtonColors
else -> TangemButtonsDefaults.secondaryButtonColors
}
TangemButton(
text = um.text?.resolveReference().orEmpty(),
icon = TangemButtonIconPosition.None,
size = TangemButtonSize.Action,
colors = colors,
showProgress = false,
onClick = um.onClick,
enabled = true,
)
}
}
}
}
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 },
)
},
)
}
@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(
TangemButtonUM(
text = TextReference.Str(value = "Wallet 1"),
type = TangemButtonType.Primary,
onClick = {},
),
TangemButtonUM(
text = TextReference.Str(value = "Wallet 2"),
type = TangemButtonType.Secondary,
onClick = {},
),
TangemButtonUM(
text = TextReference.Str(value = "Wallet 3"),
type = TangemButtonType.Secondary,
onClick = {},
),
)
private class ChooseTokenScreenPreviewProvider : PreviewParameterProvider<ChooseTokenFullUM> {
override val values: Sequence<ChooseTokenFullUM> = sequenceOf(
ChooseTokenFullUM(
initialUM = ChooseTokenInitialUM(
screenTitle = stringReference("Choose token"),
onCloseClick = {},
searchBar = searchBar,
),
contentUM = ChooseTokenUM(
walletList = WalletListUM(wallets),
isBalanceHidden = false,
isSearching = false,
tokensListData = TokenListUMData.AccountList(
tokensList = accounts,
accounts.size,
),
marketsState = SwapSelectTokenPreviewProvider.defaultState.marketsState,
),
),
)
}

View file

@ -0,0 +1,31 @@
package com.tangem.feature.swap.choosetoken.impl.ui
import com.tangem.core.ui.components.fields.entity.SearchBarUM
import com.tangem.core.ui.ds.button.TangemButtonUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.feature.swap.models.market.state.SwapMarketState
import kotlinx.collections.immutable.ImmutableList
internal data class ChooseTokenFullUM(
val initialUM: ChooseTokenInitialUM,
val contentUM: ChooseTokenUM?,
)
internal data class ChooseTokenUM(
val walletList: WalletListUM,
val isBalanceHidden: Boolean,
val isSearching: Boolean,
val tokensListData: TokenListUMData,
val marketsState: SwapMarketState?,
)
internal data class ChooseTokenInitialUM(
val screenTitle: TextReference,
val onCloseClick: () -> Unit,
val searchBar: SearchBarUM,
)
internal data class WalletListUM(
val items: ImmutableList<TangemButtonUM>,
)

View file

@ -15,27 +15,40 @@ import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.StatusSource
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.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.AccountSwapAvailability
import com.tangem.utils.converter.Converter
import com.tangem.utils.extensions.orZero
import kotlinx.collections.immutable.toPersistentList
internal class AccountTokenItemConverter(
private val appCurrency: AppCurrency,
private val unavailableErrorText: TextReference,
private val onItemClick: (String) -> Unit,
private val expandedAccounts: Map<AccountId, Boolean>,
private val onTokenItemClick: (String) -> Unit,
private val onAccountItemClick: (Account.CryptoPortfolio) -> Unit,
) : Converter<AccountSwapAvailability, TokensListItemUM.Portfolio> {
override fun convert(value: AccountSwapAvailability): TokensListItemUM.Portfolio {
return TokensListPortfolioItemConverter(
tokenItemUM = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = value.account,
onItemClick = null,
).convert(TotalFiatBalance.Failed),
isExpanded = true,
isCollapsable = false,
account = value.account.copy(
cryptoCurrencies = value.currencyList.map { it.cryptoCurrencyStatus.currency },
),
onItemClick = onAccountItemClick,
).convert(
TotalFiatBalance.Loaded(
amount = value.currencyList.sumOf { it.cryptoCurrencyStatus.value.fiatAmount.orZero() },
source = StatusSource.ONLY_CACHE,
),
),
isExpanded = expandedAccounts[value.account.accountId] != false,
isCollapsable = true,
tokens = value.currencyList.map { accountSwapCurrency ->
createAvailableItemConverter()
.convert(accountSwapCurrency.cryptoCurrencyStatus)
@ -57,7 +70,7 @@ internal class AccountTokenItemConverter(
fiatAmountStateProvider = {
createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = true)
},
onItemClick = { account, currencyStatus -> onItemClick(currencyStatus.currency.id.value) },
onItemClick = { account, currencyStatus -> onTokenItemClick(currencyStatus.currency.id.value) },
)
}

View file

@ -3,78 +3,78 @@ package com.tangem.feature.swap.converters
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.AccountId
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,
onTokenClick: (String) -> Unit,
onAccountClick: (Account.CryptoPortfolio) -> Unit,
private val expandedAccounts: Map<AccountId, Boolean>,
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,
onTokenItemClick = onTokenClick,
onAccountItemClick = onAccountClick,
expandedAccounts = expandedAccounts,
)
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
@ -44,23 +42,18 @@ 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.models.wallet.isHotWallet
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.promo.ShouldShowStoriesUseCase
import com.tangem.domain.promo.models.StoryContentIds
@ -77,8 +70,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,9 +82,11 @@ 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.*
import com.tangem.feature.swap.models.market.SwapMarketsListBatchFlowManager
import com.tangem.feature.swap.models.market.state.SwapMarketState
import com.tangem.feature.swap.models.SwapAlertUM
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.TransactionCardType
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.router.SwapRouter
@ -99,20 +94,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 timber.log.Timber
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
@ -153,15 +144,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() {
@ -180,6 +167,9 @@ internal class SwapModel @Inject constructor(
}
private val swapInteractor = swapInteractorFactory.create(userWalletId)
val isHoldToConfirmEnabled: Boolean =
holdToConfirmButtonFeatureToggles.isHoldToConfirmEnabled && userWallet.isHotWallet
private lateinit var initialFromStatus: CryptoCurrencyStatus
private var initialToStatus: CryptoCurrencyStatus? = null
@ -188,6 +178,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(),
@ -204,7 +196,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())
@ -253,44 +244,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
@ -318,35 +278,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)) {
@ -420,32 +375,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() {
@ -471,41 +400,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) {
@ -547,18 +441,18 @@ internal class SwapModel @Inject constructor(
val fromCryptoCurrency = dataState.fromCryptoCurrency
if (isApplied && fromCryptoCurrency != null) {
Timber.i(
TangemLogger.i(
"updateFeePaidCryptoCurrencyFor: id = ${fromCryptoCurrency.currency.id}, " +
"isReverseFromTo: $isReverseFromTo",
)
updateFeePaidCryptoCurrencyFor(fromCryptoCurrency)
} else {
Timber.e("updateFeePaidCryptoCurrencyFor failed: fromCryptoCurrency is null")
TangemLogger.e("updateFeePaidCryptoCurrencyFor failed: fromCryptoCurrency is null")
}
subscribeToCoinBalanceUpdatesIfNeeded()
}.onFailure { error ->
Timber.e(error)
TangemLogger.e("Error", error)
applyInitialTokenChoice(
state = TokensDataStateExpress.EMPTY,
@ -582,26 +476,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.value,
)
runCatching(dispatchers.io) {
swapInteractor.getTokensDataState(initialCurrencyFrom)
}.onSuccess { state ->
updateTokensState(state)
subscribeToCoinBalanceUpdatesIfNeeded()
applyInitialTokenChoice(
state = state,
selectedCurrency = selectedCurrency,
selectedAccount = selectedAccount,
isReverseFromTo = isOrderReversed.value,
)
swapRouter.back()
}.onFailure { error ->
Timber.e(error)
}
subscribeToCoinBalanceUpdatesIfNeeded()
swapRouter.back()
}.onFailure { error ->
TangemLogger.e("Error", error)
}
}
@ -635,7 +543,7 @@ internal class SwapModel @Inject constructor(
): Boolean {
// exceptional case
if (selectedCurrency == null) {
Timber.e("No available tokens to swap for ${initialCurrencyFrom.symbol}")
TangemLogger.e("No available tokens to swap for ${initialCurrencyFrom.symbol}")
analyticsEventHandler.send(SwapEvents.NoticeNoAvailableTokensToSwap())
uiState = stateBuilder.createNoAvailableTokensToSwapState(
uiStateHolder = uiState,
@ -691,23 +599,7 @@ internal class SwapModel @Inject constructor(
private fun updateTokensState(tokenDataState: TokensDataStateExpress) {
val tokensDataState = if (isOrderReversed.value) 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(
@ -773,10 +665,10 @@ internal class SwapModel @Inject constructor(
userWalletId = userWalletId,
cryptoCurrencyStatus = fromToken,
)
.onLeft { Timber.e("Unable to get fee paid crypto currency status for ${fromToken.currency.id}") }
.onLeft { TangemLogger.e("Unable to get fee paid crypto currency status for ${fromToken.currency.id}") }
.onRight { currencyStatus ->
if (currencyStatus == null) {
Timber.e("Fee paid crypto currency status is null for ${fromToken.currency.id}")
TangemLogger.e("Fee paid crypto currency status is null for ${fromToken.currency.id}")
}
}
.getOrNull(),
@ -836,11 +728,11 @@ internal class SwapModel @Inject constructor(
}
} else {
feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true)
Timber.e("Accidentally empty quotes list")
TangemLogger.e("Accidentally empty quotes list")
}
},
onError = { error ->
Timber.e("Error when loading quotes: $error")
TangemLogger.e("Error when loading quotes: $error")
feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true)
uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() }
},
@ -1047,14 +939,14 @@ internal class SwapModel @Inject constructor(
val provider = requireNotNull(dataState.selectedProvider) { "Selected provider is null" }
val lastLoadedQuotesState = dataState.lastLoadedSwapStates[provider] as? SwapState.QuotesLoadedState
if (lastLoadedQuotesState == null) {
Timber.e("Last loaded quotes state is null")
TangemLogger.e("Last loaded quotes state is null")
return
}
val fromCurrency = requireNotNull(dataState.fromCryptoCurrency)
val fee = getSelectedFee()
if (fee == null && tangemPayInput?.isWithdrawal != true) {
Timber.e("onSwapClick: fee is null and isWithdrawal is ${tangemPayInput?.isWithdrawal}")
TangemLogger.e("onSwapClick: fee is null and isWithdrawal is ${tangemPayInput?.isWithdrawal}")
showAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
modelScope.launch {
delay(SWAP_IN_PROGRESS_DELAY)
@ -1081,7 +973,7 @@ internal class SwapModel @Inject constructor(
when (swapTransactionState) {
is SwapTransactionState.TxSent -> {
if (fee == null) {
Timber.e("onSwapClick: onSuccess: fee is null after swap")
TangemLogger.e("onSwapClick: onSuccess: fee is null after swap")
showAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
return@onSuccess
}
@ -1093,7 +985,7 @@ internal class SwapModel @Inject constructor(
txHash = swapTransactionState.txHash,
currency = fromCurrency.currency,
).getOrElse {
Timber.i("tx hash explore not supported")
TangemLogger.i("tx hash explore not supported")
""
}
@ -1137,7 +1029,7 @@ internal class SwapModel @Inject constructor(
}
}
}.onFailure { error ->
Timber.e(error)
TangemLogger.e("Error", error)
startLoadingQuotesFromLastState()
showAlert()
}
@ -1232,7 +1124,7 @@ internal class SwapModel @Inject constructor(
val feeForPermission = when (val fee = approveDataModel.fee) {
TxFeeState.Empty -> {
showAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
Timber.e("Fee should not be Empty")
TangemLogger.e("Fee should not be Empty")
return@launch
}
is TxFeeState.MultipleFeeState -> fee.priorityFee
@ -1272,72 +1164,68 @@ internal class SwapModel @Inject constructor(
}
}.onFailure { showAlert() }
}.onFailure { error ->
Timber.e(error.message.orEmpty())
TangemLogger.e(error.message.orEmpty())
showAlert()
}
}
}
private fun onSearchEntered(searchQuery: String) {
searchDebouncer.debounce(modelScope, DEBOUNCE_SEARCH_DELAY) {
searchQueryState.value = searchQuery
val tokenDataState = dataState.tokensDataState ?: return@debounce
val group = if (isOrderReversed.value) {
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.value) {
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.value) {
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.value) {
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)
@ -1350,7 +1238,7 @@ internal class SwapModel @Inject constructor(
SwapAnalyticsEvent.TokenSelected(
token = symbol,
source = ScreensSources.Portfolio,
isSearched = searchQueryState.value.isNotEmpty(),
isSearched = isSearched,
),
)
}
@ -1417,7 +1305,7 @@ internal class SwapModel @Inject constructor(
return
}
modelScope.launch {
Timber.i(
TangemLogger.i(
"updateFeePaidCryptoCurrencyFor: id = ${fromToken.currency.id}, " +
"isOrderReversed: ${isOrderReversed.value}",
)
@ -1465,14 +1353,14 @@ internal class SwapModel @Inject constructor(
coin: CryptoCurrency.Coin,
isFromCurrency: Boolean,
) {
Timber.d("Subscribe to ${coin.id} balance updates")
TangemLogger.d("Subscribe to ${coin.id} balance updates")
getAccountCurrencyStatusUseCase(
userWalletId = userWalletId,
currency = coin,
).distinctUntilChanged { old, new -> old.status.value.amount == new.status.value.amount } // Check only balance changes
.onEach { (account, currencyStatus) ->
Timber.d("${coin.id} balance is ${currencyStatus.value.amount ?: "null"}")
TangemLogger.d("${coin.id} balance is ${currencyStatus.value.amount ?: "null"}")
if (isFromCurrency) {
dataState = dataState.copy(
@ -1481,14 +1369,14 @@ internal class SwapModel @Inject constructor(
cryptoCurrencyStatus = currencyStatus,
)
.onLeft {
Timber.e(
TangemLogger.e(
"Coin balance: Unable to get fee paid crypto currency status for " +
"${currencyStatus.currency.id}",
)
}
.onRight { status ->
if (status == null) {
Timber.e(
TangemLogger.e(
"Coin balance: Fee paid crypto currency status is null " +
"for ${currencyStatus.currency.id}",
)
@ -1542,7 +1430,7 @@ internal class SwapModel @Inject constructor(
toAccount = newToAccount,
)
isOrderReversed.value = !isOrderReversed.value
Timber.i(
TangemLogger.i(
"updateFeePaidCryptoCurrencyFor: id = ${newFromToken.currency.id}, " +
"isOrderReversed: ${isOrderReversed.value}",
)
@ -1745,8 +1633,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()
@ -1774,9 +1660,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("")
@ -1862,7 +1745,7 @@ internal class SwapModel @Inject constructor(
onReceiveCardWarningClick = {
val selectedProvider = dataState.selectedProvider ?: return@UiActions
val currencySymbol = dataState.toCryptoCurrency?.currency?.symbol ?: return@UiActions
val isPriceImpact = uiState.priceImpact is PriceImpact.Value
val isPriceImpact = uiState.priceImpact.type != PriceImpact.Type.NONE
showSwapInfoAlert(isPriceImpact, currencySymbol, selectedProvider)
},
onLinkClick = urlOpener::openUrl,
@ -2225,110 +2108,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,
@ -2355,10 +2134,7 @@ internal class SwapModel @Inject constructor(
val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content
if (feeStateUM == null) {
Timber.e(
"getSelectedFeeState: FeeSelectorUM is not Content: ${feeSelectorRepository.state.value}, " +
"returning Legacy state",
)
TangemLogger.e("getSelectedFeeState: FeeSelectorUM is not Content: $feeStateUM, returning Legacy state")
return TxFeeSealedState.Legacy(
txFeeState = TxFeeState.Empty,
selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
@ -2380,9 +2156,7 @@ internal class SwapModel @Inject constructor(
val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content
if (feeStateUM == null) {
Timber.e(
"getSelectedFee: FeeSelectorUM is not Content: ${feeSelectorRepository.state.value}, returning null",
)
TangemLogger.e("getSelectedFee: FeeSelectorUM is not Content: $feeStateUM, returning null")
return null
}
@ -2441,7 +2215,7 @@ internal class SwapModel @Inject constructor(
if (newState is FeeSelectorUM.Error) {
modelScope.launch {
Timber.e("onResult: FeeSelectorUM is Error, isHidden = true")
TangemLogger.e("onResult: FeeSelectorUM is Error, isHidden = true")
forceUpdateState.emit(newState.copy(isHidden = true))
}
return
@ -2456,7 +2230,7 @@ internal class SwapModel @Inject constructor(
newState.feeExtraInfo.feeCryptoCurrencyStatus.currency is CryptoCurrency.Coin
if (isFeeCurrencySameAsFromCurrency || isCoinFeeSelected) {
Timber.e("onResult: Fee currency is same as from currency or coin fee selected, reloading quotes")
TangemLogger.e("onResult: Fee currency is same as from currency or coin fee selected, reloading quotes")
// block swap button until fee is loaded
uiState = uiState.copy(
@ -2480,19 +2254,19 @@ internal class SwapModel @Inject constructor(
}
override suspend fun loadFee(): Either<GetFeeError, TransactionFee> {
Timber.e("loadFee: Start loading fee")
TangemLogger.e("loadFee: Start loading fee")
val fromToken = dataState.fromCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError)
val toToken = dataState.toCryptoCurrency ?: return Either.Left(GetFeeError.UnknownError)
val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!!
if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) {
Timber.e("loadFee: Quotes not loaded ${dataState.lastLoadedSwapStates[selectedProvider]}")
TangemLogger.e("loadFee: Quotes not loaded ${dataState.lastLoadedSwapStates[selectedProvider]}")
return Either.Left(GetFeeError.UnknownError)
}
if (isPermissionNotificationShown()) {
Timber.e("loadFee: Permission notification is shown, cannot load fee")
TangemLogger.e("loadFee: Permission notification is shown, cannot load fee")
return Either.Left(GetFeeError.UnknownError)
}
@ -2506,10 +2280,10 @@ internal class SwapModel @Inject constructor(
reduceBalanceBy = lastReducedBalanceBy.value,
)
.onLeft {
Timber.e("loadFee: Failed to load fee with error $it")
TangemLogger.e("loadFee: Failed to load fee with error $it")
}
.onRight {
Timber.e("loadFee: Fee loaded successfully")
TangemLogger.e("loadFee: Fee loaded successfully")
}
}
@ -2530,7 +2304,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

@ -118,6 +118,7 @@ internal class SwapNotificationsFactory(
maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, fromToken, hideFee)
maybeAddTransactionInProgressWarning(quoteModel)
maybeAddPriceImpactNotification(quoteModel.priceImpact)
}
return warnings.toPersistentList()
}
@ -143,6 +144,18 @@ internal class SwapNotificationsFactory(
}
}
private fun MutableList<NotificationUM>.maybeAddPriceImpactNotification(priceImpact: PriceImpact) {
if (priceImpact.amountSignificance == PriceImpact.AmountSignificance.LOW) return
val notification = when (priceImpact.type) {
PriceImpact.Type.HIGH -> SwapNotificationUM.Warning.TradeTooHigh
PriceImpact.Type.MEDIUM -> SwapNotificationUM.Warning.HighPriceImpact
else -> return
}
add(notification)
}
@Suppress("LongMethod")
private fun MutableList<NotificationUM>.maybeAddDomainWarnings(
quoteModel: SwapState.QuotesLoadedState,

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

@ -1,6 +1,7 @@
package com.tangem.feature.swap.models
import androidx.annotation.DrawableRes
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
@ -28,7 +29,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,
@ -48,7 +48,7 @@ sealed class SwapCardState {
data class SwapCardData(
@DrawableRes val networkIconRes: Int?,
val type: TransactionCardType,
val amountEquivalent: String?,
val amountEquivalent: TextReference?,
val token: CryptoCurrencyStatus?,
val coinId: String?,
val amountTextFieldValue: TextFieldValue?,
@ -62,7 +62,7 @@ sealed class SwapCardState {
data class Empty(
val type: TransactionCardType,
val amountEquivalent: String?,
val amountEquivalent: TextReference?,
val amountTextFieldValue: TextFieldValue?,
val canSelectAnotherToken: Boolean = false,
) : SwapCardState()
@ -76,6 +76,7 @@ data class SwapButton(
val onClick: () -> Unit,
)
@Immutable
sealed interface TransactionCardType {
val accountTitleUM: AccountTitleUM?

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

@ -5,6 +5,7 @@ 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
@ -119,7 +120,12 @@ internal class SwapMarketsTokenItemConverter(
return MarketsListItemUM.Price(
text = priceText,
annotated = tokenQuotesShort.currentPrice.toMarketsListItemPriceAnnotated(
appCurrencyCode = appCurrency.code,
appCurrencySymbol = appCurrency.symbol,
),
changeType = changeType,
fiatPrice = tokenQuotesShort.currentPrice,
)
}

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

@ -194,6 +194,17 @@ internal object SwapNotificationUM {
onClick = onConfirmClick,
),
)
data object HighPriceImpact : Warning(
title = resourceReference(R.string.swapping_high_price_impact_title),
subtitle = resourceReference(R.string.swapping_high_price_impact_text),
)
data object TradeTooHigh : Warning(
title = resourceReference(R.string.swapping_trade_too_large_title),
subtitle = resourceReference(R.string.swapping_trade_too_large_text),
iconResId = R.drawable.ic_alert_circle_24,
)
}
sealed class Info(

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.ui
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountTitleUM
@ -12,10 +13,8 @@ import com.tangem.core.ui.HoldToConfirmButtonFeatureToggles
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.format.bigdecimal.anyDecimals
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.format.bigdecimal.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
@ -23,7 +22,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
@ -38,6 +36,7 @@ import com.tangem.feature.swap.models.states.*
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.utils.Provider
import com.tangem.utils.StringsSigns
import com.tangem.utils.StringsSigns.DASH_SIGN
import com.tangem.utils.StringsSigns.TILDE_SIGN
import kotlinx.collections.immutable.ImmutableList
@ -128,7 +127,7 @@ internal class StateBuilder(
onSuccess = actions.onSuccess,
providerState = ProviderState.Empty(),
shouldShowMaxAmount = false,
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
isInsufficientFunds = false,
)
}
@ -142,7 +141,7 @@ internal class StateBuilder(
sendCardData = SwapCardState.SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable),
amountTextFieldValue = null,
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
token = fromToken,
tokenIconUrl = fromToken.currency.iconUrl,
coinId = fromToken.currency.network.backendId,
@ -155,7 +154,7 @@ internal class StateBuilder(
),
receiveCardData = SwapCardState.Empty(
type = TransactionCardType.ReadOnly(),
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
amountTextFieldValue = TextFieldValue(
text = "0",
),
@ -170,7 +169,7 @@ internal class StateBuilder(
onClick = { },
),
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
)
}
@ -193,7 +192,7 @@ internal class StateBuilder(
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
token = fromToken,
tokenIconUrl = fromToken.currency.iconUrl,
coinId = fromToken.currency.network.backendId,
@ -211,7 +210,7 @@ internal class StateBuilder(
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
token = toToken,
tokenIconUrl = toToken.currency.iconUrl,
coinId = toToken.currency.network.backendId,
@ -232,7 +231,7 @@ internal class StateBuilder(
),
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
providerState = ProviderState.Empty(),
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
)
}
@ -300,7 +299,7 @@ internal class StateBuilder(
providerState = ProviderState.Loading(),
permissionState = uiStateHolder.permissionState,
changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS,
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toToken),
)
}
@ -366,6 +365,7 @@ internal class StateBuilder(
)
}
}
val priceImpact = quoteModel.priceImpact
return uiStateHolder.copy(
sendCardData = SwapCardState.SwapCardData(
type = sendInput,
@ -392,7 +392,26 @@ internal class StateBuilder(
.formatToUIRepresentation()
.appendApproximateSign(),
),
amountEquivalent = getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat),
amountEquivalent = if (priceImpact.type.ordinal > PriceImpact.Type.LOW.ordinal) {
combinedReference(
getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat),
stringReference(StringsSigns.WHITE_SPACE),
styledStringReference(
value = "(${StringsSigns.MINUS}${priceImpact.value.format { percent() }})",
spanStyleReference = {
SpanStyle(
color = when (priceImpact.type) {
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
else -> TangemTheme.colors.text.tertiary
},
)
},
),
)
} else {
getFormattedFiatAmount(quoteModel.toTokenInfo.amountFiat)
},
token = toCurrencyStatus,
tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl,
coinId = toCurrencyStatus.currency.network.backendId,
@ -416,13 +435,13 @@ internal class StateBuilder(
fee = feeState,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
isEnabled = getSwapButtonEnabled(notifications),
isEnabled = getSwapButtonEnabled(notifications, priceImpact),
isHoldToConfirm = isHoldToConfirmEnabled,
onClick = actions.onSwapClick,
),
changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible),
providerState = swapProvider.convertToContentClickableProviderState(
isBestRate = bestRatedProviderId == swapProvider.providerId,
isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(),
fromTokenInfo = quoteModel.fromTokenInfo,
toTokenInfo = quoteModel.toTokenInfo,
isNeedBestRateBadge = isNeedBestRateBadge,
@ -430,11 +449,7 @@ internal class StateBuilder(
onProviderClick = actions.onProviderClick,
needApplyFCARestrictions = needApplyFCARestrictions,
),
priceImpact = if (quoteModel.priceImpact.value > PRICE_IMPACT_THRESHOLD) {
quoteModel.priceImpact
} else {
PriceImpact.Empty()
},
priceImpact = priceImpact,
tosState = createTosState(swapProvider),
shouldShowMaxAmount = shouldShowMaxAmount(fromToken, toCurrencyStatus.currency),
)
@ -468,7 +483,7 @@ internal class StateBuilder(
quoteModel.preparedSwapConfigState.includeFeeInAmount !is IncludeFeeInAmount.Included
}
private fun getSwapButtonEnabled(notifications: ImmutableList<NotificationUM>): Boolean {
private fun getSwapButtonEnabled(notifications: ImmutableList<NotificationUM>, priceImpact: PriceImpact): Boolean {
return notifications.none { notification ->
notification is SwapNotificationUM.Error || notification is NotificationUM.Error ||
notification is SwapNotificationUM.Warning.ExpressError ||
@ -477,7 +492,7 @@ internal class StateBuilder(
notification is SwapNotificationUM.Warning.SwapNotSupported ||
notification is SwapNotificationUM.Warning.NeedReserveToCreateAccount ||
notification is SwapNotificationUM.Info.PermissionNeeded
}
} && !priceImpact.shouldDisableButton()
}
@Suppress("LongParameterList")
@ -516,7 +531,7 @@ internal class StateBuilder(
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
token = toToken,
tokenIconUrl = uiStateHolder.receiveCardData.tokenIconUrl,
coinId = toToken.currency.network.backendId,
@ -529,7 +544,7 @@ internal class StateBuilder(
)
} ?: SwapCardState.Empty(
type = type,
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountEquivalent = getFormattedFiatAmount(BigDecimal.ZERO),
amountTextFieldValue = TextFieldValue(
text = "0",
),
@ -552,7 +567,7 @@ internal class StateBuilder(
),
changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible),
providerState = providerState,
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
tosState = createTosState(swapProvider),
)
}
@ -650,7 +665,7 @@ internal class StateBuilder(
),
changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible),
providerState = ProviderState.Empty(),
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
)
}
@ -663,21 +678,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,
@ -762,28 +762,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,
)
}
@ -891,8 +873,8 @@ internal class StateBuilder(
toTitle = getToCardAccountTitle(toAccount = dataState.toAccount),
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),
toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()),
fromTokenFiatAmount = stringReference(fromFiatAmount),
toTokenFiatAmount = stringReference(toFiatAmount),
fromTokenFiatAmount = fromFiatAmount,
toTokenFiatAmount = toFiatAmount,
fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency),
toTokenIconState = iconStateConverter.convert(toCryptoCurrency),
onExploreButtonClick = onExploreClick,
@ -931,8 +913,8 @@ internal class StateBuilder(
toTitle = getToCardAccountTitle(toAccount = dataState.toAccount),
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),
toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()),
fromTokenFiatAmount = stringReference(fromFiatAmount),
toTokenFiatAmount = stringReference(toFiatAmount),
fromTokenFiatAmount = fromFiatAmount,
toTokenFiatAmount = toFiatAmount,
fromTokenIconState = iconStateConverter.convert(fromCryptoCurrency),
toTokenIconState = iconStateConverter.convert(toCryptoCurrency),
onExploreButtonClick = onExploreClick,
@ -996,6 +978,7 @@ internal class StateBuilder(
dialogText = resourceReference(R.string.swapping_approve_information_text),
footerText = resourceReference(R.string.swap_give_permission_fee_footer),
onOpenLearnMoreAboutApproveClick = onOpenLearnMoreAboutApproveClick,
isResetApproval = permissionDataState.isResetApproval,
)
}
}
@ -1306,15 +1289,17 @@ internal class StateBuilder(
return amount.format { crypto(symbol, currency.decimals) }
}
private fun getFormattedFiatAmount(amount: BigDecimal?): String {
private fun getFormattedFiatAmount(amount: BigDecimal?): TextReference {
val appCurrency = appCurrencyProvider()
return amount.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
}
return stringReference(
amount.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
)
}
private fun SwapAmount.getFormattedCryptoAmount(token: CryptoCurrency): String {
@ -1378,7 +1363,6 @@ internal class StateBuilder(
const val ADDRESS_MIN_LENGTH = 11
const val ADDRESS_FIRST_PART_LENGTH = 7
const val ADDRESS_SECOND_PART_LENGTH = 4
private const val PRICE_IMPACT_THRESHOLD = 0.1
private const val MAX_DECIMALS_TO_SHOW = 8
private const val IF_ZERO_DECIMALS_TO_SHOW = 2
private const val FEE_READ_MORE_URL_FIRST_PART = "https://tangem.com/"

View file

@ -346,6 +346,7 @@ private fun SwapNotifications(notifications: List<NotificationUM>) {
Notification(
config = notification.config,
iconTint = when (notification) {
is SwapNotificationUM.Warning.TradeTooHigh -> TangemTheme.colors.icon.warning
is SwapNotificationUM.Error.UnableToCoverFeeWarning,
is NotificationUM.Error.TokenExceedsBalance,
is NotificationUM.Error.ExceedsBalance,
@ -412,7 +413,7 @@ private val sendCard = SwapCardState.SwapCardData(
accountTitleUM = null,
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = "1 000 000",
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
isNotNativeToken = true,
@ -427,7 +428,7 @@ private val sendCard = SwapCardState.SwapCardData(
private val receiveCard = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = "1 000 000",
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
isNotNativeToken = true,
@ -466,7 +467,7 @@ private val state = SwapStateHolder(
permissionState = GiveTxPermissionState.InProgress,
blockchainId = "POLYGON",
providerState = ProviderState.Loading(),
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
shouldShowMaxAmount = true,
isInsufficientFunds = false,
onSuccess = {},

View file

@ -1,22 +1,16 @@
package com.tangem.feature.swap.ui
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.*
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
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@ -26,32 +20,27 @@ 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.SpacerH
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
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.extensions.stringResourceSafe
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.test.MainScreenTestTags
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 +48,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 +150,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,18 +179,8 @@ 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() }
item("spacer_before_markets") {
SpacerH(32.dp)
}
swapMarketsListItems(marketsState)
@ -277,10 +249,12 @@ private fun LazyListScope.assetsTitle(count: Int, showCount: Boolean) {
private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBalanceHidden: Boolean) {
when (tokensListData) {
is TokenListUMData.AccountList -> {
tokensListData.tokensList.forEach { item ->
tokensListData.tokensList.forEachIndexed { index, item ->
portfolioTokensList(
portfolio = item,
isBalanceHidden = isBalanceHidden,
portfolioIndex = index,
modifier = Modifier,
)
}
}
@ -316,242 +290,168 @@ private fun LazyListScope.tokensList(items: ImmutableList<TokensListItemUM>, isB
)
}
internal fun LazyListScope.portfolioTokensList(portfolio: TokensListItemUM.Portfolio, isBalanceHidden: Boolean) {
internal fun LazyListScope.portfolioTokensList(
portfolio: TokensListItemUM.Portfolio,
modifier: Modifier,
portfolioIndex: Int,
isBalanceHidden: Boolean,
) {
val tokens = portfolio.tokens
val isExpanded = portfolio.isExpanded
val lastIndex = tokens.lastIndex.inc()
portfolioItem(
portfolio = portfolio,
modifier = Modifier,
modifier = modifier,
portfolioIndex = portfolioIndex,
isBalanceHidden = isBalanceHidden,
)
if (!isExpanded) return
itemsIndexed(
items = tokens,
key = { _, item -> item.id },
key = { _, item -> item.id.toString() + "-portfolio-${portfolio.id}" },
contentType = { _, item -> item::class.java },
itemContent = { tokenIndex, token ->
val indexWithHeader = tokenIndex.inc()
PortfolioTokensListItem(
state = token,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.animateItem()
SlideInItemVisibility(
currentIndex = tokenIndex,
lastIndex = lastIndex,
modifier = modifier
.testModifier(indexWithHeader)
.animateItem(fadeInSpec = null, placementSpec = null, fadeOutSpec = null)
.roundedShapeItemDecoration(
radius = TangemTheme.dimens.radius14,
currentIndex = indexWithHeader,
lastIndex = tokens.lastIndex.inc(),
lastIndex = lastIndex,
backgroundColor = TangemTheme.colors.background.primary,
)
.conditional(tokenIndex == tokens.lastIndex) {
Modifier.padding(bottom = 8.dp)
},
)
),
visible = isExpanded,
) {
val innerModifier = if (indexWithHeader == lastIndex) Modifier.padding(bottom = 8.dp) else Modifier
PortfolioTokensListItem(
state = token,
isBalanceHidden = isBalanceHidden,
modifier = innerModifier,
)
}
},
)
}
@Suppress("MagicNumber")
private fun LazyListScope.portfolioItem(
portfolio: TokensListItemUM.Portfolio,
modifier: Modifier,
portfolioIndex: Int,
isBalanceHidden: Boolean,
) {
val tokens = portfolio.tokens
val isExpanded = portfolio.isExpanded
val lastIndex = when {
isExpanded && tokens.isEmpty() -> 1
isExpanded -> tokens.lastIndex.inc()
else -> 0
}
item(
key = "account-${portfolio.id}",
contentType = "account",
contentType = "account-content",
) {
// Snap immediately on expand; on collapse, hold until all child items finish
// their shrink animation, then snap to fully-rounded shape.
val effectiveLastIndex by animateIntAsState(
targetValue = lastIndex,
animationSpec = if (lastIndex != 0) {
snap()
} else {
snap(delayMillis = minOf(50 * tokens.lastIndex, 250) + 150)
},
label = "lastIndex",
)
PortfolioListItem(
state = portfolio,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.animateItem()
modifier = modifier
.testModifier(portfolioIndex)
.roundedShapeItemDecoration(
currentIndex = 0,
lastIndex = portfolio.tokens.lastIndex.inc(),
radius = TangemTheme.dimens.radius14,
lastIndex = effectiveLastIndex,
backgroundColor = TangemTheme.colors.background.primary,
)
.then(modifier),
)
}
}
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")
private fun Modifier.testModifier(index: Int): Modifier = this
.testTag(MainScreenTestTags.TOKEN_LIST_ITEM)
.semantics { lazyListItemPosition = index }
@Suppress("MagicNumber")
@Composable
private fun TokenItem(
token: TokenToSelectState.TokenToSelect,
onTokenClick: () -> Unit,
internal fun SlideInItemVisibility(
visible: Boolean,
currentIndex: Int,
lastIndex: Int,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
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,
val maxDelay = 250
val delayEnter = minOf(50 * currentIndex, maxDelay)
val delayExit = minOf(50 * (lastIndex - currentIndex - 1), maxDelay)
AnimatedVisibility(
modifier = modifier,
visible = visible,
enter = expandVertically(
tween(200, delayMillis = delayEnter, easing = LinearOutSlowInEasing),
expandFrom = Alignment.Top,
) + fadeIn(tween(200, delayMillis = delayEnter, easing = LinearOutSlowInEasing)),
exit = shrinkVertically(
tween(150, delayMillis = delayExit, easing = FastOutLinearInEasing),
shrinkTowards = Alignment.Top,
) + fadeOut(tween(150, delayMillis = delayExit, easing = FastOutLinearInEasing)),
) {
CurrencyIcon(
state = token.tokenIcon,
shouldDisplayNetwork = true,
content()
}
}
// region Preview
@Composable
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun SwapSelectTokenScreen_Preview(
@PreviewParameter(SwapSelectTokenScreenPreviewProvider::class) params: SwapSelectTokenStateHolder,
) {
TangemThemePreview {
SwapSelectTokenScreen(
state = params,
onBack = {},
)
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,
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
onTokenSelected = {},
),
// Not found state
SwapSelectTokenStateHolder(
availableTokens = persistentListOf(),
unavailableTokens = persistentListOf(),
tokensListData = TokenListUMData.EmptyList,
marketsState = null,
isAfterSearch = true,
isBalanceHidden = false,
onSearchEntered = {},
onTokenSelected = {},
),
)
}
@Preview
@Composable
private fun TokenScreenPreview(
@PreviewParameter(SwapSelectTokenScreenPreviewProvider::class)
state: SwapSelectTokenStateHolder,
) {
TangemThemePreview {
SwapSelectTokenScreen(
state = state,
onBack = {},
override val values: Sequence<SwapSelectTokenStateHolder>
get() = sequenceOf(
// Content state with tokens and markets
SwapSelectTokenPreviewProvider.defaultState,
// Empty state
SwapSelectTokenStateHolder(
tokensListData = TokenListUMData.EmptyList,
marketsState = SwapMarketState.DefaultLoading,
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
),
// Not found state
SwapSelectTokenStateHolder(
tokensListData = TokenListUMData.EmptyList,
marketsState = SwapMarketState.SearchLoading,
isAfterSearch = true,
isBalanceHidden = false,
onSearchEntered = {},
),
)
}
}
}
// endregion

View file

@ -26,9 +26,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
@ -41,8 +39,7 @@ import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
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.SwapTokenScreenTestTags
@ -59,7 +56,7 @@ fun TransactionCard(
balance: String,
tokenIconUrl: String,
tokenCurrency: String,
amountEquivalent: String?,
amountEquivalent: TextReference?,
priceImpact: PriceImpact,
textFieldValue: TextFieldValue?,
modifier: Modifier = Modifier,
@ -129,7 +126,7 @@ fun TransactionCard(
@Composable
fun TransactionCardEmpty(
type: TransactionCardType,
amountEquivalent: String?,
amountEquivalent: TextReference?,
textFieldValue: TextFieldValue?,
modifier: Modifier = Modifier,
onChangeTokenClick: (() -> Unit)? = null,
@ -157,7 +154,7 @@ fun TransactionCardEmpty(
type = type,
amountEquivalent = amountEquivalent,
textFieldValue = textFieldValue,
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
)
}
@ -250,7 +247,7 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie
@Composable
private fun Content(
type: TransactionCardType,
amountEquivalent: String?,
amountEquivalent: TextReference?,
priceImpact: PriceImpact,
textFieldValue: TextFieldValue?,
) {
@ -320,27 +317,13 @@ private fun Content(
modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20),
verticalAlignment = Alignment.CenterVertically,
) {
if (priceImpact is PriceImpact.Value) {
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = makePriceImpactBalanceWarning(
amountEquivalent,
priceImpact.getIntPercentValue(),
),
text = amount.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(
SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_WITH_PRICE_IMPACT_WARNING,
),
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
)
} else {
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = amount,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT),
)
}
}
if (type.shouldShowWarning) {
SpacerW4()
@ -353,10 +336,10 @@ private fun Content(
Icon(
painter = painterResource(id = R.drawable.ic_information_24),
contentDescription = null,
tint = if (priceImpact is PriceImpact.Value) {
TangemTheme.colors.text.attention
} else {
TangemTheme.colors.text.tertiary
tint = when (priceImpact.type) {
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
else -> TangemTheme.colors.text.tertiary
},
modifier = Modifier
.align(Alignment.CenterVertically)
@ -368,7 +351,7 @@ private fun Content(
} else {
AnimatedContent(targetState = amountEquivalent, label = "") { amount ->
Text(
text = amount,
text = amount.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier
@ -530,19 +513,6 @@ fun ChangeTokenSelector() {
}
}
@Composable
private fun makePriceImpactBalanceWarning(value: String, priceImpactPercents: Int): AnnotatedString {
val fullValue = "$value (-$priceImpactPercents%)"
return buildAnnotatedString {
append(fullValue)
addStyle(
style = SpanStyle(color = TangemTheme.colors.text.attention),
start = value.length,
end = fullValue.length,
)
}
}
// region preview
@Preview(widthDp = 328, heightDp = 116, showBackground = true)
@ -602,14 +572,14 @@ private fun TransactionCardPreview() {
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = null,
),
amountEquivalent = "1 000 000",
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue(),
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
)
}
@ -625,14 +595,24 @@ private fun TransactionCardPreviewWithPriceImpact() {
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountEquivalent = "1 000 000",
amountEquivalent = combinedReference(
stringReference("1 000 000 $"),
styledStringReference(
" (-15%)",
{ SpanStyle(color = TangemTheme.colors.text.attention) },
),
),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue("1000000.0000000000000000000000000"),
priceImpact = PriceImpact.Value(0.15F),
priceImpact = PriceImpact(
value = 0.15F.toBigDecimal(),
type = PriceImpact.Type.MEDIUM,
amountSignificance = PriceImpact.AmountSignificance.HIGH,
),
)
}
@ -647,14 +627,14 @@ private fun TransactionCardPreviewWithoutPriceImpact() {
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountEquivalent = "1 000 000",
amountEquivalent = stringReference("1 000 000"),
tokenIconUrl = "",
tokenCurrency = "DAI",
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue(),
priceImpact = PriceImpact.Empty(),
priceImpact = PriceImpact.Empty,
)
}

View file

@ -2,36 +2,108 @@ 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.R
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
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.state.PortfolioItemContentUM
import com.tangem.core.ui.components.tokenlist.state.PortfolioTokensListItemUM
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.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
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import java.math.BigDecimal
internal class SwapSelectTokenPreviewProvider {
internal object 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(),
)
}
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 fun createPreviewMarketsState() = SwapMarketState.Content(
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,
),
)
private val tokenItemState = TokenItemState.Content(
id = "1",
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 textContentTokensState = persistentListOf(
TokensListItemUM.GroupTitle(id = 111, text = stringReference("Network Bitcoin")),
TokensListItemUM.Token(state = tokenItemState),
TokensListItemUM.GroupTitle(id = 222, text = stringReference("Network Ethereum")),
TokensListItemUM.Token(
state = tokenItemState.copy(
id = "2",
titleState = TokenItemState.TitleState.Content(text = stringReference("Ethereum")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "3 340,79 \$"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "1,856660295 ETH"),
subtitleState = TokenItemState.SubtitleState.CryptoPriceContent(
price = "1 799,41 \$",
priceChangePercent = "5,16 %",
type = PriceChangeType.UP,
),
),
),
TokensListItemUM.Token(
state = TokenItemState.Unreachable(
id = "3",
iconState = CurrencyIconState.Locked,
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Polygon")),
onItemClick = {},
onItemLongClick = {},
),
),
TokensListItemUM.Token(
state = tokenItemState.copy(
id = "4",
titleState = TokenItemState.TitleState.Content(text = stringReference(value = "Shiba Inu")),
fiatAmountState = TokenItemState.FiatAmountState.Content(text = "48,64 \$"),
subtitle2State = TokenItemState.Subtitle2State.TextContent(text = "6 200 220,00 SHIB"),
subtitleState = TokenItemState.SubtitleState.CryptoPriceContent(
price = "0.01 \$",
priceChangePercent = "1,34 %",
type = PriceChangeType.DOWN,
),
),
),
)
private val marketState = SwapMarketState.Content(
items = createPreviewMarketItems(),
loadMore = { },
onItemClick = { },
@ -41,6 +113,37 @@ internal class SwapSelectTokenPreviewProvider {
shouldAssetsCount = false,
)
val defaultState = SwapSelectTokenStateHolder(
tokensListData = TokenListUMData.AccountList(
tokensList = persistentListOf(
TokensListItemUM.Portfolio(
content = PortfolioItemContentUM.Tokens(
tokens = textContentTokensState.filterIsInstance<PortfolioTokensListItemUM>()
.toPersistentList(),
),
isExpanded = false,
isCollapsable = true,
tokenItemUM = AccountItemPreviewData.accountItem
.copy(iconState = AccountItemPreviewData.accountLetterIcon),
),
TokensListItemUM.Portfolio(
content = PortfolioItemContentUM.Tokens(
tokens = textContentTokensState.filterIsInstance<PortfolioTokensListItemUM>()
.toPersistentList(),
),
isExpanded = true,
isCollapsable = true,
tokenItemUM = AccountItemPreviewData.accountItem,
),
),
totalTokensCount = TOTAL_ITEMS,
),
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
marketsState = marketState,
)
private fun createPreviewMarketItems() = listOf(
createMarketItem(
id = "1",
@ -103,7 +206,11 @@ internal class SwapSelectTokenPreviewProvider {
iconUrl = iconUrl,
ratingPosition = ratingPosition,
marketCap = marketCap,
price = MarketsListItemUM.Price(text = "31 285.72$"),
price = MarketsListItemUM.Price(
text = "31 285.72$",
annotated = stringReference("31 285.72$"),
fiatPrice = BigDecimal("123123"),
),
trendPercentText = "12.43%",
trendType = trendType,
chartData = chartData,
@ -111,51 +218,4 @@ internal class SwapSelectTokenPreviewProvider {
stakingRate = stringReference("APY 12.34%"),
updateTimestamp = 0,
)
companion object {
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,
),
)
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"),
)
}
}