Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-13 16:42:47 +03:00
commit 43c11d0460
384 changed files with 19228 additions and 3308 deletions

View file

@ -1,5 +1,23 @@
package com.tangem.feature.swap
import com.tangem.core.configtoggle.FeatureToggles
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.swap.SwapFeatureToggles
import javax.inject.Inject
internal class DefaultSwapFeatureToggles : SwapFeatureToggles
internal class DefaultSwapFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
) : SwapFeatureToggles {
override val isSwapSwitchToTransferEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.SWAP_SWITCH_TO_TRANSFER_ENABLED,
)
override val isSwapIntegratedApproveEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.SWAP_INTEGRATED_APPROVE,
)
override val isSwapAbEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.SWAP_AB_ENABLED,
)
}

View file

@ -0,0 +1,172 @@
package com.tangem.feature.swap.converters
import com.tangem.common.ui.swap.SwapRateFormatter
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.models.states.PercentDifference
import com.tangem.feature.swap.models.states.ProviderState
/**
* Builds [ProviderState.Content] for the swap provider list / row.
*
* Pure: takes everything it needs as parameters. Designed to be unit-tested in isolation.
*/
internal object SwapProviderStateBuilder {
private val FCA_RESTRICTED_PROVIDER_IDS = setOf(
"changelly",
"changenow",
"okx-cross-chain",
"okx-on-chain",
"simpleswap",
)
/**
* Provider row on the main swap screen shows the exchange rate `1 base rate quote`
* (see [SwapRateFormatter]) and allows the user to open the provider picker.
*/
@Suppress("LongParameterList")
fun buildContentClickable(
provider: SwapProvider,
fromTokenInfo: TokenSwapInfo,
toTokenInfo: TokenSwapInfo,
permissionState: PermissionDataState,
selectionType: ProviderState.SelectionType,
isBestRate: Boolean,
isNeedBestRateBadge: Boolean,
needApplyFCARestrictions: Boolean,
onProviderClick: (String) -> Unit,
): ProviderState.Content {
val rateString = SwapRateFormatter.formatRate(
from = fromTokenInfo.swapCurrencyStatus.currency,
to = toTokenInfo.swapCurrencyStatus.currency,
fromAmount = fromTokenInfo.tokenAmount.value,
toAmount = toTokenInfo.tokenAmount.value,
)
return provider.toContent(
subtitle = stringReference(rateString),
additionalBadge = resolveBadge(
provider = provider,
needApplyFCARestrictions = needApplyFCARestrictions,
permissionState = permissionState,
isBestRate = isBestRate,
isNeedBestRateBadge = isNeedBestRateBadge,
),
selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty,
onProviderClick = onProviderClick,
)
}
/**
* Provider row in the provider-picker bottom sheet. Subtitle shows the formatted *to* amount
* (not a rate) and the row carries a percentage delta vs. the best rate.
*/
@Suppress("LongParameterList")
fun buildContentSelectable(
provider: SwapProvider,
toTokenInfo: TokenSwapInfo,
permissionState: PermissionDataState,
pricesLowerBest: Map<String, Float>,
selectionType: ProviderState.SelectionType,
needApplyFCARestrictions: Boolean,
onProviderClick: (String) -> Unit,
): ProviderState.Content {
return provider.toContent(
subtitle = buildSelectableSubtitle(toTokenInfo),
additionalBadge = resolveBadge(
provider = provider,
needApplyFCARestrictions = needApplyFCARestrictions,
permissionState = permissionState,
),
selectionType = selectionType,
percentLowerThenBest = pricesLowerBest[provider.providerId]
?.let(PercentDifference::Value)
?: PercentDifference.Value(0f),
onProviderClick = onProviderClick,
)
}
/**
* Provider row for an unavailable / errored provider subtitle is the error/alert text
* resolved by the caller.
*/
fun buildAvailableFrom(
provider: SwapProvider,
alertText: TextReference,
selectionType: ProviderState.SelectionType,
needApplyFCARestrictions: Boolean,
onProviderClick: (String) -> Unit,
): ProviderState.Content {
return provider.toContent(
subtitle = alertText,
additionalBadge = resolveBadge(
provider = provider,
needApplyFCARestrictions = needApplyFCARestrictions,
),
selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty,
onProviderClick = onProviderClick,
)
}
/**
* Subtitle (formatted *to* amount) used both for picker rows and when refreshing
* the provider-picker bottom sheet. Single source of truth so both paths stay in sync.
*/
fun buildSelectableSubtitle(toTokenInfo: TokenSwapInfo): TextReference {
val toAmount = toTokenInfo.tokenAmount.value.format {
crypto(toTokenInfo.swapCurrencyStatus.currency)
}
return stringReference(toAmount)
}
private fun resolveBadge(
provider: SwapProvider,
needApplyFCARestrictions: Boolean,
permissionState: PermissionDataState? = null,
isBestRate: Boolean = false,
isNeedBestRateBadge: Boolean = false,
): ProviderState.AdditionalBadge {
return when {
needApplyFCARestrictions && provider.isFCARestricted() ->
ProviderState.AdditionalBadge.FCAWarningList
permissionState is PermissionDataState.PermissionRequired ->
ProviderState.AdditionalBadge.PermissionRequired
provider.isRecommended ->
ProviderState.AdditionalBadge.Recommended
isNeedBestRateBadge && isBestRate && !needApplyFCARestrictions ->
ProviderState.AdditionalBadge.BestTrade
else ->
ProviderState.AdditionalBadge.Empty
}
}
private fun SwapProvider.toContent(
subtitle: TextReference,
additionalBadge: ProviderState.AdditionalBadge,
selectionType: ProviderState.SelectionType,
percentLowerThenBest: PercentDifference,
onProviderClick: (String) -> Unit,
): ProviderState.Content {
return ProviderState.Content(
id = providerId,
name = name,
iconUrl = imageLarge,
type = type.providerName,
subtitle = subtitle,
additionalBadge = additionalBadge,
selectionType = selectionType,
percentLowerThenBest = percentLowerThenBest,
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = onProviderClick,
)
}
private fun SwapProvider.isFCARestricted(): Boolean = providerId in FCA_RESTRICTED_PROVIDER_IDS
}

View file

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

View file

@ -130,6 +130,7 @@ internal class InitialCurrenciesResolver @Inject constructor(
private fun getPaymentAccountCurrencies(accountStatus: AccountStatus.Payment): List<CryptoCurrencyStatus> {
val paymentCryptoCurrencyStatus = when (val statusValue = accountStatus.value) {
is PaymentAccountStatusValue.Loaded -> statusValue.cryptoCurrencyStatus
is PaymentAccountStatusValue.Deactivated -> statusValue.cryptoCurrencyStatus
else -> null
}

View file

@ -73,6 +73,8 @@ import com.tangem.feature.swap.analytics.SwapQuotePerformanceTracker
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.converters.SwapTransactionErrorStateConverter
import com.tangem.feature.swap.domain.AllowPermissionsHandler
import com.tangem.feature.swap.domain.GetSwapUiModeUseCase
import com.tangem.feature.swap.domain.SetSwapUiModeUseCase
import com.tangem.feature.swap.domain.SwapInteractor
import com.tangem.feature.swap.domain.TransactionFeeResult
import com.tangem.feature.swap.domain.TxFeeSealedState
@ -80,15 +82,16 @@ import com.tangem.feature.swap.domain.models.ExpressDataError
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.SwapDataModel
import com.tangem.feature.swap.domain.models.domain.SwapPairLeast
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.SwapAlertUM
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.TokenSelectionDirection
import com.tangem.feature.swap.models.UiActions
import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.router.SwapRoute
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.feature.swap.ui.transfer.SwapTransferStateBuilder
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.feature.swap.utils.getContractAddress
import com.tangem.features.approval.api.GiveApprovalComponent
@ -98,6 +101,7 @@ import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.features.send.v2.api.subcomponents.feeSelector.FeeSelectorReloadTrigger
import com.tangem.features.swap.SwapComponent
import com.tangem.features.swap.SwapFeatureToggles
import com.tangem.utils.Provider
import com.tangem.utils.coroutines.*
import com.tangem.utils.isNullOrZero
@ -140,17 +144,22 @@ internal class SwapModel @Inject constructor(
private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase,
private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase,
private val swapInteractor: SwapInteractor,
private val swapTransferInteractor: SwapTransferInteractor,
private val swapTransferStateBuilder: SwapTransferStateBuilder,
private val urlOpener: UrlOpener,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase,
private val appsFlyerStore: AppsFlyerStore,
private val messageSender: UiMessageSender,
private val initialCurrenciesResolver: InitialCurrenciesResolver,
private val allowPermissionsHandler: AllowPermissionsHandler,
private val swapFeatureToggles: SwapFeatureToggles,
private val getSwapUiModeUseCase: GetSwapUiModeUseCase,
private val setSwapUiModeUseCase: SetSwapUiModeUseCase,
) : Model() {
private val params = paramsContainer.require<SwapComponent.Params>()
@ -179,12 +188,14 @@ internal class SwapModel @Inject constructor(
),
)
private val actions = createUiActions()
private val stateBuilder = StateBuilder(
actions = createUiActions(),
actions = actions,
isBalanceHiddenProvider = Provider { isBalanceHidden },
appCurrencyProvider = Provider(selectedAppCurrencyFlow::value),
isAccountsModeProvider = Provider { isAccountsMode },
iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
shouldShowAbMenu = swapFeatureToggles.isSwapAbEnabled,
)
private val inputNumberFormatter = InputNumberFormatter(
@ -282,6 +293,10 @@ internal class SwapModel @Inject constructor(
isBalanceHidden = settings.isBalanceHidden
uiState = stateBuilder.updateBalanceHiddenState(uiState, isBalanceHidden)
}.launchIn(modelScope)
modelScope.launch {
uiState = uiState.copy(swapUIMode = getSwapUiModeUseCase())
}
}
fun onStart() {
@ -557,6 +572,12 @@ internal class SwapModel @Inject constructor(
toSwapCurrencyStatus = newToSwapCurrencyStatus,
pairs = dataState.pairs,
)
val isUpdatedToTransferMode = isUpdatedToTransferMode(
fromSwapCurrencyStatus = newFromSwapCurrencyStatus,
toSwapCurrencyStatus = newToSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
)
if (isUpdatedToTransferMode) return@launch
if (toProvidersList.isEmpty()) {
handleSwapNotSupported(
fromSwapCurrencyStatus = newFromSwapCurrencyStatus,
@ -576,6 +597,12 @@ internal class SwapModel @Inject constructor(
}
private fun initSwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) {
val isUpdatedToTransferMode = isUpdatedToTransferMode(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
)
if (isUpdatedToTransferMode) return
modelScope.launch {
uiState = stateBuilder.createInitialLoadingState(
uiStateHolder = uiState,
@ -612,32 +639,11 @@ internal class SwapModel @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
} else {
uiState = stateBuilder.updateCurrenciesState(
uiStateHolder = uiState,
emptyAmountState = SwapState.EmptyAmountState(
zeroAmountEquivalent = stringReference(
BigDecimal.ZERO.format {
fiat(
fiatCurrencyCode = selectedAppCurrencyFlow.value.code,
fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol,
)
},
),
),
updateCurrenciesStateAndStartLoadingQuotes(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
shouldResetAmount = false,
)
dataState = dataState.copy(
pairs = pairs,
selectedPairProviders = providerList,
)
startLoadingQuotes(
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
toProvidersList = providerList,
providerList = providerList,
)
}
},
@ -645,6 +651,81 @@ internal class SwapModel @Inject constructor(
}.saveIn(swapPairsJobHolder)
}
private fun updateCurrenciesStateAndStartLoadingQuotes(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
pairs: List<SwapPairLeast>,
providerList: List<SwapProvider>,
) {
uiState = stateBuilder.updateCurrenciesState(
uiStateHolder = uiState,
emptyAmountState = SwapState.EmptyAmountState(
zeroAmountEquivalent = stringReference(
BigDecimal.ZERO.format {
fiat(
fiatCurrencyCode = selectedAppCurrencyFlow.value.code,
fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol,
)
},
),
),
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
shouldResetAmount = false,
)
dataState = dataState.copy(
pairs = pairs,
selectedPairProviders = providerList,
)
startLoadingQuotes(
amount = lastAmount.value,
reduceBalanceBy = lastReducedBalanceBy.value,
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
toProvidersList = providerList,
)
}
private fun isUpdatedToTransferMode(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
): Boolean {
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
fromSwapCurrencyStatus.currency,
toSwapCurrencyStatus.currency,
)
if (shouldTransferInsteadOfSwap) {
modelScope.launch {
updateTransferUIState(fromSwapCurrencyStatus, toSwapCurrencyStatus, fromTokenAmount)
}
}
return shouldTransferInsteadOfSwap
}
private suspend fun updateTransferUIState(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
fromTokenAmount: String,
) {
val swapState = swapTransferInteractor.updateTransfer(
fromSwapCurrencyStatus,
toSwapCurrencyStatus,
fromTokenAmount,
)
when (swapState) {
is SwapState.EmptyAmountState -> setupEmptyAmountUiState(swapState, fromSwapCurrencyStatus)
is SwapState.Transfer -> {
uiState = swapTransferStateBuilder.createTransferState(
actions = actions,
transferState = swapState,
uiStateHolder = uiState,
)
}
is SwapState.QuotesLoadedState, is SwapState.SwapError -> Unit
}
}
private fun retrySwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) {
if (swapPairsJobHolder.isActive) return
initSwapPairs(fromSwapCurrencyStatus, toSwapCurrencyStatus)
@ -707,6 +788,12 @@ internal class SwapModel @Inject constructor(
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
val amount = dataState.amount
if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null && amount != null) {
val isUpdatedToTransferMode = isUpdatedToTransferMode(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
)
if (isUpdatedToTransferMode) return
startLoadingQuotes(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
@ -826,6 +913,7 @@ internal class SwapModel @Inject constructor(
sendAnalyticsForNotifications(provider, fromSwapCurrencyStatus.status, toSwapCurrencyStatus.status)
updatePermissionNotificationState(state)
}
is SwapState.Transfer -> Unit
is SwapState.EmptyAmountState -> {
setupEmptyAmountUiState(state, fromSwapCurrencyStatus)
lastPermissionNotificationTokens = null
@ -1269,6 +1357,12 @@ internal class SwapModel @Inject constructor(
)
if (toSwapCurrencyStatus != null) {
val isUpdatedToTransferMode = isUpdatedToTransferMode(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
)
if (isUpdatedToTransferMode) return@launch
if (toSwapCurrencyStatus.status.value.amount != null) {
isAmountChangedByUser = true
}
@ -1422,6 +1516,9 @@ internal class SwapModel @Inject constructor(
)
}
},
onTransferClick = {
// TODO: Will be implemented in [REDACTED_TASK_KEY]
},
onChangeCardsClicked = {
onChangeCardsClicked()
analyticsEventHandler.send(SwapEvents.ButtonSwipeClicked())
@ -1528,9 +1625,16 @@ internal class SwapModel @Inject constructor(
onSuccess = {
router.replaceAll(SwapRoute.Success)
},
onSwapUIModeChange = ::onSwapUIModeChange,
)
}
private fun onSwapUIModeChange(mode: SwapUIMode) {
if (uiState.swapUIMode == mode) return
uiState = uiState.copy(swapUIMode = mode)
modelScope.launch { setSwapUiModeUseCase(mode) }
}
private fun selectWalletInSelector(
fromSwapCurrencyStatus: SwapCurrencyStatus?,
toSwapCurrencyStatus: SwapCurrencyStatus?,
@ -1554,11 +1658,15 @@ internal class SwapModel @Inject constructor(
} else {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
val shouldShowSameCoinsWithDifferentAddress = swapFeatureToggles.isSwapSwitchToTransferEnabled &&
fromSwapCurrencyStatus?.account?.accountId != accountStatus.accountId &&
fromSwapCurrencyStatus?.currency?.network?.rawId == toSwapCurrencyStatus?.currency?.network?.rawId
(fromSwapCurrencyStatus?.account?.accountId != accountStatus.accountId ||
fromSwapCurrencyStatus.currency.id != currencyStatus.currency.id) &&
(toSwapCurrencyStatus?.account?.accountId != accountStatus.accountId ||
toSwapCurrencyStatus.currency.id != currencyStatus.currency.id)
toSwapCurrencyStatus.currency.id != currencyStatus.currency.id) ||
shouldShowSameCoinsWithDifferentAddress
}
}
@ -1858,6 +1966,7 @@ internal class SwapModel @Inject constructor(
override suspend fun loadFeeExtended(
selectedToken: CryptoCurrencyStatus?,
): Either<GetFeeError, TransactionFeeExtended> {
// TODO use getFeeGaselessUsecase in transfer. Will be implemented in [REDACTED_TASK_KEY]
val fromSwapCurrencyStatus =
dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!!
@ -1911,7 +2020,7 @@ internal class SwapModel @Inject constructor(
uiState = uiState.copy(
swapButton = uiState.swapButton.copy(
isEnabled = false,
isInProgress = false,
mode = SwapButton.Mode.SWAP_PROGRESSING,
),
)
modelScope.launch {
@ -1930,7 +2039,7 @@ internal class SwapModel @Inject constructor(
override suspend fun loadFee(): Either<GetFeeError, TransactionFee> {
TangemLogger.e("loadFee: Start loading fee")
// TODO use getFeeUsecase in transfer. Will be implemented in [REDACTED_TASK_KEY]
val fromSwapCurrencyStatus =
dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val toSwapCurrencyStatus =

View file

@ -1,5 +1,6 @@
package com.tangem.feature.swap.model
import com.tangem.common.TangemSiteUrlBuilder
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
@ -35,7 +36,7 @@ import java.math.BigDecimal
@Suppress("LargeClass")
internal class SwapNotificationsFactory(
private val actions: UiActions,
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
) {
fun getGeneralErrorStateNotifications(
@ -104,14 +105,13 @@ internal class SwapNotificationsFactory(
quoteModel: SwapState.QuotesLoadedState,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
selectedFeeType: FeeType,
providerName: String,
hideFee: Boolean,
): ImmutableList<NotificationUM> {
val warnings = buildList {
maybeAddRentExemptionError(quoteModel)
maybeAddDomainWarnings(quoteModel, feeCryptoCurrencyStatus, selectedFeeType)
maybeAddNeedReserveToCreateAccountWarning(quoteModel)
maybeAddPermissionNeededWarning(quoteModel, providerName)
maybeAddPermissionNeededWarning(quoteModel)
maybeAddNetworkFeeCoverageWarning(quoteModel, selectedFeeType)
maybeAddUnableCoverFeeWarning(quoteModel, feeCryptoCurrencyStatus, hideFee)
maybeAddTransactionInProgressWarning(quoteModel)
@ -253,16 +253,12 @@ internal class SwapNotificationsFactory(
}
}
private fun MutableList<NotificationUM>.maybeAddPermissionNeededWarning(
quoteModel: SwapState.QuotesLoadedState,
providerName: String,
) {
private fun MutableList<NotificationUM>.maybeAddPermissionNeededWarning(quoteModel: SwapState.QuotesLoadedState) {
if (quoteModel.permissionState is PermissionDataState.PermissionRequired) {
add(
SwapNotificationUM.Info.PermissionNeeded(
providerName = providerName,
fromTokenSymbol = quoteModel.fromTokenInfo.swapCurrencyStatus.currency.symbol,
onApproveClick = actions.openPermissionBottomSheet,
onLearnMoreClick = { actions.onLinkClick(TangemSiteUrlBuilder.HELP_CENTER_SWAP_URL) },
),
)
}
@ -316,8 +312,7 @@ internal class SwapNotificationsFactory(
val isNotEnoughFee = feeEnoughState is SwapFeeState.NotEnough && !isCEXProvider ||
quoteModel.preparedSwapConfigState.includeFeeInAmount is IncludeFeeInAmount.BalanceNotEnough
val isGaslessAvailable = iGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider
val isGaslessAvailable = isGaslessFeeSupportedForNetwork(fromCurrency.network) && isCEXProvider
if (shouldShowCoverWarning && !isGaslessAvailable || isNotEnoughFee) {
add(
SwapNotificationUM.Error.UnableToCoverFeeWarning(

View file

@ -8,6 +8,7 @@ import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.bottomsheets.TangemBottomSheetConfig
import com.tangem.core.ui.components.currency.icon.CurrencyIconState
import com.tangem.core.ui.extensions.TextReference
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
@ -31,6 +32,8 @@ internal data class SwapStateHolder(
val swapButton: SwapButton,
val shouldShowMaxAmount: Boolean,
val tosState: TosState? = null,
val swapUIMode: SwapUIMode = SwapUIMode.Detailed,
val shouldShowAbMenu: Boolean = false,
val onRefresh: () -> Unit,
val onBackClicked: () -> Unit,
@ -39,6 +42,7 @@ internal data class SwapStateHolder(
val onSuccess: (() -> Unit),
val onMaxAmountSelected: (() -> Unit)? = null,
val onShowPermissionBottomSheet: () -> Unit = {},
val onSwapUIModeChange: (SwapUIMode) -> Unit = {},
)
@Immutable
@ -70,10 +74,20 @@ sealed class SwapCardState {
data class SwapButton(
@DrawableRes val walletInteractionIcon: Int?,
val isEnabled: Boolean,
val isInProgress: Boolean = false,
val mode: Mode = Mode.SWAP,
val isHoldToConfirm: Boolean = false,
val onClick: () -> Unit,
)
) {
enum class Mode {
SWAP_PROGRESSING,
SWAP,
TRANSFER,
TRANSFER_PROGRESSING,
}
val isInProgress
get() = mode == Mode.SWAP_PROGRESSING || mode == Mode.TRANSFER_PROGRESSING
}
@Immutable
sealed interface TransactionCardType {

View file

@ -1,6 +1,7 @@
package com.tangem.feature.swap.models
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.domain.models.ui.TxFee
import java.math.BigDecimal
@ -8,6 +9,7 @@ internal data class UiActions(
val onAmountChanged: (String) -> Unit,
val onAmountSelected: (Boolean) -> Unit,
val onSwapClick: () -> Unit,
val onTransferClick: () -> Unit,
val onChangeCardsClicked: () -> Unit,
val onBackClicked: () -> Unit,
val onMaxAmountSelected: () -> Unit,
@ -25,4 +27,5 @@ internal data class UiActions(
val onSuccess: () -> Unit,
val onLinkClick: (String) -> Unit,
val onReceiveCardWarningClick: () -> Unit,
val onSwapUIModeChange: (SwapUIMode) -> Unit,
)

View file

@ -5,8 +5,11 @@ import com.tangem.common.ui.extensions.networkIconResId
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.core.ui.components.notifications.NotificationConfig
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.styledResourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.express.models.ExpressError
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.swap.utils.getExpressErrorMessage
@ -217,14 +220,25 @@ internal object SwapNotificationUM {
iconResId = iconResId,
) {
data class PermissionNeeded(
val providerName: String,
val fromTokenSymbol: String,
val onApproveClick: () -> Unit,
val onLearnMoreClick: () -> Unit,
) : Info(
title = resourceReference(R.string.express_provider_permission_needed),
subtitle = resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, fromTokenSymbol),
subtitle = combinedReference(
resourceReference(
id = R.string.give_permission_swap_subtitle_v2,
// Arg is only used in iOS
formatArgs = wrappedList(""),
),
styledResourceReference(
id = R.string.common_learn_more,
spanStyleReference = {
TangemTheme.typography.caption2
.copy(color = TangemTheme.colors.text.accent)
.toSpanStyle()
},
onClick = onLearnMoreClick,
),
),
iconResId = R.drawable.ic_locked_24,
buttonsState = NotificationConfig.ButtonsState.SecondaryButtonConfig(

View file

@ -0,0 +1,156 @@
package com.tangem.feature.swap.ui
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerW8
import com.tangem.core.ui.components.SpacerWMax
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.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreview
import com.tangem.feature.swap.models.states.PercentDifference
import com.tangem.feature.swap.models.states.ProviderState
// TODO: [REDACTED_TASK_KEY] — remove this UI after swap migrates to swap-v2.
// Layout copied from V2 `SwapChooseProviderContent`:
// features/swap-v2/impl/src/main/java/com/tangem/features/swap/v2/impl/chooseprovider/ui/SwapChooseProviderContent.kt
@Composable
internal fun ProviderItemBlockSimple(state: ProviderState, modifier: Modifier = Modifier) {
if (state is ProviderState.Empty) return
Row(
modifier = modifier
.clip(RoundedCornerShape(TangemTheme.dimens.radius16))
.background(TangemTheme.colors.background.primary)
.clickable(
enabled = state.onProviderClick != null,
onClick = { state.onProviderClick?.invoke(state.id) },
)
.fillMaxWidth()
.padding(TangemTheme.dimens.spacing12),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(id = R.drawable.ic_stack_new_24),
tint = TangemTheme.colors.icon.accent,
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size24),
)
SpacerW8()
Text(
text = stringResourceSafe(R.string.express_provider),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.primary1,
)
SpacerWMax()
SimpleProviderTrailing(state = state)
}
}
@Composable
private fun SimpleProviderTrailing(state: ProviderState) {
when (state) {
is ProviderState.Content -> {
SubcomposeAsyncImage(
model = ImageRequest.Builder(context = LocalContext.current)
.data(state.iconUrl)
.crossfade(enable = true)
.allowHardware(false)
.build(),
loading = { RectangleShimmer(radius = 4.dp) },
error = { RectangleShimmer(radius = 4.dp) },
contentDescription = null,
modifier = Modifier
.size(TangemTheme.dimens.size20)
.clip(RoundedCornerShape(TangemTheme.dimens.radius4)),
)
Text(
text = state.name,
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.tertiary,
modifier = Modifier.padding(start = TangemTheme.dimens.spacing6),
)
Icon(
painter = painterResource(id = R.drawable.ic_chevron_24),
tint = TangemTheme.colors.icon.secondary,
contentDescription = null,
modifier = Modifier.size(TangemTheme.dimens.size24),
)
}
is ProviderState.Loading -> {
RectangleShimmer(
modifier = Modifier
.size(width = TangemTheme.dimens.size80, height = TangemTheme.dimens.size20),
radius = TangemTheme.dimens.radius4,
)
}
is ProviderState.Unavailable -> {
Text(
text = state.alertText.resolveReference(),
style = TangemTheme.typography.body2,
color = TangemTheme.colors.text.warning,
)
}
is ProviderState.Empty -> Unit
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ProviderItemBlockSimple_Preview(@PreviewParameter(SimpleProviderPreview::class) state: ProviderState) {
TangemThemePreview {
ProviderItemBlockSimple(state = state)
}
}
private class SimpleProviderPreview : PreviewParameterProvider<ProviderState> {
override val values: Sequence<ProviderState> = sequenceOf(
ProviderState.Content(
id = "1",
name = "Changelly",
type = "CEX",
iconUrl = "",
subtitle = stringReference("1 SOL ≈ 0.0011337 BTC"),
selectionType = ProviderState.SelectionType.CLICK,
additionalBadge = ProviderState.AdditionalBadge.Empty,
percentLowerThenBest = PercentDifference.Empty,
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = {},
),
ProviderState.Loading(),
ProviderState.Unavailable(
id = "2",
name = "1inch",
type = "DEX",
iconUrl = "",
alertText = stringReference("Unavailable"),
selectionType = ProviderState.SelectionType.NONE,
),
)
}
// endregion

View file

@ -28,11 +28,14 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.IncludeFeeInAmount
import com.tangem.feature.swap.domain.models.domain.RateType
import com.tangem.feature.swap.converters.SwapProviderStateBuilder
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.model.SwapNotificationsFactory
import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.SwapButton.Mode
import com.tangem.feature.swap.models.states.*
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.utils.formatToUIRepresentation
@ -45,8 +48,6 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.math.min
/**
* State builder creates a specific states for SwapScreen
@ -57,15 +58,16 @@ internal class StateBuilder(
private val isBalanceHiddenProvider: Provider<Boolean>,
private val appCurrencyProvider: Provider<AppCurrency>,
private val isAccountsModeProvider: Provider<Boolean>,
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val shouldShowAbMenu: Boolean,
) {
private val iconStateConverter by lazy(::CryptoCurrencyToIconStateConverter)
private val notificationsFactory by lazy(LazyThreadSafetyMode.NONE) {
SwapNotificationsFactory(actions, iGaslessFeeSupportedForNetwork)
SwapNotificationsFactory(actions, isGaslessFeeSupportedForNetwork)
}
fun createInitialLoadingState(): SwapStateHolder {
fun createInitialLoadingState(swapUIMode: SwapUIMode = SwapUIMode.Detailed): SwapStateHolder {
return SwapStateHolder(
sendCardData = getEmptyCardState(
isFromCard = true,
@ -79,7 +81,7 @@ internal class StateBuilder(
swapButton = SwapButton(
walletInteractionIcon = null,
isEnabled = false,
isInProgress = true,
mode = Mode.SWAP_PROGRESSING,
isHoldToConfirm = false,
onClick = {},
),
@ -95,6 +97,9 @@ internal class StateBuilder(
shouldShowMaxAmount = false,
priceImpact = PriceImpact.Empty,
isInsufficientFunds = false,
swapUIMode = swapUIMode,
onSwapUIModeChange = actions.onSwapUIModeChange,
shouldShowAbMenu = shouldShowAbMenu,
)
}
@ -461,7 +466,6 @@ internal class StateBuilder(
quoteModel = quoteModel,
feeCryptoCurrencyStatus = feeCryptoCurrencyStatus,
selectedFeeType = selectedFeeType,
providerName = swapProvider.name,
hideFee = hideFee,
)
@ -547,15 +551,16 @@ internal class StateBuilder(
onClick = actions.onSwapClick,
),
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
providerState = swapProvider.convertToContentClickableProviderState(
isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(),
providerState = SwapProviderStateBuilder.buildContentClickable(
provider = swapProvider,
fromTokenInfo = quoteModel.fromTokenInfo,
toTokenInfo = quoteModel.toTokenInfo,
isNeedBestRateBadge = isNeedBestRateBadge,
selectionType = ProviderState.SelectionType.CLICK,
onProviderClick = actions.onProviderClick,
needApplyFCARestrictions = needApplyFCARestrictions,
permissionState = quoteModel.permissionState,
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = bestRatedProviderId == swapProvider.providerId && !priceImpact.shouldShowWarning(),
isNeedBestRateBadge = isNeedBestRateBadge,
needApplyFCARestrictions = needApplyFCARestrictions,
onProviderClick = actions.onProviderClick,
),
priceImpact = priceImpact,
tosState = createTosState(swapProvider),
@ -684,27 +689,27 @@ internal class StateBuilder(
): ProviderState {
return when (expressDataError) {
is ExpressDataError.ExchangeTooSmallAmountError -> {
swapProvider.convertToAvailableFromProviderState(
swapProvider = swapProvider,
SwapProviderStateBuilder.buildAvailableFrom(
provider = swapProvider,
alertText = resourceReference(
R.string.express_provider_min_amount,
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
),
selectionType = selectionType,
onProviderClick = onProviderClick,
needApplyFCARestrictions = needApplyFCARestrictions,
onProviderClick = onProviderClick,
)
}
is ExpressDataError.ExchangeTooBigAmountError -> {
swapProvider.convertToAvailableFromProviderState(
swapProvider = swapProvider,
SwapProviderStateBuilder.buildAvailableFrom(
provider = swapProvider,
alertText = resourceReference(
R.string.express_provider_max_amount,
wrappedList(expressDataError.amount.getFormattedCryptoAmount(fromToken)),
),
selectionType = selectionType,
onProviderClick = onProviderClick,
needApplyFCARestrictions = needApplyFCARestrictions,
onProviderClick = onProviderClick,
)
}
else -> {
@ -735,6 +740,7 @@ internal class StateBuilder(
swapButton = SwapButton(
walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon),
isEnabled = false,
mode = if (emptyAmountState.isTransferMode) Mode.TRANSFER else Mode.SWAP,
isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true,
onClick = { },
),
@ -748,7 +754,7 @@ internal class StateBuilder(
return uiState.copy(
swapButton = uiState.swapButton.copy(
isEnabled = false,
isInProgress = true,
mode = Mode.SWAP_PROGRESSING,
),
)
}
@ -879,7 +885,7 @@ internal class StateBuilder(
return uiState.copy(
swapButton = uiState.swapButton.copy(
isEnabled = false,
isInProgress = false,
mode = Mode.SWAP,
),
notifications = notificationsFactory.getApprovalInProgressStateNotification(uiState.notifications),
)
@ -1051,10 +1057,8 @@ internal class StateBuilder(
providers = providers.map { providerState ->
val tokenInfo = tokenSwapInfoForProviders[providerState.id]
if (providerState is ProviderState.Content && tokenInfo != null) {
val rateString = tokenInfo.tokenAmount
.getFormattedCryptoAmount(tokenInfo.swapCurrencyStatus.currency)
providerState.copy(
subtitle = stringReference(rateString),
subtitle = SwapProviderStateBuilder.buildSelectableSubtitle(tokenInfo),
percentLowerThenBest = pricesLowerBest[providerState.id]?.let { percent ->
PercentDifference.Value(percent)
} ?: PercentDifference.Value(0f),
@ -1131,14 +1135,16 @@ internal class StateBuilder(
): ProviderState? {
val provider = this.key
return when (val state = this.value) {
is SwapState.EmptyAmountState -> null
is SwapState.EmptyAmountState, is SwapState.Transfer -> null
is SwapState.QuotesLoadedState -> {
provider.convertToContentSelectableProviderState(
state = state,
onProviderClick = onProviderSelect,
SwapProviderStateBuilder.buildContentSelectable(
provider = provider,
toTokenInfo = state.toTokenInfo,
permissionState = state.permissionState,
pricesLowerBest = pricesLowerBest,
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = needApplyFCARestrictions,
onProviderClick = onProviderSelect,
)
}
is SwapState.SwapError -> getProviderStateForError(
@ -1152,113 +1158,6 @@ internal class StateBuilder(
}
}
@Suppress("LongParameterList")
private fun SwapProvider.convertToContentClickableProviderState(
isBestRate: Boolean,
fromTokenInfo: TokenSwapInfo,
toTokenInfo: TokenSwapInfo,
selectionType: ProviderState.SelectionType,
isNeedBestRateBadge: Boolean,
onProviderClick: (String) -> Unit,
needApplyFCARestrictions: Boolean,
permissionState: PermissionDataState,
): ProviderState {
val rate = toTokenInfo.tokenAmount.value.calculateRate(
fromTokenInfo.tokenAmount.value,
toTokenInfo.swapCurrencyStatus.currency.decimals,
)
val fromCurrencySymbol = fromTokenInfo.swapCurrencyStatus.currency.symbol
val rateString = buildString {
append(BigDecimal.ONE.format { crypto(symbol = fromCurrencySymbol, decimals = 0).anyDecimals() })
append("")
append(rate.format { crypto(toTokenInfo.swapCurrencyStatus.currency) })
}
val additionalBadge = when {
needApplyFCARestrictions && isFCARestrictedProvider() -> ProviderState.AdditionalBadge.FCAWarningList
permissionState is PermissionDataState.PermissionRequired ->
ProviderState.AdditionalBadge.PermissionRequired
isRecommended -> ProviderState.AdditionalBadge.Recommended
isNeedBestRateBadge && isBestRate && !needApplyFCARestrictions -> ProviderState.AdditionalBadge.BestTrade
else -> ProviderState.AdditionalBadge.Empty
}
return ProviderState.Content(
id = this.providerId,
name = this.name,
iconUrl = this.imageLarge,
type = this.type.providerName,
subtitle = stringReference(rateString),
additionalBadge = additionalBadge,
selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty,
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = onProviderClick,
)
}
private fun SwapProvider.convertToContentSelectableProviderState(
state: SwapState.QuotesLoadedState,
selectionType: ProviderState.SelectionType,
pricesLowerBest: Map<String, Float>,
onProviderClick: (String) -> Unit,
needApplyFCARestrictions: Boolean,
): ProviderState {
val toTokenInfo = state.toTokenInfo
val rateString = toTokenInfo.tokenAmount.getFormattedCryptoAmount(toTokenInfo.swapCurrencyStatus.currency)
val additionalBadge = when {
needApplyFCARestrictions && isFCARestrictedProvider() -> ProviderState.AdditionalBadge.FCAWarningList
state.permissionState is PermissionDataState.PermissionRequired -> {
ProviderState.AdditionalBadge.PermissionRequired
}
isRecommended -> ProviderState.AdditionalBadge.Recommended
else -> ProviderState.AdditionalBadge.Empty
}
return ProviderState.Content(
id = this.providerId,
name = this.name,
iconUrl = this.imageLarge,
type = this.type.providerName,
subtitle = stringReference(rateString),
additionalBadge = additionalBadge,
selectionType = selectionType,
percentLowerThenBest = pricesLowerBest[this.providerId]?.let { percent ->
PercentDifference.Value(percent)
} ?: PercentDifference.Value(0f),
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = onProviderClick,
)
}
private fun SwapProvider.convertToAvailableFromProviderState(
swapProvider: SwapProvider,
alertText: TextReference,
selectionType: ProviderState.SelectionType,
onProviderClick: (String) -> Unit,
needApplyFCARestrictions: Boolean,
): ProviderState {
val additionalBadge = when {
needApplyFCARestrictions && isFCARestrictedProvider() -> ProviderState.AdditionalBadge.FCAWarningList
swapProvider.isRecommended -> ProviderState.AdditionalBadge.Recommended
else -> ProviderState.AdditionalBadge.Empty
}
return ProviderState.Content(
id = this.providerId,
name = this.name,
iconUrl = this.imageLarge,
type = this.type.providerName,
selectionType = selectionType,
subtitle = alertText,
additionalBadge = additionalBadge,
percentLowerThenBest = PercentDifference.Empty,
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = onProviderClick,
)
}
private fun CryptoCurrencyStatus?.getFormattedAmount(isNeedSymbol: Boolean): String {
val amount = this?.value?.amount ?: return DASH_SIGN
val symbol = if (isNeedSymbol) currency.symbol else ""
@ -1282,19 +1181,10 @@ internal class StateBuilder(
return value.format { crypto(token) }
}
private fun BigDecimal.calculateRate(to: BigDecimal, decimals: Int): BigDecimal {
val rateDecimals = if (decimals == 0) IF_ZERO_DECIMALS_TO_SHOW else decimals
return this.divide(to, min(rateDecimals, MAX_DECIMALS_TO_SHOW), RoundingMode.HALF_UP)
}
private fun String.appendApproximateSign(): String {
return "$TILDE_SIGN $this"
}
private fun SwapProvider.isFCARestrictedProvider(): Boolean {
return FCA_RESTRICTED_PROVIDER_IDS.contains(providerId)
}
private fun getCardAccountTitle(account: Account?, isFromCard: Boolean): AccountTitleUM {
val (prefix, placeholder) = if (isFromCard) {
R.string.swapping_from_account_title to R.string.swapping_from_title_v2
@ -1318,17 +1208,4 @@ internal class StateBuilder(
is Account.Payment -> AccountIconUM.Payment
}
}
private companion object {
private const val MAX_DECIMALS_TO_SHOW = 8
private const val IF_ZERO_DECIMALS_TO_SHOW = 2
private val FCA_RESTRICTED_PROVIDER_IDS = setOf(
"changelly",
"changenow",
"okx-cross-chain",
"okx-on-chain",
"simpleswap",
)
}
}

View file

@ -2,19 +2,39 @@ package com.tangem.feature.swap.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import com.tangem.core.ui.components.appbar.AppBarWithBackButton
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.appbar.AppBarWithBackButtonAndIcon
import com.tangem.core.ui.components.dropdownmenu.TangemDropdownMenu
import com.tangem.core.ui.extensions.stringResourceSafe
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.test.SwapTokenScreenTestTags
import com.tangem.core.ui.utils.WindowInsetsZero
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.models.SwapStateHolder
import com.tangem.feature.swap.models.states.ChooseFeeBottomSheetConfig
import com.tangem.feature.swap.models.states.ChooseProviderBottomSheetConfig
@ -26,13 +46,7 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent:
Scaffold(
modifier = Modifier.systemBarsPadding(),
topBar = {
AppBarWithBackButton(
text = stringResourceSafe(R.string.common_swap),
onBackClick = stateHolder.onBackClicked,
iconRes = R.drawable.ic_close_24,
)
},
topBar = { SwapTopBar(stateHolder = stateHolder) },
contentWindowInsets = WindowInsetsZero,
containerColor = TangemTheme.colors.background.secondary,
) { scaffoldPaddings ->
@ -64,4 +78,79 @@ internal fun SwapScreen(stateHolder: SwapStateHolder, feeSelectorBlockComponent:
}
}
}
}
@Composable
private fun SwapTopBar(stateHolder: SwapStateHolder) {
var shouldShowModeMenu by rememberSaveable { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) {
AppBarWithBackButtonAndIcon(
text = stringResourceSafe(R.string.common_swap),
backIconRes = R.drawable.ic_close_24,
iconRes = if (stateHolder.shouldShowAbMenu) R.drawable.ic_more_vertical_24 else null,
onIconClick = if (stateHolder.shouldShowAbMenu) {
{ shouldShowModeMenu = true }
} else {
null
},
onBackClick = stateHolder.onBackClicked,
)
if (stateHolder.shouldShowAbMenu) {
Box(modifier = Modifier.align(Alignment.TopEnd)) {
TangemDropdownMenu(
expanded = shouldShowModeMenu,
modifier = Modifier.background(TangemTheme.colors.background.primary),
offset = DpOffset(x = TangemTheme.dimens.spacing20, y = 44.dp),
onDismissRequest = { shouldShowModeMenu = false },
content = {
SwapUiModeMenuItem(
title = stringResourceSafe(R.string.swap_simple_mode),
isSelected = stateHolder.swapUIMode == SwapUIMode.Simple,
onClick = {
shouldShowModeMenu = false
stateHolder.onSwapUIModeChange(SwapUIMode.Simple)
},
)
SwapUiModeMenuItem(
title = stringResourceSafe(R.string.swap_detailed_mode),
isSelected = stateHolder.swapUIMode == SwapUIMode.Detailed,
onClick = {
shouldShowModeMenu = false
stateHolder.onSwapUIModeChange(SwapUIMode.Detailed)
},
)
},
)
}
}
}
}
@Composable
private fun SwapUiModeMenuItem(title: String, isSelected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = title,
style = TangemTheme.typography.button,
color = TangemTheme.colors.text.primary1,
modifier = Modifier.weight(1f),
)
if (isSelected) {
Icon(
painter = painterResource(id = R.drawable.ic_check_24),
tint = TangemTheme.colors.icon.primary1,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
} else {
Spacer(modifier = Modifier.width(16.dp))
}
}
}

View file

@ -14,6 +14,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@ -38,6 +39,7 @@ 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.feature.swap.domain.models.domain.SwapUIMode
import com.tangem.feature.swap.domain.models.ui.FeeType
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.*
@ -78,7 +80,11 @@ internal fun SwapScreenContent(
) {
MainInfo(state)
ProviderItemBlock(state = state.providerState)
if (state.swapUIMode == SwapUIMode.Simple) {
ProviderItemBlockSimple(state = state.providerState)
} else {
ProviderItemBlock(state = state.providerState)
}
if (feeBlock != null) {
feeBlock(Modifier.fillMaxWidth())
@ -138,14 +144,25 @@ private fun MainInfo(state: SwapStateHolder) {
onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.FROM) },
)
val marginCard = TangemTheme.dimens.spacing12
TransactionCard(
priceImpact = priceImpact,
swapCardState = state.receiveCardData,
modifier = Modifier.constrainAs(bottomCard) {
top.linkTo(topCard.bottom, margin = marginCard)
},
onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.TO) },
)
if (state.swapUIMode == SwapUIMode.Simple) {
TransactionCardSimple(
priceImpact = priceImpact,
swapCardState = state.receiveCardData,
modifier = Modifier.constrainAs(bottomCard) {
top.linkTo(topCard.bottom, margin = marginCard)
},
onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.TO) },
)
} else {
TransactionCard(
priceImpact = priceImpact,
swapCardState = state.receiveCardData,
modifier = Modifier.constrainAs(bottomCard) {
top.linkTo(topCard.bottom, margin = marginCard)
},
onSelectTokenClick = { state.onSelectTokenClick(TokenSelectionDirection.TO) },
)
}
val marginButton = TangemTheme.dimens.spacing30
SwapButton(
state,
@ -345,7 +362,7 @@ private fun MainButton(state: SwapStateHolder) {
state.swapButton.isHoldToConfirm -> {
HoldToConfirmButton(
modifier = Modifier.fillMaxWidth(),
text = stringResourceSafe(R.string.swapping_swap_action),
text = getButtonTitle(state.swapButton.mode),
enabled = state.swapButton.isEnabled,
onConfirm = state.swapButton.onClick,
isLoading = state.swapButton.isInProgress,
@ -355,11 +372,7 @@ private fun MainButton(state: SwapStateHolder) {
else -> {
PrimaryButtonIconEnd(
modifier = Modifier.fillMaxWidth(),
text = if (state.swapButton.isInProgress) {
stringResourceSafe(id = R.string.swapping_swap_action_in_progress)
} else {
stringResourceSafe(id = R.string.swapping_swap_action)
},
text = getButtonTitle(state.swapButton.mode),
iconResId = state.swapButton.walletInteractionIcon,
enabled = state.swapButton.isEnabled,
onClick = state.swapButton.onClick,
@ -368,6 +381,19 @@ private fun MainButton(state: SwapStateHolder) {
}
}
@Composable
@ReadOnlyComposable
private fun getButtonTitle(mode: SwapButton.Mode): String {
return when (mode) {
SwapButton.Mode.SWAP_PROGRESSING -> stringResourceSafe(id = R.string.swapping_swap_action_in_progress)
SwapButton.Mode.SWAP -> stringResourceSafe(id = R.string.swapping_swap_action)
SwapButton.Mode.TRANSFER -> stringResourceSafe(id = R.string.swapping_transfer_action)
SwapButton.Mode.TRANSFER_PROGRESSING -> stringResourceSafe(
id = R.string.swapping_transfer_action_in_progress,
)
}
}
// region preview
private val state = SwapStateHolder(
@ -384,9 +410,8 @@ private val state = SwapStateHolder(
),
notifications = persistentListOf(
SwapNotificationUM.Info.PermissionNeeded(
providerName = "Provider",
fromTokenSymbol = "POL",
onApproveClick = {},
onLearnMoreClick = {},
),
SwapNotificationUM.Warning.NoAvailableTokensToSwap("POLYGON"),
),

View file

@ -0,0 +1,418 @@
package com.tangem.feature.swap.ui
import android.content.res.Configuration
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.tangem.common.ui.account.AccountTitle
import com.tangem.core.ui.R
import com.tangem.core.ui.components.RectangleShimmer
import com.tangem.core.ui.components.SpacerH4
import com.tangem.core.ui.components.SpacerW16
import com.tangem.core.ui.components.TextShimmer
import com.tangem.core.ui.components.buttons.SecondarySmallButton
import com.tangem.core.ui.components.buttons.SmallButtonConfig
import com.tangem.core.ui.components.buttons.common.TangemButtonIconPosition
import com.tangem.core.ui.extensions.orMaskWithStars
import com.tangem.core.ui.extensions.resolveAnnotatedReference
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.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.models.SwapCardState
import com.tangem.feature.swap.models.TransactionCardType
import com.tangem.feature.swap.ui.preview.SwapTransactionCardPreview
@Composable
internal fun TransactionCardSimple(
priceImpact: PriceImpact,
swapCardState: SwapCardState,
onSelectTokenClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val cardTag = when (swapCardState.type) {
is TransactionCardType.Inputtable -> SwapTokenScreenTestTags.SWAP_CARD
is TransactionCardType.ReadOnly -> SwapTokenScreenTestTags.RECEIVE_CARD
}
when (swapCardState) {
is SwapCardState.Empty -> SimpleTransactionCardEmpty(
cardState = swapCardState,
onChangeTokenClick = onSelectTokenClick,
modifier = modifier.testTag(cardTag),
)
is SwapCardState.SwapCardData -> SimpleTransactionCardData(
cardState = swapCardState,
priceImpact = priceImpact,
onChangeTokenClick = onSelectTokenClick,
modifier = modifier.testTag(cardTag),
)
is SwapCardState.Loading -> SimpleTransactionCardLoading(modifier = modifier.testTag(cardTag))
}
}
@Composable
private fun SimpleTransactionCardData(
cardState: SwapCardState.SwapCardData,
priceImpact: PriceImpact,
modifier: Modifier = Modifier,
onChangeTokenClick: (() -> Unit)? = null,
) {
Box(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius16),
color = TangemTheme.colors.background.primary,
)
.fillMaxWidth(),
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.Start,
) {
SimpleHeader(
balance = stringResourceSafe(
R.string.common_balance,
cardState.balance,
).orMaskWithStars(cardState.isBalanceHidden),
type = cardState.type,
)
SimpleContent(
type = cardState.type,
textFieldValue = cardState.amountTextFieldValue,
priceImpact = priceImpact,
)
}
Box(modifier = Modifier.align(Alignment.BottomEnd)) {
Token(
currencyIconState = cardState.currencyIconState,
tokenSymbol = cardState.tokenSymbol,
)
}
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 = ripple(bounded = false),
interactionSource = remember { MutableInteractionSource() },
) { onChangeTokenClick() },
)
}
}
}
@Composable
private fun SimpleTransactionCardEmpty(
cardState: SwapCardState.Empty,
modifier: Modifier = Modifier,
onChangeTokenClick: () -> Unit,
) {
Column(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
color = TangemTheme.colors.background.primary,
)
.padding(
top = 12.dp,
start = 12.dp,
end = 12.dp,
bottom = 16.dp,
)
.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
AccountTitle(
accountTitleUM = cardState.type.accountTitleUM,
modifier = Modifier.fillMaxWidth(),
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = cardState.amountTextFieldValue?.text.orEmpty(),
color = TangemTheme.colors.text.disabled,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
minFontSize = 16.sp,
maxFontSize = TangemTheme.typography.h2.fontSize,
),
maxLines = 1,
modifier = Modifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
)
Text(
text = cardState.amountEquivalent.resolveAnnotatedReference(),
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier
.defaultMinSize(minHeight = TangemTheme.dimens.size20)
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
)
}
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.common_choose_token),
icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24),
onClick = onChangeTokenClick,
),
)
}
}
}
@Composable
private fun SimpleTransactionCardLoading(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.background(
shape = RoundedCornerShape(TangemTheme.dimens.radius12),
color = TangemTheme.colors.background.primary,
)
.padding(
top = 12.dp,
start = 12.dp,
end = 12.dp,
bottom = 16.dp,
)
.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Column(modifier = Modifier.fillMaxWidth()) {
TextShimmer(
text = stringResourceSafe(R.string.swapping_to_title),
style = TangemTheme.typography.subtitle2,
)
TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier
.testTag(SwapTokenScreenTestTags.BALANCE)
.width(60.dp),
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextShimmer(
style = TangemTheme.typography.h2,
modifier = Modifier
.width(100.dp)
.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
)
TextShimmer(
style = TangemTheme.typography.body2,
modifier = Modifier
.defaultMinSize(minHeight = 20.dp, minWidth = 40.dp)
.testTag(SwapTokenScreenTestTags.SWAP_FIAT_AMOUNT),
)
}
SecondarySmallButton(
config = SmallButtonConfig(
text = resourceReference(R.string.common_choose_token),
icon = TangemButtonIconPosition.End(R.drawable.ic_chevron_24),
isEnabled = false,
onClick = {},
),
)
}
}
}
@Composable
private fun SimpleHeader(type: TransactionCardType, balance: String, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(
bottom = TangemTheme.dimens.spacing8,
top = TangemTheme.dimens.spacing14,
start = TangemTheme.dimens.spacing12,
end = TangemTheme.dimens.spacing12,
)
.testTag(SwapTokenScreenTestTags.SWAP_BLOCK_HEADER),
) {
val titleColor = if (type.inputError is TransactionCardType.InputError.Empty) {
TangemTheme.colors.text.tertiary
} else {
TangemTheme.colors.text.warning
}
AccountTitle(
accountTitleUM = type.accountTitleUM,
textColor = titleColor,
)
SpacerW16()
if (balance.isNotBlank()) {
AnimatedContent(targetState = balance, label = "") { balanceText ->
Text(
text = balanceText,
color = TangemTheme.colors.text.tertiary,
style = TangemTheme.typography.body2,
modifier = Modifier.testTag(SwapTokenScreenTestTags.BALANCE),
)
}
} else {
RectangleShimmer(
modifier = Modifier
.width(TangemTheme.dimens.size80)
.height(TangemTheme.dimens.size12),
radius = TangemTheme.dimens.radius3,
)
}
}
}
@Suppress("LongMethod")
@Composable
private fun SimpleContent(type: TransactionCardType, priceImpact: PriceImpact, textFieldValue: TextFieldValue?) {
Row(
modifier = Modifier
.padding(
start = TangemTheme.dimens.spacing12,
bottom = TangemTheme.dimens.spacing16,
),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.Top,
) {
Column(
modifier = Modifier.padding(end = TangemTheme.dimens.spacing92),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.Start,
) {
val sumTextModifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size32)
when (type) {
is TransactionCardType.ReadOnly -> {
if (textFieldValue != null) {
Text(
text = textFieldValue.text,
color = TangemTheme.colors.text.primary1,
style = TangemTheme.typography.h2,
autoSize = TextAutoSize.StepBased(
minFontSize = 16.sp,
maxFontSize = TangemTheme.typography.h2.fontSize,
),
maxLines = 1,
modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.RECEIVE_TEXT_FIELD),
)
} else {
RectangleShimmer(
modifier = Modifier
.padding(vertical = TangemTheme.dimens.spacing4)
.width(TangemTheme.dimens.size102)
.height(TangemTheme.dimens.size24),
)
}
}
is TransactionCardType.Inputtable -> {
val focusRequester = remember { FocusRequester() }
AutoSizeTextField(
modifier = sumTextModifier.testTag(SwapTokenScreenTestTags.SWAP_TEXT_FIELD),
focusRequester = focusRequester,
textFieldValue = textFieldValue ?: TextFieldValue(),
isEnabled = type.isEnabled,
onAmountChange = { type.onAmountChanged(it) },
onFocusChange = type.onFocusChanged,
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
}
}
SpacerH4()
// Keep the same 20dp slot as Detailed (where fiat/shimmer lives)
// so that Token (BottomEnd) does not shift when switching modes.
Box(modifier = Modifier.defaultMinSize(minHeight = TangemTheme.dimens.size20)) {
if (type is TransactionCardType.ReadOnly && type.shouldShowWarning) {
WarningIcon(priceImpact = priceImpact, onClick = type.onWarningClick)
}
}
}
}
}
@Composable
private fun WarningIcon(priceImpact: PriceImpact, onClick: (() -> Unit)?) {
IconButton(
onClick = { onClick?.invoke() },
modifier = Modifier.size(TangemTheme.dimens.size20),
) {
Icon(
painter = painterResource(id = R.drawable.ic_information_24),
contentDescription = null,
tint = when (priceImpact.type) {
PriceImpact.Type.HIGH -> TangemTheme.colors.text.warning
PriceImpact.Type.MEDIUM -> TangemTheme.colors.text.attention
else -> TangemTheme.colors.text.tertiary
},
modifier = Modifier.testTag(SwapTokenScreenTestTags.RECEIVE_FIAT_AMOUNT_INFORMATION_ICON),
)
}
}
// region Preview
@Preview(showBackground = true, widthDp = 360)
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun TransactionCardSimple_Preview(@PreviewParameter(SimplePreviewProvider::class) params: SwapCardState) {
TangemThemePreview {
TransactionCardSimple(
priceImpact = PriceImpact.Empty,
swapCardState = params,
onSelectTokenClick = {},
modifier = Modifier,
)
}
}
private class SimplePreviewProvider : PreviewParameterProvider<SwapCardState> {
override val values: Sequence<SwapCardState> = sequenceOf(
SwapTransactionCardPreview.sendCard,
SwapTransactionCardPreview.receiveCard,
SwapTransactionCardPreview.emptyReadOnlyCard,
SwapTransactionCardPreview.emptyInputtableCard,
SwapTransactionCardPreview.loadingCard,
)
}
// endregion

View file

@ -0,0 +1,188 @@
package com.tangem.feature.swap.ui.transfer
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.tangem.common.ui.account.AccountIconUM
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
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.format.bigdecimal.crypto
import com.tangem.core.ui.format.bigdecimal.fiat
import com.tangem.core.ui.format.bigdecimal.format
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.utils.formatToUIRepresentation
import com.tangem.utils.StringsSigns.DASH_SIGN
import java.math.BigDecimal
import javax.inject.Inject
internal class SwapTransferStateBuilder @Inject constructor() {
private val iconConverter by lazy(::CryptoCurrencyToIconStateConverter)
fun createTransferState(
actions: UiActions,
transferState: SwapState.Transfer,
uiStateHolder: SwapStateHolder,
): SwapStateHolder {
val fromTokenSwapInfo = transferState.fromTokenInfo
val toTokenSwapInfo = transferState.toTokenInfo
val isInsufficientBalance = transferState.isInsufficientBalance
return uiStateHolder.copy(
sendCardData = createSendSwapCardState(
actions = actions,
tokenSwapInfo = fromTokenSwapInfo,
appCurrency = transferState.appCurrency,
isAccountsMode = transferState.isAccountsMode,
isFromCard = true,
isBalanceHidden = transferState.isBalanceHidden,
isInsufficientBalance = isInsufficientBalance,
),
receiveCardData = createSendSwapCardState(
actions = actions,
tokenSwapInfo = toTokenSwapInfo,
appCurrency = transferState.appCurrency,
isAccountsMode = transferState.isAccountsMode,
isFromCard = false,
isBalanceHidden = transferState.isBalanceHidden,
isInsufficientBalance = isInsufficientBalance,
),
isInsufficientFunds = isInsufficientBalance,
swapButton = SwapButton(
walletInteractionIcon = walletInterationIcon(transferState.userWallet),
isEnabled = !isInsufficientBalance,
mode = SwapButton.Mode.TRANSFER,
onClick = actions.onTransferClick,
),
)
}
@Suppress("LongParameterList")
private fun createSendSwapCardState(
actions: UiActions,
tokenSwapInfo: TokenSwapInfo,
appCurrency: AppCurrency,
isAccountsMode: Boolean,
isFromCard: Boolean,
isBalanceHidden: Boolean,
isInsufficientBalance: Boolean,
): SwapCardState {
val swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus
val formattedSwapAmount = tokenSwapInfo.tokenAmount.formatToUIRepresentation()
return SwapCardState.SwapCardData(
type = createSendTransactionCardType(
actions = actions,
swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus,
isAccountsMode = isAccountsMode,
isFromCard = isFromCard,
isInsufficientBalance = isInsufficientBalance,
),
currencyIconState = iconConverter.convert(
value = swapCurrencyStatus.status,
),
tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol),
amountEquivalent = getFormattedFiatAmount(
appCurrency = appCurrency,
amount = tokenSwapInfo.amountFiat,
),
amountTextFieldValue = TextFieldValue(
text = formattedSwapAmount,
selection = TextRange(index = formattedSwapAmount.length),
),
balance = swapCurrencyStatus.status.getFormattedAmount(),
isBalanceHidden = isBalanceHidden,
)
}
private fun createSendTransactionCardType(
actions: UiActions,
swapCurrencyStatus: SwapCurrencyStatus,
isAccountsMode: Boolean,
isFromCard: Boolean,
isInsufficientBalance: Boolean,
): TransactionCardType {
val type = if (isFromCard) {
val accountTitleUM = if (isInsufficientBalance) {
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds))
} else {
getCardAccountTitle(
account = swapCurrencyStatus.account,
isAccountsMode = isAccountsMode,
isFromCard = true,
)
}
TransactionCardType.Inputtable(
onAmountChanged = actions.onAmountChanged,
onFocusChanged = actions.onAmountSelected,
inputError = if (isInsufficientBalance) {
TransactionCardType.InputError.InsufficientFunds
} else {
TransactionCardType.InputError.Empty
},
accountTitleUM = accountTitleUM,
isEnabled = true,
)
} else {
TransactionCardType.ReadOnly(
accountTitleUM = getCardAccountTitle(
account = swapCurrencyStatus.account,
isAccountsMode = isAccountsMode,
isFromCard = false,
),
)
}
return type
}
private fun getCardAccountTitle(account: Account?, isAccountsMode: Boolean, isFromCard: Boolean): AccountTitleUM {
val (prefix, placeholder) = if (isFromCard) {
R.string.swapping_from_account_title to R.string.swapping_from_title_v2
} else {
R.string.swapping_to_account_title to R.string.swapping_to_title
}
return if (account != null && isAccountsMode) {
AccountTitleUM.Account(
prefixText = resourceReference(prefix),
name = account.accountName.toUM().value,
icon = account.toIconUM(),
)
} else {
AccountTitleUM.Text(resourceReference(placeholder))
}
}
private fun getFormattedFiatAmount(appCurrency: AppCurrency, amount: BigDecimal?): TextReference {
return stringReference(
amount.format {
fiat(
fiatCurrencyCode = appCurrency.code,
fiatCurrencySymbol = appCurrency.symbol,
)
},
)
}
private fun CryptoCurrencyStatus.getFormattedAmount(): String {
val amount = this.value.amount ?: return DASH_SIGN
return amount.format { crypto(symbol = "", decimals = currency.decimals) }
}
private fun Account.toIconUM(): AccountIconUM {
return when (this) {
is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(icon)
is Account.Payment -> AccountIconUM.Payment
}
}
}

View file

@ -30,7 +30,7 @@ internal class StateBuilderInitialStateTest {
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private lateinit var sut: StateBuilder
@ -47,7 +47,8 @@ internal class StateBuilderInitialStateTest {
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
shouldShowAbMenu = false,
)
}

View file

@ -25,7 +25,7 @@ internal class StateBuilderPairsTest {
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private lateinit var sut: StateBuilder
@ -52,7 +52,8 @@ internal class StateBuilderPairsTest {
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
shouldShowAbMenu = false,
)
}

View file

@ -2,7 +2,6 @@ package com.tangem.feature.swap
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
@ -14,12 +13,10 @@ import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.ui.StateBuilder
import com.tangem.utils.Provider
import io.mockk.every
import io.mockk.mockk
import kotlinx.collections.immutable.persistentListOf
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
@ -31,7 +28,7 @@ internal class StateBuilderQuotesTest {
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private lateinit var sut: StateBuilder
@ -52,14 +49,15 @@ internal class StateBuilderQuotesTest {
every { isBalanceHiddenProvider() } returns false
every { appCurrencyProvider() } returns AppCurrency.Default
every { isAccountsModeProvider() } returns false
every { iGaslessFeeSupportedForNetwork(any()) } returns false
every { isGaslessFeeSupportedForNetwork(any()) } returns false
sut = StateBuilder(
actions = actions,
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
shouldShowAbMenu = false,
)
}

View file

@ -4,13 +4,11 @@ import com.google.common.truth.Truth.assertThat
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.model.SwapProcessDataState
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.FeeItemState
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.ui.StateBuilder
@ -22,6 +20,8 @@ import kotlinx.collections.immutable.toImmutableList
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.EnumSource
import java.math.BigDecimal
internal class StateBuilderSwapDataTest {
@ -30,7 +30,7 @@ internal class StateBuilderSwapDataTest {
private val isBalanceHiddenProvider: Provider<Boolean> = mockk()
private val appCurrencyProvider: Provider<AppCurrency> = mockk()
private val isAccountsModeProvider: Provider<Boolean> = mockk()
private val iGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk()
private lateinit var sut: StateBuilder
@ -51,14 +51,15 @@ internal class StateBuilderSwapDataTest {
every { isBalanceHiddenProvider() } returns false
every { appCurrencyProvider() } returns AppCurrency.Default
every { isAccountsModeProvider() } returns false
every { iGaslessFeeSupportedForNetwork(any()) } returns false
every { isGaslessFeeSupportedForNetwork(any()) } returns false
sut = StateBuilder(
actions = actions,
isBalanceHiddenProvider = isBalanceHiddenProvider,
appCurrencyProvider = appCurrencyProvider,
isAccountsModeProvider = isAccountsModeProvider,
iGaslessFeeSupportedForNetwork = iGaslessFeeSupportedForNetwork,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
shouldShowAbMenu = false,
)
}
@ -134,9 +135,8 @@ internal class StateBuilderSwapDataTest {
@Test
fun `GIVEN notifications with PermissionNeeded WHEN called THEN PermissionNeeded is removed`() {
val permissionNeeded = SwapNotificationUM.Info.PermissionNeeded(
providerName = "TestProvider",
fromTokenSymbol = "ETH",
onApproveClick = {},
onLearnMoreClick = {},
)
val otherNotification = SwapNotificationUM.Warning.SwapNotSupported
val baseState = buildReadyState(coldWallet).copy(
@ -333,10 +333,17 @@ internal class StateBuilderSwapDataTest {
assertThat(result.swapButton.isEnabled).isFalse()
}
@Test
fun `WHEN called THEN swapButton isInProgress is false`() {
@ParameterizedTest
@EnumSource(
value = SwapButton.Mode::class,
mode = EnumSource.Mode.INCLUDE,
names = ["SWAP_PROGRESSING", "TRANSFER_PROGRESSING"],
)
fun `WHEN called THEN swapButton isInProgress is false`(mode: SwapButton.Mode) {
val baseState = buildReadyState(coldWallet).copy(
swapButton = buildReadyState(coldWallet).swapButton.copy(isInProgress = true),
swapButton = buildReadyState(coldWallet).swapButton.copy(
mode = mode,
),
)
val result = sut.loadingPermissionState(baseState)
@ -359,9 +366,8 @@ internal class StateBuilderSwapDataTest {
@Test
fun `GIVEN notifications with PermissionNeeded WHEN called THEN PermissionNeeded is replaced by ApprovalInProgressWarning`() {
val permissionNeeded = SwapNotificationUM.Info.PermissionNeeded(
providerName = "TestProvider",
fromTokenSymbol = "ETH",
onApproveClick = {},
onLearnMoreClick = {},
)
val baseState = buildReadyState(coldWallet).copy(
notifications = persistentListOf(permissionNeeded),

View file

@ -0,0 +1,372 @@
package com.tangem.feature.swap.converters
import com.google.common.truth.Truth.assertThat
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.feature.swap.models.states.PercentDifference
import com.tangem.feature.swap.models.states.ProviderState
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.util.Locale
internal class SwapProviderStateBuilderTest {
private var originalLocale: Locale = Locale.getDefault()
private val onProviderClick: (String) -> Unit = {}
@BeforeEach
fun setUp() {
originalLocale = Locale.getDefault()
Locale.setDefault(Locale.US)
}
@AfterEach
fun tearDown() {
Locale.setDefault(originalLocale)
}
// region buildContentClickable
@Test
fun `GIVEN best rate AND no FCA AND no permission WHEN buildContentClickable THEN BestTrade badge`() {
val provider = provider(id = "1inch", isRecommended = false)
val from = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
val to = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("3000"))
val result = SwapProviderStateBuilder.buildContentClickable(
provider = provider,
fromTokenInfo = from,
toTokenInfo = to,
permissionState = PermissionDataState.Empty,
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = true,
isNeedBestRateBadge = true,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.BestTrade)
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Empty)
assertThat(result.subtitle).isInstanceOf(TextReference.Str::class.java)
val subtitle = result.subtitle as TextReference.Str
assertThat(subtitle.value).contains("ETH")
assertThat(subtitle.value).contains("USDT")
}
@Test
fun `GIVEN recommended provider WHEN buildContentClickable THEN Recommended badge`() {
val provider = provider(id = "any", isRecommended = true)
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
val result = SwapProviderStateBuilder.buildContentClickable(
provider = provider,
fromTokenInfo = info,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = true,
isNeedBestRateBadge = true,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
}
@Test
fun `GIVEN permission required WHEN buildContentClickable THEN PermissionRequired badge`() {
val provider = provider(id = "any", isRecommended = false)
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
val result = SwapProviderStateBuilder.buildContentClickable(
provider = provider,
fromTokenInfo = info,
toTokenInfo = info,
permissionState = PermissionDataState.PermissionRequired(
isResetApproval = false,
spenderAddress = "0xspender",
),
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = true,
isNeedBestRateBadge = true,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
}
@Test
fun `GIVEN FCA restricted provider WHEN buildContentClickable THEN FCAWarningList badge`() {
val provider = provider(id = "changelly", isRecommended = true)
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
val result = SwapProviderStateBuilder.buildContentClickable(
provider = provider,
fromTokenInfo = info,
toTokenInfo = info,
permissionState = PermissionDataState.PermissionRequired(
isResetApproval = false,
spenderAddress = "0xspender",
),
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = true,
isNeedBestRateBadge = true,
needApplyFCARestrictions = true,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
}
@Test
fun `GIVEN best rate badge disabled WHEN buildContentClickable THEN Empty badge`() {
val provider = provider(id = "any", isRecommended = false)
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
val result = SwapProviderStateBuilder.buildContentClickable(
provider = provider,
fromTokenInfo = info,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = true,
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
@Test
fun `GIVEN provider WHEN buildContentClickable THEN content carries provider identity`() {
val provider = provider(id = "1inch", isRecommended = false, name = "1inch", iconUrl = "https://x")
val info = tokenInfo(symbol = "ETH", decimals = 18, amount = BigDecimal.ONE)
val result = SwapProviderStateBuilder.buildContentClickable(
provider = provider,
fromTokenInfo = info,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
selectionType = ProviderState.SelectionType.CLICK,
isBestRate = false,
isNeedBestRateBadge = false,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.id).isEqualTo("1inch")
assertThat(result.name).isEqualTo("1inch")
assertThat(result.iconUrl).isEqualTo("https://x")
assertThat(result.type).isEqualTo("DEX")
assertThat(result.selectionType).isEqualTo(ProviderState.SelectionType.CLICK)
assertThat(result.namePrefix).isEqualTo(ProviderState.PrefixType.NONE)
}
// endregion
// region buildContentSelectable
@Test
fun `GIVEN provider in pricesLowerBest WHEN buildContentSelectable THEN percentLowerThenBest is mapped`() {
val provider = provider(id = "1inch", isRecommended = false)
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
val result = SwapProviderStateBuilder.buildContentSelectable(
provider = provider,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
pricesLowerBest = mapOf("1inch" to 0.5f),
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Value(0.5f))
assertThat(result.subtitle).isInstanceOf(TextReference.Str::class.java)
val subtitle = result.subtitle as TextReference.Str
assertThat(subtitle.value).contains("USDT")
}
@Test
fun `GIVEN provider not in pricesLowerBest WHEN buildContentSelectable THEN percentLowerThenBest is zero`() {
val provider = provider(id = "any", isRecommended = false)
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
val result = SwapProviderStateBuilder.buildContentSelectable(
provider = provider,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
pricesLowerBest = emptyMap(),
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Value(0f))
}
@Test
fun `GIVEN best rate badge inputs WHEN buildContentSelectable THEN BestTrade badge is never set`() {
val provider = provider(id = "any", isRecommended = false)
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
val result = SwapProviderStateBuilder.buildContentSelectable(
provider = provider,
toTokenInfo = info,
permissionState = PermissionDataState.Empty,
pricesLowerBest = emptyMap(),
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
@Test
fun `GIVEN permission required WHEN buildContentSelectable THEN PermissionRequired badge`() {
val provider = provider(id = "any", isRecommended = false)
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
val result = SwapProviderStateBuilder.buildContentSelectable(
provider = provider,
toTokenInfo = info,
permissionState = PermissionDataState.PermissionRequired(
isResetApproval = false,
spenderAddress = "0xspender",
),
pricesLowerBest = emptyMap(),
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.PermissionRequired)
}
// endregion
// region buildAvailableFrom
@Test
fun `GIVEN alert text WHEN buildAvailableFrom THEN subtitle is the alert text`() {
val provider = provider(id = "any", isRecommended = false)
val alert: TextReference = stringReference("min amount 0.01 ETH")
val result = SwapProviderStateBuilder.buildAvailableFrom(
provider = provider,
alertText = alert,
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.subtitle).isEqualTo(alert)
assertThat(result.percentLowerThenBest).isEqualTo(PercentDifference.Empty)
}
@Test
fun `GIVEN FCA restricted WHEN buildAvailableFrom THEN FCAWarningList badge`() {
val provider = provider(id = "okx-on-chain", isRecommended = true)
val result = SwapProviderStateBuilder.buildAvailableFrom(
provider = provider,
alertText = TextReference.EMPTY,
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = true,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.FCAWarningList)
}
@Test
fun `GIVEN recommended WHEN buildAvailableFrom THEN Recommended badge`() {
val provider = provider(id = "any", isRecommended = true)
val result = SwapProviderStateBuilder.buildAvailableFrom(
provider = provider,
alertText = TextReference.EMPTY,
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Recommended)
}
@Test
fun `GIVEN no flags WHEN buildAvailableFrom THEN Empty badge`() {
val provider = provider(id = "any", isRecommended = false)
val result = SwapProviderStateBuilder.buildAvailableFrom(
provider = provider,
alertText = TextReference.EMPTY,
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = false,
onProviderClick = onProviderClick,
)
assertThat(result.additionalBadge).isEqualTo(ProviderState.AdditionalBadge.Empty)
}
// endregion
// region buildSelectableSubtitle
@Test
fun `GIVEN to token info WHEN buildSelectableSubtitle THEN string contains symbol`() {
val info = tokenInfo(symbol = "USDT", decimals = 6, amount = BigDecimal("100"))
val result = SwapProviderStateBuilder.buildSelectableSubtitle(info)
assertThat(result).isInstanceOf(TextReference.Str::class.java)
val subtitle = result as TextReference.Str
assertThat(subtitle.value).contains("USDT")
assertThat(subtitle.value).contains("100")
}
// endregion
private fun provider(
id: String,
isRecommended: Boolean,
name: String = "Provider",
iconUrl: String = "https://icon",
): SwapProvider = mockk {
every { providerId } returns id
every { this@mockk.name } returns name
every { imageLarge } returns iconUrl
every { type } returns ExchangeProviderType.DEX
every { this@mockk.isRecommended } returns isRecommended
}
private fun tokenInfo(symbol: String, decimals: Int, amount: BigDecimal): TokenSwapInfo {
val currency = mockk<CryptoCurrency.Coin> {
every { this@mockk.symbol } returns symbol
every { this@mockk.decimals } returns decimals
}
val swapStatus = mockk<SwapCurrencyStatus> {
every { this@mockk.currency } returns currency
}
return TokenSwapInfo(
tokenAmount = SwapAmount(value = amount, decimals = decimals),
amountFiat = BigDecimal.ZERO,
swapCurrencyStatus = swapStatus,
)
}
}

View file

@ -0,0 +1,247 @@
package com.tangem.feature.swap.ui.transfer
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.account.AccountTitleUM
import com.tangem.common.ui.account.CryptoPortfolioIconConverter
import com.tangem.common.ui.account.toUM
import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.domain.appcurrency.model.AppCurrency
import com.tangem.domain.models.account.Account
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.feature.swap.buildSwapCurrencyStatus
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.PriceImpact
import com.tangem.feature.swap.domain.models.ui.SwapState
import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo
import com.tangem.feature.swap.models.*
import com.tangem.feature.swap.models.states.ProviderState
import com.tangem.feature.swap.presentation.R
import com.tangem.feature.swap.utils.formatToUIRepresentation
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import java.math.BigDecimal
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SwapTransferStateBuilderTest {
private val actions: UiActions = mockk(relaxed = true)
private val sut = SwapTransferStateBuilder()
private val userWalletId = UserWalletId(stringValue = "deadbeef")
private val coldWallet: UserWallet.Cold = mockk(relaxed = true) {
every { walletId } returns userWalletId
}
private val fromCurrencyStatus: SwapCurrencyStatus = buildSwapCurrencyStatus(coldWallet)
private val toCurrencyStatus: SwapCurrencyStatus = buildSwapCurrencyStatus(coldWallet)
private val iconConverter = CryptoCurrencyToIconStateConverter()
private val fromIcon = iconConverter.convert(fromCurrencyStatus.status)
private val toIcon = iconConverter.convert(toCurrencyStatus.status)
@Test
fun `GIVEN accounts mode enabled WHEN createTransferState THEN cards expose Account titles for from and to`() {
val transferState = buildTransferState(
fromAmount = BigDecimal("1.5"),
toAmount = BigDecimal("1.5"),
isAccountsMode = true,
)
val result = sut.createTransferState(actions, transferState, baseStateHolder())
val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio
val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedAccountName = portfolioAccount.accountName.toUM().value
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_from_account_title),
name = expectedAccountName,
icon = expectedAccountIcon,
),
)
assertThat(receiveType.accountTitleUM).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_to_account_title),
name = expectedAccountName,
icon = expectedAccountIcon,
),
)
assertSharedCardShape(
result = result,
transferState = transferState,
)
}
@Test
fun `GIVEN accounts mode disabled WHEN createTransferState THEN cards fall back to Text titles for from and to`() {
val transferState = buildTransferState(
fromAmount = BigDecimal("2"),
toAmount = BigDecimal("2"),
isAccountsMode = false,
)
val result = sut.createTransferState(actions, transferState, baseStateHolder())
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)),
)
assertThat(receiveType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)),
)
assertSharedCardShape(
result = result,
transferState = transferState,
)
}
@Test
fun `GIVEN insufficient balance and accounts mode disabled WHEN createTransferState THEN from card shows insufficient funds title and error and swap is disabled`() {
val transferState = buildTransferState(
fromAmount = BigDecimal("10"),
toAmount = BigDecimal("10"),
isAccountsMode = false,
isInsufficientBalance = true,
)
val result = sut.createTransferState(actions, transferState, baseStateHolder())
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)),
)
assertThat(sendType.inputError).isEqualTo(TransactionCardType.InputError.InsufficientFunds)
assertThat(receiveType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)),
)
assertThat(result.isInsufficientFunds).isTrue()
assertThat(result.swapButton.isEnabled).isFalse()
assertThat(result.swapButton.mode).isEqualTo(SwapButton.Mode.TRANSFER)
}
@Test
fun `GIVEN insufficient balance and accounts mode enabled WHEN createTransferState THEN from card overrides Account title with insufficient funds text`() {
val transferState = buildTransferState(
fromAmount = BigDecimal("10"),
toAmount = BigDecimal("10"),
isAccountsMode = true,
isInsufficientBalance = true,
)
val result = sut.createTransferState(actions, transferState, baseStateHolder())
val portfolioAccount = toCurrencyStatus.account as Account.CryptoPortfolio
val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedAccountName = portfolioAccount.accountName.toUM().value
val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable
val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly
assertThat(sendType.accountTitleUM).isEqualTo(
AccountTitleUM.Text(resourceReference(R.string.swapping_insufficient_funds)),
)
assertThat(sendType.inputError).isEqualTo(TransactionCardType.InputError.InsufficientFunds)
assertThat(receiveType.accountTitleUM).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_to_account_title),
name = expectedAccountName,
icon = expectedAccountIcon,
),
)
assertThat(result.isInsufficientFunds).isTrue()
assertThat(result.swapButton.isEnabled).isFalse()
}
private fun assertSharedCardShape(
result: SwapStateHolder,
transferState: SwapState.Transfer,
) {
val sendCard = result.sendCardData as SwapCardState.SwapCardData
val receiveCard = result.receiveCardData as SwapCardState.SwapCardData
val expectedFromText = transferState.fromTokenInfo.tokenAmount.formatToUIRepresentation()
val expectedToText = transferState.toTokenInfo.tokenAmount.formatToUIRepresentation()
assertThat(sendCard.amountTextFieldValue).isEqualTo(
TextFieldValue(text = expectedFromText, selection = TextRange(index = expectedFromText.length)),
)
assertThat(receiveCard.amountTextFieldValue).isEqualTo(
TextFieldValue(text = expectedToText, selection = TextRange(index = expectedToText.length)),
)
assertThat(sendCard.currencyIconState).isEqualTo(fromIcon)
assertThat(receiveCard.currencyIconState).isEqualTo(toIcon)
assertThat(sendCard.isBalanceHidden).isEqualTo(transferState.isBalanceHidden)
assertThat(receiveCard.isBalanceHidden).isEqualTo(transferState.isBalanceHidden)
assertThat((sendCard.type is TransactionCardType.Inputtable)).isTrue()
assertThat(receiveCard.type).isInstanceOf(TransactionCardType.ReadOnly::class.java)
assertThat(result.swapButton).isEqualTo(
SwapButton(
walletInteractionIcon = walletInterationIcon(transferState.userWallet),
isEnabled = !transferState.isInsufficientBalance,
mode = SwapButton.Mode.TRANSFER,
onClick = actions.onTransferClick,
),
)
}
private fun buildTransferState(
fromAmount: BigDecimal,
toAmount: BigDecimal,
isAccountsMode: Boolean,
isInsufficientBalance: Boolean = false,
): SwapState.Transfer {
val fromInfo = TokenSwapInfo(
tokenAmount = SwapAmount(value = fromAmount, decimals = fromCurrencyStatus.currency.decimals),
amountFiat = fromAmount * QUOTE,
swapCurrencyStatus = fromCurrencyStatus,
)
val toInfo = TokenSwapInfo(
tokenAmount = SwapAmount(value = toAmount, decimals = toCurrencyStatus.currency.decimals),
amountFiat = toAmount * QUOTE,
swapCurrencyStatus = toCurrencyStatus,
)
return SwapState.Transfer(
userWallet = coldWallet,
fromTokenInfo = fromInfo,
toTokenInfo = toInfo,
isInsufficientBalance = isInsufficientBalance,
appCurrency = AppCurrency.Default,
isBalanceHidden = false,
isAccountsMode = isAccountsMode,
)
}
private fun baseStateHolder(): SwapStateHolder = SwapStateHolder(
sendCardData = SwapCardState.Loading(
type = TransactionCardType.ReadOnly(
accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)),
),
),
receiveCardData = SwapCardState.Loading(
type = TransactionCardType.ReadOnly(
accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)),
),
),
isInsufficientFunds = false,
changeCardsButtonState = ChangeCardsButtonState.ENABLED,
providerState = ProviderState.Empty(),
priceImpact = PriceImpact.Empty,
swapButton = SwapButton(walletInteractionIcon = null, isEnabled = false, onClick = {}),
shouldShowMaxAmount = false,
onRefresh = {},
onBackClicked = {},
onChangeCardsClicked = {},
onSelectTokenClick = {},
onSuccess = {},
)
private companion object {
val QUOTE: BigDecimal = BigDecimal("2000")
}
}