Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-06 12:18:13 +03:00
commit 531eb86ac5
1243 changed files with 49302 additions and 12422 deletions

View file

@ -2,18 +2,33 @@ package com.tangem.feature.swap
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
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
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.swapStoriesScreen.SwapStoriesScreen
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.core.ui.res.TangemTheme
import com.tangem.domain.models.currency.CryptoCurrencyStatus
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.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.swap.SwapComponent
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
@ -22,11 +37,22 @@ import dagger.assisted.AssistedInject
@Suppress("UnusedPrivateMember")
internal class DefaultSwapComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: SwapComponent.Params,
@Assisted private val params: SwapComponent.Params,
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
private val sendFeatureToggles: SendFeatureToggles,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.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 = { configuration, context -> bottomSheetChild(context) },
)
init {
lifecycle.subscribe(
onStart = model::onStart,
@ -34,8 +60,81 @@ internal class DefaultSwapComponent @AssistedInject constructor(
)
}
val slotNavigation = SlotNavigation<FeeSelectorConfig>()
val childSlot = childSlot(
source = slotNavigation,
serializer = null,
key = FEE_SELECTOR_SLOT_KEY,
childFactory = { config, context ->
createSwapFeeSelectorBlockComponent(
context = childByContext(context),
config = config,
)
},
)
private fun createSwapFeeSelectorBlockComponent(
context: AppComponentContext,
config: FeeSelectorConfig,
): SwapFeeSelectorBlockComponent {
return swapFeeSelectorBlockComponentFactory.create(
context = context,
params = SwapFeeSelectorBlockComponent.Params(
repository = model.feeSelectorRepository,
userWalletId = params.userWalletId,
sendingCryptoCurrencyStatus = config.sendingCurrencyStatus,
feeCryptoCurrencyStatus = config.feeCurrencyStatus,
analyticsParams = SwapFeeSelectorBlockComponent.AnalyticsParams(
analyticsCategoryName = CommonSendAnalyticEvents.SWAP_CATEGORY,
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Swap,
),
),
)
}
data class FeeSelectorConfig(
val sendingCurrencyStatus: CryptoCurrencyStatus,
val feeCurrencyStatus: CryptoCurrencyStatus,
)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
override fun Content(modifier: Modifier) {
if (sendFeatureToggles.isGaslessTransactionsEnabled) {
val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle()
val fromCryptoCurrency by remember { derivedStateOf { dataState.fromCryptoCurrency } }
val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } }
val amount by remember { derivedStateOf { dataState.amount } }
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, amount.isNullOrBlank()) {
if (amount.isNullOrBlank()) {
slotNavigation.dismiss()
return@LaunchedEffect
}
val sendingCryptoCurrencyStatus = fromCryptoCurrency ?: run {
slotNavigation.dismiss()
return@LaunchedEffect
}
val feeCurrencyStatus = feePaidCryptoCurrency ?: run {
slotNavigation.dismiss()
return@LaunchedEffect
}
slotNavigation.activate(
FeeSelectorConfig(
sendingCurrencyStatus = sendingCryptoCurrencyStatus,
feeCurrencyStatus = feeCurrencyStatus,
),
)
}
}
val feeSelectorChildStackState by childSlot.subscribeAsState()
val feeSelectorBlockComponent = feeSelectorChildStackState.child?.instance
val bottomSheet by bottomSheetSlot.subscribeAsState()
Crossfade(
modifier = Modifier.background(TangemTheme.colors.background.secondary),
targetState = model.currentScreen,
@ -47,16 +146,30 @@ internal class DefaultSwapComponent @AssistedInject constructor(
if (storiesConfig != null) {
SwapStoriesScreen(config = storiesConfig)
} else {
SwapScreen(stateHolder = model.uiState)
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
SwapNavScreen.Main -> SwapScreen(stateHolder = model.uiState)
SwapNavScreen.Main -> SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
SwapNavScreen.Success -> {
val successState = model.uiState.successState
val feeSelectorState by model.feeSelectorRepository.state.collectAsStateWithLifecycle()
if (successState != null) {
SwapSuccessScreen(state = successState, model.uiState.onBackClicked)
SwapSuccessScreen(
state = successState,
feeSelectorUM = feeSelectorState,
onBack = model.uiState.onBackClicked,
)
} else {
SwapScreen(stateHolder = model.uiState)
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
SwapNavScreen.SelectToken -> {
@ -64,15 +177,37 @@ internal class DefaultSwapComponent @AssistedInject constructor(
if (tokenState != null) {
SwapSelectTokenScreen(state = tokenState, onBack = model.uiState.onBackClicked)
} else {
SwapScreen(stateHolder = model.uiState)
SwapScreen(
stateHolder = model.uiState,
feeSelectorBlockComponent = feeSelectorBlockComponent,
)
}
}
}
}
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 : SwapComponent.Factory {
override fun create(context: AppComponentContext, params: SwapComponent.Params): DefaultSwapComponent
}
private companion object {
const val BOTTOM_SHEET_SLOT_KEY = "bottomSheetSlot"
const val FEE_SELECTOR_SLOT_KEY = "feeSelectorSlot"
}
}

View file

@ -1,5 +1,11 @@
package com.tangem.feature.swap
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.swap.SwapFeatureToggles
internal class DefaultSwapFeatureToggles : SwapFeatureToggles
internal class DefaultSwapFeatureToggles(
private val featureTogglesManager: FeatureTogglesManager,
) : SwapFeatureToggles {
override val isMarketListFeatureEnabled: Boolean
get() = featureTogglesManager.isFeatureEnabled("SWAP_MARKET_LIST_ENABLED")
}

View file

@ -2,11 +2,16 @@ package com.tangem.feature.swap.analytics
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO
import com.tangem.core.analytics.models.AnalyticsParam.Key.BLOCKCHAIN
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.TOKEN_PARAM
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
import com.tangem.core.analytics.models.getReferralParams
import com.tangem.feature.swap.domain.models.domain.SwapProvider
@ -20,9 +25,15 @@ sealed class SwapEvents(
params: Map<String, String> = emptyMap(),
) : AnalyticsEvent(SWAP_CATEGORY, event, params) {
data class SwapScreenOpened(val token: String) : SwapEvents(
data class SwapScreenOpened(
val token: String,
val blockchain: String,
) : SwapEvents(
event = "Swap Screen Opened",
params = mapOf("Token" to token),
params = mapOf(
TOKEN_PARAM to token,
BLOCKCHAIN to blockchain,
),
), AppsFlyerIncludedEvent
class SendTokenBalanceClicked : SwapEvents(event = "Send Token Balance Clicked")
@ -85,21 +96,24 @@ sealed class SwapEvents(
val receiveBlockchain: String,
val sendToken: String,
val receiveToken: String,
val feeToken: String,
val fromDerivationIndex: Int?,
val toDerivationIndex: Int?,
val referralId: String?,
) : SwapEvents(
event = "Swap in Progress Screen Opened",
params = mapOf(
"Provider" to provider.name,
"Commission" to if (commission == FeeType.NORMAL) "Market" else "Fast",
"Send Token" to sendToken,
"Receive Token" to receiveToken,
"Send Blockchain" to sendBlockchain,
"Receive Blockchain" to receiveBlockchain,
"Account Derivation From or To (optional)" to "$fromDerivationIndex, $toDerivationIndex",
*getReferralParams(referralId).toTypedArray(),
),
params = buildMap {
put("Provider", provider.name)
put("Commission", if (commission == FeeType.NORMAL) "Market" else "Fast")
put("Send Token", sendToken)
put("Receive Token", receiveToken)
put("Send Blockchain", sendBlockchain)
put("Receive Blockchain", receiveBlockchain)
if (fromDerivationIndex != null) put(ACCOUNT_DERIVATION_FROM, fromDerivationIndex.toString())
if (toDerivationIndex != null) put(ACCOUNT_DERIVATION_TO, toDerivationIndex.toString())
put(FEE_TOKEN, feeToken)
putAll(getReferralParams(referralId))
},
), AppsFlyerIncludedEvent
class ProviderClicked : SwapEvents("Provider Clicked")

View file

@ -0,0 +1,99 @@
package com.tangem.feature.swap.component
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import arrow.core.Either
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.factory.ComponentFactory
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.transaction.error.GetFeeError
import com.tangem.domain.transaction.models.TransactionFeeExtended
import com.tangem.features.send.v2.api.FeeSelectorBlockComponent
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.params.FeeSelectorParams
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
class SwapFeeSelectorBlockComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted params: Params,
feeSelectorBlockComponentFactory: FeeSelectorBlockComponent.Factory,
) : AppComponentContext by appComponentContext, ComposableContentComponent {
private val feeSelectorBlockComponent =
feeSelectorBlockComponentFactory.create(
context = child("swapFeeSelectorBlock"),
params = FeeSelectorParams.FeeSelectorBlockParams(
state = params.repository.state.value,
userWalletId = params.userWalletId,
onLoadFee = params.repository::loadFee,
onLoadFeeExtended = if (params.repository is ModelRepositoryExtended) {
params.repository::loadFeeExtended
} else {
null
},
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.ExcludeLow,
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.sendingCryptoCurrencyStatus,
analyticsCategoryName = params.analyticsParams.analyticsCategoryName,
analyticsSendSource = params.analyticsParams.analyticsSendSource,
bottomSheetShown = params.repository::choosingInProgress,
),
onResult = params.repository::onResult,
)
init {
params.repository.state
.onEach(feeSelectorBlockComponent::updateState)
.launchIn(componentScope)
}
@Composable
override fun Content(modifier: Modifier) {
feeSelectorBlockComponent.Content(modifier = modifier)
}
interface ModelRepository {
val state: StateFlow<FeeSelectorUM>
get() = MutableStateFlow<FeeSelectorUM>(FeeSelectorUM.Loading)
fun onResult(newState: FeeSelectorUM)
suspend fun loadFee(): Either<GetFeeError, TransactionFee>
fun choosingInProgress(updatedState: Boolean)
}
interface ModelRepositoryExtended : ModelRepository {
suspend fun loadFeeExtended(
selectedToken: CryptoCurrencyStatus? = null,
): Either<GetFeeError, TransactionFeeExtended>
}
class AnalyticsParams(
val analyticsCategoryName: String,
val analyticsSendSource: CommonSendAnalyticEvents.CommonSendSource,
)
class Params(
val userWalletId: UserWalletId,
val sendingCryptoCurrencyStatus: CryptoCurrencyStatus,
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
val analyticsParams: AnalyticsParams,
val repository: ModelRepository,
)
@AssistedFactory
interface Factory : ComponentFactory<Params, SwapFeeSelectorBlockComponent>
}

View file

@ -0,0 +1,26 @@
package com.tangem.feature.swap.converters
import com.tangem.data.common.currency.getTokenIconUrlFromDefaultHost
import com.tangem.domain.markets.TokenMarketInfo
import com.tangem.domain.markets.TokenMarketParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.utils.converter.Converter
internal class TokenMarketInfoToParamsConverter : Converter<TokenMarketInfo, TokenMarketParams> {
override fun convert(value: TokenMarketInfo): TokenMarketParams {
val tokenId = CryptoCurrency.RawID(value.id)
return TokenMarketParams(
id = tokenId,
name = value.name,
symbol = value.symbol,
tokenQuotes = TokenMarketParams.Quotes(
currentPrice = value.quotes.currentPrice,
h24Percent = value.quotes.h24ChangePercent,
weekPercent = value.quotes.weekChangePercent,
monthPercent = value.quotes.monthChangePercent,
),
imageUrl = getTokenIconUrlFromDefaultHost(tokenId),
)
}
}

View file

@ -31,32 +31,44 @@ internal class TokensDataConverterV2(
override fun transform(prevState: SwapStateHolder): SwapStateHolder {
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 }
TokenListUMData.AccountList(
tokensList = accountListItemConverter.convertList(accountList).toPersistentList(),
tokensList = portfolioList,
totalTokensCount = totalTokensCount,
)
} else {
TokenListUMData.TokenList(
tokensList = persistentListOf(
TokensListItemUM.GroupTitle(
id = "available_tokens_title",
text = resourceReference(R.string.exchange_tokens_available_tokens_header),
),
) + accountList.flatMap { (_, currencyList) ->
currencyList.asSequence().map { accountSwapCurrency ->
if (accountSwapCurrency.isAvailable) {
accountListItemConverter.createAvailableItemConverter()
} else {
accountListItemConverter.createUnavailableItemConverter()
}.convert(accountSwapCurrency.cryptoCurrencyStatus)
}.map(TokensListItemUM::Token).toPersistentList()
}.toPersistentList(),
)
val tokensList = accountList.flatMap { (_, currencyList) ->
currencyList.asSequence().map { accountSwapCurrency ->
if (accountSwapCurrency.isAvailable) {
accountListItemConverter.createAvailableItemConverter()
} else {
accountListItemConverter.createUnavailableItemConverter()
}.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,

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.di
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.feature.swap.DefaultSwapComponent
import com.tangem.feature.swap.DefaultSwapFeatureToggles
import com.tangem.features.swap.SwapComponent
@ -17,8 +18,8 @@ internal object SwapFeatureModule {
@Provides
@Singleton
fun provideSwapFeatureToggles(): SwapFeatureToggles {
return DefaultSwapFeatureToggles()
fun provideSwapFeatureToggles(featureTogglesManager: FeatureTogglesManager): SwapFeatureToggles {
return DefaultSwapFeatureToggles(featureTogglesManager)
}
}

View file

@ -6,9 +6,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import arrow.core.Either
import arrow.core.getOrElse
import com.arkivanov.decompose.router.slot.SlotNavigation
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.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
@ -28,16 +34,20 @@ 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.GetTokenMarketInfoUseCase
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.promo.GetStoryContentUseCase
@ -47,35 +57,55 @@ import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.settings.usercountry.models.needApplyFCARestrictions
import com.tangem.domain.tangempay.GetTangemPayCurrencyStatusUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tokens.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.GetSingleCryptoCurrencyStatusUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.transaction.error.GetFeeError
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.StoriesEvents
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.converters.TokenMarketInfoToParamsConverter
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.TransactionFeeResult
import com.tangem.feature.swap.domain.TxFeeSealedState
import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.ExpressException
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.AddToPortfolioRoute
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.models.market.SwapMarketsListBatchFlowManager
import com.tangem.feature.swap.models.market.state.SwapMarketState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.router.SwapRouter
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.feature.swap.utils.formatToUIRepresentation
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.SendFeatureToggles
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 kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -119,6 +149,16 @@ internal class SwapModel @Inject constructor(
private val accountsFeatureToggles: AccountsFeatureToggles,
private val getTangemPayCurrencyStatusUseCase: GetTangemPayCurrencyStatusUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val sendFeatureToggles: SendFeatureToggles,
private val getMarketsTokenListFlowUseCase: GetMarketsTokenListFlowUseCase,
swapFeatureToggles: SwapFeatureToggles,
private val addToPortfolioManagerFactory: AddToPortfolioManager.Factory,
private val getTokenMarketInfoUseCase: GetTokenMarketInfoUseCase,
private val excludedBlockchains: ExcludedBlockchains,
private val getUserWalletsUseCase: GetWalletsUseCase,
private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase,
private val appsFlyerStore: AppsFlyerStore,
) : Model() {
@ -151,6 +191,7 @@ internal class SwapModel @Inject constructor(
isBalanceHiddenProvider = Provider { isBalanceHidden },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
isAccountsModeProvider = Provider { isAccountsMode },
iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork,
)
private val inputNumberFormatter =
@ -159,9 +200,15 @@ 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>>()
private var dataState by mutableStateOf(SwapProcessDataState())
val dataStateStateFlow = MutableStateFlow(SwapProcessDataState())
var dataState
get() = dataStateStateFlow.value
set(value) {
dataStateStateFlow.value = value
}
var uiState: SwapStateHolder by mutableStateOf(
stateBuilder.createInitialLoadingState(
@ -172,16 +219,26 @@ internal class SwapModel @Inject constructor(
)
private set
val feeSelectorRepository = FeeSelectorRepository()
// shows currency order (direct - swap initial to selected, reversed = selected to initial)
private var isOrderReversed = false
var isOrderReversed by mutableStateOf(false)
private val lastAmount = mutableStateOf(INITIAL_AMOUNT)
private val lastReducedBalanceBy = mutableStateOf(BigDecimal.ZERO)
private val swapRouter: SwapRouter = SwapRouter(router = router)
private var userCountry: UserCountry? = null
private lateinit var fromAccountCurrencyStatus: AccountCryptoCurrencyStatus
private var fromAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null
private var toAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null
/**
* If accountsFeatureToggles is off OR user came from Tangem Pay -> fromAccountCurrencyStatus == null
* If accountsFeatureToggles is on AND user didn't come from Tangem Pay -> fromAccountCurrencyStatus != null
*
* Remove when accounts are integrated into Tangem Pay
*/
private val canUseFromAccountCurrencyStatus = accountsFeatureToggles.isFeatureEnabled && tangemPayInput == null
private val isUserResolvableError: (SwapState) -> Boolean = { swapState ->
swapState is SwapState.SwapError &&
(
@ -192,13 +249,52 @@ 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 searchMarketsListManager by lazy {
SwapMarketsListBatchFlowManager(
getMarketsTokenListFlowUseCase = getMarketsTokenListFlowUseCase,
batchFlowType = GetMarketsTokenListFlowUseCase.BatchFlowType.Search,
currentAppCurrency = Provider { selectedAppCurrencyFlow.value },
currentSearchText = Provider { searchQueryState.value },
modelScope = modelScope,
dispatchers = dispatchers,
)
}
val currentScreen: SwapNavScreen
get() = swapRouter.currentScreen
val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
override fun onSuccess(addedToken: CryptoCurrency) {
modelScope.launch {
bottomSheetNavigation.dismiss()
uiState.selectTokenState?.let { currentSelectState ->
uiState = uiState.copy(
selectTokenState = currentSelectState.copy(
marketsState = null,
),
)
}
getAccountCurrencyStatusUseCase.invoke(userWalletId, addedToken)
.firstOrNull {
it.status.value is CryptoCurrencyStatus.Loaded
}?.let { (account, status) ->
applyAddedToken(status, account)
}
}
}
}
var addToPortfolioManager: AddToPortfolioManager? = null
init {
userCountry = getUserCountryUseCase.invokeSync().getOrNull()
?: UserCountry.Other(Locale.getDefault().country)
@ -208,7 +304,7 @@ internal class SwapModel @Inject constructor(
}
modelScope.launch(dispatchers.io) {
if (accountsFeatureToggles.isFeatureEnabled && tangemPayInput == null) {
if (canUseFromAccountCurrencyStatus) {
isAccountsMode = isAccountsModeEnabledUseCase.invokeSync()
val fromAccountStatus = getAccountCurrencyStatusUseCase.invokeSync(
@ -250,7 +346,12 @@ internal class SwapModel @Inject constructor(
}
}
analyticsEventHandler.send(SwapEvents.SwapScreenOpened(initialCurrencyFrom.symbol))
analyticsEventHandler.send(
SwapEvents.SwapScreenOpened(
token = initialCurrencyFrom.symbol,
blockchain = initialCurrencyFrom.network.name,
),
)
getBalanceHidingSettingsUseCase()
.onEach { settings ->
@ -258,6 +359,63 @@ internal class SwapModel @Inject constructor(
uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden)
}
.launchIn(modelScope)
if (swapFeatureToggles.isMarketListFeatureEnabled) {
combine(
flow = searchQueryState
.onEach { searchQuery ->
searchMarketsListManager.reload(searchQuery)
},
flow2 = searchMarketsListManager.uiItems,
flow3 = searchMarketsListManager.isInInitialLoadingErrorState,
flow4 = searchMarketsListManager.isSearchNotFoundState,
flow5 = searchMarketsListManager.totalCount.filterNotNull(),
) { searchQuery, uiItems, isError, isSearchNotFound, total ->
when {
searchQuery.isEmpty() -> {
visibleMarketItemIds.value = emptyList()
null
}
isError -> SwapMarketState.LoadingError(
onRetryClicked = { searchMarketsListManager.reload(searchQuery) },
)
isSearchNotFound -> SwapMarketState.SearchNothingFound
uiItems.isEmpty() -> SwapMarketState.Loading
else -> SwapMarketState.Content(
items = uiItems,
loadMore = { searchMarketsListManager.loadMore() },
onItemClick = { item ->
addToPortfolioItem(item)
},
visibleIdsChanged = { visibleMarketItemIds.value = it },
total = total,
)
}
}
.distinctUntilChanged()
.onEach { marketsState ->
uiState.selectTokenState?.let { currentSelectState ->
uiState = uiState.copy(
selectTokenState = currentSelectState.copy(
marketsState = marketsState,
),
)
}
}
.launchIn(modelScope)
}
modelScope.launch {
visibleMarketItemIds.mapNotNull { rawIDS ->
if (rawIDS.isNotEmpty()) {
searchMarketsListManager.getBatchKeysByItemIds(rawIDS)
} else {
null
}
}.distinctUntilChanged().collectLatest { visibleBatchKeys ->
searchMarketsListManager.loadCharts(visibleBatchKeys)
}
}
}
fun onStart() {
@ -283,6 +441,7 @@ internal class SwapModel @Inject constructor(
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(hasAvailableTokens = isAnyAvailableTokens))
}
@Suppress("LongMethod")
private fun initTokens(isReverseFromTo: Boolean) {
modelScope.launch(dispatchers.main) {
runCatching(dispatchers.io) {
@ -318,21 +477,22 @@ internal class SwapModel @Inject constructor(
isReverseFromTo = isReverseFromTo,
)
(dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = coin,
isFromCurrency = true,
val fromCryptoCurrency = if (isOrderReversed) {
dataState.toCryptoCurrency
} else {
dataState.fromCryptoCurrency
}
fromCryptoCurrency?.let { cryptoCurrency ->
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = cryptoCurrency,
).getOrNull(),
)
}
(dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = coin,
isFromCurrency = false,
)
}
subscribeToCoinBalanceUpdatesIfNeeded()
}.onFailure { error ->
Timber.e(error)
@ -358,6 +518,47 @@ 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)
applyInitialTokenChoice(
state = state,
selectedCurrency = addedToken,
selectedAccount = addedAccount,
isReverseFromTo = isOrderReversed,
)
subscribeToCoinBalanceUpdatesIfNeeded()
swapRouter.back()
}.onFailure { error ->
Timber.e(error)
}
}
}
private fun subscribeToCoinBalanceUpdatesIfNeeded() {
(dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = coin,
isFromCurrency = true,
)
}
(dataState.toCryptoCurrency?.currency as? CryptoCurrency.Coin)?.let { coin ->
subscribeToCoinBalanceUpdates(
userWalletId = userWalletId,
coin = coin,
isFromCurrency = false,
)
}
}
private fun initStories() {
modelScope.launch {
getStoryContentUseCase.invokeSync(StoryContentIds.STORY_FIRST_TIME_SWAP.id).fold(
@ -394,11 +595,11 @@ internal class SwapModel @Inject constructor(
} else {
initialFromStatus to selectedCurrency
}
val (fromAccount, toAccount) = if (accountsFeatureToggles.isFeatureEnabled && tangemPayInput == null) {
val (fromAccount, toAccount) = if (canUseFromAccountCurrencyStatus) {
if (isOrderReversed) {
selectedAccount to fromAccountCurrencyStatus.account
selectedAccount to requireNotNull(fromAccountCurrencyStatus).account
} else {
fromAccountCurrencyStatus.account to selectedAccount
requireNotNull(fromAccountCurrencyStatus).account to selectedAccount
}
} else {
null to null
@ -448,6 +649,7 @@ internal class SwapModel @Inject constructor(
reduceBalanceBy: BigDecimal,
toProvidersList: List<SwapProvider>,
isSilent: Boolean = false,
updateFeeBlock: Boolean = true,
) {
singleTaskScheduler.cancelTask()
if (!isSilent) {
@ -459,6 +661,7 @@ internal class SwapModel @Inject constructor(
toAccount = toAccount,
mainTokenId = initialCurrencyFrom.id.value,
)
feeSelectorRepository.state.value = FeeSelectorUM.Loading
}
singleTaskScheduler.scheduleTask(
modelScope,
@ -470,11 +673,12 @@ internal class SwapModel @Inject constructor(
amount = amount,
reduceBalanceBy = reduceBalanceBy,
toProvidersList = toProvidersList,
updateFeeBlock = updateFeeBlock,
),
)
}
private fun startLoadingQuotesFromLastState(isSilent: Boolean = false) {
private fun startLoadingQuotesFromLastState(isSilent: Boolean = false, updateFeeBlock: Boolean = true) {
val fromCurrency = dataState.fromCryptoCurrency
val toCurrency = dataState.toCryptoCurrency
val amount = dataState.amount
@ -488,6 +692,7 @@ internal class SwapModel @Inject constructor(
isSilent = isSilent,
reduceBalanceBy = dataState.reduceBalanceBy,
toProvidersList = findSwapProviders(fromCurrency, toCurrency),
updateFeeBlock = updateFeeBlock,
)
}
}
@ -500,7 +705,9 @@ internal class SwapModel @Inject constructor(
amount: String,
reduceBalanceBy: BigDecimal,
toProvidersList: List<SwapProvider>,
updateFeeBlock: Boolean = true,
): PeriodicTask<Map<SwapProvider, SwapState>> {
var shouldUpdateFeeBlock = updateFeeBlock
return PeriodicTask(
delay = UPDATE_DELAY,
task = {
@ -520,7 +727,7 @@ internal class SwapModel @Inject constructor(
providers = toProvidersList,
amountToSwap = amount,
reduceBalanceBy = reduceBalanceBy,
selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
txFeeSealedState = getSelectedFeeState(),
)
}
},
@ -536,12 +743,19 @@ internal class SwapModel @Inject constructor(
tokenSwapInfoForProviders = successStates.entries
.associate { it.key.providerId to it.value.toTokenInfo },
)
if (shouldUpdateFeeBlock) {
modelScope.launch { feeSelectorReloadTrigger.triggerUpdate() }
} else {
shouldUpdateFeeBlock = true
}
} else {
feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true)
Timber.e("Accidentally empty quotes list")
}
},
onError = { error ->
Timber.e("Error when loading quotes: $error")
feeSelectorRepository.state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true)
uiState = stateBuilder.addNotification(uiState, null) { startLoadingQuotesFromLastState() }
},
)
@ -581,7 +795,7 @@ internal class SwapModel @Inject constructor(
swapProvider = provider,
bestRatedProviderId = bestRatedProviderId,
isNeedBestRateBadge = dataState.lastLoadedSwapStates.consideredProvidersStates().size > 1,
selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL,
isReverseSwapPossible = isReverseSwapPossible(),
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
hideFee = tangemPayInput?.isWithdrawal == true,
@ -723,8 +937,8 @@ internal class SwapModel @Inject constructor(
}
}
private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee? {
val selectedFeeType = dataState.selectedFee?.feeType ?: FeeType.NORMAL
private fun updateOrSelectFee(state: SwapState.QuotesLoadedState): TxFee.Legacy? {
val selectedFeeType = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL
return when (val txFee = state.txFee) {
TxFeeState.Empty -> null
is TxFeeState.MultipleFeeState -> {
@ -751,10 +965,14 @@ internal class SwapModel @Inject constructor(
return
}
val fromCurrency = requireNotNull(dataState.fromCryptoCurrency)
val fee = dataState.selectedFee
val fee = getSelectedFee()
if (fee == null && tangemPayInput?.isWithdrawal != true) {
makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
modelScope.launch {
delay(SWAP_IN_PROGRESS_DELAY)
startLoadingQuotesFromLastState()
}
return
}
modelScope.launch(dispatchers.main) {
@ -779,10 +997,12 @@ internal class SwapModel @Inject constructor(
makeDefaultAlert(resourceReference(R.string.swapping_fee_estimation_error_text))
return@onSuccess
}
sendSuccessSwapEvent(fromCurrency.currency, fee.feeType)
sendSuccessSwapEvent(
fromCurrency.currency,
(getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL,
)
val url = getExplorerTransactionUrlUseCase(
txHash = swapTransactionState.txHash,
networkId = fromCurrency.currency.network.id,
currency = fromCurrency.currency,
).getOrElse {
Timber.i("tx hash explore not supported")
@ -892,7 +1112,7 @@ internal class SwapModel @Inject constructor(
private suspend fun sendSuccessEvent() {
val provider = dataState.selectedProvider ?: return
val fee = dataState.selectedFee?.feeType ?: return
val fee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL
val fromCurrency = dataState.fromCryptoCurrency?.currency ?: return
val toCurrency = dataState.toCryptoCurrency?.currency ?: return
val fromDerivationIndex = dataState.fromAccount?.derivationIndex?.value
@ -906,6 +1126,7 @@ internal class SwapModel @Inject constructor(
receiveBlockchain = toCurrency.network.name,
sendToken = fromCurrency.symbol,
receiveToken = toCurrency.symbol,
feeToken = getFeeToken().symbol,
fromDerivationIndex = fromDerivationIndex,
toDerivationIndex = toDerivationIndex,
referralId = appsFlyerStore.get()?.refcode,
@ -953,6 +1174,7 @@ internal class SwapModel @Inject constructor(
}.onSuccess { swapTransactionState ->
when (swapTransactionState) {
is SwapTransactionState.TxSent -> {
// TODO [REDACTED_TASK_KEY] gasless analytics
sendApproveSuccessEvent(fromToken, feeForPermission.feeType, approveType)
updateWalletBalance()
uiState = stateBuilder.loadingPermissionState(uiState)
@ -988,8 +1210,10 @@ internal class SwapModel @Inject constructor(
}
private fun onSearchEntered(searchQuery: String) {
modelScope.launch(dispatchers.io) {
val tokenDataState = dataState.tokensDataState ?: return@launch
searchDebouncer.debounce(modelScope, DEBOUNCE_SEARCH_DELAY) {
searchQueryState.value = searchQuery
val tokenDataState = dataState.tokensDataState ?: return@debounce
val group = if (isOrderReversed) {
tokenDataState.fromGroup
} else {
@ -1062,7 +1286,7 @@ internal class SwapModel @Inject constructor(
fromAccount = foundAccount
toToken = initialFromStatus
toAccount = if (accountsFeatureToggles.isFeatureEnabled) {
fromAccountCurrencyStatus.account
fromAccountCurrencyStatus?.account
} else {
null
}
@ -1080,7 +1304,7 @@ internal class SwapModel @Inject constructor(
} else {
fromToken = initialFromStatus
fromAccount = if (accountsFeatureToggles.isFeatureEnabled) {
fromAccountCurrencyStatus.account
fromAccountCurrencyStatus?.account
} else {
null
}
@ -1166,12 +1390,14 @@ internal class SwapModel @Inject constructor(
.onEach { (account, currencyStatus) ->
Timber.d("${coin.id} balance is ${currencyStatus.value.amount ?: "null"}")
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = currencyStatus,
).getOrNull() ?: currencyStatus,
)
if (isFromCurrency) {
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = currencyStatus,
).getOrNull() ?: currencyStatus,
)
}
uiState = when {
isFromCurrency && currencyStatus.currency.id == dataState.fromCryptoCurrency?.currency?.id -> {
@ -1204,12 +1430,14 @@ internal class SwapModel @Inject constructor(
.onEach { status ->
Timber.d("${coin.id} balance is ${status.value.amount ?: "null"}")
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = status,
).getOrNull() ?: status,
)
if (isFromCurrency) {
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = status,
).getOrNull() ?: status,
)
}
uiState = when {
isFromCurrency && status.currency.id == dataState.fromCryptoCurrency?.currency?.id -> {
@ -1416,7 +1644,7 @@ internal class SwapModel @Inject constructor(
uiState = stateBuilder.updateApproveType(uiState, approveType)
},
onClickFee = {
val selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL
val selectedFee = (getSelectedFee() as? TxFee.Legacy)?.feeType ?: FeeType.NORMAL
val txFeeState =
dataState.getCurrentLoadedSwapState()?.txFee as? TxFeeState.MultipleFeeState ?: return@UiActions
uiState = stateBuilder.showSelectFeeBottomSheet(
@ -1427,9 +1655,9 @@ internal class SwapModel @Inject constructor(
uiState = stateBuilder.dismissBottomSheet(uiState)
}
},
onSelectFeeType = { feeType ->
onSelectFeeType = { txFee ->
uiState = stateBuilder.dismissBottomSheet(uiState)
dataState = dataState.copy(selectedFee = feeType)
dataState = dataState.copy(selectedFee = txFee)
modelScope.launch(dispatchers.io) {
startLoadingQuotesFromLastState(false)
}
@ -1451,6 +1679,10 @@ internal class SwapModel @Inject constructor(
val swapState = dataState.lastLoadedSwapStates[provider]
val fromToken = dataState.fromCryptoCurrency
if (provider != null && swapState != null && fromToken != null) {
modelScope.launch {
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerUpdate()
}
analyticsEventHandler.send(SwapEvents.ProviderChosen(provider))
uiState = stateBuilder.dismissBottomSheet(uiState)
setupLoadedState(
@ -1511,6 +1743,7 @@ internal class SwapModel @Inject constructor(
blockchain = fromToken.network.name,
token = fromToken.symbol,
feeType = AnalyticsParam.FeeType.fromString(feeType.getNameForAnalytics()),
feeToken = getFeeToken().symbol,
)
analyticsEventHandler.send(
Basic.TransactionSent(
@ -1520,12 +1753,26 @@ internal class SwapModel @Inject constructor(
)
}
private fun getFeeToken(): CryptoCurrency {
val fromToken = requireNotNull(dataState.fromCryptoCurrency) {
"fromCryptoCurrency should not be null"
}
return when (val fee = getSelectedFee()) {
is TxFee.FeeComponent -> fee.selectedToken?.currency ?: fromToken.currency
is TxFee.Legacy,
null,
-> fromToken.currency
}
}
private fun sendApproveSuccessEvent(fromToken: CryptoCurrency, feeType: FeeType, approveType: SwapApproveType) {
val feeToken = getFeeToken().symbol
val event = AnalyticsParam.TxSentFrom.Approve(
blockchain = fromToken.network.name,
token = fromToken.symbol,
feeType = AnalyticsParam.FeeType.fromString(feeType.getNameForAnalytics()),
permissionType = approveType.getNameForAnalytics(),
feeToken = feeToken,
)
analyticsEventHandler.send(
Basic.TransactionSent(
@ -1753,16 +2000,20 @@ internal class SwapModel @Inject constructor(
uiState = uiState,
error = SwapTransactionState.Error.TangemPayWithdrawalError(txId.orEmpty()),
onDismiss = { uiState = stateBuilder.clearAlert(uiState) },
onSupportClick = ::onTangemPaySupportClick,
onSupportClick = {
val customerId = getTangemPayCustomerIdUseCase(userWallet.walletId).getOrNull() ?: "Unknown"
onTangemPaySupportClick(customerId = customerId, txId = txId)
},
isReverseSwapPossible = isReverseSwapPossible(),
)
}
private fun onTangemPaySupportClick(txId: String?) {
private fun onTangemPaySupportClick(customerId: String, txId: String?) {
modelScope.launch {
val metaInfo = getWalletMetaInfoUseCase(userWallet.walletId).getOrNull() ?: return@launch
val email = FeedbackEmailType.Visa.Withdrawal(
walletMetaInfo = metaInfo,
customerId = customerId,
providerName = dataState.selectedProvider?.name.orEmpty(),
txId = txId.orEmpty(),
)
@ -1784,7 +2035,11 @@ internal class SwapModel @Inject constructor(
destinationAddress = transaction?.txTo.orEmpty(),
tokenSymbol = fromCurrencyStatus.currency.symbol,
amount = dataState.amount.orEmpty(),
fee = dataState.selectedFee?.feeCryptoFormatted.orEmpty(),
fee = when (val fee = getSelectedFee()) {
is TxFee.FeeComponent -> fee.fee.amount.value?.toString()
is TxFee.Legacy -> fee.feeCryptoFormatted
null -> ""
},
),
)
@ -1801,6 +2056,42 @@ internal class SwapModel @Inject constructor(
}
}
private fun addToPortfolioItem(item: MarketsListItemUM) {
modelScope.launch {
val tokenInfo = getTokenMarketInfoUseCase(
selectedAppCurrencyFlow.value,
item.id,
item.currencySymbol,
).getOrNull() ?: return@launch
val converter = TokenMarketInfoToParamsConverter()
val param = converter.convert(tokenInfo)
val hasOnlyHotWallets = getUserWalletsUseCase.invokeSync().all { it is UserWallet.Hot }
val networks = tokenInfo.networks?.filter { network ->
BlockchainUtils.isSupportedNetworkId(
blockchainId = network.networkId,
excludedBlockchains = excludedBlockchains,
hotExcludedBlockchains = hotWalletExcludedBlockchains,
hasOnlyHotWallets = hasOnlyHotWallets,
)
}.orEmpty()
addToPortfolioManager = addToPortfolioManagerFactory
.create(
scope = modelScope,
token = param,
analyticsParams = null,
).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,
@ -1824,11 +2115,167 @@ internal class SwapModel @Inject constructor(
}
}
private fun getSelectedFeeState(): TxFeeSealedState {
if (!sendFeatureToggles.isGaslessTransactionsEnabled) {
return TxFeeSealedState.Legacy(
txFeeState = TxFeeState.Empty,
selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
)
}
val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content
?: return TxFeeSealedState.Legacy(
txFeeState = TxFeeState.Empty,
selectedFee = dataState.selectedFee?.feeType ?: FeeType.NORMAL,
)
val transactionFeeExtended = feeStateUM.feeExtraInfo.transactionFeeExtended
return TxFeeSealedState.Component(
txFee = TxFee.FeeComponent(
transactionFeeResult = transactionFeeExtended?.let { TransactionFeeResult.from(it) }
?: TransactionFeeResult.from(feeStateUM.fees),
fee = feeStateUM.selectedFeeItem.fee,
selectedToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus,
),
)
}
private fun getSelectedFee(): TxFee? {
if (!sendFeatureToggles.isGaslessTransactionsEnabled) {
return dataState.selectedFee
}
val feeStateUM = feeSelectorRepository.state.value as? FeeSelectorUM.Content ?: return null
val transactionFeeExtended = feeStateUM.feeExtraInfo.transactionFeeExtended
return TxFee.FeeComponent(
transactionFeeResult = transactionFeeExtended?.let { TransactionFeeResult.from(it) }
?: TransactionFeeResult.from(feeStateUM.fees),
fee = feeStateUM.selectedFeeItem.fee,
selectedToken = feeStateUM.feeExtraInfo.feeCryptoCurrencyStatus,
)
}
@Suppress("UnsafeCallOnNullableType")
inner class FeeSelectorRepository : SwapFeeSelectorBlockComponent.ModelRepositoryExtended {
override val state = MutableStateFlow<FeeSelectorUM>(
FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true),
)
override suspend fun loadFeeExtended(
selectedToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended> {
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 (selectedProvider.type != ExchangeProviderType.CEX) {
return Either.Left(GetFeeError.GaslessError.NetworkIsNotSupported)
}
if (dataState.lastLoadedSwapStates[selectedProvider] !is SwapState.QuotesLoadedState) {
return Either.Left(GetFeeError.UnknownError)
}
if (isPermissionNotificationShown()) {
return Either.Left(GetFeeError.UnknownError)
}
return swapInteractor.loadFeeForSwapTransaction(
fromToken = fromToken,
fromAccount = dataState.fromAccount,
toToken = toToken,
toAccount = dataState.toAccount,
provider = selectedProvider,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
selectedFeeToken = selectedToken,
)
}
override fun onResult(newState: FeeSelectorUM) {
if (isPermissionNotificationShown()) {
state.value = FeeSelectorUM.Error(GetFeeError.UnknownError, isHidden = true)
return
}
state.value = newState
// If fee currency is same as from currency, we need to reload quotes to update fee info
val isFeeCurrencySameAsFromCurrency = newState is FeeSelectorUM.Content &&
dataState.fromCryptoCurrency?.currency?.id == newState.feeExtraInfo.feeCryptoCurrencyStatus.currency.id
// If fee currency is coin, we need to reload quotes to update fee related warnings (e.g. insufficient funds)
val isCoinFeeSelected = newState is FeeSelectorUM.Content &&
newState.feeExtraInfo.feeCryptoCurrencyStatus.currency is CryptoCurrency.Coin
if (isFeeCurrencySameAsFromCurrency || isCoinFeeSelected) {
// block swap button until fee is loaded
uiState = uiState.copy(
swapButton = uiState.swapButton.copy(
isEnabled = false,
isInProgress = false,
),
)
modelScope.launch {
startLoadingQuotesFromLastState(
isSilent = true,
updateFeeBlock = false,
)
}
}
}
private fun isPermissionNotificationShown(): Boolean {
val permissionState = dataState.getCurrentLoadedSwapState()?.permissionState
return permissionState != null && permissionState !is PermissionDataState.Empty
}
override suspend fun loadFee(): Either<GetFeeError, TransactionFee> {
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) {
return Either.Left(GetFeeError.UnknownError)
}
if (isPermissionNotificationShown()) {
return Either.Left(GetFeeError.UnknownError)
}
return swapInteractor.loadFeeForSwapTransaction(
fromToken = fromToken,
fromAccount = dataState.fromAccount,
toToken = toToken,
toAccount = dataState.toAccount,
provider = selectedProvider,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
)
}
override fun choosingInProgress(updatedState: Boolean) {
// We shouldn't load quotes while user is choosing fee
if (updatedState) {
singleTaskScheduler.cancelTask()
} else {
startLoadingQuotesFromLastState(
isSilent = true,
updateFeeBlock = false,
)
}
}
}
private companion object {
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

@ -12,8 +12,10 @@ import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
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
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
import com.tangem.feature.swap.domain.models.domain.SwapFeeState
import com.tangem.feature.swap.domain.models.ui.*
@ -32,6 +34,7 @@ import java.math.BigDecimal
@Suppress("LargeClass")
internal class SwapNotificationsFactory(
private val actions: UiActions,
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
) {
fun getInitialErrorStateNotifications(code: Int, onRefreshClick: () -> Unit): ImmutableList<NotificationUM> {
@ -162,7 +165,7 @@ internal class SwapNotificationsFactory(
addExistentialWarningNotification(
existentialDeposit = quoteModel.currencyCheck?.existentialDeposit,
feeAmount = fee?.feeValue.orZero(),
feeAmount = fee?.fee?.amount?.value.orZero(),
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = fromCurrencyStatus,
onReduceClick = { reduceBy, reduceByDiff, _ ->
@ -185,7 +188,7 @@ internal class SwapNotificationsFactory(
if (!isCardano) {
addDustWarningNotification(
dustValue = quoteModel.currencyCheck?.dustValue,
feeValue = fee?.feeValue.orZero(),
feeValue = fee?.fee?.amount?.value.orZero(),
sendingAmount = amountToRequest.value,
cryptoCurrencyStatus = fromCurrencyStatus,
feeCurrencyStatus = feeCryptoCurrencyStatus,
@ -273,7 +276,7 @@ internal class SwapNotificationsFactory(
}
}
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee? {
private fun selectFeeByType(feeType: FeeType, txFeeState: TxFeeState): TxFee.Legacy? {
return when (txFeeState) {
TxFeeState.Empty -> null
is TxFeeState.SingleFeeState -> txFeeState.fee
@ -294,7 +297,13 @@ internal class SwapNotificationsFactory(
val shouldShowCoverWarning = quoteModel.preparedSwapConfigState.isBalanceEnough &&
quoteModel.permissionState !is PermissionDataState.PermissionLoading &&
feeEnoughState.feeCurrency != fromToken
if (shouldShowCoverWarning) {
val isNotEnoughFee =
quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough
val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromToken.network) &&
quoteModel.swapProvider.type == ExchangeProviderType.CEX
if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) {
add(
SwapNotificationUM.Error.UnableToCoverFeeWarning(
fromToken = fromToken,

View file

@ -22,7 +22,7 @@ data class SwapProcessDataState(
val reduceBalanceBy: BigDecimal = BigDecimal.ZERO,
val approveDataModel: RequestApproveStateData? = null,
val swapDataModel: SwapDataModel? = null,
val selectedFee: TxFee? = null,
val selectedFee: TxFee.Legacy? = null,
val tokensDataState: TokensDataStateExpress? = null,
val selectedProvider: SwapProvider? = null,
val lastLoadedSwapStates: Map<SwapProvider, SwapState> = emptyMap(),

View file

@ -0,0 +1,7 @@
package com.tangem.feature.swap.models
import com.tangem.core.decompose.navigation.Route
import kotlinx.serialization.Serializable
@Serializable
internal data object AddToPortfolioRoute : Route

View file

@ -3,12 +3,14 @@ package com.tangem.feature.swap.models
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
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 tokensListData: TokenListUMData,
val isBalanceHidden: Boolean,
val isAfterSearch: Boolean,
@ -39,16 +41,34 @@ internal data class TokenBalanceData(
internal sealed interface TokenListUMData {
val tokensList: ImmutableList<TokensListItemUM>
val totalTokensCount: Int
data class AccountList(
override val tokensList: ImmutableList<TokensListItemUM.Portfolio>,
override val totalTokensCount: Int,
) : TokenListUMData
data class TokenList(
override val tokensList: ImmutableList<TokensListItemUM>,
override val totalTokensCount: Int,
) : TokenListUMData
data object EmptyList : TokenListUMData {
override val tokensList: ImmutableList<TokensListItemUM> = persistentListOf()
override val totalTokensCount: Int = EMPTY_TOKENS_COUNT
}
}
private companion object {
const val EMPTY_TOKENS_COUNT = 0
}
}
internal val SwapSelectTokenStateHolder.isNotFoundState: Boolean
get() = availableTokens.isEmpty() && unavailableTokens.isEmpty() &&
tokensListData.tokensList.isEmpty() && isAfterSearch &&
marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading
internal val SwapSelectTokenStateHolder.isEmptyState: Boolean
get() = availableTokens.isEmpty() && unavailableTokens.isEmpty() &&
tokensListData.tokensList.isEmpty() && !isAfterSearch &&
marketsState !is SwapMarketState.Content && marketsState !is SwapMarketState.Loading

View file

@ -78,6 +78,8 @@ sealed class SwapCardState {
data class SwapButton(
@DrawableRes val walletInteractionIcon: Int?,
val isEnabled: Boolean,
val isInProgress: Boolean = false,
val isHoldToConfirm: Boolean = false,
val onClick: () -> Unit,
)

View file

@ -7,7 +7,7 @@ import com.tangem.core.ui.extensions.TextReference
data class SwapSuccessStateHolder(
val timestamp: Long,
val txUrl: String,
val fee: TextReference,
val fee: TextReference?,
val rate: TextReference,
val shouldShowStatusButton: Boolean,
val providerName: TextReference,

View file

@ -24,7 +24,7 @@ data class UiActions(
val onStoriesClose: (Int) -> Unit,
val onRetryClick: () -> Unit,
val onClickFee: () -> Unit,
val onSelectFeeType: (TxFee) -> Unit,
val onSelectFeeType: (TxFee.Legacy) -> Unit,
val onProviderClick: (String) -> Unit,
val onProviderSelect: (String) -> Unit,
val onBuyClick: (CryptoCurrency) -> Unit,

View file

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

View file

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

View file

@ -0,0 +1,26 @@
package com.tangem.feature.swap.models.market.state
import androidx.compose.runtime.Immutable
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.domain.models.currency.CryptoCurrency
import kotlinx.collections.immutable.ImmutableList
@Immutable
internal sealed class SwapMarketState {
data class Content(
val items: ImmutableList<MarketsListItemUM>,
val total: Int,
val loadMore: () -> Unit,
val onItemClick: (MarketsListItemUM) -> Unit,
val visibleIdsChanged: (List<CryptoCurrency.RawID>) -> Unit,
) : SwapMarketState()
data object Loading : SwapMarketState()
data class LoadingError(
val onRetryClicked: () -> Unit,
) : SwapMarketState()
data object SearchNothingFound : SwapMarketState()
}

View file

@ -2,7 +2,7 @@ package com.tangem.feature.swap.preview
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
@ -22,12 +22,12 @@ internal data object SwapSuccessStatePreview {
fromTitle = AccountTitleUM.Account(
prefixText = stringReference("From"),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
toTitle = AccountTitleUM.Account(
prefixText = stringReference("To"),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
fromTokenAmount = TextReference.Str("1 000 DAI"),
toTokenAmount = TextReference.Str("1 000 MATIC"),

View file

@ -8,7 +8,6 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
@ -20,7 +19,6 @@ import com.tangem.core.ui.components.rows.SelectorRowItem
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.SelectNetworkFeeBottomSheetTestTags
import com.tangem.feature.swap.domain.models.ui.FeeType
import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig
import com.tangem.feature.swap.models.states.FeeItemState
@ -90,8 +88,7 @@ private fun FooterBlock(readMore: TextReference, onReadMoreClick: () -> Unit) {
.padding(
vertical = TangemTheme.dimens.spacing8,
horizontal = TangemTheme.dimens.spacing16,
)
.testTag(SelectNetworkFeeBottomSheetTestTags.READ_MORE_TEXT),
),
style = TangemTheme.typography.caption2.copy(textAlign = TextAlign.Start),
onClick = click,
)

View file

@ -3,8 +3,10 @@ package com.tangem.feature.swap.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -16,10 +18,13 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.bottomsheets.sheet.TangemBottomSheet
import com.tangem.core.ui.components.SpacerH
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheet
import com.tangem.core.ui.components.bottomsheets.modal.TangemModalBottomSheetTitle
import com.tangem.core.ui.components.notifications.Notification
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.selectedBorder
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
@ -32,13 +37,20 @@ import kotlinx.collections.immutable.persistentListOf
@Composable
fun ChooseProviderBottomSheet(config: TangemBottomSheetConfig) {
TangemBottomSheet(
TangemModalBottomSheet<ChooseProviderBottomSheetConfig>(
config = config,
containerColor = TangemTheme.colors.background.tertiary,
titleText = resourceReference(R.string.express_choose_providers_title),
) { content: ChooseProviderBottomSheetConfig ->
ChooseProviderBottomSheetContent(content = content)
}
title = {
TangemModalBottomSheetTitle(
title = resourceReference(R.string.express_choose_providers_title),
endIconRes = R.drawable.ic_close_24,
onEndClick = config.onDismissRequest,
)
},
content = { content ->
ChooseProviderBottomSheetContent(content = content)
},
)
}
@Suppress("LongMethod")
@ -50,10 +62,7 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.secondary,
modifier = Modifier
.padding(
top = 10.dp,
bottom = 16.dp,
)
.padding(bottom = 14.dp)
.padding(horizontal = TangemTheme.dimens.spacing56),
textAlign = TextAlign.Center,
)
@ -76,31 +85,30 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC
start = 16.dp,
end = 16.dp,
bottom = 14.dp,
)
.background(
color = TangemTheme.colors.background.action,
shape = TangemTheme.shapes.roundedCornersXMedium,
)
.clip(shape = TangemTheme.shapes.roundedCornersXMedium),
),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
content.providers.forEach { provider ->
val isSelected = provider.id == content.selectedProviderId
ProviderItem(
state = provider,
isSelected = isSelected,
isSelected = false,
modifier = Modifier
.selectedBorder(isSelected = isSelected)
.clip(RoundedCornerShape(TangemTheme.dimens.radius14))
.background(color = TangemTheme.colors.background.action)
.clickable(
enabled = provider.onProviderClick != null,
onClick = { provider.onProviderClick?.invoke(provider.id) },
)
.padding(
top = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
vertical = 16.dp,
horizontal = 2.dp,
),
)
}
}
SpacerH(6.dp)
Icon(
painterResource(id = R.drawable.ic_lightning_16),
contentDescription = null,
@ -111,7 +119,7 @@ private fun ChooseProviderBottomSheetContent(content: ChooseProviderBottomSheetC
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.icon.informative,
modifier = Modifier
.padding(top = TangemTheme.dimens.spacing6, bottom = TangemTheme.dimens.spacing16)
.padding(top = 4.dp, bottom = 32.dp)
.padding(horizontal = TangemTheme.dimens.spacing56),
textAlign = TextAlign.Center,
)

View file

@ -5,6 +5,7 @@ import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
@ -99,7 +100,7 @@ private fun ProviderContentState(
isSelected: Boolean = false,
) {
Box(modifier = modifier.fillMaxWidth()) {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
SubcomposeAsyncImage(
modifier = Modifier
.padding(start = TangemTheme.dimens.spacing12)
@ -120,8 +121,10 @@ private fun ProviderContentState(
Column(
modifier = Modifier
.heightIn(min = TangemTheme.dimens.size40)
.padding(start = TangemTheme.dimens.spacing12)
.testTag(SwapTokenScreenTestTags.PROVIDERS_BLOCK),
verticalArrangement = Arrangement.SpaceBetween,
) {
Row {
if (state.namePrefix == ProviderState.PrefixType.PROVIDED_BY) {
@ -158,7 +161,6 @@ private fun ProviderContentState(
}
Row(
modifier = Modifier.padding(
top = TangemTheme.dimens.spacing6,
end = TangemTheme.dimens.spacing56,
),
) {
@ -205,7 +207,7 @@ private fun ProviderUnavailableState(
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxWidth()) {
Row {
Row(verticalAlignment = Alignment.CenterVertically) {
val (alpha, colorFilter) = GRAY_SCALE_ALPHA to GrayscaleColorFilter
SubcomposeAsyncImage(
modifier = Modifier
@ -228,7 +230,10 @@ private fun ProviderUnavailableState(
)
Column(
modifier = Modifier.padding(start = TangemTheme.dimens.spacing12),
modifier = Modifier
.heightIn(min = TangemTheme.dimens.size40)
.padding(start = TangemTheme.dimens.spacing12),
verticalArrangement = Arrangement.SpaceBetween,
) {
Row {
AnimatedContent(targetState = state.name, label = "") { name ->
@ -247,12 +252,16 @@ private fun ProviderUnavailableState(
)
}
}
AnimatedContent(targetState = state.alertText, label = "") { alertText ->
AnimatedContent(
targetState = state.alertText,
contentAlignment = Alignment.BottomStart,
label = "",
) { alertText ->
Text(
text = alertText.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(top = TangemTheme.dimens.spacing6),
modifier = Modifier,
)
}
}

View file

@ -3,6 +3,7 @@ package com.tangem.feature.swap.ui
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.alerts.models.AlertDemoModeUM
import com.tangem.common.ui.bottomsheet.permission.state.*
@ -24,7 +25,9 @@ import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.promo.models.StoryContent
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.converters.TokensDataConverterV2
@ -64,6 +67,7 @@ internal class StateBuilder(
private val isBalanceHiddenProvider: Provider<Boolean>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val isAccountsModeProvider: Provider<Boolean>,
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
@ -76,7 +80,7 @@ internal class StateBuilder(
)
private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) {
SwapNotificationsFactory(actions)
SwapNotificationsFactory(actions, iGaslessFeeSupportedForNetwork)
}
fun createInitialLoadingState(
@ -123,6 +127,7 @@ internal class StateBuilder(
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
isEnabled = false,
isHoldToConfirm = userWalletProvider().isHotWallet,
onClick = {},
),
onRefresh = {},
@ -182,6 +187,7 @@ internal class StateBuilder(
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
isEnabled = false,
isHoldToConfirm = userWalletProvider().isHotWallet,
onClick = { },
),
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
@ -247,6 +253,7 @@ internal class StateBuilder(
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
isEnabled = false,
isHoldToConfirm = userWalletProvider().isHotWallet,
onClick = {},
),
providerState = ProviderState.Loading(),
@ -369,6 +376,7 @@ internal class StateBuilder(
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
isEnabled = getSwapButtonEnabled(notifications),
isHoldToConfirm = userWalletProvider().isHotWallet,
onClick = actions.onSwapClick,
),
changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible),
@ -497,6 +505,7 @@ internal class StateBuilder(
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
isEnabled = false,
isHoldToConfirm = userWalletProvider().isHotWallet,
onClick = actions.onSwapClick,
),
changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible),
@ -594,6 +603,7 @@ internal class StateBuilder(
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(userWalletProvider()),
isEnabled = false,
isHoldToConfirm = userWalletProvider().isHotWallet,
onClick = { },
),
changeCardsButtonState = getChangeCardsButtonState(isReverseSwapPossible),
@ -606,6 +616,7 @@ internal class StateBuilder(
return uiState.copy(
swapButton = uiState.swapButton.copy(
isEnabled = false,
isInProgress = true,
),
)
}
@ -615,13 +626,14 @@ internal class StateBuilder(
fromToken: CryptoCurrency,
tokensDataState: CurrenciesGroup,
): SwapStateHolder {
val currentMarketsState = uiState.selectTokenState?.marketsState
return uiState.copy(
selectTokenState = tokensDataConverter.convert(
value = CurrenciesGroupWithFromCurrency(
fromCurrency = fromToken,
group = tokensDataState,
),
),
).copy(marketsState = currentMarketsState),
)
}
@ -808,6 +820,7 @@ internal class StateBuilder(
return uiState.copy(
swapButton = uiState.swapButton.copy(
isEnabled = false,
isInProgress = false,
),
permissionState = GiveTxPermissionState.InProgress,
notifications = notificationsFactory.getApprovalInProgressStateNotification(uiState.notifications),
@ -823,7 +836,6 @@ internal class StateBuilder(
onStatusClick: () -> Unit,
txUrl: String,
): SwapStateHolder {
val fee = requireNotNull(dataState.selectedFee)
val fromCryptoCurrency = requireNotNull(dataState.fromCryptoCurrency)
val toCryptoCurrency = requireNotNull(dataState.toCryptoCurrency)
val fromAmount = swapTransactionState.fromAmountValue ?: BigDecimal.ZERO
@ -843,7 +855,9 @@ internal class StateBuilder(
shouldShowStatusButton = shouldShowStatus,
providerIcon = providerState.iconUrl,
rate = providerState.subtitle,
fee = stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})"),
fee = dataState.selectedFee?.let { fee ->
stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})")
},
fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount),
toTitle = getToCardAccountTitle(toAccount = dataState.toAccount),
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),
@ -1415,7 +1429,7 @@ internal class StateBuilder(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = fromAccount.accountName.toUM().value,
icon = fromAccount.icon.toUM(),
icon = CryptoPortfolioIconConverter.convert(fromAccount.icon),
)
} else {
AccountTitleUM.Text(resourceReference(R.string.swapping_from_title))
@ -1427,7 +1441,7 @@ internal class StateBuilder(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_to),
name = toAccount.accountName.toUM().value,
icon = toAccount.icon.toUM(),
icon = CryptoPortfolioIconConverter.convert(toAccount.icon),
)
} else {
AccountTitleUM.Text(resourceReference(R.string.swapping_to_title))

View file

@ -1,24 +1,27 @@
package com.tangem.feature.swap.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import com.tangem.common.ui.bottomsheet.permission.GiveTxPermissionBottomSheet
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionBottomSheetConfig
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
import com.tangem.feature.swap.presentation.R
@Composable
internal fun SwapScreen(stateHolder: SwapStateHolder) {
internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent: SwapFeeSelectorBlockComponent?) {
BackHandler(onBack = stateHolder.onBackClicked)
Scaffold(
@ -36,6 +39,17 @@ internal fun SwapScreen(stateHolder: SwapStateHolder) {
SwapScreenContent(
state = stateHolder,
feeBlock = if (feeSelectorBlockComponent != null) {
@Composable { modifier: Modifier ->
feeSelectorBlockComponent.Content(
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
)
}
} else {
null
},
modifier = Modifier.padding(scaffoldPaddings),
)

View file

@ -49,7 +49,11 @@ import kotlinx.collections.immutable.persistentListOf
@Suppress("LongMethod")
@Composable
internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modifier) {
internal fun SwapScreenContent(
state: SwapStateHolder,
modifier: Modifier = Modifier,
feeBlock: @Composable ((Modifier) -> Unit)? = null,
) {
val keyboard by keyboardAsState()
Box(
@ -74,7 +78,11 @@ internal fun SwapScreenContent(state: SwapStateHolder, modifier: Modifier = Modi
ProviderItemBlock(state = state.providerState)
FeeItemBlock(state = state.fee)
if (feeBlock != null) {
feeBlock(Modifier.fillMaxWidth())
} else {
FeeItemBlock(state = state.fee)
}
if (state.notifications.isNotEmpty()) SwapNotifications(notifications = state.notifications)
@ -362,21 +370,42 @@ private fun SwapNotifications(notifications: List<NotificationUM>) {
@Composable
private fun MainButton(state: SwapStateHolder) {
if (state.isInsufficientFunds) {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(id = R.string.swapping_insufficient_funds),
enabled = false,
onClick = state.swapButton.onClick,
)
} else {
PrimaryButtonIconEnd(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(id = R.string.swapping_swap_action),
iconResId = state.swapButton.walletInteractionIcon,
enabled = state.swapButton.isEnabled,
onClick = state.swapButton.onClick,
)
when {
state.isInsufficientFunds -> {
PrimaryButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(id = R.string.swapping_insufficient_funds),
enabled = false,
onClick = state.swapButton.onClick,
)
}
state.swapButton.isHoldToConfirm -> {
HoldToConfirmButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(
R.string.common_hold_to,
stringResourceSafe(id = R.string.swapping_swap_action),
),
enabled = state.swapButton.isEnabled,
onConfirm = state.swapButton.onClick,
isLoading = state.swapButton.isInProgress,
)
}
else -> {
PrimaryButtonIconEnd(
modifier = Modifier.fillMaxWidth(),
text = if (state.swapButton.isInProgress) {
stringResourceSafe(id = R.string.swapping_swap_action_in_progress)
} else {
stringResourceSafe(id = R.string.swapping_swap_action)
},
iconResId = state.swapButton.walletInteractionIcon,
enabled = state.swapButton.isEnabled,
onClick = state.swapButton.onClick,
)
}
}
}

View file

@ -7,26 +7,38 @@ 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.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerH32
import com.tangem.core.ui.components.SpacerW2
import com.tangem.core.ui.components.appbar.ExpandableSearchView
import com.tangem.core.ui.components.list.InfiniteListHandler
import com.tangem.core.ui.components.atoms.text.EllipsisText
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.tokenlist.PortfolioListItem
import com.tangem.core.ui.components.tokenlist.PortfolioTokensListItem
import com.tangem.core.ui.components.tokenlist.TokenListItem
@ -38,12 +50,18 @@ import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.BuyTokenScreenTestTags
import com.tangem.core.ui.utils.lazyListItemPosition
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.TokenBalanceData
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
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.toImmutableList
import kotlinx.collections.immutable.persistentListOf
private const val LOAD_MORE_BUFFER = 25
@Composable
internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) {
@ -56,17 +74,14 @@ internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: ()
content = { padding ->
val modifier = Modifier.padding(padding)
when {
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() &&
state.tokensListData.tokensList.isEmpty() && state.isAfterSearch -> {
TokensNotFound(modifier)
}
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() &&
state.tokensListData.tokensList.isEmpty() && !state.isAfterSearch -> {
EmptyTokensList(modifier)
}
else -> {
ListOfTokens(state = state, modifier = modifier)
}
state.isNotFoundState -> TokensNotFound(modifier)
state.isEmptyState -> EmptyTokensList(modifier)
state.marketsState != null -> ListOfTokensWithMarkets(
state = state,
marketsState = state.marketsState,
modifier = modifier,
)
else -> ListOfTokens(state = state, modifier = modifier)
}
},
topBar = {
@ -135,6 +150,7 @@ private fun TokensNotFound(modifier: Modifier = Modifier) {
@Composable
private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier = Modifier) {
val screenBackgroundColor = TangemTheme.colors.background.secondary
LazyColumn(
modifier = modifier
.background(color = screenBackgroundColor)
@ -142,29 +158,129 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier =
.imePadding(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (val list = state.tokensListData) {
is TokenListUMData.AccountList -> list.tokensList.forEach { item ->
portfolioTokensList(
portfolio = item,
isBalanceHidden = state.isBalanceHidden,
)
}
is TokenListUMData.TokenList -> {
tokensList(
items = list.tokensList,
isBalanceHidden = state.isBalanceHidden,
)
}
TokenListUMData.EmptyList -> Unit
}
tokensListItems(
tokensListData = state.tokensListData,
isBalanceHidden = state.isBalanceHidden,
)
tokensToSelectItems(state.availableTokens, state.onTokenSelected)
item { SpacerH12() }
tokensToSelectItems(state.unavailableTokens, state.onTokenSelected)
}
}
item { SpacerH12() }
@Composable
private fun ListOfTokensWithMarkets(
state: SwapSelectTokenStateHolder,
marketsState: SwapMarketState,
modifier: Modifier = Modifier,
) {
val screenBackgroundColor = TangemTheme.colors.background.secondary
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = modifier
.background(color = screenBackgroundColor)
.fillMaxSize()
.imePadding(),
horizontalAlignment = Alignment.CenterHorizontally,
state = lazyListState,
) {
if (state.tokensListData !is TokenListUMData.EmptyList) {
assetsTitle(count = state.tokensListData.totalTokensCount)
}
tokensListItems(
tokensListData = state.tokensListData,
isBalanceHidden = state.isBalanceHidden,
)
if (state.tokensListData is TokenListUMData.EmptyList) {
item { SpacerH12() }
} else {
item { SpacerH32() }
}
swapMarketsListItems(marketsState)
}
(marketsState as? SwapMarketState.Content)?.let { content ->
VisibleItemsTracker(
lazyListState = lazyListState,
marketState = content,
)
InfiniteListHandler(
listState = lazyListState,
buffer = LOAD_MORE_BUFFER,
triggerLoadMoreCheckOnItemsCountChange = true,
onLoadMore = remember(content) {
{
content.loadMore()
true
}
},
)
}
}
@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(count: Int) {
item(key = "assets_title") {
Text(
text = buildAnnotatedString {
append(stringResourceSafe(R.string.swap_your_assets_title))
withStyle(SpanStyle(color = TangemTheme.colors.text.tertiary)) {
append(" $count")
}
},
style = TangemTheme.typography.h3,
color = TangemTheme.colors.text.primary1,
modifier = Modifier
.fillMaxWidth()
.padding(
start = TangemTheme.dimens.spacing16,
end = TangemTheme.dimens.spacing16,
top = TangemTheme.dimens.spacing12,
),
)
}
}
private fun LazyListScope.tokensListItems(tokensListData: TokenListUMData, isBalanceHidden: Boolean) {
when (tokensListData) {
is TokenListUMData.AccountList -> {
tokensListData.tokensList.forEach { item ->
portfolioTokensList(
portfolio = item,
isBalanceHidden = isBalanceHidden,
)
}
}
is TokenListUMData.TokenList -> {
tokensList(
items = tokensListData.tokensList,
isBalanceHidden = isBalanceHidden,
)
}
TokenListUMData.EmptyList -> Unit
}
}
@ -387,50 +503,45 @@ private fun TokenItem(
}
}
private val token = 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 title = TokenToSelectState.Title(
title = stringReference("MY TOKENS"),
)
@Preview
@Composable
private fun TokenScreenPreview() {
TangemThemePreview {
SwapSelectTokenScreen(
state = SwapSelectTokenStateHolder(
availableTokens = listOf(title, token, token, token).toImmutableList(),
unavailableTokens = listOf(title, token, token, token).toImmutableList(),
tokensListData = TokenListUMData.EmptyList,
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
onTokenSelected = {},
),
onBack = {},
)
}
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 EmptyTokensListPreview() {
private fun TokenScreenPreview(
@PreviewParameter(SwapSelectTokenScreenPreviewProvider::class)
state: SwapSelectTokenStateHolder,
) {
TangemThemePreview {
EmptyTokensList()
SwapSelectTokenScreen(
state = state,
onBack = {},
)
}
}

View file

@ -29,14 +29,16 @@ import com.tangem.core.ui.utils.toTimeFormat
import com.tangem.feature.swap.models.SwapSuccessStateHolder
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.preview.SwapSuccessStatePreview
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.common.ui.FeeBlockSuccess
@Composable
fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) {
fun SwapSuccessScreen(state: SwapSuccessStateHolder, feeSelectorUM: FeeSelectorUM?, onBack: () -> Unit) {
Scaffold(
modifier = Modifier.systemBarsPadding(),
containerColor = TangemTheme.colors.background.secondary,
content = { padding ->
SwapSuccessScreenContent(padding = padding, state = state)
SwapSuccessScreenContent(padding = padding, feeSelectorUM = feeSelectorUM, state = state)
},
topBar = {
AppBarWithBackButton(
@ -58,7 +60,11 @@ fun SwapSuccessScreen(state: SwapSuccessStateHolder, onBack: () -> Unit) {
}
@Composable
private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: PaddingValues) {
private fun SwapSuccessScreenContent(
state: SwapSuccessStateHolder,
feeSelectorUM: FeeSelectorUM?,
padding: PaddingValues,
) {
Column(
modifier = Modifier
.fillMaxSize()
@ -101,7 +107,10 @@ private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: Pad
.background(TangemTheme.colors.background.action),
)
SpacerH16()
if (state.fee != TextReference.EMPTY) {
if (feeSelectorUM != null) {
FeeBlockSuccess(feeSelectorUM)
} else if (state.fee != null && state.fee != TextReference.EMPTY) {
InputRowDefault(
title = TextReference.Res(R.string.common_network_fee_title),
text = state.fee,
@ -205,7 +214,7 @@ private fun SwapSuccessScreenButtons(
@Composable
private fun Preview_Success() {
TangemThemePreview {
SwapSuccessScreen(SwapSuccessStatePreview.state) {}
SwapSuccessScreen(SwapSuccessStatePreview.state, null) {}
}
}
// endregion preview

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
@ -37,7 +38,7 @@ import coil.request.ImageRequest
import com.tangem.common.ui.account.AccountNameUM
import com.tangem.common.ui.account.AccountTitle
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.toUM
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
@ -266,11 +267,15 @@ private fun Content(
when (type) {
is TransactionCardType.ReadOnly -> {
if (textFieldValue != null) {
ResizableText(
Text(
text = textFieldValue.text,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h2,
fontSizeRange = FontSizeRange(min = 16.sp, max = TangemTheme.typography.h2.fontSize),
autoSize = TextAutoSize.StepBased(
minFontSize = 16.sp,
maxFontSize = TangemTheme.typography.h2.fontSize,
),
maxLines = 1,
modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.RECEIVE_TEXT_FIELD),
)
} else {
@ -599,7 +604,7 @@ private fun TransactionCardPreviewWithPriceImpact() {
accountTitleUM = AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountEquivalent = "1 000 000",
@ -608,7 +613,7 @@ private fun TransactionCardPreviewWithPriceImpact() {
networkIconRes = R.drawable.img_polygon_22,
onChangeTokenClick = {},
balance = "123",
textFieldValue = TextFieldValue(),
textFieldValue = TextFieldValue("1000000.0000000000000000000000000"),
priceImpact = PriceImpact.Value(0.15F),
)
}
@ -621,7 +626,7 @@ private fun TransactionCardPreviewWithoutPriceImpact() {
accountTitleUM = AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
icon = CryptoPortfolioIconConverter.convert(CryptoPortfolioIcon.ofDefaultCustomAccount()),
),
),
amountEquivalent = "1 000 000",

View file

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

View file

@ -0,0 +1,157 @@
package com.tangem.feature.swap.ui.preview
import com.tangem.common.ui.charts.state.MarketChartRawData
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.marketprice.PriceChangeType
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
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
internal class SwapSelectTokenPreviewProvider {
fun provideSwapSelectTokenState(): SwapSelectTokenStateHolder {
return SwapSelectTokenStateHolder(
availableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(),
unavailableTokens = listOf(previewTitle, previewToken, previewToken, previewToken).toImmutableList(),
tokensListData = TokenListUMData.EmptyList,
isAfterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
onTokenSelected = {},
marketsState = createPreviewMarketsState(),
)
}
private fun createPreviewMarketsState() = SwapMarketState.Content(
items = createPreviewMarketItems(),
loadMore = { },
onItemClick = { },
visibleIdsChanged = { },
total = TOTAL_ITEMS,
)
private fun createPreviewMarketItems() = listOf(
createMarketItem(
id = "1",
iconUrl = "",
ratingPosition = "10",
marketCap = "$6.233 B",
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "2",
ratingPosition = "10",
marketCap = "$6.233 B",
trendType = PriceChangeType.NEUTRAL,
chartData = null,
),
createMarketItem(
id = "3",
name = "Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin Bitcoin",
ratingPosition = "10",
marketCap = "$6.23348172384781234 B",
trendType = PriceChangeType.DOWN,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "4",
ratingPosition = "10",
marketCap = null,
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "5",
ratingPosition = null,
marketCap = "$6.233 B",
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
createMarketItem(
id = "6",
ratingPosition = null,
marketCap = null,
trendType = PriceChangeType.UP,
chartData = PREVIEW_CHART_DATA,
),
).toImmutableList()
private fun createMarketItem(
id: String,
name: String = "Bitcoin",
iconUrl: String? = null,
ratingPosition: String?,
marketCap: String?,
trendType: PriceChangeType,
chartData: MarketChartRawData?,
) = MarketsListItemUM(
id = CryptoCurrency.RawID(id),
name = name,
currencySymbol = "BTC",
iconUrl = iconUrl,
ratingPosition = ratingPosition,
marketCap = marketCap,
price = MarketsListItemUM.Price(text = "31 285.72$"),
trendPercentText = "12.43%",
trendType = trendType,
chartData = chartData,
isUnder100kMarketCap = false,
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"),
)
}
}