Updated on 2026-08-14

This commit is contained in:
Tangem 2023-11-21 18:49:21 +03:00
parent 3c5c53ce92
commit 537543636b
19 changed files with 431 additions and 145 deletions

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="44dp"
android:height="44dp"
android:viewportWidth="44"
android:viewportHeight="44">
<path
android:pathData="M6.31,26.499C6.508,25.666 6.773,24.813 7.102,23.949C8.493,26.484 10.368,29 12.684,31.316C15.001,33.632 17.516,35.507 20.051,36.898C19.187,37.227 18.334,37.492 17.501,37.69C13.387,38.669 10.043,37.991 8.026,35.974C6.009,33.957 5.331,30.613 6.31,26.499ZM6.862,37.139C3.507,33.784 3.448,27.963 6.13,22C3.448,16.037 3.507,10.216 6.862,6.862C10.216,3.507 16.037,3.448 22,6.13C27.963,3.448 33.784,3.507 37.139,6.862C40.493,10.216 40.552,16.037 37.87,22C40.553,27.963 40.493,33.784 37.139,37.139C33.784,40.493 27.963,40.552 22,37.87C16.037,40.552 10.216,40.493 6.862,37.139ZM13.849,30.152C11.273,27.576 9.292,24.765 7.948,22C9.292,19.235 11.273,16.424 13.849,13.849C16.424,11.273 19.235,9.292 22,7.948C24.765,9.292 27.576,11.273 30.152,13.849C32.728,16.424 34.708,19.235 36.052,22C34.708,24.765 32.727,27.576 30.152,30.152C27.576,32.727 24.765,34.708 22,36.052C19.235,34.708 16.425,32.727 13.849,30.152ZM12.684,12.684C15,10.368 17.515,8.493 20.051,7.102C19.187,6.773 18.334,6.508 17.502,6.31C13.388,5.331 10.044,6.009 8.026,8.026C6.009,10.043 5.331,13.387 6.31,17.501C6.509,18.334 6.773,19.187 7.102,20.051C8.493,17.515 10.368,15 12.684,12.684ZM31.316,12.684C29,10.368 26.485,8.493 23.949,7.102C24.813,6.773 25.666,6.508 26.499,6.31C30.613,5.331 33.957,6.009 35.974,8.026C37.991,10.043 38.669,13.387 37.69,17.501C37.492,18.334 37.227,19.187 36.898,20.051C35.507,17.515 33.632,15 31.316,12.684ZM31.316,31.316C33.632,29 35.507,26.484 36.898,23.949C37.227,24.813 37.492,25.666 37.69,26.499C38.67,30.613 37.992,33.957 35.974,35.974C33.957,37.991 30.613,38.669 26.499,37.69C25.666,37.492 24.813,37.227 23.949,36.898C26.485,35.507 29,33.632 31.316,31.316ZM22.539,19.035L21.919,14.838L21.299,19.035C21.112,20.303 20.116,21.299 18.848,21.486L14.65,22.106L18.848,22.726C20.116,22.914 21.112,23.91 21.299,25.178L21.919,29.375L22.539,25.178C22.726,23.91 23.722,22.914 24.991,22.726L29.188,22.106L24.991,21.486C23.722,21.299 22.726,20.303 22.539,19.035Z"
android:fillColor="#EBEBEB"
android:fillType="evenOdd"/>
</vector>

View file

@ -67,6 +67,14 @@ internal class DefaultQuotesRepository(
}
}
override suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote {
return withContext(dispatchers.io) {
val quote = quotesStore.getSync(setOf(currencyId)).firstOrNull()
requireNotNull(quote) { "Unable to get quote for $currencyId" }
quotesConverter.convert(quote)
}
}
private suspend fun fetchExpiredQuotes(
currenciesIds: Set<CryptoCurrency.ID>,
appCurrencyId: String,

View file

@ -0,0 +1,36 @@
package com.tangem.domain.tokens
import arrow.core.Either
import com.tangem.domain.tokens.error.TokenListError
import com.tangem.domain.tokens.error.mapper.mapToTokenListError
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.operations.CurrenciesStatusesOperations
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.domain.wallets.models.UserWalletId
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
class GetCryptoCurrencyStatusSyncUseCase(
internal val currenciesRepository: CurrenciesRepository,
internal val quotesRepository: QuotesRepository,
internal val networksRepository: NetworksRepository,
internal val dispatchers: CoroutineDispatcherProvider,
) {
suspend operator fun invoke(
userWalletId: UserWalletId,
cryptoCurrencyId: CryptoCurrency.ID,
): Either<TokenListError, CryptoCurrencyStatus> {
val operations = CurrenciesStatusesOperations(
userWalletId = userWalletId,
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
)
return operations.getCurrencyStatusSync(cryptoCurrencyId)
.mapLeft { error -> error.mapToTokenListError() }
}
}

View file

@ -85,6 +85,28 @@ internal class CurrenciesStatusesOperations(
}
}
suspend fun getCurrencyStatusSync(cryptoCurrencyId: CryptoCurrency.ID): Either<Error, CryptoCurrencyStatus> {
return either {
catch(
block = {
val currency =
currenciesRepository.getMultiCurrencyWalletCurrency(userWalletId, cryptoCurrencyId)
val quotes = quotesRepository.getQuoteSync(cryptoCurrencyId).right()
val networkStatuses =
networksRepository.getNetworkStatusesSync(
userWalletId,
setOf(currency.network),
false,
).firstOrNull {
it.network == currency.network
}.right()
return createCurrencyStatus(currency, quotes, networkStatuses)
},
catch = { raise(Error.DataError(it)) },
)
}
}
fun getCardCurrenciesStatusesFlow(): Flow<Either<Error, List<CryptoCurrencyStatus>>> {
return flow {
val nonEmptyCurrencies = recover(

View file

@ -29,4 +29,6 @@ interface QuotesRepository {
* @return A [Flow] emitting a set of quotes corresponding to the specified cryptocurrencies.
*/
suspend fun getQuotesSync(currenciesIds: Set<CryptoCurrency.ID>, refresh: Boolean): Set<Quote>
suspend fun getQuoteSync(currencyId: CryptoCurrency.ID): Quote
}

View file

@ -1,12 +1,9 @@
package com.tangem.feature.swap.domain
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.feature.swap.domain.models.domain.NetworkInfo
interface BlockchainInteractor {
fun getTokenDecimals(token: CryptoCurrency): Int
/**
* In app blockchain id, actual in blockchain sdk, not the same as networkId
*

View file

@ -1,6 +1,5 @@
package com.tangem.feature.swap.domain
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.feature.swap.domain.models.domain.NetworkInfo
import com.tangem.lib.crypto.TransactionManager
import javax.inject.Inject
@ -22,12 +21,4 @@ internal class DefaultBlockchainInteractor @Inject constructor(
override fun getExplorerTransactionLink(networkId: String, txAddress: String): String {
return transactionManager.getExplorerTransactionLink(networkId, txAddress)
}
override fun getTokenDecimals(token: CryptoCurrency): Int {
return if (token is CryptoCurrency.Token) {
token.decimals
} else {
transactionManager.getNativeTokenDecimals(token.network.backendId)
}
}
}

View file

@ -2,6 +2,7 @@ package com.tangem.feature.swap.domain
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
@ -110,4 +111,6 @@ interface SwapInteractor {
networkId: String,
fromToken: CryptoCurrency,
): Boolean
fun getSelectedWallet(): UserWallet?
}

View file

@ -8,6 +8,7 @@ import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.domain.tokens.model.Network
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.wallets.models.UserWallet
import com.tangem.domain.wallets.usecase.GetSelectedWalletSyncUseCase
import com.tangem.feature.swap.domain.cache.SwapDataCache
import com.tangem.feature.swap.domain.converters.SwapCurrencyConverter
@ -93,6 +94,10 @@ internal class SwapInteractorImpl @Inject constructor(
)
}
override fun getSelectedWallet(): UserWallet? {
return getSelectedWalletSyncUseCase().getOrNull()
}
private fun getToCurrenciesGroup(
currency: CryptoCurrency,
leastPairs: List<SwapPairLeast>,

View file

@ -1,7 +1,6 @@
package com.tangem.feature.swap.domain.di
import com.tangem.domain.tokens.GetCardTokensListUseCase
import com.tangem.domain.tokens.GetCryptoCurrenciesUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyStatusesSyncUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
@ -66,13 +65,6 @@ class SwapDomainModule {
return GetSelectedWalletSyncUseCase(walletsStateHolder = walletsStateHolder)
}
@SwapScope
@Provides
@Singleton
fun providesGetCryptoCurrenciesUseCase(currenciesRepository: CurrenciesRepository): GetCryptoCurrenciesUseCase {
return GetCryptoCurrenciesUseCase(currenciesRepository = currenciesRepository)
}
@SwapScope
@Provides
@Singleton

View file

@ -21,6 +21,10 @@ dependencies {
implementation(projects.domain.appCurrency.models)
implementation(projects.domain.balanceHiding)
implementation(projects.domain.balanceHiding.models)
implementation(projects.domain.tokens)
implementation(projects.domain.tokens.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
/** AndroidX */
implementation(deps.androidx.activity.compose)

View file

@ -32,13 +32,17 @@ class TokensDataConverter(
availableTokens = value.available.map { tokenWithBalanceToTokenToSelect(it, true) }
.toMutableList()
.apply {
this.add(0, availableTitle)
if (this.isNotEmpty()) {
this.add(0, availableTitle)
}
}
.toImmutableList(),
unavailableTokens = value.unavailable.map { tokenWithBalanceToTokenToSelect(it, false) }
.toMutableList()
.apply {
this.add(0, unavailableTitle)
if (this.isNotEmpty()) {
this.add(0, unavailableTitle)
}
}
.toImmutableList(),
onSearchEntered = onSearchEntered,

View file

@ -0,0 +1,34 @@
package com.tangem.feature.swap.di
import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.repository.CurrenciesRepository
import com.tangem.domain.tokens.repository.NetworksRepository
import com.tangem.domain.tokens.repository.QuotesRepository
import com.tangem.feature.swap.domain.*
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped
@Module
@InstallIn(ViewModelComponent::class)
class SwapPresentationModule {
@ViewModelScoped
@Provides
fun providesGetCryptoCurrenciesUseCase(
currenciesRepository: CurrenciesRepository,
dispatcherProvider: CoroutineDispatcherProvider,
quotesRepository: QuotesRepository,
networksRepository: NetworksRepository,
): GetCryptoCurrencyStatusSyncUseCase {
return GetCryptoCurrencyStatusSyncUseCase(
currenciesRepository = currenciesRepository,
quotesRepository = quotesRepository,
networksRepository = networksRepository,
dispatchers = dispatcherProvider,
)
}
}

View file

@ -7,8 +7,8 @@ import com.tangem.core.ui.components.states.SelectableItemsState
import com.tangem.feature.swap.domain.models.ui.TxFee
data class SwapStateHolder(
val sendCardData: SwapCardData,
val receiveCardData: SwapCardData,
val sendCardData: SwapCardState,
val receiveCardData: SwapCardState,
val networkCurrency: String,
val networkId: String,
val blockchainId: String, // not the same as networkId, its local id in app
@ -33,18 +33,28 @@ data class SwapStateHolder(
val onCancelPermissionBottomSheet: () -> Unit = {},
)
data class SwapCardData(
val type: TransactionCardType,
val amountEquivalent: String?,
val coinId: String?,
val amountTextFieldValue: TextFieldValue?,
val tokenIconUrl: String?,
val tokenCurrency: String,
val balance: String,
val isBalanceHidden: Boolean,
val isNotNativeToken: Boolean,
val canSelectAnotherToken: Boolean = false,
)
sealed class SwapCardState {
data class SwapCardData(
val type: TransactionCardType,
val amountEquivalent: String?,
val coinId: String?,
val amountTextFieldValue: TextFieldValue?,
val tokenIconUrl: String?,
val tokenCurrency: String,
val balance: String,
val isBalanceHidden: Boolean,
val isNotNativeToken: Boolean,
val canSelectAnotherToken: Boolean = false,
) : SwapCardState()
data class Empty(
val type: TransactionCardType,
val amountEquivalent: String?,
val amountTextFieldValue: TextFieldValue?,
val canSelectAnotherToken: Boolean = false,
) : SwapCardState()
}
data class SwapButton(
val enabled: Boolean,

View file

@ -8,9 +8,12 @@ import com.tangem.core.ui.components.states.Item
import com.tangem.core.ui.components.states.SelectableItemsState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.utils.BigDecimalFormatter
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.swap.converters.TokensDataConverter
import com.tangem.feature.swap.domain.models.DataError
import com.tangem.feature.swap.domain.models.domain.NetworkInfo
@ -28,7 +31,7 @@ import kotlinx.collections.immutable.toImmutableList
internal class StateBuilder(
private val actions: UiActions,
private val isBalanceHiddenProvider: Provider<Boolean>,
appCurrencyProvider: Provider<AppCurrency>,
private val appCurrencyProvider: Provider<AppCurrency>,
) {
private val tokensDataConverter = TokensDataConverter(
@ -42,7 +45,7 @@ internal class StateBuilder(
return SwapStateHolder(
networkId = initialCurrency.network.backendId,
blockchainId = networkInfo.blockchainId,
sendCardData = SwapCardData(
sendCardData = SwapCardState.SwapCardData(
type = TransactionCardType.SendCard(actions.onAmountChanged, actions.onAmountSelected),
amountEquivalent = null,
amountTextFieldValue = null,
@ -54,7 +57,7 @@ internal class StateBuilder(
balance = "",
isBalanceHidden = true,
),
receiveCardData = SwapCardData(
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReceiveCard(),
amountEquivalent = null,
tokenIconUrl = "",
@ -79,6 +82,53 @@ internal class StateBuilder(
)
}
fun createNoAvailableTokensToSwapState(
uiStateHolder: SwapStateHolder,
fromToken: CryptoCurrencyStatus,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
return uiStateHolder.copy(
sendCardData = SwapCardState.SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard),
amountTextFieldValue = TextFieldValue(
text = "0",
),
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
tokenIconUrl = uiStateHolder.sendCardData.tokenIconUrl,
coinId = uiStateHolder.sendCardData.coinId,
isNotNativeToken = uiStateHolder.sendCardData.isNotNativeToken,
tokenCurrency = uiStateHolder.sendCardData.tokenCurrency,
canSelectAnotherToken = uiStateHolder.sendCardData.canSelectAnotherToken,
balance = fromToken.getFormattedAmount(),
isBalanceHidden = isBalanceHiddenProvider(),
),
receiveCardData = SwapCardState.Empty(
type = TransactionCardType.ReceiveCard(),
amountEquivalent = "0 ${appCurrencyProvider.invoke().symbol}",
amountTextFieldValue = TextFieldValue(
text = "0",
),
canSelectAnotherToken = true,
),
warnings = listOf(
SwapWarning.NoAvailableTokensToSwap(
notificationConfig = NotificationConfig(
title = stringReference("No tokens"),
subtitle = stringReference("Swap tokens not available"),
iconResId = R.drawable.ic_alert_24,
),
),
),
fee = FeeState.Empty,
swapButton = SwapButton(
enabled = false,
loading = false,
onClick = { },
),
updateInProgress = false,
)
}
fun createQuotesLoadingState(
uiStateHolder: SwapStateHolder,
fromToken: CryptoCurrency,
@ -87,8 +137,10 @@ internal class StateBuilder(
): SwapStateHolder {
val canSelectSendToken = mainTokenId != fromToken.id.value // TODO look at id matching
val canSelectReceiveToken = mainTokenId != toToken.id.value // TODO look at id matching
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
return uiStateHolder.copy(
sendCardData = SwapCardData(
sendCardData = SwapCardState.SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard),
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = null,
@ -100,7 +152,7 @@ internal class StateBuilder(
balance = if (!canSelectSendToken) uiStateHolder.sendCardData.balance else "",
isBalanceHidden = isBalanceHiddenProvider(),
),
receiveCardData = SwapCardData(
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReceiveCard(),
amountTextFieldValue = null,
amountEquivalent = null,
@ -128,12 +180,15 @@ internal class StateBuilder(
* @param onFeeSetup callback for reset fee after auto update
* @return updated whole screen state
*/
@Suppress("LongMethod")
fun createQuotesLoadedState(
uiStateHolder: SwapStateHolder,
quoteModel: SwapState.QuotesLoadedState,
fromToken: CryptoCurrency,
onFeeSetup: (TxFee) -> Unit,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
val warnings = mutableListOf<SwapWarning>()
if (!quoteModel.preparedSwapConfigState.isAllowedToSpend &&
quoteModel.preparedSwapConfigState.isFeeEnough &&
@ -159,7 +214,7 @@ internal class StateBuilder(
}
val feeState = createFeeState(quoteModel, uiStateHolder, onFeeSetup)
return uiStateHolder.copy(
sendCardData = SwapCardData(
sendCardData = SwapCardState.SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard),
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = quoteModel.fromTokenInfo.tokenFiatBalance,
@ -171,7 +226,7 @@ internal class StateBuilder(
balance = quoteModel.fromTokenInfo.tokenWalletBalance,
isBalanceHidden = isBalanceHiddenProvider(),
),
receiveCardData = SwapCardData(
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReceiveCard(),
amountTextFieldValue = TextFieldValue(quoteModel.toTokenInfo.tokenAmount.formatToUIRepresentation()),
amountEquivalent = quoteModel.toTokenInfo.tokenFiatBalance,
@ -208,8 +263,10 @@ internal class StateBuilder(
uiStateHolder: SwapStateHolder,
emptyAmountState: SwapState.EmptyAmountState,
): SwapStateHolder {
if (uiStateHolder.sendCardData !is SwapCardState.SwapCardData) return uiStateHolder
if (uiStateHolder.receiveCardData !is SwapCardState.SwapCardData) return uiStateHolder
return uiStateHolder.copy(
sendCardData = SwapCardData(
sendCardData = SwapCardState.SwapCardData(
type = requireNotNull(uiStateHolder.sendCardData.type as? TransactionCardType.SendCard),
amountTextFieldValue = uiStateHolder.sendCardData.amountTextFieldValue,
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
@ -221,7 +278,7 @@ internal class StateBuilder(
balance = emptyAmountState.fromTokenWalletBalance,
isBalanceHidden = isBalanceHiddenProvider(),
),
receiveCardData = SwapCardData(
receiveCardData = SwapCardState.SwapCardData(
type = TransactionCardType.ReceiveCard(),
amountTextFieldValue = TextFieldValue("0"),
amountEquivalent = emptyAmountState.zeroAmountEquivalent,
@ -268,6 +325,7 @@ internal class StateBuilder(
}
fun updateSwapAmount(uiState: SwapStateHolder, amount: String): SwapStateHolder {
if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState
return uiState.copy(
sendCardData = uiState.sendCardData.copy(
amountTextFieldValue = TextFieldValue(
@ -279,6 +337,8 @@ internal class StateBuilder(
}
fun updateBalanceHiddenState(uiState: SwapStateHolder, isBalanceHidden: Boolean): SwapStateHolder {
if (uiState.sendCardData !is SwapCardState.SwapCardData) return uiState
if (uiState.receiveCardData !is SwapCardState.SwapCardData) return uiState
val patchedSendCardData = uiState.sendCardData.copy(
isBalanceHidden = isBalanceHidden,
)
@ -666,11 +726,26 @@ internal class StateBuilder(
return "$firstAddressPart...$secondAddressPart"
}
private fun CryptoCurrencyStatus.getFormattedAmount(): String {
val amount = value.amount ?: return UNKNOWN_AMOUNT_SIGN
return BigDecimalFormatter.formatCryptoAmount(amount, currency.symbol, currency.decimals)
}
@Suppress("UnusedPrivateMember")
private fun CryptoCurrencyStatus.getFormattedFiatAmount(): String {
val fiatAmount = value.fiatAmount ?: return UNKNOWN_AMOUNT_SIGN
val appCurrency = appCurrencyProvider()
return BigDecimalFormatter.formatFiatAmount(fiatAmount, appCurrency.code, appCurrency.symbol)
}
private companion object {
const val ADDRESS_MIN_LENGTH = 11
const val ADDRESS_FIRST_PART_LENGTH = 7
const val ADDRESS_SECOND_PART_LENGTH = 4
private const val PRICE_IMPACT_THRESHOLD = 0.1
private const val HUNDRED_PERCENTS = 100
private const val UNKNOWN_AMOUNT_SIGN = ""
}
}

View file

@ -145,48 +145,24 @@ private fun MainInfo(state: SwapStateHolder) {
}
val (topCard, bottomCard, button) = createRefs()
val priceImpactWarning = state.warnings.filterIsInstance<SwapWarning.HighPriceImpact>().firstOrNull()
TransactionCard(
type = state.sendCardData.type,
balance = if (state.sendCardData.isBalanceHidden) {
STARS
} else {
state.sendCardData.balance
},
textFieldValue = state.sendCardData.amountTextFieldValue,
amountEquivalent = state.sendCardData.amountEquivalent,
tokenIconUrl = state.sendCardData.tokenIconUrl ?: "",
tokenCurrency = state.sendCardData.tokenCurrency,
priceImpact = priceImpactWarning,
networkIconRes = if (state.sendCardData.isNotNativeToken) networkIconRes else null,
iconPlaceholder = state.sendCardData.coinId?.let {
getActiveIconResByCoinId(it)
},
onChangeTokenClick = if (state.sendCardData.canSelectAnotherToken) state.onSelectTokenClick else null,
TransactionCardData(
priceImpactWarning = priceImpactWarning,
networkIconRes = networkIconRes,
swapCardState = state.sendCardData,
modifier = Modifier.constrainAs(topCard) {
top.linkTo(parent.top)
},
onSelectTokenClick = state.onSelectTokenClick,
)
val marginCard = TangemTheme.dimens.spacing16
TransactionCard(
type = state.receiveCardData.type,
balance = if (state.receiveCardData.isBalanceHidden) STARS else state.receiveCardData.balance,
textFieldValue = state.receiveCardData.amountTextFieldValue,
amountEquivalent = state.receiveCardData.amountEquivalent,
tokenIconUrl = state.receiveCardData.tokenIconUrl ?: "",
tokenCurrency = state.receiveCardData.tokenCurrency,
priceImpact = priceImpactWarning,
networkIconRes = if (state.receiveCardData.isNotNativeToken) networkIconRes else null,
iconPlaceholder = state.receiveCardData.coinId?.let {
getActiveIconResByCoinId(it)
},
onChangeTokenClick = if (state.receiveCardData.canSelectAnotherToken) {
state.onSelectTokenClick
} else {
null
},
TransactionCardData(
priceImpactWarning = priceImpactWarning,
networkIconRes = networkIconRes,
swapCardState = state.receiveCardData,
modifier = Modifier.constrainAs(bottomCard) {
top.linkTo(topCard.bottom, margin = marginCard)
},
onSelectTokenClick = state.onSelectTokenClick,
)
val marginButton = TangemTheme.dimens.spacing32
SwapButton(
@ -200,6 +176,48 @@ private fun MainInfo(state: SwapStateHolder) {
}
}
@Composable
private fun TransactionCardData(
priceImpactWarning: SwapWarning.HighPriceImpact?,
networkIconRes: Int?,
swapCardState: SwapCardState,
onSelectTokenClick: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
when (swapCardState) {
is SwapCardState.Empty -> {
TransactionCardEmpty(
type = swapCardState.type,
amountEquivalent = swapCardState.amountEquivalent,
textFieldValue = swapCardState.amountTextFieldValue,
onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null,
modifier = modifier,
)
}
is SwapCardState.SwapCardData -> {
TransactionCard(
type = swapCardState.type,
balance = if (swapCardState.isBalanceHidden) {
STARS
} else {
swapCardState.balance
},
textFieldValue = swapCardState.amountTextFieldValue,
amountEquivalent = swapCardState.amountEquivalent,
tokenIconUrl = swapCardState.tokenIconUrl ?: "",
tokenCurrency = swapCardState.tokenCurrency,
priceImpact = priceImpactWarning,
networkIconRes = if (swapCardState.isNotNativeToken) networkIconRes else null,
iconPlaceholder = swapCardState.coinId?.let {
getActiveIconResByCoinId(it)
},
onChangeTokenClick = if (swapCardState.canSelectAnotherToken) onSelectTokenClick else null,
modifier = modifier,
)
}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SwapButton(state: SwapStateHolder, modifier: Modifier = Modifier) {
@ -268,7 +286,8 @@ private fun FeeItem(feeState: FeeState, currency: String) {
}
}
is FeeState.Empty -> {
SmallInfoCard(startText = titleString, endText = "")
// show nothing
// SmallInfoCard(startText = titleString, endText = "")
}
}
}
@ -355,7 +374,7 @@ private fun MainButton(state: SwapStateHolder, onPermissionWarningClick: () -> U
// region preview
private val sendCard = SwapCardData(
private val sendCard = SwapCardState.SwapCardData(
type = TransactionCardType.SendCard({}) {},
amountTextFieldValue = TextFieldValue(),
amountEquivalent = "1 000 000",
@ -368,7 +387,7 @@ private val sendCard = SwapCardData(
isBalanceHidden = false,
)
private val receiveCard = SwapCardData(
private val receiveCard = SwapCardState.SwapCardData(
type = TransactionCardType.ReceiveCard(),
amountTextFieldValue = TextFieldValue(),
amountEquivalent = "1 000 000",

View file

@ -68,7 +68,7 @@ fun TransactionCard(
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.Start,
) {
Header(balance = balance, type = type)
Header(balance = stringResource(R.string.common_balance, balance), type = type)
Content(
type = type,
@ -106,6 +106,67 @@ fun TransactionCard(
}
}
@Composable
fun TransactionCardEmpty(
type: TransactionCardType,
amountEquivalent: String?,
textFieldValue: TextFieldValue?,
modifier: Modifier = Modifier,
onChangeTokenClick: (() -> Unit)? = null,
) {
Card(
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
backgroundColor = TangemTheme.colors.background.primary,
elevation = TangemTheme.dimens.elevation2,
modifier = modifier,
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.Start,
) {
Header(
balance = stringResource(id = R.string.swapping_token_not_available),
type = type,
)
Content(
type = type,
amountEquivalent = amountEquivalent,
textFieldValue = textFieldValue,
priceImpact = null,
)
}
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
Token(
tokenIconUrl = "",
tokenCurrency = "",
iconPlaceholder = R.drawable.ic_no_token_44,
)
}
if (onChangeTokenClick != null) {
Box(modifier = Modifier.align(Alignment.CenterEnd)) {
ChangeTokenSelector()
}
Box(
Modifier
.align(Alignment.CenterEnd)
.height(TangemTheme.dimens.size116)
.width(TangemTheme.dimens.size102)
.clickable(
indication = rememberRipple(bounded = false),
interactionSource = remember { MutableInteractionSource() },
) { onChangeTokenClick() },
)
}
}
}
}
@Composable
private fun Header(type: TransactionCardType, balance: String, modifier: Modifier = Modifier) {
Row(
@ -134,7 +195,7 @@ private fun Header(type: TransactionCardType, balance: String, modifier: Modifie
SpacerW16()
if (balance.isNotBlank()) {
Text(
text = stringResource(R.string.common_balance, balance),
text = balance,
color = TangemTheme.colors.text.tertiary,
style = MaterialTheme.typography.body2,
modifier = Modifier

View file

@ -1,6 +1,6 @@
package com.tangem.feature.swap.viewmodels
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.domain.Currency
import com.tangem.feature.swap.domain.models.ui.RequestApproveStateData
import com.tangem.feature.swap.domain.models.ui.SwapStateData
@ -14,8 +14,8 @@ data class SwapProcessDataState(
val fromCurrency: Currency? = null,
@Deprecated("used in old swap mechanism")
val toCurrency: Currency? = null,
val fromCryptoCurrency: CryptoCurrency? = null,
val toCryptoCurrency: CryptoCurrency? = null,
val fromCryptoCurrency: CryptoCurrencyStatus? = null,
val toCryptoCurrency: CryptoCurrencyStatus? = null,
// Amount from input
val amount: String? = null,
val approveDataModel: RequestApproveStateData? = null,

View file

@ -11,7 +11,9 @@ import com.tangem.core.ui.utils.InputNumberFormatter
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.tokens.GetCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.tokens.model.CryptoCurrency
import com.tangem.domain.tokens.model.CryptoCurrencyStatus
import com.tangem.feature.swap.analytics.SwapEvents
import com.tangem.feature.swap.domain.BlockchainInteractor
import com.tangem.feature.swap.domain.SwapInteractor
@ -49,11 +51,13 @@ internal class SwapViewModel @Inject constructor(
private val analyticsEventHandler: AnalyticsEventHandler,
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase,
private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase,
private val getCryptoCurrencyStatusUseCase: GetCryptoCurrencyStatusSyncUseCase,
savedStateHandle: SavedStateHandle,
) : ViewModel(), DefaultLifecycleObserver {
private val initialCryptoCurrency: CryptoCurrency = savedStateHandle[SwapFragment.CURRENCY_BUNDLE_KEY]
?: error("no expected parameter CryptoCurrency found`")
private lateinit var initialCryptoCurrencyStatus: CryptoCurrencyStatus
private var isBalanceHidden = true
@ -88,11 +92,20 @@ internal class SwapViewModel @Inject constructor(
get() = swapRouter.currentScreen
init {
swapInteractor.initDerivationPathAndNetwork(
derivationPath = initialCryptoCurrency.network.derivationPath.value,
network = initialCryptoCurrency.network,
)
initTokens()
viewModelScope.launch(dispatchers.io) {
swapInteractor.getSelectedWallet()?.let {
initialCryptoCurrencyStatus =
requireNotNull(getCryptoCurrencyStatusUseCase(it.walletId, initialCryptoCurrency.id).getOrNull()) {
"Failed to get initial crypto currency status"
}
swapInteractor.initDerivationPathAndNetwork(
derivationPath = initialCryptoCurrency.network.derivationPath.value,
network = initialCryptoCurrency.network,
)
initTokens()
}
}
}
override fun onCreate(owner: LifecycleOwner) {
@ -137,44 +150,38 @@ internal class SwapViewModel @Inject constructor(
runCatching(dispatchers.io) {
swapInteractor.getTokensDataState(initialCryptoCurrency)
}.onSuccess { state ->
dataState = dataState.copy(
fromCryptoCurrency = initialCryptoCurrency,
toCryptoCurrency = state.toGroup.available.first().currencyStatus.currency,
tokensDataState = state,
)
updateTokensState(state)
startLoadingQuotes(
fromToken = initialCryptoCurrency,
toToken = state.toGroup.available.first().currencyStatus.currency,
amount = lastAmount.value,
)
applyInitialTokenChoice(state, selectInitialCurrencyToSwap(state))
}.onFailure {
Timber.tag(loggingTag).e(it)
}
}
}
// old flow
// viewModelScope.launch(dispatchers.main) {
// runCatching(dispatchers.io) {
// swapInteractor.initTokensToSwap(currency)
// }
// .onSuccess { state ->
// // dataState = dataState.copy(
// // fromCurrency = state.preselectTokens.fromToken,
// // toCurrency = state.preselectTokens.toToken,
// // )
// // updateTokensState(dataState = state.foundTokensState)
// // startLoadingQuotes(
// // fromToken = state.preselectTokens.fromToken,
// // toToken = state.preselectTokens.toToken,
// // amount = lastAmount.value,
// // )
// }
// .onFailure {
// Timber.tag(loggingTag).e(it)
// }
// }
private fun applyInitialTokenChoice(state: TokensDataStateExpress, selectedCurrency: CryptoCurrencyStatus?) {
val fromCurrencyStatus = initialCryptoCurrencyStatus
dataState = dataState.copy(
fromCryptoCurrency = fromCurrencyStatus,
toCryptoCurrency = selectedCurrency,
tokensDataState = state,
)
if (selectedCurrency == null) {
uiState = stateBuilder.createNoAvailableTokensToSwapState(
uiStateHolder = uiState,
fromToken = fromCurrencyStatus,
)
} else {
startLoadingQuotes(
fromToken = fromCurrencyStatus.currency,
toToken = selectedCurrency.currency,
amount = lastAmount.value,
)
}
}
private fun selectInitialCurrencyToSwap(state: TokensDataStateExpress): CryptoCurrencyStatus? {
// todo add algorithm to select initial currency
return state.toGroup.available.firstOrNull()?.currencyStatus
}
private fun updateTokensState(dataState: TokensDataStateExpress) {
@ -203,7 +210,7 @@ internal class SwapViewModel @Inject constructor(
val toCurrency = dataState.toCryptoCurrency
val amount = dataState.amount
if (fromCurrency != null && toCurrency != null && amount != null) {
startLoadingQuotes(fromCurrency, toCurrency, amount)
startLoadingQuotes(fromCurrency.currency, toCurrency.currency, amount)
}
}
@ -285,8 +292,8 @@ internal class SwapViewModel @Inject constructor(
swapInteractor.onSwap(
networkId = dataState.networkId,
swapStateData = requireNotNull(dataState.swapDataModel),
currencyToSend = requireNotNull(dataState.fromCryptoCurrency),
currencyToGet = requireNotNull(dataState.toCryptoCurrency),
currencyToSend = requireNotNull(dataState.fromCryptoCurrency?.currency),
currencyToGet = requireNotNull(dataState.toCryptoCurrency?.currency),
amountToSwap = requireNotNull(dataState.amount),
fee = requireNotNull(dataState.selectedFee),
)
@ -335,10 +342,10 @@ internal class SwapViewModel @Inject constructor(
approveData = requireNotNull(dataState.approveDataModel) {
"dataState.approveDataModel might not be null"
},
forTokenContractAddress = (dataState.fromCryptoCurrency as? CryptoCurrency.Token)
forTokenContractAddress = (dataState.fromCryptoCurrency?.currency as? CryptoCurrency.Token)
?.contractAddress
?: "",
fromToken = requireNotNull(dataState.fromCryptoCurrency) {
fromToken = requireNotNull(dataState.fromCryptoCurrency?.currency) {
"dataState.fromCurrency might not be null"
},
approveType = requireNotNull(uiState.permissionState as? SwapPermissionState.ReadyForRequest) {
@ -384,28 +391,34 @@ internal class SwapViewModel @Inject constructor(
private fun onTokenSelect(id: String) {
val tokens = dataState.tokensDataState ?: return
val foundToken = tokens.toGroup.available.firstOrNull {
it.currencyStatus.currency.id.value == id
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
}
}
analyticsEventHandler.send(
event = SwapEvents.SearchTokenClicked(currencySymbol = foundToken?.currencyStatus?.currency?.symbol),
)
if (foundToken != null) {
val fromToken: CryptoCurrency
val toToken: CryptoCurrency
val fromToken: CryptoCurrencyStatus
val toToken: CryptoCurrencyStatus
if (isOrderReversed) {
fromToken = foundToken.currencyStatus.currency
toToken = initialCryptoCurrency
fromToken = foundToken.currencyStatus
toToken = initialCryptoCurrencyStatus
} else {
fromToken = initialCryptoCurrency
toToken = foundToken.currencyStatus.currency
fromToken = initialCryptoCurrencyStatus
toToken = foundToken.currencyStatus
}
dataState = dataState.copy(
fromCryptoCurrency = fromToken,
toCryptoCurrency = toToken,
)
startLoadingQuotes(fromToken, toToken, lastAmount.value)
startLoadingQuotes(fromToken.currency, toToken.currency, lastAmount.value)
swapRouter.openScreen(SwapNavScreen.Main)
}
}
@ -419,13 +432,13 @@ internal class SwapViewModel @Inject constructor(
toCryptoCurrency = newToToken,
)
isOrderReversed = !isOrderReversed
val decimals = blockchainInteractor.getTokenDecimals(newFromToken)
val decimals = newFromToken.currency.decimals
lastAmount.value = cutAmountWithDecimals(decimals, lastAmount.value)
uiState = stateBuilder.updateSwapAmount(
uiState,
inputNumberFormatter.formatWithThousands(lastAmount.value, decimals),
)
startLoadingQuotes(newFromToken, newToToken, lastAmount.value)
startLoadingQuotes(newFromToken.currency, newToToken.currency, lastAmount.value)
}
}
@ -433,20 +446,20 @@ internal class SwapViewModel @Inject constructor(
val fromToken = dataState.fromCryptoCurrency
val toToken = dataState.toCryptoCurrency
if (fromToken != null && toToken != null) {
val decimals = blockchainInteractor.getTokenDecimals(fromToken)
val decimals = fromToken.currency.decimals
val cutValue = cutAmountWithDecimals(decimals, value)
lastAmount.value = cutValue
uiState =
stateBuilder.updateSwapAmount(uiState, inputNumberFormatter.formatWithThousands(cutValue, decimals))
amountDebouncer.debounce(viewModelScope, DEBOUNCE_AMOUNT_DELAY) {
startLoadingQuotes(fromToken, toToken, lastAmount.value)
startLoadingQuotes(fromToken.currency, toToken.currency, lastAmount.value)
}
}
}
private fun onMaxAmountClicked() {
dataState.fromCryptoCurrency?.let {
val balance = swapInteractor.getTokenBalance(initialCryptoCurrency.network.id.value, it)
val balance = swapInteractor.getTokenBalance(initialCryptoCurrency.network.id.value, it.currency)
onAmountChanged(balance.formatToUIRepresentation())
}
}
@ -522,7 +535,7 @@ internal class SwapViewModel @Inject constructor(
dataState = dataState.copy(selectedFee = feeItem.data)
val spendAmount = dataState.amount?.let { amount ->
val fromToken = dataState.fromCryptoCurrency ?: return@let null
swapInteractor.getSwapAmountForToken(amount, fromToken)
swapInteractor.getSwapAmountForToken(amount, fromToken.currency)
} ?: dataState.approveDataModel?.fromTokenAmount
spendAmount ?: return@UiActions
val fromToken = dataState.fromCryptoCurrency ?: return@UiActions
@ -531,7 +544,7 @@ internal class SwapViewModel @Inject constructor(
fee = feeItem.data.feeValue,
spendAmount = spendAmount,
networkId = dataState.networkId,
fromToken = fromToken,
fromToken = fromToken.currency,
)
uiState = stateBuilder.updateFeeSelectedItem(uiState, feeItem, isFeeEnough)
}