Updated on 2026-08-14

This commit is contained in:
Tangem 2025-10-28 15:57:21 +03:00
commit 31be70ecf3
529 changed files with 15730 additions and 4170 deletions

View file

@ -0,0 +1,147 @@
package com.tangem.feature.swap.converters
import com.tangem.common.ui.account.AccountCryptoPortfolioItemStateConverter
import com.tangem.common.ui.tokens.TokenItemStateConverter
import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.getFormattedCryptoAmount
import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.getFormattedFiatAmount
import com.tangem.common.ui.tokens.TokenItemStateConverter.Companion.isFlickering
import com.tangem.core.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.core.ui.components.token.state.TokenItemState
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.TotalFiatBalance
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.AccountSwapAvailability
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toPersistentList
internal class AccountTokenItemConverter(
private val appCurrency: AppCurrency,
private val unavailableErrorText: TextReference,
private val onItemClick: (String) -> Unit,
) : Converter<AccountSwapAvailability, TokensListItemUM.Portfolio> {
override fun convert(value: AccountSwapAvailability): TokensListItemUM.Portfolio {
return TokensListItemUM.Portfolio(
tokenItemUM = AccountCryptoPortfolioItemStateConverter(
appCurrency = appCurrency,
account = value.account,
onItemClick = null,
).convert(TotalFiatBalance.Failed),
isExpanded = true,
isCollapsable = false,
tokens = value.currencyList.map { accountSwapCurrency ->
if (accountSwapCurrency.isAvailable) {
createAvailableItemConverter()
} else {
createUnavailableItemConverter()
}.convert(accountSwapCurrency.cryptoCurrencyStatus)
}.map(TokensListItemUM::Token).toPersistentList(),
)
}
fun createAvailableItemConverter(): TokenItemStateConverter {
return TokenItemStateConverter(
appCurrency = appCurrency,
subtitleStateProvider = {
createSubtitleState(
status = it,
isAvailable = true,
text = stringReference(value = it.currency.symbol),
)
},
subtitle2StateProvider = ::createSubtitle2State,
fiatAmountStateProvider = {
createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = true)
},
onItemClick = { account, currencyStatus -> onItemClick(currencyStatus.currency.id.value) },
)
}
fun createUnavailableItemConverter(): TokenItemStateConverter {
return TokenItemStateConverter(
appCurrency = appCurrency,
iconStateProvider = { CryptoCurrencyToIconStateConverter(isAvailable = false).convert(it) },
titleStateProvider = {
TokenItemState.TitleState.Content(
text = stringReference(value = it.currency.name),
isAvailable = false,
)
},
subtitleStateProvider = {
createSubtitleState(
status = it,
isAvailable = false,
text = unavailableErrorText,
)
},
subtitle2StateProvider = ::createSubtitle2State,
fiatAmountStateProvider = {
createFiatAmountStateProvider(status = it, appCurrency = appCurrency, isAvailable = false)
},
)
}
private fun createSubtitleState(
status: CryptoCurrencyStatus,
isAvailable: Boolean,
text: TextReference,
): TokenItemState.SubtitleState {
return when (status.value) {
CryptoCurrencyStatus.Loading -> TokenItemState.SubtitleState.Loading
else -> {
TokenItemState.SubtitleState.TextContent(
value = text,
isAvailable = isAvailable,
)
}
}
}
private fun createSubtitle2State(status: CryptoCurrencyStatus): TokenItemState.Subtitle2State? {
return when (status.value) {
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.Custom,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
-> {
TokenItemState.Subtitle2State.TextContent(
text = status.getFormattedCryptoAmount(),
isFlickering = status.value.isFlickering(),
)
}
is CryptoCurrencyStatus.Loading,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.NoAmount,
-> null
}
}
private fun createFiatAmountStateProvider(
status: CryptoCurrencyStatus,
appCurrency: AppCurrency,
isAvailable: Boolean,
): TokenItemState.FiatAmountState? {
return when (status.value) {
is CryptoCurrencyStatus.Loaded,
is CryptoCurrencyStatus.Custom,
is CryptoCurrencyStatus.NoQuote,
is CryptoCurrencyStatus.NoAccount,
-> {
TokenItemState.FiatAmountState.TextContent(
text = status.getFormattedFiatAmount(appCurrency = appCurrency),
isAvailable = isAvailable,
isFlickering = status.value.isFlickering(),
)
}
is CryptoCurrencyStatus.Unreachable,
is CryptoCurrencyStatus.NoAmount,
is CryptoCurrencyStatus.MissedDerivation,
is CryptoCurrencyStatus.Loading,
-> null
}
}
}

View file

@ -12,13 +12,14 @@ import com.tangem.feature.swap.domain.models.domain.CryptoCurrencySwapInfo
import com.tangem.feature.swap.models.CurrenciesGroupWithFromCurrency
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.presentation.R
import com.tangem.utils.Provider
import com.tangem.utils.converter.Converter
import kotlinx.collections.immutable.toImmutableList
class TokensDataConverter(
internal class TokensDataConverter(
private val onSearchEntered: (String) -> Unit,
private val onTokenSelected: (String) -> Unit,
private val isBalanceHiddenProvider: Provider<Boolean>,
@ -53,8 +54,10 @@ class TokensDataConverter(
}
}
.toImmutableList(),
tokensListData = TokenListUMData.EmptyList,
onSearchEntered = onSearchEntered,
onTokenSelected = onTokenSelected,
isBalanceHidden = isBalanceHiddenProvider(),
afterSearch = group.isAfterSearch,
)
}

View file

@ -0,0 +1,67 @@
package com.tangem.feature.swap.converters
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.feature.swap.domain.models.ui.CurrenciesGroup
import com.tangem.feature.swap.models.SwapSelectTokenStateHolder
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.TokenListUMData
import com.tangem.feature.swap.presentation.R
import com.tangem.utils.Provider
import com.tangem.utils.transformer.Transformer
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.plus
import kotlinx.collections.immutable.toPersistentList
internal class TokensDataConverterV2(
private val onSearchEntered: (String) -> Unit,
private val onTokenSelected: (String) -> Unit,
private val tokensDataState: CurrenciesGroup,
private val isBalanceHidden: Boolean,
private val isAccountsMode: Boolean,
appCurrencyProvider: Provider<AppCurrency>,
) : Transformer<SwapStateHolder> {
private val accountListItemConverter = AccountTokenItemConverter(
appCurrency = appCurrencyProvider(),
unavailableErrorText = resourceReference(R.string.tokens_list_unavailable_to_swap_source_header),
onItemClick = onTokenSelected,
)
override fun transform(prevState: SwapStateHolder): SwapStateHolder {
val accountList = tokensDataState.accountCurrencyList
return prevState.copy(
selectTokenState = SwapSelectTokenStateHolder(
availableTokens = persistentListOf(),
unavailableTokens = persistentListOf(),
tokensListData = if (isAccountsMode) {
TokenListUMData.AccountList(
tokensList = accountListItemConverter.convertList(accountList).toPersistentList(),
)
} 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(),
)
},
onSearchEntered = onSearchEntered,
onTokenSelected = onTokenSelected,
isBalanceHidden = isBalanceHidden,
afterSearch = tokensDataState.isAfterSearch,
),
)
}
}

View file

@ -20,6 +20,10 @@ import com.tangem.core.navigation.url.UrlOpener
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.utils.InputNumberFormatter
import com.tangem.domain.account.featuretoggle.AccountsFeatureToggles
import com.tangem.domain.account.status.model.AccountCryptoCurrencyStatus
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
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
@ -29,6 +33,7 @@ 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.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
@ -105,6 +110,9 @@ internal class SwapModel @Inject constructor(
swapInteractorFactory: SwapInteractor.Factory,
private val urlOpener: UrlOpener,
router: AppRouter,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val accountsFeatureToggles: AccountsFeatureToggles,
) : Model() {
private val params = paramsContainer.require<SwapComponent.Params>()
@ -133,6 +141,7 @@ internal class SwapModel @Inject constructor(
actions = createUiActions(),
isBalanceHiddenProvider = Provider { isBalanceHidden },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
isAccountsModeProvider = Provider { isAccountsMode },
)
private val inputNumberFormatter =
@ -158,6 +167,10 @@ internal class SwapModel @Inject constructor(
private var swapRouter: SwapRouter = SwapRouter(router = router)
private var userCountry: UserCountry? = null
private lateinit var fromAccountCurrencyStatus: AccountCryptoCurrencyStatus
private var toAccountCurrencyStatus: AccountCryptoCurrencyStatus? = null
private var isAccountsMode: Boolean = false
private val isUserResolvableError: (SwapState) -> Boolean = {
it is SwapState.SwapError &&
(
@ -183,19 +196,48 @@ internal class SwapModel @Inject constructor(
}
modelScope.launch(dispatchers.io) {
val fromStatus =
getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWalletId, initialCurrencyFrom.id)
.getOrNull()
val toStatus = initialCurrencyTo?.let {
getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(userWalletId, it.id).getOrNull()
}
if (accountsFeatureToggles.isFeatureEnabled) {
isAccountsMode = isAccountsModeEnabledUseCase.invokeSync()
if (fromStatus == null) {
uiState = stateBuilder.addAlert(uiState = uiState, onDismiss = swapRouter::back)
val fromAccountStatus = getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = userWalletId,
currency = initialCurrencyFrom,
).getOrNull()
val toAccountStatus = initialCurrencyTo?.let {
getAccountCurrencyStatusUseCase.invokeSync(
userWalletId = userWalletId,
currency = it,
).getOrNull()
}
if (fromAccountStatus == null) {
uiState = stateBuilder.addAlert(uiState = uiState, onDismiss = swapRouter::back)
} else {
fromAccountCurrencyStatus = fromAccountStatus
toAccountCurrencyStatus = toAccountStatus
initialFromStatus = fromAccountStatus.status
initialToStatus = toAccountStatus?.status
initTokens(isInitiallyReversed)
}
} else {
initialFromStatus = fromStatus
initialToStatus = toStatus
initTokens(isInitiallyReversed)
val fromStatus = getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWalletId = userWalletId,
cryptoCurrencyId = initialCurrencyFrom.id,
).getOrNull()
val toStatus = initialCurrencyTo?.let {
getSingleCryptoCurrencyStatusUseCase.invokeMultiWalletSync(
userWalletId = userWalletId,
cryptoCurrencyId = it.id,
).getOrNull()
}
if (fromStatus == null) {
uiState = stateBuilder.addAlert(uiState = uiState, onDismiss = swapRouter::back)
} else {
initialFromStatus = fromStatus
initialToStatus = toStatus
initTokens(isInitiallyReversed)
}
}
}
@ -225,7 +267,10 @@ internal class SwapModel @Inject constructor(
private fun sendSelectTokenScreenOpenedEvent() {
val isAnyAvailableTokensTo = dataState.tokensDataState?.toGroup?.available?.isNotEmpty() == true
val isAnyAvailableTokensFrom = dataState.tokensDataState?.fromGroup?.available?.isNotEmpty() == true
val isAnyAvailableTokens = isAnyAvailableTokensTo || isAnyAvailableTokensFrom
val isAnyAvailableAccountTokensTo = !dataState.tokensDataState?.toGroup?.accountCurrencyList.isNullOrEmpty()
val isAnyAvailableAccountTokensFrom = !dataState.tokensDataState?.fromGroup?.accountCurrencyList.isNullOrEmpty()
val isAnyAvailableTokens = isAnyAvailableTokensTo || isAnyAvailableTokensFrom ||
isAnyAvailableAccountTokensTo || isAnyAvailableAccountTokensFrom
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenOpened(availableTokens = isAnyAvailableTokens))
}
@ -235,15 +280,32 @@ internal class SwapModel @Inject constructor(
swapInteractor.getTokensDataState(initialCurrencyFrom)
}.onSuccess { state ->
updateTokensState(state)
val selectedCurrency = initialToStatus ?: swapInteractor.getInitialCurrencyToSwap(
initialCryptoCurrency = initialCurrencyFrom,
state = state,
isReverseFromTo = isReverseFromTo,
)
val (selectedCurrency, selectedAccount) = if (accountsFeatureToggles.isFeatureEnabled) {
val selectedAccountCurrency = toAccountCurrencyStatus ?: swapInteractor.getInitialCurrencyToSwapV2(
initialCryptoCurrency = initialCurrencyFrom,
state = state,
isReverseFromTo = isReverseFromTo,
)?.let {
AccountCryptoCurrencyStatus(
account = it.account,
status = it.cryptoCurrencyStatus,
)
}
selectedAccountCurrency?.status to selectedAccountCurrency?.account
} else {
val selectedCurrency = initialToStatus ?: swapInteractor.getInitialCurrencyToSwap(
initialCryptoCurrency = initialCurrencyFrom,
state = state,
isReverseFromTo = isReverseFromTo,
)
selectedCurrency to null
}
applyInitialTokenChoice(
state = state,
selectedCurrency = selectedCurrency,
selectedAccount = selectedAccount,
isReverseFromTo = isReverseFromTo,
)
@ -268,6 +330,7 @@ internal class SwapModel @Inject constructor(
applyInitialTokenChoice(
state = TokensDataStateExpress.EMPTY,
selectedCurrency = null,
selectedAccount = null,
isReverseFromTo = isReverseFromTo,
)
@ -304,6 +367,7 @@ internal class SwapModel @Inject constructor(
private fun applyInitialTokenChoice(
state: TokensDataStateExpress,
selectedCurrency: CryptoCurrencyStatus?,
selectedAccount: Account.CryptoPortfolio?,
isReverseFromTo: Boolean,
) {
// exceptional case
@ -321,14 +385,27 @@ internal class SwapModel @Inject constructor(
} else {
initialFromStatus to selectedCurrency
}
val (fromAccount, toAccount) = if (accountsFeatureToggles.isFeatureEnabled) {
if (isOrderReversed) {
selectedAccount to fromAccountCurrencyStatus.account
} else {
fromAccountCurrencyStatus.account to selectedAccount
}
} else {
null to null
}
dataState = dataState.copy(
fromCryptoCurrency = fromCurrencyStatus,
fromAccount = fromAccount,
toCryptoCurrency = toCurrencyStatus,
toAccount = toAccount,
tokensDataState = state,
)
startLoadingQuotes(
fromToken = fromCurrencyStatus,
fromAccount = fromAccount,
toToken = toCurrencyStatus,
toAccount = toAccount,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(fromCurrencyStatus, toCurrencyStatus),
@ -337,16 +414,27 @@ internal class SwapModel @Inject constructor(
private fun updateTokensState(tokenDataState: TokensDataStateExpress) {
val tokensDataState = if (isOrderReversed) tokenDataState.fromGroup else tokenDataState.toGroup
uiState = stateBuilder.addTokensToState(
uiState = uiState,
tokensDataState = tokensDataState,
fromToken = dataState.fromCryptoCurrency?.currency ?: initialCurrencyFrom,
)
uiState = if (accountsFeatureToggles.isFeatureEnabled) {
stateBuilder.addTokensToStateV2(
uiState = uiState,
tokensDataState = tokensDataState,
isAccountsMode = isAccountsMode,
)
} else {
stateBuilder.addTokensToState(
uiState = uiState,
tokensDataState = tokensDataState,
fromToken = dataState.fromCryptoCurrency?.currency ?: initialCurrencyFrom,
)
}
}
private fun startLoadingQuotes(
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
amount: String,
reduceBalanceBy: BigDecimal,
toProvidersList: List<SwapProvider>,
@ -355,17 +443,21 @@ internal class SwapModel @Inject constructor(
singleTaskScheduler.cancelTask()
if (!isSilent) {
uiState = stateBuilder.createQuotesLoadingState(
uiState,
fromToken.currency,
toToken.currency,
initialCurrencyFrom.id.value,
uiStateHolder = uiState,
fromToken = fromToken.currency,
toToken = toToken.currency,
fromAccount = fromAccount,
toAccount = toAccount,
mainTokenId = initialCurrencyFrom.id.value,
)
}
singleTaskScheduler.scheduleTask(
modelScope,
loadQuotesTask(
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
amount = amount,
reduceBalanceBy = reduceBalanceBy,
toProvidersList = toProvidersList,
@ -380,7 +472,9 @@ internal class SwapModel @Inject constructor(
if (fromCurrency != null && toCurrency != null && amount != null) {
startLoadingQuotes(
fromToken = fromCurrency,
fromAccount = dataState.fromAccount,
toToken = toCurrency,
toAccount = dataState.toAccount,
amount = amount,
isSilent = isSilent,
reduceBalanceBy = dataState.reduceBalanceBy,
@ -391,7 +485,9 @@ internal class SwapModel @Inject constructor(
private fun loadQuotesTask(
fromToken: CryptoCurrencyStatus,
fromAccount: Account.CryptoPortfolio?,
toToken: CryptoCurrencyStatus,
toAccount: Account.CryptoPortfolio?,
amount: String,
reduceBalanceBy: BigDecimal,
toProvidersList: List<SwapProvider>,
@ -409,7 +505,9 @@ internal class SwapModel @Inject constructor(
)
swapInteractor.findBestQuote(
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
providers = toProvidersList,
amountToSwap = amount,
reduceBalanceBy = reduceBalanceBy,
@ -475,6 +573,7 @@ internal class SwapModel @Inject constructor(
fromTokenStatus = fromToken,
toTokenStatus = toTokenStatus,
isReverseSwapPossible = isReverseSwapPossible(),
toAccount = dataState.toAccount,
)
}
is SwapState.SwapError -> {
@ -488,6 +587,7 @@ internal class SwapModel @Inject constructor(
includeFeeInAmount = state.includeFeeInAmount,
isReverseSwapPossible = isReverseSwapPossible(),
needApplyFCARestrictions = userCountry.needApplyFCARestrictions(),
toAccount = dataState.toAccount,
)
sendErrorAnalyticsEvent(state.error, provider)
}
@ -774,6 +874,7 @@ internal class SwapModel @Inject constructor(
} else {
tokenDataState.toGroup
}
val available = group.available.filter {
it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) ||
it.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true)
@ -782,11 +883,28 @@ internal class SwapModel @Inject constructor(
it.currencyStatus.currency.name.contains(searchQuery, ignoreCase = true) ||
it.currencyStatus.currency.symbol.contains(searchQuery, ignoreCase = true)
}
val accountCurrencyList = group.accountCurrencyList.mapNotNull { accountSwapAvailability ->
val filteredCurrencies = accountSwapAvailability.currencyList.filter { accountSwapCurrency ->
val currency = accountSwapCurrency.cryptoCurrencyStatus.currency
currency.name.contains(searchQuery, ignoreCase = true) ||
currency.symbol.contains(searchQuery, ignoreCase = true)
}
if (filteredCurrencies.isEmpty()) {
return@mapNotNull null
}
accountSwapAvailability.copy(
currencyList = filteredCurrencies,
)
}
val filteredTokenDataState = if (isOrderReversed) {
tokenDataState.copy(
fromGroup = tokenDataState.fromGroup.copy(
available = available,
unavailable = unavailable,
accountCurrencyList = accountCurrencyList,
isAfterSearch = true,
),
)
@ -795,6 +913,7 @@ internal class SwapModel @Inject constructor(
toGroup = tokenDataState.toGroup.copy(
available = available,
unavailable = unavailable,
accountCurrencyList = accountCurrencyList,
isAfterSearch = true,
),
)
@ -805,25 +924,26 @@ internal class SwapModel @Inject constructor(
private fun onTokenSelect(id: String) {
val tokens = dataState.tokensDataState ?: return
val foundToken = if (isOrderReversed) {
tokens.fromGroup.available.firstOrNull {
it.currencyStatus.currency.id.value == id
}
} else {
tokens.toGroup.available.firstOrNull {
it.currencyStatus.currency.id.value == id
}
}
foundToken?.currencyStatus?.currency?.symbol?.let {
val (foundToken, foundAccount) = getSelectedTokenAndAccount(tokens, id)
foundToken?.currency?.symbol?.let {
analyticsEventHandler.send(SwapEvents.ChooseTokenScreenResult(tokenChosen = true, token = it))
}
if (foundToken != null) {
val fromToken: CryptoCurrencyStatus
val fromAccount: Account.CryptoPortfolio?
val toToken: CryptoCurrencyStatus
val toAccount: Account.CryptoPortfolio?
if (isOrderReversed) {
fromToken = foundToken.currencyStatus
fromToken = foundToken
fromAccount = foundAccount
toToken = initialFromStatus
toAccount = if (accountsFeatureToggles.isFeatureEnabled) {
fromAccountCurrencyStatus.account
} else {
null
}
val newToken = fromToken.currency as? CryptoCurrency.Coin
if (newToken != null) {
@ -835,7 +955,13 @@ internal class SwapModel @Inject constructor(
}
} else {
fromToken = initialFromStatus
toToken = foundToken.currencyStatus
fromAccount = if (accountsFeatureToggles.isFeatureEnabled) {
fromAccountCurrencyStatus.account
} else {
null
}
toToken = foundToken
toAccount = foundAccount
val newToken = toToken.currency as? CryptoCurrency.Coin
if (newToken != null) {
@ -853,12 +979,16 @@ internal class SwapModel @Inject constructor(
dataState = dataState.copy(
fromCryptoCurrency = fromToken,
fromAccount = fromAccount,
toCryptoCurrency = toToken,
toAccount = toAccount,
selectedProvider = null,
)
startLoadingQuotes(
fromToken = fromToken,
fromAccount = fromAccount,
toToken = toToken,
toAccount = toAccount,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(fromToken, toToken),
@ -868,6 +998,28 @@ internal class SwapModel @Inject constructor(
}
}
private fun getSelectedTokenAndAccount(
tokens: TokensDataStateExpress,
id: String,
): Pair<CryptoCurrencyStatus?, Account.CryptoPortfolio?> {
return if (accountsFeatureToggles.isFeatureEnabled) {
val accountCryptoCurrencyStatus = if (isOrderReversed) {
tokens.fromGroup
} else {
tokens.toGroup
}.accountCurrencyList.firstNotNullOfOrNull {
it.currencyList.firstOrNull { it.cryptoCurrencyStatus.currency.id.value == id }
}
accountCryptoCurrencyStatus?.cryptoCurrencyStatus to accountCryptoCurrencyStatus?.account
} else {
if (isOrderReversed) {
tokens.fromGroup
} else {
tokens.toGroup
}.available.firstOrNull { it.currencyStatus.currency.id.value == id }?.currencyStatus to null
}
}
private fun subscribeToCoinBalanceUpdates(
userWalletId: UserWalletId,
coin: CryptoCurrency.Coin,
@ -875,34 +1027,65 @@ internal class SwapModel @Inject constructor(
) {
Timber.d("Subscribe to ${coin.id} balance updates")
getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet(
userWalletId = userWalletId,
currencyId = coin.id,
isSingleWalletWithTokens = false,
)
.mapNotNull { (it as? Either.Right)?.value }
.distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes
.onEach {
Timber.d("${coin.id} balance is ${it.value.amount}")
if (accountsFeatureToggles.isFeatureEnabled) {
getAccountCurrencyStatusUseCase(
userWalletId = userWalletId,
currency = coin,
).distinctUntilChanged { old, new -> old.status.value.amount == new.status.value.amount } // Check only balance changes
.onEach { (account, currencyStatus) ->
Timber.d("${coin.id} balance is ${currencyStatus.value.amount}")
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = it,
).getOrNull() ?: it,
)
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = currencyStatus,
).getOrNull() ?: currencyStatus,
)
uiState = if (isFromCurrency) {
dataState = dataState.copy(fromCryptoCurrency = it)
stateBuilder.updateSendCurrencyBalance(uiState, it)
} else {
dataState = dataState.copy(toCryptoCurrency = it)
stateBuilder.updateReceiveCurrencyBalance(uiState, it)
uiState = if (isFromCurrency) {
dataState = dataState.copy(
fromCryptoCurrency = currencyStatus,
fromAccount = account,
)
stateBuilder.updateSendCurrencyBalance(uiState, currencyStatus)
} else {
dataState = dataState.copy(
toCryptoCurrency = currencyStatus,
toAccount = account,
)
stateBuilder.updateReceiveCurrencyBalance(uiState, currencyStatus)
}
startLoadingQuotesFromLastState(isSilent = true)
}
} else {
getSingleCryptoCurrencyStatusUseCase.invokeMultiWallet(
userWalletId = userWalletId,
currencyId = coin.id,
isSingleWalletWithTokens = false,
).mapNotNull { (it as? Either.Right)?.value }
.distinctUntilChanged { old, new -> old.value.amount == new.value.amount } // Check only balance changes
.onEach {
Timber.d("${coin.id} balance is ${it.value.amount}")
startLoadingQuotesFromLastState(isSilent = true)
}
.flowOn(dispatchers.main)
dataState = dataState.copy(
feePaidCryptoCurrency = getFeePaidCryptoCurrencyStatusSyncUseCase(
userWalletId = userWalletId,
cryptoCurrencyStatus = it,
).getOrNull() ?: it,
)
uiState = if (isFromCurrency) {
dataState = dataState.copy(fromCryptoCurrency = it)
stateBuilder.updateSendCurrencyBalance(uiState, it)
} else {
dataState = dataState.copy(toCryptoCurrency = it)
stateBuilder.updateReceiveCurrencyBalance(uiState, it)
}
startLoadingQuotesFromLastState(isSilent = true)
}
}.flowOn(dispatchers.main)
.launchIn(modelScope)
.saveIn(if (isFromCurrency) fromTokenBalanceJobHolder else toTokenBalanceJobHolder)
}
@ -910,14 +1093,18 @@ internal class SwapModel @Inject constructor(
private fun onChangeCardsClicked() {
modelScope.launch {
val newFromToken = dataState.toCryptoCurrency
val newFromAccount = dataState.toAccount
val newToToken = dataState.fromCryptoCurrency
val newToAccount = dataState.fromAccount
if (newFromToken != null && newToToken != null) {
isAmountChangedByUser = true
dataState = dataState.copy(
fromCryptoCurrency = newFromToken,
fromAccount = newFromAccount,
toCryptoCurrency = newToToken,
toAccount = newToAccount,
)
isOrderReversed = !isOrderReversed
dataState.tokensDataState?.let {
@ -937,10 +1124,13 @@ internal class SwapModel @Inject constructor(
amountRaw = lastAmount.value,
fromToken = newFromToken.currency,
minTxAmount = minTxAmount,
fromAccount = dataState.fromAccount,
)
startLoadingQuotes(
fromToken = newFromToken,
fromAccount = newFromAccount,
toToken = newToToken,
toAccount = newToAccount,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(newFromToken, newToToken),
@ -972,6 +1162,7 @@ internal class SwapModel @Inject constructor(
amountRaw = lastAmount.value,
fromToken = fromToken.currency,
minTxAmount = minTxAmount,
fromAccount = dataState.fromAccount,
)
if (toToken != null) {
@ -982,7 +1173,9 @@ internal class SwapModel @Inject constructor(
amountDebouncer.debounce(modelScope, DEBOUNCE_AMOUNT_DELAY, forceUpdate = forceQuotesUpdate) {
startLoadingQuotes(
fromToken = fromToken,
fromAccount = dataState.fromAccount,
toToken = toToken,
toAccount = dataState.toAccount,
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
toProvidersList = findSwapProviders(fromToken, toToken),
@ -1267,7 +1460,14 @@ internal class SwapModel @Inject constructor(
toToken.currency.id.value
}
return groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }?.providers.orEmpty()
return if (accountsFeatureToggles.isFeatureEnabled) {
groupToFind.accountCurrencyList.firstNotNullOfOrNull { (_, currencyList) ->
currencyList.find { idToFind == it.cryptoCurrencyStatus.currency.id.value && it.isAvailable }
}?.providers
} else {
groupToFind.available.find { idToFind == it.currencyStatus.currency.id.value }
?.providers
}.orEmpty()
}
private fun Map<SwapProvider, SwapState>.getLastLoadedSuccessStates(): SuccessLoadedSwapData {
@ -1293,9 +1493,11 @@ internal class SwapModel @Inject constructor(
val chosen = if (isOrderReversed) from else to
return currenciesGroup.available
.map { it.currencyStatus.currency }
.contains(chosen.currency)
return if (accountsFeatureToggles.isFeatureEnabled) {
currenciesGroup.accountCurrencyList.flatMap { it.currencyList.map { it.cryptoCurrencyStatus } }
} else {
currenciesGroup.available.map { it.currencyStatus }
}.map { it.currency }.contains(chosen.currency)
}
private fun sendPermissionApproveClickedEvent() {

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.model
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapProvider
@ -14,6 +15,8 @@ data class SwapProcessDataState(
val fromCryptoCurrency: CryptoCurrencyStatus? = null,
val toCryptoCurrency: CryptoCurrencyStatus? = null,
val feePaidCryptoCurrency: CryptoCurrencyStatus? = null,
val fromAccount: Account.CryptoPortfolio? = null,
val toAccount: Account.CryptoPortfolio? = null,
// Amount from input
val amount: String? = null,
val reduceBalanceBy: BigDecimal = BigDecimal.ZERO,

View file

@ -1,18 +1,22 @@
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 kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class SwapSelectTokenStateHolder(
internal data class SwapSelectTokenStateHolder(
val availableTokens: ImmutableList<TokenToSelectState>,
val unavailableTokens: ImmutableList<TokenToSelectState>,
val tokensListData: TokenListUMData,
val isBalanceHidden: Boolean,
val afterSearch: Boolean,
val onSearchEntered: (String) -> Unit,
val onTokenSelected: (String) -> Unit,
)
sealed class TokenToSelectState {
internal sealed class TokenToSelectState {
data class Title(val title: TextReference) : TokenToSelectState()
@ -26,8 +30,25 @@ sealed class TokenToSelectState {
) : TokenToSelectState()
}
data class TokenBalanceData(
internal data class TokenBalanceData(
val amount: String?,
val amountEquivalent: String?,
val isBalanceHidden: Boolean,
)
)
internal sealed interface TokenListUMData {
val tokensList: ImmutableList<TokensListItemUM>
data class AccountList(
override val tokensList: ImmutableList<TokensListItemUM.Portfolio>,
) : TokenListUMData
data class TokenList(
override val tokensList: ImmutableList<TokensListItemUM>,
) : TokenListUMData
data object EmptyList : TokenListUMData {
override val tokensList: ImmutableList<TokensListItemUM> = persistentListOf()
}
}

View file

@ -2,10 +2,10 @@ package com.tangem.feature.swap.models
import androidx.annotation.DrawableRes
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.swapStoriesScreen.SwapStoriesUM
import com.tangem.core.ui.R
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.event.StateEvent
import com.tangem.core.ui.event.consumedEvent
@ -83,21 +83,21 @@ data class SwapButton(
sealed interface TransactionCardType {
val header: TextReference
val accountTitleUM: AccountTitleUM?
val inputError: InputError
data class Inputtable(
val onAmountChanged: ((String) -> Unit),
val onFocusChanged: ((Boolean) -> Unit),
override val inputError: InputError,
override val header: TextReference = TextReference.Res(R.string.swapping_from_title),
override val accountTitleUM: AccountTitleUM?,
) : TransactionCardType
data class ReadOnly(
val showWarning: Boolean = false,
val onWarningClick: (() -> Unit)? = null,
override val inputError: InputError = InputError.Empty,
override val header: TextReference = TextReference.Res(R.string.swapping_to_title),
override val accountTitleUM: AccountTitleUM? = null,
) : TransactionCardType
sealed interface InputError {

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.models
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
@ -12,6 +13,8 @@ data class SwapSuccessStateHolder(
val providerName: TextReference,
val providerType: TextReference,
val providerIcon: String,
val fromTitle: AccountTitleUM,
val toTitle: AccountTitleUM,
val fromTokenAmount: TextReference,
val toTokenAmount: TextReference,
val fromTokenFiatAmount: TextReference,

View file

@ -1,7 +1,12 @@
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.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.models.SwapSuccessStateHolder
@ -14,6 +19,16 @@ internal data object SwapSuccessStatePreview {
providerType = TextReference.Str(ExchangeProviderType.DEX.providerName),
showStatusButton = false,
providerIcon = "",
fromTitle = AccountTitleUM.Account(
prefixText = stringReference("From"),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
),
toTitle = AccountTitleUM.Account(
prefixText = stringReference("To"),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
),
fromTokenAmount = TextReference.Str("1 000 DAI"),
toTokenAmount = TextReference.Str("1 000 MATIC"),
fromTokenFiatAmount = TextReference.Str("1 000 $"),

View file

@ -2,6 +2,8 @@ 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.toUM
import com.tangem.common.ui.alerts.models.AlertDemoModeUM
import com.tangem.common.ui.bottomsheet.permission.state.*
import com.tangem.common.ui.notifications.NotificationUM
@ -18,12 +20,14 @@ import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.appcurrency.model.AppCurrency
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.promo.models.StoryContent
import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.converters.TokensDataConverterV2
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
@ -59,6 +63,7 @@ internal class StateBuilder(
private val actions: UiActions,
private val isBalanceHiddenProvider: Provider<Boolean>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val isAccountsModeProvider: Provider<Boolean>,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
@ -86,6 +91,7 @@ internal class StateBuilder(
onAmountChanged = actions.onAmountChanged,
onFocusChanged = actions.onAmountSelected,
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = null,
),
amountEquivalent = null,
amountTextFieldValue = null,
@ -183,11 +189,14 @@ internal class StateBuilder(
)
}
@Suppress("LongParameterList")
fun createQuotesLoadingState(
uiStateHolder: SwapStateHolder,
fromToken: CryptoCurrency,
toToken: CryptoCurrency,
mainTokenId: String,
fromAccount: Account.CryptoPortfolio?,
toAccount: Account.CryptoPortfolio?,
): SwapStateHolder {
val canSelectSendToken = mainTokenId != fromToken.id.value
val canSelectReceiveToken = mainTokenId != toToken.id.value
@ -199,7 +208,7 @@ internal class StateBuilder(
} else {
sendInputType.copy(
inputError = TransactionCardType.InputError.Empty,
header = TextReference.Res(R.string.swapping_from_title),
accountTitleUM = getFromCardAccountTitle(fromAccount),
)
}
return uiStateHolder.copy(
@ -218,7 +227,9 @@ internal class StateBuilder(
isBalanceHidden = isBalanceHiddenProvider(),
),
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(),
type = TransactionCardType.ReadOnly(
accountTitleUM = getToCardAccountTitle(toAccount),
),
amountTextFieldValue = null,
amountEquivalent = null,
token = uiStateHolder.receiveCardData.token,
@ -280,10 +291,12 @@ internal class StateBuilder(
val fromCurrencyStatus = quoteModel.fromTokenInfo.cryptoCurrencyStatus
val toCurrencyStatus = quoteModel.toTokenInfo.cryptoCurrencyStatus
val isInsufficientFunds = isInsufficientFundsCondition(quoteModel)
val insufficientFundsHeader = if (isInsufficientFunds) {
TextReference.Res(R.string.swapping_insufficient_funds)
} else {
TextReference.Res(R.string.swapping_from_title)
val fromAccount = quoteModel.fromTokenInfo.account
val toAccount = quoteModel.toTokenInfo.account
val fromAccountTitleUM = when {
isInsufficientFunds -> AccountTitleUM.Text(TextReference.Res(R.string.swapping_insufficient_funds))
else -> getFromCardAccountTitle(fromAccount)
}
val sendCardType = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.Inputtable)
val sendInput = when (sendCardType.inputError) {
@ -299,7 +312,7 @@ internal class StateBuilder(
}
sendCardType.copy(
inputError = error,
header = insufficientFundsHeader,
accountTitleUM = fromAccountTitleUM,
)
}
}
@ -321,7 +334,8 @@ internal class StateBuilder(
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(
showWarning = true,
actions.onReceiveCardWarningClick,
onWarningClick = actions.onReceiveCardWarningClick,
accountTitleUM = getToCardAccountTitle(toAccount),
),
amountTextFieldValue = TextFieldValue(
quoteModel.toTokenInfo.tokenAmount
@ -423,6 +437,7 @@ internal class StateBuilder(
swapProvider: SwapProvider,
fromToken: TokenSwapInfo,
toToken: CryptoCurrencyStatus?,
toAccount: Account.CryptoPortfolio?,
includeFeeInAmount: IncludeFeeInAmount,
expressDataError: ExpressDataError,
isReverseSwapPossible: Boolean,
@ -445,9 +460,10 @@ internal class StateBuilder(
selectionType = ProviderState.SelectionType.CLICK,
needApplyFCARestrictions = needApplyFCARestrictions,
)
val type = TransactionCardType.ReadOnly(accountTitleUM = getToCardAccountTitle(toAccount))
val receiveCardData = toToken?.let {
SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(),
type = type,
amountTextFieldValue = TextFieldValue(
text = "0",
),
@ -463,7 +479,7 @@ internal class StateBuilder(
isBalanceHidden = isBalanceHiddenProvider(),
)
} ?: SwapCardState.Empty(
type = TransactionCardType.ReadOnly(),
type = type,
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountTextFieldValue = TextFieldValue(
text = "0",
@ -535,11 +551,13 @@ internal class StateBuilder(
}
}
@Suppress("LongParameterList")
fun createQuotesEmptyAmountState(
uiStateHolder: SwapStateHolder,
emptyAmountState: SwapState.EmptyAmountState,
fromTokenStatus: CryptoCurrencyStatus,
toTokenStatus: CryptoCurrencyStatus?,
toAccount: Account.CryptoPortfolio?,
isReverseSwapPossible: Boolean,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
@ -560,7 +578,9 @@ internal class StateBuilder(
isBalanceHidden = isBalanceHiddenProvider(),
),
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReadOnly(),
type = TransactionCardType.ReadOnly(
accountTitleUM = getToCardAccountTitle(toAccount),
),
amountTextFieldValue = TextFieldValue("0"),
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
token = uiStateHolder.receiveCardData.token,
@ -614,17 +634,34 @@ internal class StateBuilder(
)
}
fun addTokensToStateV2(
uiState: SwapStateHolder,
tokensDataState: CurrenciesGroup,
isAccountsMode: Boolean,
): SwapStateHolder {
return TokensDataConverterV2(
onSearchEntered = actions.onSearchEntered,
onTokenSelected = actions.onTokenSelected,
appCurrencyProvider = appCurrencyProvider,
tokensDataState = tokensDataState,
isBalanceHidden = isBalanceHiddenProvider(),
isAccountsMode = isAccountsMode,
).transform(uiState)
}
fun createSilentLoadState(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS,
)
}
@Suppress("LongParameterList")
fun updateSwapAmount(
uiState: SwapStateHolder,
amountFormatted: String,
amountRaw: String,
fromToken: CryptoCurrency,
fromAccount: Account.CryptoPortfolio?,
minTxAmount: BigDecimal?,
): SwapStateHolder {
if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState
@ -635,12 +672,14 @@ internal class StateBuilder(
}
(uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy(
inputError = TransactionCardType.InputError.WrongAmount,
header = resourceReference(R.string.transfer_min_amount_error, wrappedList(minAmountFormatted)),
accountTitleUM = AccountTitleUM.Text(
resourceReference(R.string.transfer_min_amount_error, wrappedList(minAmountFormatted)),
),
) ?: uiState.sendCardData.type
} else {
(uiState.sendCardData.type as? TransactionCardType.Inputtable)?.copy(
inputError = TransactionCardType.InputError.Empty,
header = TextReference.Res(R.string.swapping_from_title),
accountTitleUM = getFromCardAccountTitle(fromAccount),
) ?: uiState.sendCardData.type
}
return uiState.copy(
@ -692,6 +731,7 @@ internal class StateBuilder(
isBalanceHidden = isBalanceHidden,
)
val selectTokenState = uiState.selectTokenState?.copy(
isBalanceHidden = isBalanceHidden,
availableTokens = uiState.selectTokenState.availableTokens.map {
when (it) {
is TokenToSelectState.TokenToSelect -> {
@ -811,6 +851,8 @@ internal class StateBuilder(
providerIcon = providerState.iconUrl,
rate = providerState.subtitle,
fee = stringReference("${fee.feeCryptoFormattedWithNative} (${fee.feeFiatFormattedWithNative})"),
fromTitle = getFromCardAccountTitle(fromAccount = dataState.fromAccount),
toTitle = getToCardAccountTitle(toAccount = dataState.toAccount),
fromTokenAmount = stringReference(swapTransactionState.fromAmount.orEmpty()),
toTokenAmount = stringReference(swapTransactionState.toAmount.orEmpty()),
fromTokenFiatAmount = stringReference(fromFiatAmount),
@ -1327,6 +1369,30 @@ internal class StateBuilder(
return FCA_RESTRICTED_PROVIDER_IDS.contains(providerId)
}
private fun getFromCardAccountTitle(fromAccount: Account.CryptoPortfolio?): AccountTitleUM {
return if (fromAccount != null && isAccountsModeProvider()) {
AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = fromAccount.accountName.toUM().value,
icon = fromAccount.icon.toUM(),
)
} else {
AccountTitleUM.Text(resourceReference(R.string.swapping_from_title))
}
}
private fun getToCardAccountTitle(toAccount: Account.CryptoPortfolio?): AccountTitleUM {
return if (toAccount != null && isAccountsModeProvider()) {
AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_to),
name = toAccount.accountName.toUM().value,
icon = toAccount.icon.toUM(),
)
} else {
AccountTitleUM.Text(resourceReference(R.string.swapping_to_title))
}
}
private companion object {
private const val RU_LOCALE = "ru"
private const val EN_LOCALE = "en"

View file

@ -383,7 +383,12 @@ private fun MainButton(state: SwapStateHolder) {
// region preview
private val sendCard = SwapCardState.SwapCardData(
type = TransactionCardType.Inputtable({}, {}, TransactionCardType.InputError.Empty),
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = null,
),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = "1 000 000",
tokenIconUrl = "",

View file

@ -14,32 +14,39 @@ import androidx.compose.runtime.Composable
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.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.SpacerH12
import com.tangem.core.ui.components.SpacerW2
import com.tangem.core.ui.components.appbar.ExpandableSearchView
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
import com.tangem.core.ui.components.tokenlist.state.TokensListItemUM
import com.tangem.core.ui.decorations.roundedShapeItemDecoration
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.*
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.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.presentation.R
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Composable
fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) {
internal fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit) {
BackHandler(onBack = onBack)
Scaffold(
@ -49,10 +56,12 @@ fun SwapSelectTokenScreen(state: SwapSelectTokenStateHolder, onBack: () -> Unit)
content = { padding ->
val modifier = Modifier.padding(padding)
when {
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && state.afterSearch -> {
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() &&
state.tokensListData.tokensList.isEmpty() && state.afterSearch -> {
TokensNotFound(modifier)
}
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() && !state.afterSearch -> {
state.availableTokens.isEmpty() && state.unavailableTokens.isEmpty() &&
state.tokensListData.tokensList.isEmpty() && !state.afterSearch -> {
EmptyTokensList(modifier)
}
else -> {
@ -133,6 +142,22 @@ 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
}
tokensToSelectItems(state.availableTokens, state.onTokenSelected)
item { SpacerH12() }
@ -143,6 +168,86 @@ private fun ListOfTokens(state: SwapSelectTokenStateHolder, modifier: Modifier =
}
}
private fun LazyListScope.tokensList(items: ImmutableList<TokensListItemUM>, isBalanceHidden: Boolean) {
itemsIndexed(
items = items,
key = { _, item -> item.id },
contentType = { _, item -> item::class.java },
itemContent = { index, item ->
TokenListItem(
state = item,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.roundedShapeItemDecoration(
currentIndex = index,
lastIndex = items.lastIndex,
backgroundColor = TangemTheme.colors.background.primary,
)
.testTag(BuyTokenScreenTestTags.LAZY_LIST_ITEM)
.semantics { lazyListItemPosition = index },
)
},
)
}
internal fun LazyListScope.portfolioTokensList(portfolio: TokensListItemUM.Portfolio, isBalanceHidden: Boolean) {
val tokens = portfolio.tokens
val isExpanded = portfolio.isExpanded
portfolioItem(
portfolio = portfolio,
modifier = Modifier.padding(top = 8.dp),
isBalanceHidden = isBalanceHidden,
)
if (!isExpanded) return
itemsIndexed(
items = tokens,
key = { _, item -> item.id },
contentType = { _, item -> item::class.java },
itemContent = { tokenIndex, token ->
val indexWithHeader = tokenIndex.inc()
PortfolioTokensListItem(
state = token,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.animateItem()
.roundedShapeItemDecoration(
currentIndex = indexWithHeader,
lastIndex = tokens.lastIndex.inc(),
backgroundColor = TangemTheme.colors.background.primary,
)
.conditional(tokenIndex == tokens.lastIndex) {
Modifier.padding(bottom = 8.dp)
},
)
},
)
}
private fun LazyListScope.portfolioItem(
portfolio: TokensListItemUM.Portfolio,
modifier: Modifier,
isBalanceHidden: Boolean,
) {
item(
key = "account-${portfolio.id}",
contentType = "account",
) {
PortfolioListItem(
state = portfolio,
isBalanceHidden = isBalanceHidden,
modifier = Modifier
.animateItem()
.roundedShapeItemDecoration(
currentIndex = 0,
lastIndex = portfolio.tokens.lastIndex.inc(),
backgroundColor = TangemTheme.colors.background.primary,
)
.then(modifier),
)
}
}
private fun LazyListScope.tokensToSelectItems(
items: ImmutableList<TokenToSelectState>,
onTokenClick: (String) -> Unit,
@ -311,7 +416,9 @@ private fun TokenScreenPreview() {
state = SwapSelectTokenStateHolder(
availableTokens = listOf(title, token, token, token).toImmutableList(),
unavailableTokens = listOf(title, token, token, token).toImmutableList(),
tokensListData = TokenListUMData.EmptyList,
afterSearch = false,
isBalanceHidden = false,
onSearchEntered = {},
onTokenSelected = {},
),

View file

@ -5,21 +5,23 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.common.ui.account.AccountTitle
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.core.ui.components.*
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import com.tangem.core.ui.components.currency.icon.CurrencyIcon
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.components.inputrow.InputRowBestRate
import com.tangem.core.ui.components.inputrow.InputRowDefault
import com.tangem.core.ui.components.inputrow.InputRowImage
import com.tangem.core.ui.components.transactions.TransactionDoneTitle
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.extensions.wrappedList
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.utils.DateTimeFormatters
@ -75,26 +77,18 @@ private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: Pad
),
)
SpacerH16()
InputRowImage(
title = TextReference.Res(R.string.swapping_from_title),
SwapAmountBlock(
title = state.fromTitle,
subtitle = state.fromTokenAmount,
caption = state.fromTokenFiatAmount,
tokenIconState = state.fromTokenIconState ?: CurrencyIconState.Loading,
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
showNetworkIcon = true,
tokenIconState = state.fromTokenIconState,
)
SpacerH16()
InputRowImage(
title = TextReference.Res(R.string.swapping_to_title),
SwapAmountBlock(
title = state.toTitle,
subtitle = state.toTokenAmount,
caption = state.toTokenFiatAmount,
tokenIconState = state.toTokenIconState ?: CurrencyIconState.Loading,
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action),
showNetworkIcon = true,
tokenIconState = state.toTokenIconState,
)
SpacerH16()
InputRowBestRate(
@ -117,6 +111,48 @@ private fun SwapSuccessScreenContent(state: SwapSuccessStateHolder, padding: Pad
}
}
@Composable
private fun SwapAmountBlock(
title: AccountTitleUM,
subtitle: TextReference,
caption: TextReference,
tokenIconState: CurrencyIconState?,
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.clip(TangemTheme.shapes.roundedCornersXMedium)
.background(TangemTheme.colors.background.action)
.fillMaxWidth()
.padding(12.dp),
) {
AccountTitle(title)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CurrencyIcon(
state = tokenIconState ?: CurrencyIconState.Loading,
shouldDisplayNetwork = true,
)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = subtitle.resolveReference(),
style = TangemTheme.typography.subtitle2,
color = TangemTheme.colors.text.primary1,
)
Text(
text = caption.resolveReference(),
style = TangemTheme.typography.caption2,
color = TangemTheme.colors.text.tertiary,
)
}
}
}
}
@Suppress("LongParameterList")
@Composable
private fun SwapSuccessScreenButtons(

View file

@ -34,14 +34,20 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import coil.compose.SubcomposeAsyncImage
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.core.ui.R
import com.tangem.core.ui.components.*
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.core.ui.test.SwapTokenScreenTestTags
import com.tangem.core.ui.utils.ImageBackgroundContrastChecker
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.account.CryptoPortfolioIcon
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.TransactionCardType
import kotlinx.coroutines.launch
@ -193,14 +199,22 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie
} else {
TangemTheme.colors.text.warning
}
Text(
text = type.header.resolveReference(),
color = titleColor,
maxLines = 1,
style = TangemTheme.typography.subtitle2,
modifier = Modifier
.align(Alignment.CenterVertically),
)
AnimatedContent(
targetState = type.accountTitleUM,
label = "",
) { currentAccountTitle ->
if (currentAccountTitle != null) {
AccountTitle(
accountTitleUM = currentAccountTitle,
textColor = titleColor,
)
} else {
TextShimmer(
text = stringResourceSafe(R.string.swapping_to_title),
style = TangemTheme.typography.subtitle2,
)
}
}
SpacerW16()
if (balance.isNotBlank()) {
AnimatedContent(targetState = balance, label = "") {
@ -559,7 +573,12 @@ private fun Preview_TransactionCardWithoutPriceImpact_InDarkTheme() {
@Composable
private fun TransactionCardPreview() {
TransactionCard(
type = TransactionCardType.Inputtable({}, {}, TransactionCardType.InputError.Empty),
type = TransactionCardType.Inputtable(
onAmountChanged = {},
onFocusChanged = {},
inputError = TransactionCardType.InputError.Empty,
accountTitleUM = null,
),
amountEquivalent = "1 000 000",
tokenIconUrl = "",
tokenCurrency = "DAI",
@ -575,7 +594,14 @@ private fun TransactionCardPreview() {
@Suppress("MagicNumber")
private fun TransactionCardPreviewWithPriceImpact() {
TransactionCard(
type = TransactionCardType.ReadOnly(showWarning = true),
type = TransactionCardType.ReadOnly(
showWarning = true,
accountTitleUM = AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
),
),
amountEquivalent = "1 000 000",
tokenIconUrl = "",
tokenCurrency = "DAI",
@ -591,7 +617,13 @@ private fun TransactionCardPreviewWithPriceImpact() {
@Suppress("MagicNumber")
private fun TransactionCardPreviewWithoutPriceImpact() {
TransactionCard(
type = TransactionCardType.ReadOnly(),
type = TransactionCardType.ReadOnly(
accountTitleUM = AccountTitleUM.Account(
prefixText = resourceReference(R.string.common_from),
name = AccountNameUM.DefaultMain.value,
icon = CryptoPortfolioIcon.ofDefaultCustomAccount().toUM(),
),
),
amountEquivalent = "1 000 000",
tokenIconUrl = "",
tokenCurrency = "DAI",