From bb5d6848d929b635e48b96b8a436aff49b145339 Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 29 May 2026 19:47:19 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../swap/domain/models/ui/SwapState.kt | 2 + .../domain/transfer/SwapTransferInteractor.kt | 1 + .../transfer/SwapTransferInteractorImpl.kt | 59 ++++++++++-- .../SwapTransferInteractorImplTest.kt | 95 +++++++++++++++++-- .../feature/swap/analytics/SwapEvents.kt | 45 ++++++++- .../tangem/feature/swap/model/SwapModel.kt | 55 +++++++++-- .../SwapTransferNotificationsFactory.kt | 22 +++++ .../ui/transfer/SwapTransferStateBuilder.kt | 35 +++++-- .../SwapTransferNotificationsFactoryTest.kt | 32 +++++++ .../transfer/SwapTransferStateBuilderTest.kt | 36 +++++-- 10 files changed, 339 insertions(+), 43 deletions(-) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt index 861066f760..8e147f1458 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/models/ui/SwapState.kt @@ -6,6 +6,7 @@ import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.wallet.UserWallet import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.PreparedSwapConfigState @@ -36,6 +37,7 @@ sealed interface SwapState { val userWallet: UserWallet, val fromTokenInfo: TokenSwapInfo, val toTokenInfo: TokenSwapInfo, + val cryptoCurrencyWarning: CryptoCurrencyWarning?, val isInsufficientBalance: Boolean, val appCurrency: AppCurrency, val isBalanceHidden: Boolean, diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt index 976de31b40..fc42b91521 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt @@ -37,6 +37,7 @@ interface SwapTransferInteractor { fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, fromTokenAmount: BigDecimal, + selectedToken: CryptoCurrencyStatus?, ): Either suspend fun sendTransfer( diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt index ef795d8ae8..d9e91edffc 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -22,9 +22,11 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.TangemPayWithdrawUseCase +import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck +import com.tangem.domain.tokens.model.warnings.CryptoCurrencyWarning import com.tangem.domain.transaction.error.GetFeeError import com.tangem.domain.transaction.error.SendTransactionError import com.tangem.domain.transaction.models.TransactionFeeExtended @@ -46,7 +48,7 @@ import kotlinx.coroutines.flow.first import java.math.BigDecimal import javax.inject.Inject -@Suppress("LongParameterList") +@Suppress("LongParameterList", "LargeClass") class SwapTransferInteractorImpl @Inject constructor( private val swapFeatureToggles: SwapFeatureToggles, private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, @@ -60,6 +62,7 @@ class SwapTransferInteractorImpl @Inject constructor( private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase, private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase, private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase, + private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase, ) : SwapTransferInteractor { override suspend fun updateTransfer( @@ -110,10 +113,19 @@ class SwapTransferInteractorImpl @Inject constructor( fee = fee, currencyCheck = currencyCheck, ) + val cryptoCurrencyWarning = feePaidCurrencyStatus?.let { feeStatus -> + getCryptoCurrencyWarning( + feeValue = fee?.amount?.value.orZero(), + userWallet = userWallet, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + feeStatus = feeStatus, + ) + } return SwapState.Transfer( userWallet = userWallet, fromTokenInfo = fromTokenInfo, toTokenInfo = toTokenInfo, + cryptoCurrencyWarning = cryptoCurrencyWarning, isInsufficientBalance = fromTokenAmountValue > fromTokenBalance, appCurrency = appCurrency, isBalanceHidden = isBalanceHidden, @@ -124,6 +136,20 @@ class SwapTransferInteractorImpl @Inject constructor( ) } + private suspend fun getCryptoCurrencyWarning( + feeValue: BigDecimal, + userWallet: UserWallet, + fromSwapCurrencyStatus: SwapCurrencyStatus, + feeStatus: CryptoCurrencyStatus, + ): CryptoCurrencyWarning? { + return getBalanceNotEnoughForFeeWarningUseCase( + fee = feeValue, + userWalletId = userWallet.walletId, + tokenStatus = fromSwapCurrencyStatus.status, + feeStatus = feeStatus, + ).getOrNull() + } + private suspend fun getCoverageState( fromTokenInfo: TokenSwapInfo, userWallet: UserWallet, @@ -210,12 +236,22 @@ class SwapTransferInteractorImpl @Inject constructor( val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError( message = "Destination address is null", ) + val userWallet = fromSwapCurrencyStatus.userWallet + val currency = fromSwapCurrencyStatus.currency + val transactionData = createTransferTransactionUseCase( + amount = fromTokenAmount.convertToSdkAmount( + cryptoCurrencyStatus = fromSwapCurrencyStatus.status, + ), + memo = null, + destination = destination, + userWalletId = userWallet.walletId, + network = currency.network, + ).getOrNull() ?: return feeDataError("Failed to build transfer transaction") return getFeeUseCase( - amount = fromTokenAmount, - destination = destination, userWallet = fromSwapCurrencyStatus.userWallet, - cryptoCurrency = fromSwapCurrencyStatus.currency, + network = fromSwapCurrencyStatus.currency.network, + transactionData = transactionData, ) } @@ -223,6 +259,7 @@ class SwapTransferInteractorImpl @Inject constructor( fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus, fromTokenAmount: BigDecimal, + selectedToken: CryptoCurrencyStatus?, ): Either { val destination = toSwapCurrencyStatus.destinationAddress() ?: return feeDataError( message = "Destination address is null", @@ -244,7 +281,13 @@ class SwapTransferInteractorImpl @Inject constructor( userWallet = userWallet, network = currency.network, transactionData = transactionData, - ) + ).map { transactionFeeExtended -> + selectedToken ?: return@map transactionFeeExtended + val selectedTokenId = selectedToken.currency.id + transactionFeeExtended.copy( + feeTokenId = selectedTokenId, + ) + } } override suspend fun sendTransfer( @@ -310,9 +353,9 @@ class SwapTransferInteractorImpl @Inject constructor( transactionFeeResult: TransactionFeeResult, txData: TransactionData, ): Either { - val isToken = cryptoCurrencyStatus.currency is CryptoCurrency.Token - val isGaslessToken = isToken && transactionFeeResult is TransactionFeeResult.LoadedExtended - return if (isGaslessToken) { + val isFeeInTokenCurrency = transactionFeeResult is TransactionFeeResult.LoadedExtended && + transactionFeeResult.fee.transactionFee.normal is Fee.Ethereum.TokenCurrency + return if (isFeeInTokenCurrency) { createAndSendGaslessTransactionUseCase( transactionData = txData, userWallet = userWallet, diff --git a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt index 3116e627ff..fc31a2c1ef 100644 --- a/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -19,6 +19,7 @@ import com.tangem.domain.models.wallet.UserWalletId import com.tangem.domain.pay.WithdrawalResult import com.tangem.domain.swap.models.SwapCurrencyStatus import com.tangem.domain.tangempay.TangemPayWithdrawUseCase +import com.tangem.domain.tokens.GetBalanceNotEnoughForFeeWarningUseCase import com.tangem.domain.tokens.GetCurrencyCheckUseCase import com.tangem.domain.tokens.IsAmountSubtractAvailableUseCase import com.tangem.domain.tokens.model.warnings.CryptoCurrencyCheck @@ -59,6 +60,7 @@ internal class SwapTransferInteractorImplTest { private val getCurrencyCheckUseCase: GetCurrencyCheckUseCase = mockk() private val isAmountSubtractAvailableUseCase: IsAmountSubtractAvailableUseCase = mockk() private val tangemPayWithdrawUseCase: TangemPayWithdrawUseCase = mockk() + private val getBalanceNotEnoughForFeeWarningUseCase: GetBalanceNotEnoughForFeeWarningUseCase = mockk(relaxed = true) private val sut = SwapTransferInteractorImpl( swapFeatureToggles = swapFeatureToggles, @@ -73,6 +75,7 @@ internal class SwapTransferInteractorImplTest { getCurrencyCheckUseCase = getCurrencyCheckUseCase, isAmountSubtractAvailableUseCase = isAmountSubtractAvailableUseCase, tangemPayWithdrawUseCase = tangemPayWithdrawUseCase, + getBalanceNotEnoughForFeeWarningUseCase = getBalanceNotEnoughForFeeWarningUseCase, ) @AfterEach @@ -170,6 +173,7 @@ internal class SwapTransferInteractorImplTest { swapCurrencyStatus = toCurrencyStatus, amountFiat = expectedFiat, ), + cryptoCurrencyWarning = null, isInsufficientBalance = false, appCurrency = appCurrency, isBalanceHidden = true, @@ -240,6 +244,7 @@ internal class SwapTransferInteractorImplTest { swapCurrencyStatus = toCurrencyStatus, amountFiat = expectedFiat, ), + cryptoCurrencyWarning = null, isInsufficientBalance = true, appCurrency = appCurrency, isBalanceHidden = true, @@ -313,24 +318,36 @@ internal class SwapTransferInteractorImplTest { @Test fun `GIVEN valid amount and destination WHEN loadFee THEN return TransactionFee from use case`() = runTest { - val userWallet: UserWallet = mockk() + val userWalletId: UserWalletId = mockk() + val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } + val network: Network = mockk() val fromCurrencyStatus = buildCurrencyStatus( rawCurrencyId = FROM_RAW_CURRENCY_ID, decimals = FROM_DECIMALS, userWallet = userWallet, + network = network, ) val toCurrencyStatus = buildCurrencyStatus( rawCurrencyId = TO_RAW_CURRENCY_ID, decimals = TO_DECIMALS, destinationAddress = DESTINATION_ADDRESS, ) + val transactionData: TransactionData.Uncompiled = mockk() val transactionFee: TransactionFee = mockk() coEvery { - getFeeUseCase( - amount = BigDecimal("1.5"), + createTransferTransactionUseCase( + amount = any(), + memo = null, destination = DESTINATION_ADDRESS, + userWalletId = userWalletId, + network = network, + ) + } returns transactionData.right() + coEvery { + getFeeUseCase( userWallet = userWallet, - cryptoCurrency = fromCurrencyStatus.currency, + network = network, + transactionData = transactionData, ) } returns transactionFee.right() @@ -343,10 +360,9 @@ internal class SwapTransferInteractorImplTest { assertThat(result).isEqualTo(transactionFee.right()) coVerify { getFeeUseCase( - amount = BigDecimal("1.5"), - destination = DESTINATION_ADDRESS, userWallet = userWallet, - cryptoCurrency = fromCurrencyStatus.currency, + network = network, + transactionData = transactionData, ) } } @@ -394,6 +410,7 @@ internal class SwapTransferInteractorImplTest { fromSwapCurrencyStatus = fromCurrencyStatus, toSwapCurrencyStatus = toCurrencyStatus, fromTokenAmount = BigDecimal("2.0"), + selectedToken = null, ) assertThat(result).isEqualTo(feeExtended.right()) @@ -493,7 +510,7 @@ internal class SwapTransferInteractorImplTest { } @Test - fun `GIVEN token and LoadedExtended fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() = + fun `GIVEN LoadedExtended fee with TokenCurrency normal fee WHEN sendTransfer THEN route via createAndSendGaslessTransactionUseCase`() = runTest { val userWalletId: UserWalletId = mockk() val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } @@ -511,7 +528,11 @@ internal class SwapTransferInteractorImplTest { ) val fee: Fee = mockk() val txData: TransactionData.Uncompiled = mockk() - val transactionFeeExtended: TransactionFeeExtended = mockk() + val transactionFeeExtended: TransactionFeeExtended = mockk { + every { transactionFee } returns mockk { + every { normal } returns mockk() + } + } val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended) coEvery { createTransferTransactionUseCase( @@ -602,6 +623,62 @@ internal class SwapTransferInteractorImplTest { } } + @Test + fun `GIVEN LoadedExtended fee with non-TokenCurrency normal fee WHEN sendTransfer THEN fall back to sendTransactionUseCase`() = + runTest { + val userWalletId: UserWalletId = mockk() + val userWallet: UserWallet = mockk { every { walletId } returns userWalletId } + val network: Network = mockk() + val fromCurrencyStatus = buildTokenCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + userWallet = userWallet, + network = network, + ) + val toCurrencyStatus = buildTokenCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + destinationAddress = DESTINATION_ADDRESS, + ) + val fee: Fee = mockk() + val txData: TransactionData.Uncompiled = mockk() + val transactionFeeExtended: TransactionFeeExtended = mockk { + every { transactionFee } returns mockk { + every { normal } returns mockk() + } + } + val transactionFeeResult = TransactionFeeResult.LoadedExtended(transactionFeeExtended) + coEvery { + createTransferTransactionUseCase( + amount = any(), + fee = fee, + memo = null, + destination = DESTINATION_ADDRESS, + userWalletId = userWalletId, + network = network, + ) + } returns txData.right() + coEvery { + sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network) + } returns TX_HASH.right() + + val result = sut.sendTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + sendingAmount = BigDecimal("1.0"), + fee = fee, + transactionFeeResult = transactionFeeResult, + ) + + assertThat(result).isEqualTo(TX_HASH.right()) + coVerify { + sendTransactionUseCase(txData = txData, userWallet = userWallet, network = network) + } + coVerify(exactly = 0) { + createAndSendGaslessTransactionUseCase(any(), any(), any()) + } + } + @Test fun `GIVEN createTransferTransactionUseCase fails WHEN sendTransfer THEN return DataError`() = runTest { val userWalletId: UserWalletId = mockk() diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt index 9a5ad696ea..0af80a1a91 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/analytics/SwapEvents.kt @@ -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_TOKEN @@ -13,6 +13,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.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.FeeBucket @@ -247,4 +248,46 @@ sealed class SwapEvents( "Provider" to provider.name, ), ) + + 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 } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt index 59973dc978..3e0eb1ed9f 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/model/SwapModel.kt @@ -601,7 +601,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( @@ -770,7 +778,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, @@ -1291,6 +1302,12 @@ 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) { TangemLogger.e("onTransferClick: missing currency status, aborting") @@ -1332,6 +1349,7 @@ internal class SwapModel @Inject constructor( TangemLogger.e( messageString = "onTransferClick: withdrawTangemPay failed: ${error.getAnalyticsDescription()}", ) + startLoadingQuotesFromLastState() showAlert() } .onRight { result -> @@ -1343,9 +1361,11 @@ internal class SwapModel @Inject constructor( } private fun updateTransferModeTangemPayState() { + sendTransferInProgressEvent() uiState = swapTransferStateBuilder.createTangemPayWithdrawalSuccessState( uiState = uiState, dataState = dataState, + fee = getSelectedSwapFee()?.fee, onExploreClick = { val txUrl = uiState.successState?.txUrl.orEmpty() if (txUrl.isNotEmpty()) { @@ -1373,6 +1393,7 @@ internal class SwapModel @Inject constructor( ).fold( ifLeft = { error -> TangemLogger.e("onTransferClick: transfer failed: ${error.getAnalyticsDescription()}") + startLoadingQuotesFromLastState() showAlert() }, ifRight = { txHash -> @@ -1384,14 +1405,13 @@ internal class SwapModel @Inject constructor( "" } updateWalletBalance() + sendTransferInProgressEvent() uiState = swapTransferStateBuilder.createSuccessState( uiState = uiState, dataState = dataState, - appCurrency = selectedAppCurrencyFlow.value, - isAccountsMode = isAccountsMode, txUrl = txUrl, timestamp = System.currentTimeMillis(), - fee = null, + fee = getSelectedSwapFee()?.fee, onExplorerClick = { if (txUrl.isNotEmpty()) { urlOpener.openUrl(txUrl) @@ -1403,6 +1423,18 @@ internal class SwapModel @Inject constructor( ) } + 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, @@ -2245,6 +2277,7 @@ internal class SwapModel @Inject constructor( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, fromTokenAmount = amount, + selectedToken = selectedToken, ) } val quoteState = dataState.getCurrentLoadedSwapState() ?: return Either.Left(GetFeeError.UnknownError) @@ -2300,10 +2333,6 @@ internal class SwapModel @Inject constructor( modelScope.launch { forceUpdateState.emit(newState.copy(isHidden = true)) } return } - refreshTransferUIStateIfNeeded( - feePaidCryptoCurrencyStatus = dataState.feePaidCryptoCurrency, - fee = (newState as? FeeSelectorUM.Content)?.selectedFeeItem?.fee, - ) val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus @@ -2312,7 +2341,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 diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt index 7468e1af03..5ae7859709 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactory.kt @@ -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 { 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.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 = {}, + ) + } } \ No newline at end of file diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt index 97e06f6f2b..9f2b1065b0 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -59,6 +59,7 @@ internal class SwapTransferStateBuilder @Inject constructor( transferState = transferState, feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, fee = fee, + onBuyClick = actions.openTokenDetailsScreen, onReduceByAmount = actions.onReduceByAmount, onReduceToAmount = actions.onReduceToAmount, ) @@ -227,6 +228,7 @@ internal class SwapTransferStateBuilder @Inject constructor( transferState = transferState, feeCryptoCurrencyStatus = feePaidCryptoCurrencyStatus, fee = fee, + onBuyClick = actions.openTokenDetailsScreen, onReduceByAmount = actions.onReduceByAmount, onReduceToAmount = actions.onReduceToAmount, ) @@ -325,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 @@ -341,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), ) @@ -359,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), @@ -385,6 +386,7 @@ internal class SwapTransferStateBuilder @Inject constructor( fun createTangemPayWithdrawalSuccessState( uiState: SwapStateHolder, dataState: SwapProcessDataState, + fee: Fee?, onExploreClick: () -> Unit, ): SwapStateHolder { val fromSwapCurrencyStatus = requireNotNull(dataState.fromSwapCurrencyStatus) @@ -407,7 +409,7 @@ internal class SwapTransferStateBuilder @Inject constructor( isTransferMode = true, providerIcon = "", rate = TextReference.EMPTY, - fee = null, + fee = fee?.let { formatFeeForSuccess(transferState = transferState, fee = it) }, fromTitle = getCardAccountTitle( account = fromSwapCurrencyStatus.account, isAccountsMode = transferState.isAccountsMode, @@ -429,4 +431,19 @@ internal class SwapTransferStateBuilder @Inject constructor( ), ) } + + 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)") + } } \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt index ab218123aa..f541df7896 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferNotificationsFactoryTest.kt @@ -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()).hasSize(1) @@ -91,6 +93,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = fee, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -113,6 +116,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -131,6 +135,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -154,6 +159,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).hasSize(1) @@ -176,6 +182,7 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) val reserve = result.filterIsInstance() @@ -199,15 +206,39 @@ internal class SwapTransferNotificationsFactoryTest { fee = null, onReduceByAmount = { _, _ -> }, onReduceToAmount = {}, + onBuyClick = {}, ) assertThat(result.filterIsInstance()).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()).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, diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt index b33d90d5e3..16bddb47cc 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -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() @@ -330,6 +336,7 @@ internal class SwapTransferStateBuilderTest { fee = fee, onReduceByAmount = any(), onReduceToAmount = any(), + onBuyClick = any(), ) } } @@ -468,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 = {}, ) @@ -495,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() @@ -613,6 +635,7 @@ internal class SwapTransferStateBuilderTest { val result = sut.createTangemPayWithdrawalSuccessState( uiState = baseStateHolder(), dataState = dataState, + fee = null, onExploreClick = onExploreClick, ) val after = System.currentTimeMillis() @@ -707,6 +730,7 @@ internal class SwapTransferStateBuilderTest { userWallet = coldWallet, fromTokenInfo = fromInfo, toTokenInfo = toInfo, + cryptoCurrencyWarning = null, isInsufficientBalance = isInsufficientBalance, appCurrency = AppCurrency.Default, isBalanceHidden = false,