Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-01 20:05:51 +03:00
commit d0b35bc331
680 changed files with 26556 additions and 2709 deletions

View file

@ -20,22 +20,16 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.childByContext
import com.tangem.core.decompose.model.getOrCreateModel
import com.tangem.core.decompose.navigation.inner.InnerRouter
import com.tangem.core.ui.R
import com.tangem.core.ui.decompose.ComposableContentComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.utils.parseBigDecimalOrNull
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.model.SwapModel
import com.tangem.feature.swap.models.SwapPermissionUM
import com.tangem.feature.swap.router.SwapRoute
import com.tangem.feature.swap.ui.SwapScreen
import com.tangem.feature.swap.ui.SwapSuccessScreen
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalEntryComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenComponent
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
import com.tangem.features.swap.SwapComponent
@ -50,7 +44,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@Assisted private val params: SwapComponent.Params,
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
private val giveApprovalComponentFactory: GiveApprovalComponent.Factory,
private val giveApprovalEntryComponentFactory: GiveApprovalEntryComponent.Factory,
private val chooseTokenComponentFactory: ChooseTokenComponent.Factory,
) : SwapComponent, AppComponentContext by appComponentContext {
@ -78,12 +72,10 @@ internal class DefaultSwapComponent @AssistedInject constructor(
source = model.approvalSlotNavigation,
serializer = null,
handleBackButton = true,
childFactory = { _, factoryContext ->
val approvalParams = getApprovalParams()
?: error("Approval params are not available")
giveApprovalComponentFactory.create(
childFactory = { params, factoryContext ->
giveApprovalEntryComponentFactory.create(
context = childByContext(factoryContext),
params = approvalParams,
params = GiveApprovalEntryComponent.Params(params),
)
},
)
@ -123,6 +115,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
analyticsCategoryName = CommonSendAnalyticEvents.SWAP_CATEGORY,
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Swap,
),
isTransferMode = config.isTransferMode,
),
)
}
@ -143,6 +136,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
data class FeeSelectorConfig(
val sendingCurrencyStatus: CryptoCurrencyStatus,
val feeCurrencyStatus: CryptoCurrencyStatus,
val isTransferMode: Boolean,
)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -151,22 +145,21 @@ internal class DefaultSwapComponent @AssistedInject constructor(
val dataState by model.dataStateStateFlow.collectAsStateWithLifecycle()
val fromCryptoCurrency by remember { derivedStateOf { dataState.fromSwapCurrencyStatus?.status } }
val feePaidCryptoCurrency by remember { derivedStateOf { dataState.feePaidCryptoCurrency } }
val isInTransferMode by remember { derivedStateOf { dataState.currentTransferState != null } }
val shouldHideBlock by remember {
derivedStateOf {
val isAmountEmptyOrZero = dataState.amount?.parseBigDecimalOrNull().isNullOrZero()
val isInsufficientFunds = model.uiState.isInsufficientFunds
val isProviderMissing = dataState.selectedProvider == null
val loadedState = dataState.getCurrentLoadedSwapState()
val isPermissionNotReady = loadedState?.permissionState !is PermissionDataState.Empty
val isInTransferMode = dataState.currentTransferState != null
val isSwapNotReady = !isInTransferMode && (isProviderMissing || isPermissionNotReady)
val isPermissionNotNeeded = model.isPermissionNotNeeded
val isSwapNotReady = !isInTransferMode && (isProviderMissing || !isPermissionNotNeeded)
val isTangemPayWithdrawal = model.isTangemPayWithdrawal()
isAmountEmptyOrZero || isInsufficientFunds || isSwapNotReady || isTangemPayWithdrawal
}
}
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock) {
LaunchedEffect(fromCryptoCurrency, feePaidCryptoCurrency, shouldHideBlock, isInTransferMode) {
if (shouldHideBlock) {
TangemLogger.e(
messageString = "Dismissing fee selector: " +
@ -194,6 +187,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
FeeSelectorConfig(
sendingCurrencyStatus = sendingCryptoCurrencyStatus,
feeCurrencyStatus = feeCurrencyStatus,
isTransferMode = isInTransferMode,
),
)
}
@ -246,34 +240,6 @@ internal class DefaultSwapComponent @AssistedInject constructor(
}
}
private fun getApprovalParams(): GiveApprovalComponent.Params? {
val permissionState = model.uiState.permissionUM as? SwapPermissionUM.PermissionRequired ?: return null
val fromSwapCurrencyStatus = model.dataState.fromSwapCurrencyStatus ?: return null
val feeCryptoCurrency = model.dataState.feePaidCryptoCurrency ?: return null
val providerName = model.dataState.selectedProvider?.name.orEmpty()
val isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet
return GiveApprovalComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
feeCryptoCurrencyStatus = feeCryptoCurrency,
amount = model.dataState.amount.orEmpty(),
spenderAddress = permissionState.spenderAddress,
amountFooter = if (permissionState.isResetApproval) {
resourceReference(R.string.update_approval_permission_subtitle)
} else {
resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, fromSwapCurrencyStatus.currency.symbol),
)
},
feeFooter = resourceReference(R.string.swap_give_permission_fee_footer),
isResetApproval = permissionState.isResetApproval,
isHoldToConfirm = isHoldToConfirm,
callback = model.approvalCallback,
)
}
private fun onChildBack() {
val isEmptyStack = childStack.value.backStack.isEmpty()
val isSuccess = model.uiState.successState != null

View file

@ -9,12 +9,16 @@ internal class DefaultSwapFeatureToggles @Inject constructor(
featureTogglesManager: FeatureTogglesManager,
) : SwapFeatureToggles {
override val isYieldSwapEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.TWI_1326_YIELD_MODE_SWAP_ENABLED,
)
override val isSwapSwitchToTransferEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.AND_15207_SWAP_SWITCH_TO_TRANSFER_ENABLED,
)
override val isSwapIntegratedApproveEnabled: Boolean = featureTogglesManager.isFeatureEnabled(
toggle = FeatureToggles.SWAP_INTEGRATED_APPROVE,
toggle = FeatureToggles.AND_15120_SWAP_INTEGRATED_APPROVE,
)
override val isSwapAbEnabled: Boolean = featureTogglesManager.isFeatureEnabled(

View file

@ -1,11 +1,11 @@
package com.tangem.feature.swap.analytics
import com.tangem.core.analytics.models.AnalyticsEvent
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_FROM
import com.tangem.core.analytics.models.AnalyticsParam.Key.ACCOUNT_DERIVATION_TO
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_CODE
import com.tangem.core.analytics.models.AnalyticsParam.Key.ERROR_MESSAGE
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.AnalyticsParam.Key.FEE_TOKEN
import com.tangem.core.analytics.models.AnalyticsParam.Key.PROVIDER
import com.tangem.core.analytics.models.AnalyticsParam.Key.RECEIVE_BLOCKCHAIN
@ -15,6 +15,7 @@ import com.tangem.core.analytics.models.AnalyticsParam.Key.SEND_TOKEN
import com.tangem.core.analytics.models.AppsFlyerIncludedEvent
import com.tangem.core.analytics.models.getReferralParams
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.network.Network
import com.tangem.domain.swap.models.PredefinedPercentAmount
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
@ -297,6 +298,48 @@ sealed class SwapEvents(
event = "Fast amount input",
params = mapOf("Percentage" to percent.toAnalyticsValue()),
)
class TransferModeSwitched(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
) : SwapEvents(
event = "Transfer Mode Switched",
params = mapOf(
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
),
)
class ButtonTransferClicked(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
) : SwapEvents(
event = "Button - Transfer",
params = mapOf(
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
),
)
@Suppress("NullableToStringCall", "LongParameterList")
class TransferInProgressScreen(
fromCurrency: CryptoCurrency?,
toCurrency: CryptoCurrency?,
feeNetwork: Network,
) : SwapEvents(
event = "Transfer in Progress Screen Opened",
params = mapOf(
SEND_TOKEN to fromCurrency?.symbol.orEmpty(),
"Send Blockchain" to fromCurrency?.network?.name.orEmpty(),
RECEIVE_TOKEN to toCurrency?.symbol.orEmpty(),
"Receive Blockchain" to toCurrency?.network?.name.orEmpty(),
"Network fee" to feeNetwork.name,
),
), AppsFlyerIncludedEvent
}
private fun PredefinedPercentAmount.toAnalyticsValue(): String = when (this) {

View file

@ -19,11 +19,7 @@ import com.tangem.features.send.v2.api.params.FeeSelectorParams
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.*
class SwapFeeSelectorBlockComponent @AssistedInject constructor(
@Assisted appComponentContext: AppComponentContext,
@ -44,7 +40,11 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor(
null
},
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
feeStateConfiguration = FeeSelectorParams.FeeStateConfiguration.ExcludeLow,
feeStateConfiguration = if (params.isTransferMode) {
FeeSelectorParams.FeeStateConfiguration.None
} else {
FeeSelectorParams.FeeStateConfiguration.ExcludeLow
},
feeCryptoCurrencyStatus = params.feeCryptoCurrencyStatus,
cryptoCurrencyStatus = params.sendingCryptoCurrencyStatus,
analyticsCategoryName = params.analyticsParams.analyticsCategoryName,
@ -100,6 +100,7 @@ class SwapFeeSelectorBlockComponent @AssistedInject constructor(
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
val analyticsParams: AnalyticsParams,
val repository: ModelRepository,
val isTransferMode: Boolean,
)
@AssistedFactory

View file

@ -59,6 +59,7 @@ internal object SwapProviderStateBuilder {
),
selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = onProviderClick,
)
}
@ -78,6 +79,7 @@ internal object SwapProviderStateBuilder {
isNeedBestRateBadge: Boolean = false,
needApplyFCARestrictions: Boolean,
onProviderClick: (String) -> Unit,
onApprovalSelectClick: (SwapProvider) -> Unit = {},
): ProviderState.Content {
return provider.toContent(
subtitle = buildSelectableSubtitle(toTokenInfo),
@ -92,6 +94,12 @@ internal object SwapProviderStateBuilder {
percentLowerThenBest = pricesLowerBest[provider.providerId]
?.let(PercentDifference::Value)
?: PercentDifference.Value(0f),
approvalSettings = when (permissionState) {
is PermissionDataState.PermissionSettings -> ProviderState.ApprovalSettings.Content(
onApprovalSelectClick = { onApprovalSelectClick(provider) },
)
else -> ProviderState.ApprovalSettings.Empty
},
onProviderClick = onProviderClick,
)
}
@ -115,6 +123,7 @@ internal object SwapProviderStateBuilder {
),
selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = onProviderClick,
)
}
@ -151,11 +160,13 @@ internal object SwapProviderStateBuilder {
}
}
@Suppress("LongParameterList")
private fun SwapProvider.toContent(
subtitle: TextReference,
additionalBadge: ProviderState.AdditionalBadge,
selectionType: ProviderState.SelectionType,
percentLowerThenBest: PercentDifference,
approvalSettings: ProviderState.ApprovalSettings,
onProviderClick: (String) -> Unit,
): ProviderState.Content {
return ProviderState.Content(
@ -169,6 +180,7 @@ internal object SwapProviderStateBuilder {
percentLowerThenBest = percentLowerThenBest,
namePrefix = ProviderState.PrefixType.NONE,
onProviderClick = onProviderClick,
approvalSettings = approvalSettings,
)
}

View file

@ -15,6 +15,7 @@ import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.blockchainsdk.utils.fromNetworkId
import com.tangem.common.routing.AppRoute
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
@ -57,6 +58,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.network.Network
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.models.wallet.isHotWallet
import com.tangem.domain.pay.WithdrawalResult
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
@ -68,7 +70,7 @@ import com.tangem.domain.swap.models.PredefinedPercentAmount
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.swap.usecase.CalculateAmountUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.transaction.error.GetFeeError
@ -89,10 +91,7 @@ import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.*
import com.tangem.feature.swap.domain.models.ui.*
import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor
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.models.*
import com.tangem.feature.swap.models.states.SwapNotificationUM
import com.tangem.feature.swap.router.SwapRoute
import com.tangem.feature.swap.ui.StateBuilder
@ -100,6 +99,8 @@ 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
import com.tangem.features.approval.api.GiveApprovalEntryComponent
import com.tangem.features.approval.api.SelectApprovalTypeComponent
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenAnalyticsPayload
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenResult
@ -155,7 +156,7 @@ internal class SwapModel @Inject constructor(
private val urlOpener: UrlOpener,
private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase,
private val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase,
private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase,
private val tangemPayWithdrawWithSwapUseCase: TangemPayWithdrawWithSwapUseCase,
private val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork,
private val feeSelectorReloadTrigger: FeeSelectorReloadTrigger,
private val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase,
@ -163,7 +164,7 @@ internal class SwapModel @Inject constructor(
private val messageSender: UiMessageSender,
private val initialCurrenciesResolver: InitialCurrenciesResolver,
private val allowPermissionsHandler: AllowPermissionsHandler,
swapFeatureToggles: SwapFeatureToggles,
private val swapFeatureToggles: SwapFeatureToggles,
private val getSwapUiModeUseCase: GetSwapUiModeUseCase,
private val setSwapUiModeUseCase: SetSwapUiModeUseCase,
private val calculateAmountUseCase: CalculateAmountUseCase,
@ -223,7 +224,7 @@ internal class SwapModel @Inject constructor(
}
var uiState: SwapStateHolder by mutableStateOf(stateBuilder.createInitialLoadingState())
private set
internal set
val feeSelectorRepository = FeeSelectorRepository()
@ -249,9 +250,17 @@ internal class SwapModel @Inject constructor(
private var preselectedFromCurrency: CryptoCurrency? = null
private var preselectedToCurrency: CryptoCurrency? = null
val approvalSlotNavigation = SlotNavigation<Unit>()
val isPermissionNotNeeded: Boolean
get() {
val permissionState = dataState.getCurrentLoadedSwapState()?.permissionState
return permissionState == PermissionDataState.Empty ||
swapFeatureToggles.isSwapIntegratedApproveEnabled &&
permissionState is PermissionDataState.PermissionSettings
}
val approvalCallback = object : GiveApprovalComponent.Callback {
val approvalSlotNavigation = SlotNavigation<GiveApprovalEntryComponent.Mode>()
internal val approvalFullCallback = object : GiveApprovalComponent.Callback {
override fun onApproveClick() {}
override fun onApproveDone() {
@ -276,6 +285,46 @@ internal class SwapModel @Inject constructor(
}
}
internal val approvalSelectorCallback = object : SelectApprovalTypeComponent.Callback {
override fun onApproveTypeSelected(spenderAddress: String, approveType: ApproveType) {
val (swapState, permission) = dataState.lastLoadedSwapStates.firstNotNullOfOrNull { (provider, state) ->
if (state !is SwapState.QuotesLoadedState) return@firstNotNullOfOrNull null
val permissionState = state.permissionState
if (permissionState is PermissionDataState.PermissionSettings &&
permissionState.spenderAddress == spenderAddress
) {
state to permissionState
} else {
null
}
} ?: return
if (permission.type == approveType) {
approvalSlotNavigation.dismiss()
return
}
dataState = dataState.copy(
lastLoadedSwapStates = dataState.lastLoadedSwapStates.toMutableMap().apply {
put(
swapState.swapProvider,
swapState.copy(permissionState = permission.copy(type = approveType)),
)
},
)
approvalSlotNavigation.dismiss()
modelScope.launch {
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerLoadingState()
feeSelectorReloadTrigger.triggerUpdate()
}
}
override fun onCancelClick() {
approvalSlotNavigation.dismiss()
}
}
init {
subscribeToTokenSelection()
@ -603,7 +652,15 @@ internal class SwapModel @Inject constructor(
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
)
if (isUpdatedToTransferMode) return
if (isUpdatedToTransferMode) {
analyticsEventHandler.send(
event = SwapEvents.TransferModeSwitched(
fromCurrency = fromSwapCurrencyStatus.currency,
toCurrency = toSwapCurrencyStatus.currency,
),
)
return
}
dataState = dataState.copy(currentTransferState = null)
modelScope.launch {
uiState = stateBuilder.createInitialLoadingState(
@ -740,14 +797,18 @@ internal class SwapModel @Inject constructor(
feePaidCryptoCurrencyStatus = feePaidCryptoCurrency,
fee = selectedFee,
)
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerUpdate()
if (isTangemPayWithdrawal()) {
refreshTransferUIStateIfNeeded()
} else {
feeSelectorRepository.state.value = FeeSelectorUM.Loading
feeSelectorReloadTrigger.triggerUpdate()
}
}
is SwapState.QuotesLoadedState, is SwapState.SwapError -> Unit
}
}
private fun refreshTransferUIStateAfterFeeUpdateIfNeeded(
private fun refreshTransferUIStateIfNeeded(
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus? = null,
fee: Fee? = null,
) {
@ -768,7 +829,10 @@ internal class SwapModel @Inject constructor(
feePaidCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
) as? SwapState.Transfer ?: currentTransferState
dataState = dataState.copy(currentTransferState = refreshed)
dataState = dataState.copy(
currentTransferState = refreshed,
feePaidCryptoCurrency = feePaidCryptoCurrencyStatus ?: dataState.feePaidCryptoCurrency,
)
uiState = swapTransferStateBuilder.updateTransferButtonEnableState(
dataState = dataState,
transferState = refreshed,
@ -776,6 +840,7 @@ internal class SwapModel @Inject constructor(
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
isTangemPayWithdrawal = isTangemPayWithdrawal(),
)
}
}
@ -960,8 +1025,6 @@ internal class SwapModel @Inject constructor(
tokenSwapInfoForProviders = successStates.entries
.associate { it.key.providerId to it.value.toTokenInfo },
)
val isPermissionNotNeeded =
dataState.getCurrentLoadedSwapState()?.permissionState == PermissionDataState.Empty
if (shouldUpdateFeeBlock && isPermissionNotNeeded) {
modelScope.launch { feeSelectorReloadTrigger.triggerUpdate() }
} else {
@ -1288,62 +1351,144 @@ internal class SwapModel @Inject constructor(
private fun onTransferClick() {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
analyticsEventHandler.send(
event = SwapEvents.ButtonTransferClicked(
fromCurrency = fromSwapCurrencyStatus?.currency,
toCurrency = toSwapCurrencyStatus?.currency,
),
)
val fee = (feeSelectorRepository.state.value as? FeeSelectorUM.Content)?.selectedFeeItem?.fee
if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null || fee == null) {
TangemLogger.e("onTransferClick: missing currency status or fee, aborting")
if (fromSwapCurrencyStatus == null || toSwapCurrencyStatus == null) {
TangemLogger.e("onTransferClick: missing currency status, aborting")
showAlert()
return
}
val transferState = dataState.currentTransferState ?: return
uiState = swapTransferStateBuilder.createTransferInProgressState(uiState)
modelScope.launch(dispatchers.main) {
swapTransferInteractor.sendTransfer(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
sendingAmount = transferState.sendingAmount,
fee = fee,
transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) {
"It should be not null at this stage"
},
).fold(
ifLeft = { error ->
TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}")
when {
isTangemPayWithdrawal() -> withdrawTangemPay(
transferState = transferState,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
fee != null -> sendTransfer(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
transferState = transferState,
fee = fee,
)
else -> {
TangemLogger.e("onTransferClick: Illegal state, aborting")
showAlert()
},
ifRight = { txHash ->
val txUrl = getExplorerTransactionUrlUseCase(
txHash = txHash,
currency = fromSwapCurrencyStatus.currency,
).getOrElse {
TangemLogger.i("onTransferClick: tx hash explore not supported")
""
}
updateWalletBalance()
uiState = swapTransferStateBuilder.createSuccessState(
uiState = uiState,
dataState = dataState,
appCurrency = selectedAppCurrencyFlow.value,
isAccountsMode = isAccountsMode,
txUrl = txUrl,
timestamp = System.currentTimeMillis(),
fee = null,
onExplorerClick = {
if (txUrl.isNotEmpty()) {
urlOpener.openUrl(txUrl)
}
},
)
router.replaceAll(SwapRoute.Success)
},
)
}
}
}
}
private suspend fun withdrawTangemPay(
transferState: SwapState.Transfer,
toSwapCurrencyStatus: SwapCurrencyStatus,
) {
swapTransferInteractor.withdrawTangemPay(
userWallet = transferState.userWallet,
cryptoAmount = transferState.sendingAmount,
toSwapCurrencyStatus = toSwapCurrencyStatus,
)
.onLeft { error ->
TangemLogger.e(
messageString = "onTransferClick: withdrawTangemPay failed: ${error.getAnalyticsDescription()}",
)
startLoadingQuotesFromLastState()
showAlert()
}
.onRight { result ->
when (result) {
WithdrawalResult.Cancelled -> startLoadingQuotesFromLastState()
WithdrawalResult.Success -> updateTransferModeTangemPayState()
}
}
}
private fun updateTransferModeTangemPayState() {
sendTransferInProgressEvent()
uiState = swapTransferStateBuilder.createTangemPayWithdrawalSuccessState(
uiState = uiState,
dataState = dataState,
fee = getSelectedSwapFee()?.fee,
onExploreClick = {
val txUrl = uiState.successState?.txUrl.orEmpty()
if (txUrl.isNotEmpty()) {
urlOpener.openUrl(txUrl)
}
},
)
router.replaceAll(SwapRoute.Success)
}
private suspend fun sendTransfer(
fromSwapCurrencyStatus: SwapCurrencyStatus,
toSwapCurrencyStatus: SwapCurrencyStatus,
transferState: SwapState.Transfer,
fee: Fee,
) {
swapTransferInteractor.sendTransfer(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
sendingAmount = transferState.sendingAmount,
fee = fee,
transactionFeeResult = requireNotNull(getSelectedSwapFee()?.transactionFeeResult) {
"It should be not null at this stage"
},
).fold(
ifLeft = { error ->
TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}")
startLoadingQuotesFromLastState()
showAlert()
},
ifRight = { txHash ->
val txUrl = getExplorerTransactionUrlUseCase(
txHash = txHash,
currency = fromSwapCurrencyStatus.currency,
).getOrElse {
TangemLogger.i("onTransferClick: tx hash explore not supported")
""
}
updateWalletBalance()
sendTransferInProgressEvent()
uiState = swapTransferStateBuilder.createSuccessState(
uiState = uiState,
dataState = dataState,
txUrl = txUrl,
timestamp = System.currentTimeMillis(),
fee = getSelectedSwapFee()?.fee,
onExplorerClick = {
if (txUrl.isNotEmpty()) {
urlOpener.openUrl(txUrl)
}
},
)
router.replaceAll(SwapRoute.Success)
},
)
}
private fun sendTransferInProgressEvent() {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
analyticsEventHandler.send(
event = SwapEvents.TransferInProgressScreen(
fromCurrency = fromSwapCurrencyStatus?.currency,
toCurrency = toSwapCurrencyStatus?.currency,
feeNetwork = getFeeToken().network,
),
)
}
private suspend fun processTangemPayWithdrawal(
fromSwapCurrencyStatus: SwapCurrencyStatus,
swapTransactionState: SwapTransactionState.TangemPayWithdrawalData,
) {
tangemPayWithdrawUseCase(
tangemPayWithdrawWithSwapUseCase(
userWallet = fromSwapCurrencyStatus.userWallet,
cryptoAmount = swapTransactionState.cryptoAmount,
cryptoCurrencyId = swapTransactionState.cryptoCurrencyId,
@ -1689,13 +1834,27 @@ internal class SwapModel @Inject constructor(
onPredefinedPercentSelected = ::onPredefinedPercentSelected,
onReduceToAmount = ::onReduceAmountClicked,
onReduceByAmount = ::onReduceAmountClicked,
openPermissionBottomSheet = {
onApproveClick = {
singleTaskScheduler.cancelTask()
sendGivePermissionClickedEvent()
approvalSlotNavigation.activate(Unit)
val approval = getApprovalParams()
if (approval != null) {
approvalSlotNavigation.activate(
GiveApprovalEntryComponent.Mode.FullApproval(approval),
)
}
},
onApproveTypeSelect = { provider ->
val approval = getSelectApprovalTypeParams(provider)
if (approval != null) {
approvalSlotNavigation.activate(
GiveApprovalEntryComponent.Mode.SelectOnly(approval),
)
}
},
onAmountSelected = { onAmountSelected(it) },
onProviderClick = { providerId ->
singleTaskScheduler.cancelTask()
analyticsEventHandler.send(SwapEvents.ProviderClicked())
val states = dataState.lastLoadedSwapStates.getLastLoadedSuccessStates()
val pricesLowerBest = getPricesLowerBest(providerId, states)
@ -2140,6 +2299,7 @@ internal class SwapModel @Inject constructor(
dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val toSwapCurrencyStatus =
dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val amount = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError)
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
fromSwapCurrencyStatus.currency,
toSwapCurrencyStatus.currency,
@ -2148,7 +2308,7 @@ internal class SwapModel @Inject constructor(
return swapTransferInteractor.loadFee(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
fromTokenAmount = amount,
).onLeft {
TangemLogger.e("loadFee[transfer]: Failed to load fee with error $it")
}.onRight {
@ -2162,9 +2322,7 @@ internal class SwapModel @Inject constructor(
return Either.Left(GetFeeError.UnknownError)
}
val amountDecimal = lastAmount.value.replace(",", ".").toBigDecimalOrNull()
?: return Either.Left(GetFeeError.UnknownError)
val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals)
val swapDataForCall = when (quoteState.swapProvider.type) {
ExchangeProviderType.DEX, ExchangeProviderType.DEX_BRIDGE -> {
quoteState.swapDataModel ?: return Either.Left(GetFeeError.UnknownError)
@ -2196,6 +2354,8 @@ internal class SwapModel @Inject constructor(
dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val toSwapCurrencyStatus =
dataState.toSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError)
val amount = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError)
val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap(
fromSwapCurrencyStatus.currency,
toSwapCurrencyStatus.currency,
@ -2204,7 +2364,8 @@ internal class SwapModel @Inject constructor(
return swapTransferInteractor.loadFeeExtended(
fromSwapCurrencyStatus = fromSwapCurrencyStatus,
toSwapCurrencyStatus = toSwapCurrencyStatus,
fromTokenAmount = lastAmount.value,
fromTokenAmount = amount,
selectedToken = selectedToken,
)
}
val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError)
@ -2213,8 +2374,7 @@ internal class SwapModel @Inject constructor(
return Either.Left(GetFeeError.UnknownError)
}
val amountDecimal = lastAmount.value.parseBigDecimalOrNull() ?: return Either.Left(GetFeeError.UnknownError)
val swapAmount = SwapAmount(amountDecimal, fromSwapCurrencyStatus.currency.decimals)
val swapAmount = SwapAmount(amount, fromSwapCurrencyStatus.currency.decimals)
// DEX path requires a SwapDataModel.
val swapDataForCall = when (quoteState.swapProvider.type) {
@ -2252,7 +2412,7 @@ internal class SwapModel @Inject constructor(
if (newState is FeeSelectorUM.Error) {
TangemLogger.e("loadFee: ${newState.error}, isHidden = true")
refreshTransferUIStateAfterFeeUpdateIfNeeded()
refreshTransferUIStateIfNeeded()
uiState = stateBuilder.createFeeErrorState(
uiStateHolder = uiState,
quoteModel = dataState.getCurrentLoadedSwapState() ?: return,
@ -2262,10 +2422,6 @@ internal class SwapModel @Inject constructor(
modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) }
return
}
refreshTransferUIStateAfterFeeUpdateIfNeeded(
feePaidCryptoCurrencyStatus = dataState.feePaidCryptoCurrency,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
)
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus
val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus
@ -2274,7 +2430,13 @@ internal class SwapModel @Inject constructor(
fromSwapCurrencyStatus?.currency,
toSwapCurrencyStatus?.currency,
)
if (shouldTransferInsteadOfSwap) return
if (shouldTransferInsteadOfSwap) {
refreshTransferUIStateIfNeeded(
feePaidCryptoCurrencyStatus = getSelectedSwapFee()?.selectedFeeToken,
fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee,
)
return
}
val quoteState = dataState.getCurrentLoadedSwapState() ?: return
val swapFee = getSelectedSwapFee() ?: return
@ -2321,6 +2483,54 @@ internal class SwapModel @Inject constructor(
}
}
internal fun getSelectApprovalTypeParams(provider: SwapProvider): SelectApprovalTypeComponent.Params? {
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return null
val swapState = dataState.lastLoadedSwapStates[provider] as? SwapState.QuotesLoadedState ?: return null
val permissionState = swapState.permissionState as? PermissionDataState.PermissionSettings ?: return null
val providerName = swapState.swapProvider.name
val approvalType = permissionState.type
return SelectApprovalTypeComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
initialApproveType = approvalType,
amountFooter = resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, fromSwapCurrencyStatus.currency.symbol),
),
spenderAddress = permissionState.spenderAddress,
callback = approvalSelectorCallback,
)
}
internal fun getApprovalParams(): GiveApprovalComponent.Params? {
val permissionState = uiState.permissionUM as? SwapPermissionUM.PermissionRequired ?: return null
val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return null
val feeCryptoCurrency = dataState.feePaidCryptoCurrency ?: return null
val providerName = dataState.selectedProvider?.name.orEmpty()
val isHoldToConfirm = fromSwapCurrencyStatus.userWallet.isHotWallet
return GiveApprovalComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = fromSwapCurrencyStatus.status,
feeCryptoCurrencyStatus = feeCryptoCurrency,
amount = dataState.amount.orEmpty(),
spenderAddress = permissionState.spenderAddress,
amountFooter = if (permissionState.isResetApproval) {
resourceReference(R.string.update_approval_permission_subtitle)
} else {
resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, fromSwapCurrencyStatus.currency.symbol),
)
},
feeFooter = resourceReference(R.string.swap_give_permission_fee_footer),
isResetApproval = permissionState.isResetApproval,
isHoldToConfirm = isHoldToConfirm,
callback = approvalFullCallback,
)
}
private companion object {
const val INITIAL_AMOUNT = ""
const val UPDATE_DELAY = 10000L

View file

@ -249,7 +249,7 @@ internal class SwapNotificationsFactory(
if (quoteModel.permissionState is PermissionDataState.PermissionRequired) {
add(
SwapNotificationUM.Info.PermissionNeeded(
onApproveClick = actions.openPermissionBottomSheet,
onApproveClick = actions.onApproveClick,
onLearnMoreClick = { actions.onLinkClick(TangemSiteUrlBuilder.HELP_CENTER_SWAP_URL) },
),
)

View file

@ -4,6 +4,7 @@ import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.express.models.ProviderFilterType
import com.tangem.domain.swap.models.PredefinedPercentAmount
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.domain.SwapProvider
import com.tangem.feature.swap.domain.models.domain.SwapUIMode
import java.math.BigDecimal
@ -18,7 +19,8 @@ internal data class UiActions(
val onPredefinedPercentSelected: (PredefinedPercentAmount) -> Unit,
val onReduceToAmount: (SwapAmount) -> Unit,
val onReduceByAmount: (SwapAmount, reduceBy: BigDecimal) -> Unit,
val openPermissionBottomSheet: () -> Unit,
val onApproveClick: () -> Unit,
val onApproveTypeSelect: (SwapProvider) -> Unit,
// region new actions
val onRetryClick: () -> Unit,
val onProviderClick: (String) -> Unit,

View file

@ -29,6 +29,7 @@ sealed class ProviderState {
val additionalBadge: AdditionalBadge,
val percentLowerThenBest: PercentDifference = PercentDifference.Empty,
val namePrefix: PrefixType,
val approvalSettings: ApprovalSettings = ApprovalSettings.Empty,
override val onProviderClick: (String) -> Unit,
) : ProviderState()
@ -61,6 +62,14 @@ sealed class ProviderState {
enum class PrefixType {
NONE, PROVIDED_BY
}
@Immutable
sealed class ApprovalSettings {
data object Empty : ApprovalSettings()
data class Content(
val onApprovalSelectClick: () -> Unit,
) : ApprovalSettings()
}
}
@Immutable

View file

@ -178,6 +178,7 @@ private fun Preview_ChooseProviderBottomSheet() {
percentLowerThenBest = PercentDifference.Value(-1.0f),
selectionType = ProviderState.SelectionType.SELECT,
namePrefix = ProviderState.PrefixType.NONE,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = {},
),
ProviderState.Unavailable(

View file

@ -4,26 +4,32 @@ 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.layout.Arrangement
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
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.SpacerWMax
import com.tangem.core.ui.extensions.resolveReference
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.stringResourceSafe
@ -194,6 +200,23 @@ private fun ProviderContentState(
}
}
}
if (state.approvalSettings is ProviderState.ApprovalSettings.Content) {
SpacerWMax()
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_filter_default_24),
contentDescription = null,
tint = TangemTheme.colors.icon.informative,
modifier = Modifier
.padding(end = 14.dp)
.size(20.dp)
.clickable(
indication = ripple(false),
interactionSource = remember { MutableInteractionSource() },
onClick = state.approvalSettings.onApprovalSelectClick,
),
)
}
}
ProviderChevron(selectionType = state.selectionType, isSelected = isSelected)
@ -442,10 +465,9 @@ private fun ProviderItemPreview(
@PreviewParameter(ProviderItemParameterProvider::class) state: Pair<ProviderState, Boolean>,
) {
TangemThemePreview {
ProviderItem(
ProviderItemBlock(
modifier = Modifier.background(TangemTheme.colors.background.action),
state = state.first,
isSelected = state.second,
)
}
}
@ -460,22 +482,28 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
subtitle = stringReference(value = "0,64554846 DAI ≈ 1 MATIC"),
additionalBadge = ProviderState.AdditionalBadge.Empty,
percentLowerThenBest = PercentDifference.Value(value = 12.0f),
selectionType = ProviderState.SelectionType.SELECT,
selectionType = ProviderState.SelectionType.NONE,
namePrefix = ProviderState.PrefixType.PROVIDED_BY,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = {},
)
val contentState2 = contentState.copy(
val contentStatePermissionRequired = contentState.copy(
subtitle = stringReference(value = "1 132,46 MATIC"),
additionalBadge = ProviderState.AdditionalBadge.PermissionRequired,
percentLowerThenBest = PercentDifference.Value(value = 5f),
)
val contentStatePermissionIntegrated = contentState.copy(
subtitle = stringReference(value = "1 132,46 MATIC"),
percentLowerThenBest = PercentDifference.Value(value = 5f),
approvalSettings = ProviderState.ApprovalSettings.Content({}),
)
val unavailableState = ProviderState.Unavailable(
id = "1",
name = "1inch",
type = "DEX",
iconUrl = "",
alertText = stringReference(value = "Not available"),
selectionType = ProviderState.SelectionType.SELECT,
selectionType = ProviderState.SelectionType.NONE,
onProviderClick = {},
)
val loadingState = ProviderState.Loading()
@ -483,8 +511,11 @@ private class ProviderItemParameterProvider : CollectionPreviewParameterProvider
add(contentState to true)
add(contentState to false)
add(contentState2 to true)
add(contentState2 to false)
add(contentStatePermissionRequired to true)
add(contentStatePermissionRequired to false)
add(contentStatePermissionIntegrated to true)
add(contentStatePermissionIntegrated to false)
add(unavailableState to true)
add(unavailableState to false)

View file

@ -173,6 +173,7 @@ private class SimpleProviderPreview : PreviewParameterProvider<ProviderState> {
additionalBadge = ProviderState.AdditionalBadge.Empty,
percentLowerThenBest = PercentDifference.Empty,
namePrefix = ProviderState.PrefixType.NONE,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = {},
),
ProviderState.Content(

View file

@ -100,7 +100,7 @@ internal class StateBuilder(
onChangeCardsClicked = actions.onChangeCardsClicked,
onMaxAmountSelected = actions.onMaxAmountSelected,
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
onShowPermissionBottomSheet = actions.openPermissionBottomSheet,
onShowPermissionBottomSheet = actions.onApproveClick,
onSelectTokenClick = actions.onSelectTokenClick,
onSuccess = actions.onSuccess,
providerState = ProviderState.Empty(),
@ -1078,6 +1078,7 @@ internal class StateBuilder(
pricesLowerBest = pricesLowerBest,
onProviderSelect = actions.onProviderSelect,
needApplyFCARestrictions = needApplyFCARestrictions,
onApprovalSelectClick = actions.onApproveTypeSelect,
bestRatedProviderId = bestRatedProviderId,
isNeedBestRateBadge = isNeedBestRateBadge,
)
@ -1183,6 +1184,7 @@ internal class StateBuilder(
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderBottomSheetState(
pricesLowerBest: Map<String, Float>,
onProviderSelect: (String) -> Unit,
onApprovalSelectClick: (SwapProvider) -> Unit,
needApplyFCARestrictions: Boolean,
bestRatedProviderId: String,
isNeedBestRateBadge: Boolean,
@ -1201,6 +1203,7 @@ internal class StateBuilder(
isBestRate = bestRatedProviderId == provider.providerId && !state.priceImpact.shouldShowWarning(),
isNeedBestRateBadge = isNeedBestRateBadge,
onProviderClick = onProviderSelect,
onApprovalSelectClick = onApprovalSelectClick,
)
}
is SwapState.SwapError -> getProviderStateForError(

View file

@ -3,12 +3,14 @@ package com.tangem.feature.swap.ui.transfer
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.common.ui.notifications.NotificationUM
import com.tangem.common.ui.notifications.NotificationsFactory.addDustWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExceedsBalanceNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addExistentialWarningNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addFeeCoverageNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addReserveAmountErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addTransactionLimitErrorNotification
import com.tangem.common.ui.notifications.NotificationsFactory.addValidateTransactionNotifications
import com.tangem.core.ui.utils.parseBigDecimal
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.SwapAmount
import com.tangem.feature.swap.domain.models.ui.SwapState
@ -24,12 +26,14 @@ import javax.inject.Inject
internal class SwapTransferNotificationsFactory @Inject constructor() {
@Suppress("LongParameterList")
fun getNotifications(
transferState: SwapState.Transfer,
feeCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
onReduceByAmount: (SwapAmount, BigDecimal) -> Unit,
onReduceToAmount: (SwapAmount) -> Unit,
onBuyClick: (CryptoCurrency) -> Unit,
): ImmutableList<NotificationUM> {
return buildList {
maybeAddRentExemptionError(transferState)
@ -41,6 +45,7 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
onReduceToAmount = onReduceToAmount,
)
maybeAddNeedReserveToCreateAccountWarning(transferState)
maybeAddExceedsBalanceNotification(transferState, onBuyClick)
}.toPersistentList()
}
@ -172,4 +177,21 @@ internal class SwapTransferNotificationsFactory @Inject constructor() {
)
}
}
private fun MutableList<NotificationUM>.maybeAddExceedsBalanceNotification(
transferState: SwapState.Transfer,
onBuyClick: (CryptoCurrency) -> Unit,
) {
val cryptoCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus.status
addExceedsBalanceNotification(
cryptoCurrencyWarning = transferState.cryptoCurrencyWarning,
cryptoCurrencyStatus = cryptoCurrencyStatus,
shouldMergeFeeNetworkName = BlockchainUtils.isArbitrum(
networkId = cryptoCurrencyStatus.currency.network.rawId,
),
onClick = onBuyClick,
onAnalyticsEvent = {},
onResetAnalyticsEvent = {},
)
}
}

View file

@ -59,6 +59,7 @@ internal class SwapTransferStateBuilder @Inject constructor(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
onBuyClick = actions.openTokenDetailsScreen,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
)
@ -210,6 +211,9 @@ internal class SwapTransferStateBuilder @Inject constructor(
}
}
/**
* [isTangemPayWithdrawal] - if true - Tangem pay withdrawal done with no fee, skip fee nullability check
*/
@Suppress("LongParameterList")
fun updateTransferButtonEnableState(
dataState: SwapProcessDataState,
@ -218,18 +222,20 @@ internal class SwapTransferStateBuilder @Inject constructor(
uiStateHolder: SwapStateHolder,
feePaidCryptoCurrencyStatus: CryptoCurrencyStatus?,
fee: Fee?,
isTangemPayWithdrawal: Boolean,
): SwapStateHolder {
val notifications = notificationsFactory.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus,
fee = fee,
onBuyClick = actions.openTokenDetailsScreen,
onReduceByAmount = actions.onReduceByAmount,
onReduceToAmount = actions.onReduceToAmount,
)
return uiStateHolder.copy(
notifications = notifications,
swapButton = uiStateHolder.swapButton.copy(
isEnabled = getTransferButtonEnabled(notifications, fee),
isEnabled = getTransferButtonEnabled(notifications, fee, isTangemPayWithdrawal),
),
transferFooter = getSendingFooterText(
dataState = dataState,
@ -240,8 +246,12 @@ internal class SwapTransferStateBuilder @Inject constructor(
)
}
private fun getTransferButtonEnabled(notifications: ImmutableList<NotificationUM>, fee: Fee?): Boolean {
return fee != null && notifications.none { notification ->
private fun getTransferButtonEnabled(
notifications: ImmutableList<NotificationUM>,
fee: Fee?,
isTangemPayWithdrawal: Boolean,
): Boolean {
return (fee != null || isTangemPayWithdrawal) && notifications.none { notification ->
notification is SwapNotificationUM.Error || notification is NotificationUM.Error ||
notification is SwapNotificationUM.Warning.ExpressErrorWarning ||
notification is SwapNotificationUM.Warning.ExpressGeneralError ||
@ -317,13 +327,12 @@ internal class SwapTransferStateBuilder @Inject constructor(
fun createSuccessState(
uiState: SwapStateHolder,
dataState: SwapProcessDataState,
appCurrency: AppCurrency,
isAccountsMode: Boolean,
fee: Fee?,
txUrl: String,
timestamp: Long,
fee: TextReference?,
onExplorerClick: () -> Unit,
): SwapStateHolder {
val transferState = requireNotNull(dataState.currentTransferState)
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
val amount = dataState.amount?.parseBigDecimalOrNull() ?: BigDecimal.ZERO
@ -333,11 +342,11 @@ internal class SwapTransferStateBuilder @Inject constructor(
val fromAmountText = amount.format { crypto(fromCurrency.symbol, fromCurrency.decimals) }
val toAmountText = amount.format { crypto(toCurrency.symbol, toCurrency.decimals) }
val fromFiatAmount = getFormattedFiatAmount(
appCurrency = appCurrency,
appCurrency = transferState.appCurrency,
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
)
val toFiatAmount = getFormattedFiatAmount(
appCurrency = appCurrency,
appCurrency = transferState.appCurrency,
amount = toSwapCurrencyStatus.status.value.fiatRate?.multiply(amount),
)
@ -351,15 +360,15 @@ internal class SwapTransferStateBuilder @Inject constructor(
isTransferMode = true,
providerIcon = "",
rate = TextReference.EMPTY,
fee = fee,
fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) },
fromTitle = getCardAccountTitle(
account = fromSwapCurrencyStatus.account,
isAccountsMode = isAccountsMode,
isAccountsMode = transferState.isAccountsMode,
isFromCard = true,
),
toTitle = getCardAccountTitle(
account = toSwapCurrencyStatus.account,
isAccountsMode = isAccountsMode,
isAccountsMode = transferState.isAccountsMode,
isFromCard = false,
),
fromTokenAmount = stringReference(fromAmountText),
@ -373,4 +382,68 @@ internal class SwapTransferStateBuilder @Inject constructor(
),
)
}
fun createTangemPayWithdrawalSuccessState(
uiState: SwapStateHolder,
dataState: SwapProcessDataState,
fee: Fee?,
onExploreClick: () -> Unit,
): SwapStateHolder {
val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus)
val toSwapCurrencyStatus = requireNotNull(dataState.toSwapCurrencyStatus)
val transferState = requireNotNull(dataState.currentTransferState)
val amountValue = transferState.sendingAmount
val fiatAmount = getFormattedFiatAmount(
appCurrency = transferState.appCurrency,
amount = fromSwapCurrencyStatus.status.value.fiatRate?.multiply(amountValue),
)
return uiState.copy(
successState = SwapSuccessStateHolder(
timestamp = System.currentTimeMillis(),
txUrl = "",
providerName = stringReference(""),
providerType = stringReference(""),
shouldShowStatusButton = false,
isTransferMode = true,
providerIcon = "",
rate = TextReference.EMPTY,
fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) },
fromTitle = getCardAccountTitle(
account = fromSwapCurrencyStatus.account,
isAccountsMode = transferState.isAccountsMode,
isFromCard = true,
),
toTitle = getCardAccountTitle(
account = toSwapCurrencyStatus.account,
isAccountsMode = transferState.isAccountsMode,
isFromCard = false,
),
fromTokenAmount = stringReference(amountValue.toString()),
toTokenAmount = stringReference(amountValue.toString()),
fromTokenFiatAmount = fiatAmount,
toTokenFiatAmount = fiatAmount,
fromTokenIconState = iconConverter.convert(fromSwapCurrencyStatus.status),
toTokenIconState = iconConverter.convert(toSwapCurrencyStatus.status),
onExploreButtonClick = onExploreClick,
onStatusButtonClick = {},
),
)
}
private fun formatFeeForSuccess(transferState: SwapState.Transfer, fee: Fee): TextReference {
val feeAmount = fee.amount
val totalFeeValue = feeAmount.value ?: BigDecimal.ZERO
val cryptoFormatted = totalFeeValue.format {
crypto(symbol = feeAmount.currencySymbol, decimals = feeAmount.decimals)
}
val appCurrency = transferState.appCurrency
val swapCurrencyStatus = transferState.fromTokenInfo.swapCurrencyStatus
val fiatRate = swapCurrencyStatus.status.value.fiatRate
val fiatFormatted = fiatRate?.multiply(totalFeeValue).format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
return stringReference("$cryptoFormatted ($fiatFormatted)")
}
}

View file

@ -0,0 +1,70 @@
package com.tangem.feature.swap.model
import com.tangem.domain.models.currency.CryptoCurrency
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Tests for [SwapModel.approvalFullCallback] (the full give-approval flow callback).
*
* Covers the synchronously-verifiable side effects. The quote-reload path
* ([SwapModel] `startLoadingQuotesFromLastState`) short-circuits because `dataState.amount` is null
* in these setups, keeping the callbacks side-effect-bounded.
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class SwapModelApprovalFullCallbackTest : SwapModelTestBase() {
@BeforeEach
fun setUp() {
setUpBase()
}
@Test
fun `GIVEN token from-currency WHEN onApproveDone THEN adds contract address to in-progress`() = runTest {
val model = createModel()
val token = mockk<CryptoCurrency.Token>(relaxed = true) {
every { contractAddress } returns "0xContract"
}
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = swapCurrencyStatus(currency = token),
)
model.approvalFullCallback.onApproveDone()
verify(exactly = 1) { allowPermissionsHandler.addAddressToInProgress("0xContract") }
}
@Test
fun `GIVEN null from-currency WHEN onApproveDone THEN does not add address to in-progress`() = runTest {
val model = createModel()
model.dataState = model.dataState.copy(fromSwapCurrencyStatus = null)
model.approvalFullCallback.onApproveDone()
verify(exactly = 0) { allowPermissionsHandler.addAddressToInProgress(any()) }
}
@Test
fun `GIVEN failure WHEN onApproveFailed THEN sends alert message`() = runTest {
val model = createModel()
model.approvalFullCallback.onApproveFailed()
verify(exactly = 1) { messageSender.send(any()) }
}
@Test
fun `WHEN onApproveClick THEN no interactions`() = runTest {
val model = createModel()
// onApproveClick is intentionally a no-op; assert it does not crash.
model.approvalFullCallback.onApproveClick()
verify(exactly = 0) { allowPermissionsHandler.addAddressToInProgress(any()) }
}
}

View file

@ -0,0 +1,111 @@
package com.tangem.feature.swap.model
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import io.mockk.coVerify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Tests for [SwapModel.approvalSelectorCallback] (the [PermissionDataState.PermissionSettings]
* approval-type selector).
*/
@OptIn(ExperimentalCoroutinesApi::class)
internal class SwapModelApprovalSelectorCallbackTest : SwapModelTestBase() {
@BeforeEach
fun setUp() {
setUpBase()
}
@Test
fun `GIVEN no current loaded state WHEN onApproveTypeSelected THEN no state change and no reload`() = runTest {
val model = createModel()
val before = model.dataState
model.approvalSelectorCallback.onApproveTypeSelected("0xSpender", ApproveType.UNLIMITED)
assertThat(model.dataState).isEqualTo(before)
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerLoadingState() }
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerUpdate() }
}
@Test
fun `GIVEN permission is not PermissionSettings WHEN onApproveTypeSelected THEN no reload`() = runTest {
val provider = swapProvider()
val model = createModel()
model.dataState = model.dataState.copy(
selectedProvider = provider,
lastLoadedSwapStates = mapOf(provider to quotesLoadedState(provider, PermissionDataState.Empty)),
)
model.approvalSelectorCallback.onApproveTypeSelected("0xSpender", ApproveType.UNLIMITED)
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerLoadingState() }
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerUpdate() }
}
@Test
fun `GIVEN same approve type WHEN onApproveTypeSelected THEN no state change and no reload`() = runTest {
val provider = swapProvider()
val model = createModel()
model.dataState = model.dataState.copy(
selectedProvider = provider,
lastLoadedSwapStates = mapOf(
provider to quotesLoadedState(provider, permissionSettings(type = ApproveType.LIMITED)),
),
)
val before = model.dataState
model.approvalSelectorCallback.onApproveTypeSelected("0xSpender", ApproveType.LIMITED)
assertThat(model.dataState).isEqualTo(before)
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerLoadingState() }
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerUpdate() }
}
@Test
fun `GIVEN different approve type WHEN onApproveTypeSelected THEN updates state and triggers reload`() = runTest {
val provider = swapProvider()
val model = createModel()
model.dataState = model.dataState.copy(
selectedProvider = provider,
lastLoadedSwapStates = mapOf(
provider to quotesLoadedState(provider, permissionSettings(type = ApproveType.LIMITED)),
),
)
model.approvalSelectorCallback.onApproveTypeSelected("0xSpender", ApproveType.UNLIMITED)
val updated = model.dataState.getCurrentLoadedSwapState()
val settings = updated?.permissionState as? PermissionDataState.PermissionSettings
assertThat(settings).isNotNull()
assertThat(settings!!.type).isEqualTo(ApproveType.UNLIMITED)
assertThat(model.feeSelectorRepository.state.value).isEqualTo(FeeSelectorUM.Loading)
coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerLoadingState() }
coVerify(exactly = 1) { feeSelectorReloadTrigger.triggerUpdate() }
}
@Test
fun `GIVEN any state WHEN onCancelClick THEN no state change and no reload`() = runTest {
val provider = swapProvider()
val model = createModel()
model.dataState = model.dataState.copy(
selectedProvider = provider,
lastLoadedSwapStates = mapOf(
provider to quotesLoadedState(provider, permissionSettings()),
),
)
val before = model.dataState
model.approvalSelectorCallback.onCancelClick()
assertThat(model.dataState).isEqualTo(before)
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerLoadingState() }
coVerify(exactly = 0) { feeSelectorReloadTrigger.triggerUpdate() }
}
}

View file

@ -0,0 +1,142 @@
package com.tangem.feature.swap.model
import com.google.common.truth.Truth.assertThat
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.feature.swap.models.SwapPermissionUM
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Tests for [SwapModel.getApprovalParams].
*
* Builds [com.tangem.features.approval.api.GiveApprovalComponent.Params] only when:
* - `uiState.permissionUM` is [SwapPermissionUM.PermissionRequired]
* - `fromSwapCurrencyStatus` is non-null
* - `feePaidCryptoCurrency` is non-null
*/
internal class SwapModelGetApprovalParamsTest : SwapModelTestBase() {
@BeforeEach
fun setUp() {
setUpBase()
}
@Test
fun `GIVEN permissionUM is not PermissionRequired THEN returns null`() {
val model = createModel()
// default uiState has permissionUM == Empty
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = swapCurrencyStatus(),
feePaidCryptoCurrency = mockk(relaxed = true),
)
assertThat(model.getApprovalParams()).isNull()
}
@Test
fun `GIVEN null fromSwapCurrencyStatus THEN returns null`() {
val model = createModel()
model.uiState = model.uiState.copy(
permissionUM = SwapPermissionUM.PermissionRequired(isResetApproval = false, spenderAddress = "0xSpender"),
)
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = null,
feePaidCryptoCurrency = mockk(relaxed = true),
)
assertThat(model.getApprovalParams()).isNull()
}
@Test
fun `GIVEN null feePaidCryptoCurrency THEN returns null`() {
val model = createModel()
model.uiState = model.uiState.copy(
permissionUM = SwapPermissionUM.PermissionRequired(isResetApproval = false, spenderAddress = "0xSpender"),
)
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = swapCurrencyStatus(),
feePaidCryptoCurrency = null,
)
assertThat(model.getApprovalParams()).isNull()
}
@Test
fun `GIVEN give-approval prerequisites met THEN builds params with give footer`() {
val model = createModel()
val coldWallet = mockk<UserWallet.Cold>(relaxed = true)
val currency = mockk<CryptoCurrency>(relaxed = true) { every { symbol } returns "DAI" }
val status = mockk<CryptoCurrencyStatus>(relaxed = true)
val from = swapCurrencyStatus(wallet = coldWallet, status = status, currency = currency)
val feeStatus: CryptoCurrencyStatus = mockk(relaxed = true)
val provider = swapProvider(name = "1inch")
model.uiState = model.uiState.copy(
permissionUM = SwapPermissionUM.PermissionRequired(isResetApproval = false, spenderAddress = "0xSpender"),
)
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = from,
feePaidCryptoCurrency = feeStatus,
selectedProvider = provider,
amount = "12.5",
)
val params = model.getApprovalParams()
assertThat(params).isNotNull()
assertThat(params!!.userWalletId).isEqualTo(userWalletId)
assertThat(params.cryptoCurrencyStatus).isEqualTo(status)
assertThat(params.feeCryptoCurrencyStatus).isEqualTo(feeStatus)
assertThat(params.amount).isEqualTo("12.5")
assertThat(params.spenderAddress).isEqualTo("0xSpender")
assertThat(params.isResetApproval).isFalse()
// Cold wallet -> not hold-to-confirm
assertThat(params.isHoldToConfirm).isFalse()
assertThat(params.callback).isSameInstanceAs(model.approvalFullCallback)
}
@Test
fun `GIVEN hot wallet THEN isHoldToConfirm is true`() {
val model = createModel()
val hotWallet = mockk<UserWallet.Hot>(relaxed = true)
val from = swapCurrencyStatus(wallet = hotWallet)
model.uiState = model.uiState.copy(
permissionUM = SwapPermissionUM.PermissionRequired(isResetApproval = false, spenderAddress = "0xSpender"),
)
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = from,
feePaidCryptoCurrency = mockk(relaxed = true),
amount = "1",
)
val params = model.getApprovalParams()
assertThat(params).isNotNull()
assertThat(params!!.isHoldToConfirm).isTrue()
}
@Test
fun `GIVEN reset approval THEN isResetApproval is true`() {
val model = createModel()
val from = swapCurrencyStatus()
model.uiState = model.uiState.copy(
permissionUM = SwapPermissionUM.PermissionRequired(isResetApproval = true, spenderAddress = "0xSpender"),
)
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = from,
feePaidCryptoCurrency = mockk(relaxed = true),
amount = "1",
)
val params = model.getApprovalParams()
assertThat(params).isNotNull()
assertThat(params!!.isResetApproval).isTrue()
}
}

View file

@ -0,0 +1,76 @@
package com.tangem.feature.swap.model
import com.google.common.truth.Truth.assertThat
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import com.tangem.feature.swap.domain.models.ui.SwapState
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Tests for [SwapModel.getSelectApprovalTypeParams].
*
* Builds [com.tangem.features.approval.api.SelectApprovalTypeComponent.Params] from the current
* `fromSwapCurrencyStatus` and the provider's loaded swap state. Returns `null` when prerequisites
* are missing. `initialApproveType` is taken from [PermissionDataState.PermissionSettings.type] or
* falls back to [ApproveType.LIMITED].
*/
internal class SwapModelGetSelectApprovalTypeParamsTest : SwapModelTestBase() {
@BeforeEach
fun setUp() {
setUpBase()
}
@Test
fun `GIVEN null fromSwapCurrencyStatus THEN returns null`() {
val provider = swapProvider()
val model = createModel()
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = null,
lastLoadedSwapStates = mapOf(provider to quotesLoadedState(provider, permissionSettings())),
)
assertThat(model.getSelectApprovalTypeParams(provider)).isNull()
}
@Test
fun `GIVEN provider state is not QuotesLoadedState THEN returns null`() {
val provider = swapProvider()
val notLoaded: SwapState.EmptyAmountState = mockk(relaxed = true)
val model = createModel()
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = swapCurrencyStatus(),
lastLoadedSwapStates = mapOf(provider to notLoaded),
)
assertThat(model.getSelectApprovalTypeParams(provider)).isNull()
}
@Test
fun `GIVEN PermissionSettings present THEN uses its type as initialApproveType`() {
val provider = swapProvider(name = "ParaSwap")
val currency = mockk<CryptoCurrency>(relaxed = true) { every { symbol } returns "USDT" }
val status = mockk<CryptoCurrencyStatus>(relaxed = true)
val from = swapCurrencyStatus(status = status, currency = currency)
val model = createModel()
model.dataState = model.dataState.copy(
fromSwapCurrencyStatus = from,
lastLoadedSwapStates = mapOf(
provider to quotesLoadedState(provider, permissionSettings(type = ApproveType.UNLIMITED)),
),
)
val params = model.getSelectApprovalTypeParams(provider)
assertThat(params).isNotNull()
assertThat(params!!.userWalletId).isEqualTo(userWalletId)
assertThat(params.cryptoCurrencyStatus).isEqualTo(status)
assertThat(params.initialApproveType).isEqualTo(ApproveType.UNLIMITED)
assertThat(params.callback).isSameInstanceAs(model.approvalSelectorCallback)
}
}

View file

@ -0,0 +1,92 @@
package com.tangem.feature.swap.model
import com.google.common.truth.Truth.assertThat
import com.tangem.feature.swap.domain.models.ui.PermissionDataState
import io.mockk.every
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
/**
* Tests for [SwapModel.isPermissionNotNeeded].
*
* The getter is `true` when the current loaded swap state needs no approval:
* - always when [PermissionDataState.Empty]
* - additionally for [PermissionDataState.PermissionSettings] when the integrated-approve toggle is ON
*/
internal class SwapModelIsPermissionNotNeededTest : SwapModelTestBase() {
@BeforeEach
fun setUp() {
setUpBase()
}
private fun modelWithPermissionState(
permissionState: PermissionDataState,
isIntegratedApproveEnabled: Boolean,
): SwapModel {
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns isIntegratedApproveEnabled
val provider = swapProvider()
val model = createModel()
model.dataState = model.dataState.copy(
selectedProvider = provider,
lastLoadedSwapStates = mapOf(provider to quotesLoadedState(provider, permissionState)),
)
return model
}
@Test
fun `GIVEN toggle OFF and Empty permission THEN permission is not needed`() {
val model = modelWithPermissionState(PermissionDataState.Empty, isIntegratedApproveEnabled = false)
assertThat(model.isPermissionNotNeeded).isTrue()
}
@Test
fun `GIVEN toggle OFF and PermissionSettings THEN permission is needed`() {
val model = modelWithPermissionState(permissionSettings(), isIntegratedApproveEnabled = false)
assertThat(model.isPermissionNotNeeded).isFalse()
}
@Test
fun `GIVEN toggle OFF and PermissionRequired THEN permission is needed`() {
val model = modelWithPermissionState(
PermissionDataState.PermissionRequired(isResetApproval = false, spenderAddress = "0x"),
isIntegratedApproveEnabled = false,
)
assertThat(model.isPermissionNotNeeded).isFalse()
}
@Test
fun `GIVEN toggle ON and Empty permission THEN permission is not needed`() {
val model = modelWithPermissionState(PermissionDataState.Empty, isIntegratedApproveEnabled = true)
assertThat(model.isPermissionNotNeeded).isTrue()
}
@Test
fun `GIVEN toggle ON and PermissionSettings THEN permission is not needed`() {
val model = modelWithPermissionState(permissionSettings(), isIntegratedApproveEnabled = true)
assertThat(model.isPermissionNotNeeded).isTrue()
}
@Test
fun `GIVEN toggle ON and PermissionRequired THEN permission is needed`() {
val model = modelWithPermissionState(
PermissionDataState.PermissionRequired(isResetApproval = false, spenderAddress = "0x"),
isIntegratedApproveEnabled = true,
)
assertThat(model.isPermissionNotNeeded).isFalse()
}
@Test
fun `GIVEN no current loaded state THEN permission is needed`() {
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
val model = createModel()
assertThat(model.isPermissionNotNeeded).isFalse()
}
}

View file

@ -0,0 +1,236 @@
package com.tangem.feature.swap.model
import arrow.core.right
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.decompose.model.MutableParamsContainer
import com.tangem.core.decompose.navigation.Router
import com.tangem.core.decompose.ui.UiMessageSender
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.common.routing.AppRouter
import com.tangem.datasource.local.appsflyer.AppsFlyerStore
import com.tangem.domain.account.status.usecase.GetAccountCurrencyStatusUseCase
import com.tangem.domain.account.status.usecase.GetFeePaidCryptoCurrencyStatusSyncUseCase
import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase
import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase
import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase
import com.tangem.domain.feedback.GetWalletMetaInfoUseCase
import com.tangem.domain.feedback.SaveBlockchainErrorUseCase
import com.tangem.domain.feedback.SendFeedbackEmailUseCase
import com.tangem.domain.models.currency.CryptoCurrency
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
import com.tangem.domain.pay.usecase.GetPaymentAccountCryptoCurrencyStatusUseCase
import com.tangem.domain.settings.usercountry.GetUserCountryUseCase
import com.tangem.domain.settings.usercountry.models.UserCountry
import com.tangem.domain.stories.ShouldShowStoriesUseCase
import com.tangem.domain.swap.models.SwapCurrencyStatus
import com.tangem.domain.swap.usecase.CalculateAmountUseCase
import com.tangem.domain.tangempay.GetTangemPayCustomerIdUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawUseCase
import com.tangem.domain.tangempay.TangemPayWithdrawWithSwapUseCase
import com.tangem.domain.tokens.GetMinimumTransactionAmountSyncUseCase
import com.tangem.domain.tokens.UpdateDelayedNetworkStatusUseCase
import com.tangem.domain.transaction.usecase.gasless.IsGaslessFeeSupportedForNetwork
import com.tangem.domain.txhistory.usecase.GetExplorerTransactionUrlUseCase
import com.tangem.feature.swap.domain.GetSwapUiModeUseCase
import com.tangem.feature.swap.domain.SetSwapUiModeUseCase
import com.tangem.feature.swap.domain.AllowPermissionsHandler
import com.tangem.feature.swap.domain.SwapInteractor
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.SwapState
import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor
import com.tangem.feature.swap.ui.transfer.SwapTransferStateBuilder
import com.tangem.features.commonfeatures.api.choosetoken.ChooseTokenBridge
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.coroutines.TestingCoroutineDispatcherProvider
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
/**
* Shared test infrastructure for [SwapModel] unit tests.
*
* Wires every constructor dependency as a relaxed MockK and stubs the init-block calls so that
* constructing the model has no observable side effects. See `swap-model-test-plan.md` for details.
*/
internal abstract class SwapModelTestBase {
protected val router: Router = mockk(relaxed = true)
protected val appRouter: AppRouter = mockk(relaxed = true)
protected val analyticsEventHandler: AnalyticsEventHandler = mockk(relaxed = true)
protected val analyticsErrorEventHandler: AnalyticsErrorHandler = mockk(relaxed = true)
protected val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk(relaxed = true)
protected val updateDelayedCurrencyStatusUseCase: UpdateDelayedNetworkStatusUseCase = mockk(relaxed = true)
protected val getFeePaidCryptoCurrencyStatusSyncUseCase: GetFeePaidCryptoCurrencyStatusSyncUseCase =
mockk(relaxed = true)
protected val getWalletMetaInfoUseCase: GetWalletMetaInfoUseCase = mockk(relaxed = true)
protected val saveBlockchainErrorUseCase: SaveBlockchainErrorUseCase = mockk(relaxed = true)
protected val sendFeedbackEmailUseCase: SendFeedbackEmailUseCase = mockk(relaxed = true)
protected val getMinimumTransactionAmountSyncUseCase: GetMinimumTransactionAmountSyncUseCase =
mockk(relaxed = true)
protected val getExplorerTransactionUrlUseCase: GetExplorerTransactionUrlUseCase = mockk(relaxed = true)
protected val shouldShowStoriesUseCase: ShouldShowStoriesUseCase = mockk(relaxed = true)
protected val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk(relaxed = true)
protected val swapInteractor: SwapInteractor = mockk(relaxed = true)
protected val swapTransferInteractor: SwapTransferInteractor = mockk(relaxed = true)
protected val swapTransferStateBuilder: SwapTransferStateBuilder = mockk(relaxed = true)
protected val urlOpener: UrlOpener = mockk(relaxed = true)
protected val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase = mockk(relaxed = true)
protected val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase =
mockk(relaxed = true)
protected val tangemPayWithdrawWithSwapUseCase: TangemPayWithdrawWithSwapUseCase = mockk(relaxed = true)
protected val isGaslessFeeSupportedForNetwork: IsGaslessFeeSupportedForNetwork = mockk(relaxed = true)
protected val feeSelectorReloadTrigger: FeeSelectorReloadTrigger = mockk(relaxed = true)
protected val getTangemPayCustomerIdUseCase: GetTangemPayCustomerIdUseCase = mockk(relaxed = true)
protected val appsFlyerStore: AppsFlyerStore = mockk(relaxed = true)
protected val messageSender: UiMessageSender = mockk(relaxed = true)
protected val initialCurrenciesResolver: InitialCurrenciesResolver = mockk(relaxed = true)
protected val allowPermissionsHandler: AllowPermissionsHandler = mockk(relaxed = true)
protected val swapFeatureToggles: SwapFeatureToggles = mockk(relaxed = true)
protected val getSwapUiModeUseCase: GetSwapUiModeUseCase = mockk(relaxed = true)
protected val setSwapUiModeUseCase: SetSwapUiModeUseCase = mockk(relaxed = true)
protected val calculateAmountUseCase: CalculateAmountUseCase = mockk(relaxed = true)
private val chooseTokenBridgeFactory: ChooseTokenBridge.Factory = mockk(relaxed = true)
private val getUserCountryUseCase: GetUserCountryUseCase = mockk(relaxed = true)
private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase = mockk(relaxed = true)
protected val userWalletId = UserWalletId(stringValue = "0123456789ABCDEF")
/** Stubs init-block calls so [createModel] has no side effects. Call from `@BeforeEach`. */
protected fun setUpBase() {
val bridge = mockk<ChooseTokenBridge>(relaxed = true) {
every { onCurrencyChosen } returns Channel()
every { onClose } returns Channel()
}
every { chooseTokenBridgeFactory.create(any(), any(), any()) } returns bridge
every { getUserCountryUseCase.invokeSync() } returns UserCountry.Other("US").right()
every { getBalanceHidingSettingsUseCase.invoke() } returns emptyFlow()
coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false
coEvery { shouldShowStoriesUseCase.invokeSync(any()) } returns false
coEvery { initialCurrenciesResolver.invoke(any(), any(), any(), any()) } returns (null to null)
every { getSelectedAppCurrencyUseCase.invoke() } returns emptyFlow()
every { swapFeatureToggles.isSwapIntegratedApproveEnabled } returns true
}
protected fun createParams(): SwapComponent.Params = SwapComponent.Params(
userWalletId = userWalletId,
cryptoCurrency = null,
screenSource = "Test",
)
@Suppress("LongMethod")
protected fun createModel(): SwapModel = SwapModel(
paramsContainer = MutableParamsContainer(createParams()),
getUserCountryUseCase = getUserCountryUseCase,
getBalanceHidingSettingsUseCase = getBalanceHidingSettingsUseCase,
chooseTokenBridgeFactory = chooseTokenBridgeFactory,
router = router,
appRouter = appRouter,
dispatchers = TestingCoroutineDispatcherProvider(),
analyticsEventHandler = analyticsEventHandler,
analyticsErrorEventHandler = analyticsErrorEventHandler,
getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase,
updateDelayedCurrencyStatusUseCase = updateDelayedCurrencyStatusUseCase,
getFeePaidCryptoCurrencyStatusSyncUseCase = getFeePaidCryptoCurrencyStatusSyncUseCase,
getWalletMetaInfoUseCase = getWalletMetaInfoUseCase,
saveBlockchainErrorUseCase = saveBlockchainErrorUseCase,
sendFeedbackEmailUseCase = sendFeedbackEmailUseCase,
getMinimumTransactionAmountSyncUseCase = getMinimumTransactionAmountSyncUseCase,
getExplorerTransactionUrlUseCase = getExplorerTransactionUrlUseCase,
shouldShowStoriesUseCase = shouldShowStoriesUseCase,
isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase,
swapInteractor = swapInteractor,
swapTransferInteractor = swapTransferInteractor,
swapTransferStateBuilder = swapTransferStateBuilder,
urlOpener = urlOpener,
getAccountCurrencyStatusUseCase = getAccountCurrencyStatusUseCase,
getPaymentAccountCryptoCurrencyStatusUseCase = getPaymentAccountCryptoCurrencyStatusUseCase,
tangemPayWithdrawWithSwapUseCase = tangemPayWithdrawWithSwapUseCase,
isGaslessFeeSupportedForNetwork = isGaslessFeeSupportedForNetwork,
feeSelectorReloadTrigger = feeSelectorReloadTrigger,
getTangemPayCustomerIdUseCase = getTangemPayCustomerIdUseCase,
appsFlyerStore = appsFlyerStore,
messageSender = messageSender,
initialCurrenciesResolver = initialCurrenciesResolver,
allowPermissionsHandler = allowPermissionsHandler,
swapFeatureToggles = swapFeatureToggles,
getSwapUiModeUseCase = getSwapUiModeUseCase,
setSwapUiModeUseCase = setSwapUiModeUseCase,
calculateAmountUseCase = calculateAmountUseCase,
)
// region builders
protected fun swapProvider(
id: String = "provider-1",
name: String = "1inch",
type: ExchangeProviderType = ExchangeProviderType.DEX,
): SwapProvider = SwapProvider(
providerId = id,
name = name,
type = type,
imageLarge = "",
termsOfUse = null,
privacyPolicy = null,
slippage = null,
)
protected fun permissionSettings(
type: ApproveType = ApproveType.LIMITED,
spender: String = "0xSpender",
): PermissionDataState.PermissionSettings = PermissionDataState.PermissionSettings(
type = type,
spenderAddress = spender,
)
protected fun quotesLoadedState(
provider: SwapProvider,
permissionState: PermissionDataState = PermissionDataState.Empty,
): SwapState.QuotesLoadedState = mockk(relaxed = true) {
every { swapProvider } returns provider
every { this@mockk.permissionState } returns permissionState
every {
copy(
fromTokenInfo = any(),
toTokenInfo = any(),
priceImpact = any(),
preparedSwapConfigState = any(),
permissionState = any(),
swapDataModel = any(),
currencyCheck = any(),
validationResult = any(),
minAdaValue = any(),
swapProvider = any(),
)
} answers {
quotesLoadedState(
provider = provider,
permissionState = arg(4),
)
}
}
protected fun swapCurrencyStatus(
wallet: UserWallet = mockk(relaxed = true),
status: CryptoCurrencyStatus = mockk(relaxed = true),
currency: CryptoCurrency = mockk(relaxed = true),
): SwapCurrencyStatus = mockk(relaxed = true) {
every { userWallet } returns wallet
every { this@mockk.status } returns status
every { this@mockk.currency } returns currency
}
// endregion
}

View file

@ -43,6 +43,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result).isEmpty()
@ -65,6 +66,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Solana.RentInfo>()).hasSize(1)
@ -91,6 +93,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = fee,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Error.ExistentialDeposit>()).hasSize(1)
@ -113,6 +116,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Error.MinimumAmountError>()).hasSize(1)
@ -131,6 +135,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Cardano.MinAdaValueCharged>()).hasSize(1)
@ -154,6 +159,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Warning.FeeCoverageNotification>()).hasSize(1)
@ -176,6 +182,7 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
val reserve = result.filterIsInstance<SwapNotificationUM.Warning.NeedReserveToCreateAccount>()
@ -199,15 +206,39 @@ internal class SwapTransferNotificationsFactoryTest {
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<SwapNotificationUM.Warning.ReduceAmount>()).hasSize(1)
}
@Test
fun `GIVEN BalanceNotEnoughForFee warning WHEN getNotifications THEN TokenExceedsBalance is added`() = runTest {
val warning = CryptoCurrencyWarning.BalanceNotEnoughForFee(
tokenCurrency = buildCoin(),
coinCurrency = buildCoin(),
)
val transferState = buildTransferState(
cryptoCurrencyWarning = warning,
)
val result = sut.getNotifications(
transferState = transferState,
feeCryptoCurrencyStatus = null,
fee = null,
onReduceByAmount = { _, _ -> },
onReduceToAmount = {},
onBuyClick = {},
)
assertThat(result.filterIsInstance<NotificationUM.Error.TokenExceedsBalance>()).hasSize(1)
}
@Suppress("LongParameterList")
private fun buildTransferState(
fromTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
toTokenInfo: TokenSwapInfo = buildTokenInfo(buildCoinStatus()),
cryptoCurrencyWarning: CryptoCurrencyWarning? = null,
currencyCheck: CryptoCurrencyCheck? = null,
validationResult: Throwable? = null,
minAdaValue: BigDecimal? = null,
@ -217,6 +248,7 @@ internal class SwapTransferNotificationsFactoryTest {
userWallet = coldWallet,
fromTokenInfo = fromTokenInfo,
toTokenInfo = toTokenInfo,
cryptoCurrencyWarning = cryptoCurrencyWarning,
isInsufficientBalance = false,
appCurrency = AppCurrency.Default,
isBalanceHidden = false,

View file

@ -56,6 +56,7 @@ internal class SwapTransferStateBuilderTest {
fee = any(),
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
} returns persistentListOf()
}
@ -129,6 +130,7 @@ internal class SwapTransferStateBuilderTest {
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -170,6 +172,7 @@ internal class SwapTransferStateBuilderTest {
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -212,6 +215,7 @@ internal class SwapTransferStateBuilderTest {
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -260,6 +264,7 @@ internal class SwapTransferStateBuilderTest {
fee = null,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -307,6 +312,7 @@ internal class SwapTransferStateBuilderTest {
fee = fee,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
} returns persistentListOf()
@ -317,6 +323,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
)
assertThat(result.swapButton.isEnabled).isTrue()
@ -329,6 +336,7 @@ internal class SwapTransferStateBuilderTest {
fee = fee,
onReduceByAmount = any(),
onReduceToAmount = any(),
onBuyClick = any(),
)
}
}
@ -358,6 +366,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
)
assertThat(result.transferFooter).isInstanceOf(TextReference.Combined::class.java)
@ -408,6 +417,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
)
assertThat(result.transferFooter).isEqualTo(
@ -452,6 +462,7 @@ internal class SwapTransferStateBuilderTest {
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = fee,
isTangemPayWithdrawal = false,
)
assertThat(result.transferFooter).isEqualTo(
@ -464,25 +475,40 @@ internal class SwapTransferStateBuilderTest {
@Test
fun `GIVEN dataState with from-to currencies WHEN createSuccessState THEN success holder is built in transfer mode with given fee and txUrl`() {
val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$")
val amount = BigDecimal("1.5")
val transferState = buildTransferState(
fromAmount = amount,
toAmount = amount,
isAccountsMode = true,
)
val dataState = SwapProcessDataState(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
amount = amount.toPlainString(),
currentTransferState = transferState,
)
val feeValue = BigDecimal("0.001")
val fee = Fee.Common(
amount = Amount(currencySymbol = "ETH", value = feeValue, decimals = 18),
)
val appCurrency = transferState.appCurrency
val expectedFee = stringReference(
"${feeValue.format { crypto(symbol = "ETH", decimals = 18) }} " +
"(${
fromCurrencyStatus.status.value.fiatRate!!.multiply(feeValue).format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
}
})",
)
val fee: TextReference = stringReference("0.001 ETH")
val txUrl = "https://explorer.example/tx/0xabc"
val timestamp = 1_700_000_000_000L
val result = sut.createSuccessState(
uiState = baseStateHolder(),
dataState = dataState,
appCurrency = appCurrency,
isAccountsMode = true,
fee = fee,
txUrl = txUrl,
timestamp = timestamp,
fee = fee,
onExplorerClick = {},
)
@ -491,7 +517,7 @@ internal class SwapTransferStateBuilderTest {
assertThat(success.shouldShowStatusButton).isFalse()
assertThat(success.timestamp).isEqualTo(timestamp)
assertThat(success.txUrl).isEqualTo(txUrl)
assertThat(success.fee).isEqualTo(fee)
assertThat(success.fee).isEqualTo(expectedFee)
assertThat(success.providerName).isEqualTo(TextReference.EMPTY)
assertThat(success.providerType).isEqualTo(TextReference.EMPTY)
assertThat(success.providerIcon).isEmpty()
@ -518,6 +544,140 @@ internal class SwapTransferStateBuilderTest {
)
}
@Test
fun `GIVEN null fee but tangem pay withdrawal WHEN updateTransferButtonEnableState THEN swap button is enabled with no footer`() =
runTest {
val transferState = buildTransferState(
fromAmount = BigDecimal("1"),
toAmount = BigDecimal("1"),
isAccountsMode = false,
)
val dataState = SwapProcessDataState()
val uiState = baseStateHolder().copy(
swapButton = SwapButton(
walletInteractionIcon = null,
isEnabled = false,
mode = SwapButton.Mode.TRANSFER,
onClick = {},
),
)
val result = sut.updateTransferButtonEnableState(
dataState = dataState,
transferState = transferState,
actions = actions,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
isTangemPayWithdrawal = true,
)
assertThat(result.swapButton.isEnabled).isTrue()
assertThat(result.swapButton.mode).isEqualTo(SwapButton.Mode.TRANSFER)
// fee is null → footer is omitted, but the button stays enabled because it is a Tangem Pay withdrawal
assertThat(result.transferFooter).isNull()
assertThat(result.notifications).isEmpty()
}
@Test
fun `GIVEN null fee and not tangem pay withdrawal WHEN updateTransferButtonEnableState THEN swap button stays disabled`() =
runTest {
val transferState = buildTransferState(
fromAmount = BigDecimal("1"),
toAmount = BigDecimal("1"),
isAccountsMode = false,
)
val dataState = SwapProcessDataState()
val uiState = baseStateHolder().copy(
swapButton = SwapButton(
walletInteractionIcon = null,
isEnabled = false,
mode = SwapButton.Mode.TRANSFER,
onClick = {},
),
)
val result = sut.updateTransferButtonEnableState(
dataState = dataState,
transferState = transferState,
actions = actions,
uiStateHolder = uiState,
feePaidCryptoCurrencyStatus = null,
fee = null,
isTangemPayWithdrawal = false,
)
assertThat(result.swapButton.isEnabled).isFalse()
}
@Test
fun `GIVEN transfer dataState WHEN createTangemPayWithdrawalSuccessState THEN feeless transfer success holder is built`() {
val sendingAmount = BigDecimal("1.5")
val transferState = buildTransferState(
fromAmount = sendingAmount,
toAmount = sendingAmount,
isAccountsMode = true,
)
val dataState = SwapProcessDataState(
fromSwapCurrencyStatus = fromCurrencyStatus,
toSwapCurrencyStatus = toCurrencyStatus,
currentTransferState = transferState,
)
val onExploreClick = {}
val appCurrency = transferState.appCurrency
val expectedFiat = stringReference(
fromCurrencyStatus.status.value.fiatRate!!.multiply(sendingAmount).format {
fiat(fiatCurrencyCode = appCurrency.code, fiatCurrencySymbol = appCurrency.symbol)
},
)
val before = System.currentTimeMillis()
val result = sut.createTangemPayWithdrawalSuccessState(
uiState = baseStateHolder(),
dataState = dataState,
fee = null,
onExploreClick = onExploreClick,
)
val after = System.currentTimeMillis()
val success = requireNotNull(result.successState)
assertThat(success.isTransferMode).isTrue()
assertThat(success.shouldShowStatusButton).isFalse()
assertThat(success.fee).isNull()
assertThat(success.txUrl).isEmpty()
assertThat(success.providerName).isEqualTo(stringReference(""))
assertThat(success.providerType).isEqualTo(stringReference(""))
assertThat(success.providerIcon).isEmpty()
assertThat(success.rate).isEqualTo(TextReference.EMPTY)
assertThat(success.timestamp).isAtLeast(before)
assertThat(success.timestamp).isAtMost(after)
assertThat(success.fromTokenAmount).isEqualTo(stringReference(sendingAmount.toString()))
assertThat(success.toTokenAmount).isEqualTo(stringReference(sendingAmount.toString()))
assertThat(success.fromTokenFiatAmount).isEqualTo(expectedFiat)
assertThat(success.toTokenFiatAmount).isEqualTo(expectedFiat)
assertThat(success.fromTokenIconState).isEqualTo(fromIcon)
assertThat(success.toTokenIconState).isEqualTo(toIcon)
assertThat(success.onExploreButtonClick).isEqualTo(onExploreClick)
val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio
val expectedIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon)
val expectedName = portfolioAccount.accountName.toUM().value
assertThat(success.fromTitle).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_from_account_title),
name = expectedName,
icon = expectedIcon,
),
)
assertThat(success.toTitle).isEqualTo(
AccountTitleUM.Account(
prefixText = resourceReference(R.string.swapping_to_account_title),
name = expectedName,
icon = expectedIcon,
),
)
}
private fun assertSharedCardShape(
result: SwapStateHolder,
transferState: SwapState.Transfer,
@ -570,6 +730,7 @@ internal class SwapTransferStateBuilderTest {
userWallet = coldWallet,
fromTokenInfo = fromInfo,
toTokenInfo = toInfo,
cryptoCurrencyWarning = null,
isInsufficientBalance = isInsufficientBalance,
appCurrency = AppCurrency.Default,
isBalanceHidden = false,