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)")
}
}