Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-29 23:38:31 +05:00
parent e2d0973392
commit d9bc010cd0
12 changed files with 242 additions and 94 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),
)
},
)
@ -159,9 +151,8 @@ internal class DefaultSwapComponent @AssistedInject constructor(
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 isSwapNotReady = !isInTransferMode && (isProviderMissing || isPermissionNotReady)
val isPermissionNotNeeded = model.isPermissionNotNeeded
val isSwapNotReady = !isInTransferMode && (isProviderMissing || !isPermissionNotNeeded)
val isTangemPayWithdrawal = model.isTangemPayWithdrawal()
isAmountEmptyOrZero || isInsufficientFunds || isSwapNotReady || isTangemPayWithdrawal
@ -249,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

@ -59,6 +59,7 @@ internal object SwapProviderStateBuilder {
),
selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = onProviderClick,
)
}
@ -76,6 +77,7 @@ internal object SwapProviderStateBuilder {
selectionType: ProviderState.SelectionType,
needApplyFCARestrictions: Boolean,
onProviderClick: (String) -> Unit,
onApprovalSelectClick: (SwapProvider) -> Unit = {},
): ProviderState.Content {
return provider.toContent(
subtitle = buildSelectableSubtitle(toTokenInfo),
@ -88,6 +90,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,
)
}
@ -111,6 +119,7 @@ internal object SwapProviderStateBuilder {
),
selectionType = selectionType,
percentLowerThenBest = PercentDifference.Empty,
approvalSettings = ProviderState.ApprovalSettings.Empty,
onProviderClick = onProviderClick,
)
}
@ -147,11 +156,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(
@ -165,6 +176,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
@ -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
@ -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()
@ -963,8 +1012,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 {
@ -1751,13 +1798,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)
@ -2359,6 +2420,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

@ -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

@ -99,7 +99,7 @@ internal class StateBuilder(
onMaxAmountSelected = actions.onMaxAmountSelected,
onPredefinedPercentSelected = actions.onPredefinedPercentSelected,
changeCardsButtonState = ChangeCardsButtonState.DISABLED,
onShowPermissionBottomSheet = actions.openPermissionBottomSheet,
onShowPermissionBottomSheet = actions.onApproveClick,
onSelectTokenClick = actions.onSelectTokenClick,
onSuccess = actions.onSuccess,
providerState = ProviderState.Empty(),
@ -1033,6 +1033,7 @@ internal class StateBuilder(
pricesLowerBest = pricesLowerBest,
onProviderSelect = actions.onProviderSelect,
needApplyFCARestrictions = needApplyFCARestrictions,
onApprovalSelectClick = actions.onApproveTypeSelect,
)
}
.sortedWith(ProviderPercentDiffComparator)
@ -1136,6 +1137,7 @@ internal class StateBuilder(
private fun Map.Entry<SwapProvider, SwapState>.convertToProviderBottomSheetState(
pricesLowerBest: Map<String, Float>,
onProviderSelect: (String) -> Unit,
onApprovalSelectClick: (SwapProvider) -> Unit,
needApplyFCARestrictions: Boolean,
): ProviderState? {
val provider = this.key
@ -1150,6 +1152,7 @@ internal class StateBuilder(
selectionType = ProviderState.SelectionType.SELECT,
needApplyFCARestrictions = needApplyFCARestrictions,
onProviderClick = onProviderSelect,
onApprovalSelectClick = onApprovalSelectClick,
)
}
is SwapState.SwapError -> getProviderStateForError(