Updated on 2026-08-14

This commit is contained in:
Tangem 2026-02-27 12:16:41 +04:00
parent c62f464b86
commit e047c491cf
11 changed files with 158 additions and 20 deletions

View file

@ -4,12 +4,12 @@ import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.TextReference
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.domain.models.wallet.UserWallet
import com.tangem.domain.models.wallet.UserWalletId
interface GiveApprovalComponent : ComposableBottomSheetComponent {
data class Params(
val userWallet: UserWallet,
val userWalletId: UserWalletId,
val cryptoCurrencyStatus: CryptoCurrencyStatus,
val feeCryptoCurrencyStatus: CryptoCurrencyStatus,
val amount: String,
@ -19,6 +19,7 @@ interface GiveApprovalComponent : ComposableBottomSheetComponent {
)
interface Callback {
fun onApproveClick()
fun onApproveDone()
fun onApproveFailed()
fun onCancelClick()

View file

@ -22,6 +22,8 @@ dependencies {
implementation(projects.core.navigation)
implementation(projects.core.ui)
implementation(projects.core.utils)
implementation(projects.core.analytics)
implementation(projects.core.analytics.models)
/** Common */
implementation(projects.common.ui)
@ -33,6 +35,7 @@ dependencies {
/** Domain */
implementation(projects.domain.models)
implementation(projects.domain.wallets)
implementation(projects.domain.wallets.models)
implementation(projects.domain.transaction.models)
implementation(projects.domain.transaction)

View file

@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.core.decompose.context.AppComponentContext
import com.tangem.core.decompose.context.child
import com.tangem.core.decompose.model.getOrCreateModel
@ -46,7 +45,7 @@ internal class DefaultGiveApprovalComponent @AssistedInject constructor(
feeDisplaySource = FeeSelectorParams.FeeDisplaySource.Screen,
analyticsCategoryName = CommonSendAnalyticEvents.APPROVE_CATEGORY,
analyticsSendSource = CommonSendAnalyticEvents.CommonSendSource.Approve,
userWalletId = params.userWallet.walletId,
userWalletId = params.userWalletId,
),
onResult = model::onFeeResult,
)
@ -84,7 +83,7 @@ internal class DefaultGiveApprovalComponent @AssistedInject constructor(
approveType = uiState.approveType,
approveItems = uiState.approveItems,
onChangeApproveType = model::onChangeApproveType,
walletInteractionIcon = walletInterationIcon(params.userWallet),
walletInteractionIcon = uiState.walletInteractionIcon,
isApproveEnabled = uiState.isApproveButtonEnabled,
isApproveLoading = uiState.isApproveLoading,
onApproveClick = model::onApproveClick,

View file

@ -2,8 +2,9 @@ package com.tangem.features.approval.impl
import com.tangem.core.configtoggle.feature.FeatureTogglesManager
import com.tangem.features.approval.api.GiveApprovalFeatureToggles
import javax.inject.Inject
internal class DefaultGiveApprovalFeatureToggles(
internal class DefaultGiveApprovalFeatureToggles @Inject constructor(
private val featureToggles: FeatureTogglesManager,
) : GiveApprovalFeatureToggles {

View file

@ -3,7 +3,9 @@ package com.tangem.features.approval.impl.di
import com.tangem.core.decompose.di.ModelComponent
import com.tangem.core.decompose.model.Model
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalFeatureToggles
import com.tangem.features.approval.impl.DefaultGiveApprovalComponent
import com.tangem.features.approval.impl.DefaultGiveApprovalFeatureToggles
import com.tangem.features.approval.impl.model.GiveApprovalModel
import dagger.Binds
import dagger.Module
@ -17,6 +19,10 @@ import javax.inject.Singleton
@Module
internal interface GiveApprovalFeatureModule {
@Singleton
@Binds
fun bindGiveApprovalFeatureToggle(toggles: DefaultGiveApprovalFeatureToggles): GiveApprovalFeatureToggles
@Binds
@Singleton
fun bindComponentFactory(factory: DefaultGiveApprovalComponent.Factory): GiveApprovalComponent.Factory

View file

@ -8,6 +8,9 @@ import com.tangem.blockchain.common.TransactionData
import com.tangem.blockchain.common.transaction.Fee
import com.tangem.blockchain.common.transaction.TransactionFee
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.core.analytics.api.AnalyticsEventHandler
import com.tangem.core.analytics.models.AnalyticsParam
import com.tangem.core.analytics.models.Basic
import com.tangem.core.decompose.di.ModelScoped
import com.tangem.core.decompose.model.Model
import com.tangem.core.decompose.model.ParamsContainer
@ -28,6 +31,8 @@ import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.send.v2.api.callbacks.FeeSelectorModelCallback
import com.tangem.features.send.v2.api.entity.FeeSelectorUM
import com.tangem.core.navigation.url.UrlOpener
import com.tangem.common.ui.userwallet.ext.walletInterationIcon
import com.tangem.domain.wallets.usecase.GetUserWalletUseCase
import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
import kotlinx.coroutines.flow.MutableStateFlow
@ -52,14 +57,23 @@ internal class GiveApprovalModel @Inject constructor(
private val createAndSendGaslessTransactionUseCase: CreateAndSendGaslessTransactionUseCase,
private val uiMessageSender: UiMessageSender,
private val urlOpener: UrlOpener,
private val getUserWalletUseCase: GetUserWalletUseCase,
private val analyticsEventHandler: AnalyticsEventHandler,
) : Model(), FeeSelectorModelCallback {
private val params: GiveApprovalComponent.Params = paramsContainer.require()
private val userWallet by lazy {
requireNotNull(
getUserWalletUseCase(params.userWalletId).getOrNull(),
) { "No wallet found for id: $params.userWalletId" }
}
val uiState: StateFlow<GiveApprovalUM>
field = MutableStateFlow(
GiveApprovalUM(
approveType = ApproveType.LIMITED,
walletInteractionIcon = walletInterationIcon(userWallet),
isApproveButtonEnabled = false,
isApproveLoading = false,
),
@ -73,6 +87,7 @@ internal class GiveApprovalModel @Inject constructor(
}
fun onApproveClick() {
params.callback.onApproveClick()
uiState.update { it.copy(isApproveLoading = true) }
modelScope.launch(dispatchers.main) {
val isSuccess = sendApprovalTransaction()
@ -113,7 +128,7 @@ internal class GiveApprovalModel @Inject constructor(
return createApprovalTransactionUseCase(
cryptoCurrencyStatus = cryptoCurrencyStatus,
userWalletId = params.userWallet.walletId,
userWalletId = params.userWalletId,
amount = getApprovalAmount(),
contractAddress = tokenCurrency.contractAddress,
spenderAddress = params.spenderAddress,
@ -126,7 +141,7 @@ internal class GiveApprovalModel @Inject constructor(
return getFeeUseCase(
transactionData = approvalTransaction,
userWallet = params.userWallet,
userWallet = userWallet,
network = params.cryptoCurrencyStatus.currency.network,
)
}
@ -138,13 +153,13 @@ internal class GiveApprovalModel @Inject constructor(
return if (maybeToken == null) {
getFeeForGaslessUseCase(
transactionData = approvalTransaction,
userWallet = params.userWallet,
userWallet = userWallet,
network = params.cryptoCurrencyStatus.currency.network,
)
} else {
getFeeForTokenUseCase(
transactionData = approvalTransaction,
userWallet = params.userWallet,
userWallet = userWallet,
token = maybeToken.currency,
)
}
@ -162,7 +177,7 @@ internal class GiveApprovalModel @Inject constructor(
val transactionData = createApprovalTransactionUseCase(
cryptoCurrencyStatus = cryptoCurrencyStatus,
userWalletId = params.userWallet.walletId,
userWalletId = params.userWalletId,
amount = getApprovalAmount(),
fee = selectedFee,
contractAddress = tokenCurrency.contractAddress,
@ -174,14 +189,14 @@ internal class GiveApprovalModel @Inject constructor(
return if (isFeeInTokenCurrency) {
createAndSendGaslessTransactionUseCase(
userWallet = params.userWallet,
userWallet = userWallet,
transactionData = transactionData,
fee = feeExtended,
)
} else {
sendTransactionUseCase(
txData = transactionData,
userWallet = params.userWallet,
userWallet = userWallet,
network = tokenCurrency.network,
)
}.fold(
@ -189,7 +204,32 @@ internal class GiveApprovalModel @Inject constructor(
Timber.e("Failed to send approval transaction: $error")
false
},
ifRight = { true },
ifRight = {
sendApproveSuccessAnalytics(feeContent)
true
},
)
}
private fun sendApproveSuccessAnalytics(feeContent: FeeSelectorUM.Content) {
val currency = params.cryptoCurrencyStatus.currency
val feeToken = feeContent.feeExtraInfo.feeCryptoCurrencyStatus.currency.symbol
val permissionType = when (uiState.value.approveType) {
ApproveType.LIMITED -> "Current transaction"
ApproveType.UNLIMITED -> "Unlimited"
}
val event = AnalyticsParam.TxSentFrom.Approve(
blockchain = currency.network.name,
token = currency.symbol,
feeType = feeContent.toAnalyticType(),
feeToken = feeToken,
permissionType = permissionType,
)
analyticsEventHandler.send(
Basic.TransactionSent(
sentFrom = event,
memoType = Basic.TransactionSent.MemoType.Null,
),
)
}

View file

@ -1,5 +1,6 @@
package com.tangem.features.approval.impl.model
import androidx.annotation.DrawableRes
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@ -7,6 +8,7 @@ import kotlinx.collections.immutable.toImmutableList
internal data class GiveApprovalUM(
val approveType: ApproveType,
val approveItems: ImmutableList<ApproveType> = ApproveType.entries.toImmutableList(),
@DrawableRes val walletInteractionIcon: Int?,
val isApproveButtonEnabled: Boolean,
val isApproveLoading: Boolean,
)

View file

@ -84,6 +84,7 @@ dependencies {
/** Api */
implementation(projects.features.swap.api)
implementation(projects.features.tokendetails.api)
implementation(projects.features.approval.api)
/** Libs */
implementation(projects.libs.crypto)

View file

@ -12,11 +12,15 @@ import com.arkivanov.decompose.router.slot.activate
import com.arkivanov.decompose.router.slot.childSlot
import com.arkivanov.decompose.router.slot.dismiss
import com.arkivanov.essenty.lifecycle.subscribe
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState
import com.tangem.common.ui.swapStoriesScreen.SwapStoriesScreen
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.ui.R
import com.tangem.core.ui.decompose.ComposableBottomSheetComponent
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.res.TangemTheme
import com.tangem.domain.models.currency.CryptoCurrencyStatus
import com.tangem.feature.swap.component.SwapFeeSelectorBlockComponent
@ -26,6 +30,7 @@ import com.tangem.feature.swap.router.SwapNavScreen
import com.tangem.feature.swap.ui.SwapScreen
import com.tangem.feature.swap.ui.SwapSelectTokenScreen
import com.tangem.feature.swap.ui.SwapSuccessScreen
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.feed.components.market.details.portfolio.add.AddToPortfolioComponent
import com.tangem.features.send.v2.api.SendFeatureToggles
import com.tangem.features.send.v2.api.analytics.CommonSendAnalyticEvents
@ -43,6 +48,7 @@ internal class DefaultSwapComponent @AssistedInject constructor(
private val swapFeeSelectorBlockComponentFactory: SwapFeeSelectorBlockComponent.Factory,
private val sendFeatureToggles: SendFeatureToggles,
private val addToPortfolioComponentFactory: AddToPortfolioComponent.Factory,
private val giveApprovalComponentFactory: GiveApprovalComponent.Factory,
) : SwapComponent, AppComponentContext by appComponentContext {
private val model: SwapModel = getOrCreateModel(params)
@ -55,6 +61,21 @@ internal class DefaultSwapComponent @AssistedInject constructor(
childFactory = { configuration, context -> bottomSheetChild(context) },
)
private val approvalSlot = childSlot(
key = APPROVAL_SLOT_KEY,
source = model.approvalSlotNavigation,
serializer = null,
handleBackButton = true,
childFactory = { _, factoryContext ->
val approvalParams = getApprovalParams()
?: error("Approval params are not available")
giveApprovalComponentFactory.create(
context = childByContext(factoryContext),
params = approvalParams,
)
},
)
init {
lifecycle.subscribe(
onStart = model::onStart,
@ -191,6 +212,9 @@ internal class DefaultSwapComponent @AssistedInject constructor(
}
bottomSheet.child?.instance?.BottomSheet()
val approvalSlotState by approvalSlot.subscribeAsState()
approvalSlotState.child?.instance?.BottomSheet()
}
@Suppress("UnsafeCallOnNullableType")
@ -205,6 +229,27 @@ internal class DefaultSwapComponent @AssistedInject constructor(
)
}
fun getApprovalParams(): GiveApprovalComponent.Params? {
val permissionState = model.uiState.permissionState as? GiveTxPermissionState.ReadyForRequest
?: return null
val fromCryptoCurrency = model.dataState.fromCryptoCurrency ?: return null
val feeCryptoCurrency = model.dataState.feePaidCryptoCurrency ?: return null
val providerName = model.dataState.selectedProvider?.name.orEmpty()
return GiveApprovalComponent.Params(
userWalletId = params.userWalletId,
cryptoCurrencyStatus = fromCryptoCurrency,
feeCryptoCurrencyStatus = feeCryptoCurrency,
amount = permissionState.amount,
spenderAddress = requireNotNull(model.dataState.approveDataModel).spenderAddress,
subtitle = resourceReference(
id = R.string.give_permission_swap_subtitle,
formatArgs = wrappedList(providerName, permissionState.currency),
),
callback = model.approvalCallback,
)
}
private fun toBigDecimalOrZero(bigDecimalString: String?): BigDecimal {
return bigDecimalString?.replace(",", ".")?.toBigDecimalOrNull() ?: BigDecimal.ZERO
}
@ -217,5 +262,6 @@ internal class DefaultSwapComponent @AssistedInject constructor(
private companion object {
const val BOTTOM_SHEET_SLOT_KEY = "bottomSheetSlot"
const val FEE_SELECTOR_SLOT_KEY = "feeSelectorSlot"
const val APPROVAL_SLOT_KEY = "approvalSlot"
}
}

View file

@ -14,6 +14,8 @@ import com.tangem.blockchainsdk.utils.ExcludedBlockchains
import com.tangem.common.routing.AppRouter
import com.tangem.common.ui.bottomsheet.permission.state.ApproveType
import com.tangem.common.ui.bottomsheet.permission.state.GiveTxPermissionState.InProgress.getApproveTypeOrNull
import com.tangem.features.approval.api.GiveApprovalComponent
import com.tangem.features.approval.api.GiveApprovalFeatureToggles
import com.tangem.common.ui.markets.models.MarketsListItemUM
import com.tangem.core.analytics.api.AnalyticsErrorHandler
import com.tangem.core.analytics.api.AnalyticsEventHandler
@ -31,9 +33,9 @@ import com.tangem.core.ui.R
import com.tangem.core.ui.extensions.TextReference
import com.tangem.core.ui.extensions.combinedReference
import com.tangem.core.ui.extensions.resourceReference
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.extensions.stringReference
import com.tangem.core.ui.extensions.toWrappedList
import com.tangem.core.ui.extensions.wrappedList
import com.tangem.core.ui.message.DialogMessage
import com.tangem.core.ui.message.EventMessageAction
import com.tangem.core.ui.utils.InputNumberFormatter
@ -118,6 +120,8 @@ import com.tangem.utils.Provider
import com.tangem.utils.TangemBlogUrlBuilder.RESOURCE_TO_LEARN_ABOUT_APPROVING_IN_SWAP
import com.tangem.utils.coroutines.*
import com.tangem.utils.isNullOrZero
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import timber.log.Timber
@ -173,6 +177,7 @@ internal class SwapModel @Inject constructor(
private val appsFlyerStore: AppsFlyerStore,
private val holdToConfirmButtonFeatureToggles: HoldToConfirmButtonFeatureToggles,
private val messageSender: UiMessageSender,
giveApprovalFeatureToggles: GiveApprovalFeatureToggles,
) : Model() {
private val params = paramsContainer.require<SwapComponent.Params>()
@ -301,6 +306,33 @@ internal class SwapModel @Inject constructor(
get() = swapRouter.currentScreen
val bottomSheetNavigation: SlotNavigation<AddToPortfolioRoute> = SlotNavigation()
val approvalSlotNavigation = SlotNavigation<Unit>()
private val shouldUseGaslessApproval: Boolean = giveApprovalFeatureToggles.isGaslessApprovalEnabled
val approvalCallback = object : GiveApprovalComponent.Callback {
override fun onApproveClick() {
sendPermissionApproveClickedEvent()
}
override fun onApproveDone() {
approvalSlotNavigation.dismiss()
updateWalletBalance()
uiState = stateBuilder.loadingPermissionState(uiState)
startLoadingQuotesFromLastState(isSilent = true)
}
override fun onApproveFailed() {
approvalSlotNavigation.dismiss()
showAlert()
}
override fun onCancelClick() {
approvalSlotNavigation.dismiss()
startLoadingQuotesFromLastState(isSilent = true)
analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked())
}
}
val addToPortfolioCallback = object : AddToPortfolioComponent.Callback {
override fun onDismiss() = bottomSheetNavigation.dismiss()
@ -1224,7 +1256,7 @@ internal class SwapModel @Inject constructor(
fromTokenStatus = fromCryptoCurrency,
approveType = approveType,
txFee = feeForPermission,
spenderAddress = approveDataModel.spenderAddress,
spenderAddress = requireNotNull(dataState.approveDataModel).spenderAddress,
),
)
}.onSuccess { swapTransactionState ->
@ -1785,10 +1817,14 @@ internal class SwapModel @Inject constructor(
openPermissionBottomSheet = {
singleTaskScheduler.cancelTask()
sendGivePermissionClickedEvent()
uiState = stateBuilder.showPermissionBottomSheet(uiState) {
startLoadingQuotesFromLastState(isSilent = true)
analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked())
uiState = stateBuilder.dismissBottomSheet(uiState)
if (shouldUseGaslessApproval) {
approvalSlotNavigation.activate(Unit)
} else {
uiState = stateBuilder.showPermissionBottomSheet(uiState) {
startLoadingQuotesFromLastState(isSilent = true)
analyticsEventHandler.send(SwapEvents.ButtonPermissionCancelClicked())
uiState = stateBuilder.dismissBottomSheet(uiState)
}
}
},
onAmountSelected = { onAmountSelected(it) },

View file

@ -711,6 +711,9 @@ internal class StateBuilder(
fun createSilentLoadState(uiState: SwapStateHolder): SwapStateHolder {
return uiState.copy(
changeCardsButtonState = ChangeCardsButtonState.UPDATE_IN_PROGRESS,
notifications = uiState.notifications
.filterNot { it is SwapNotificationUM.Info.PermissionNeeded }
.toImmutableList(),
)
}