From d5554a7f7d0dbcc15f374442643c8b27fa86bf0d Mon Sep 17 00:00:00 2001 From: Tangem Date: Tue, 12 May 2026 10:50:31 +0100 Subject: [PATCH] Updated on 2026-08-14 --- .../CryptoCurrencyToIconStateConverterTest.kt | 282 +++++++++++++++++ core/res/src/main/res/values/strings.xml | 12 +- features/swap/domain/build.gradle.kts | 10 +- .../swap/domain/di/SwapDomainModule.kt | 6 + .../swap/domain/models/ui/SwapState.kt | 17 +- .../domain/transfer/SwapTransferInteractor.kt | 16 + .../transfer/SwapTransferInteractorImpl.kt | 97 ++++++ .../SwapTransferInteractorImplTest.kt | 287 ++++++++++++++++++ .../tangem/feature/swap/model/SwapModel.kt | 153 ++++++++-- .../feature/swap/models/SwapStateHolder.kt | 14 +- .../tangem/feature/swap/models/UiActions.kt | 1 + .../tangem/feature/swap/ui/StateBuilder.kt | 10 +- .../feature/swap/ui/SwapScreenContent.kt | 22 +- .../ui/transfer/SwapTransferStateBuilder.kt | 172 +++++++++++ .../feature/swap/StateBuilderSwapDataTest.kt | 15 +- .../transfer/SwapTransferStateBuilderTest.kt | 191 ++++++++++++ 16 files changed, 1248 insertions(+), 57 deletions(-) create mode 100644 common/ui/src/test/kotlin/com/tangem/common/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverterTest.kt create mode 100644 features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt create mode 100644 features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt create mode 100644 features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt create mode 100644 features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt create mode 100644 features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt diff --git a/common/ui/src/test/kotlin/com/tangem/common/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverterTest.kt b/common/ui/src/test/kotlin/com/tangem/common/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverterTest.kt new file mode 100644 index 0000000000..f3a3e6a414 --- /dev/null +++ b/common/ui/src/test/kotlin/com/tangem/common/ui/components/currency/icon/converter/CryptoCurrencyToIconStateConverterTest.kt @@ -0,0 +1,282 @@ +package com.tangem.common.ui.components.currency.icon.converter + +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.extensions.networkIconResId +import com.tangem.core.ui.components.currency.icon.CurrencyIconState +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.models.network.Network +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class CryptoCurrencyToIconStateConverterTest { + + private val sut = CryptoCurrencyToIconStateConverter(isAvailable = true) + private val sutUnavailable = CryptoCurrencyToIconStateConverter(isAvailable = false) + + @BeforeEach + fun setUp() { + mockkStatic("com.tangem.common.ui.extensions.NetworkIconExtKt") + } + + @AfterEach + fun tearDown() { + unmockkAll() + } + + // region public API — convert(value: CryptoCurrencyStatus) + + @Test + fun `GIVEN coin status WHEN convert THEN return CoinIcon with currency and network fields`() { + val coin = buildCoin( + isTestnet = false, + isCustom = false, + iconUrl = "https://example.com/eth.png", + ) + val status = buildStatus(currency = coin, isError = false) + + val result = sut.convert(status) + + assertThat(result).isEqualTo( + CurrencyIconState.CoinIcon( + url = "https://example.com/eth.png", + fallbackResId = NETWORK_ICON_RES_ID, + isGrayscale = false, + shouldShowCustomBadge = false, + ), + ) + } + + @Test + fun `GIVEN token status WHEN convert THEN return TokenIcon with currency and network fields`() { + val token = buildToken( + isTestnet = false, + isCustom = false, + iconUrl = "https://example.com/usdt.png", + contractAddress = USDT_CONTRACT, + ) + val status = buildStatus(currency = token, isError = false) + + val result = sut.convert(status) as CurrencyIconState.TokenIcon + + assertThat(result.url).isEqualTo("https://example.com/usdt.png") + assertThat(result.topBadgeIconResId).isEqualTo(NETWORK_ICON_RES_ID) + assertThat(result.isGrayscale).isFalse() + assertThat(result.shouldShowCustomBadge).isFalse() + } + + // endregion + + // region public API — convert(currency: CryptoCurrency) + + @Test + fun `GIVEN coin currency WHEN convert without status THEN return CoinIcon with isUnreachable=false`() { + val coin = buildCoin(isTestnet = false, isCustom = false, iconUrl = "url") + + val result = sut.convert(currency = coin) as CurrencyIconState.CoinIcon + + assertThat(result.isGrayscale).isFalse() + assertThat(result.url).isEqualTo("url") + } + + @Test + fun `GIVEN token currency WHEN convert without status THEN return TokenIcon with isErrorStatus=false`() { + val token = buildToken( + isTestnet = false, + isCustom = false, + iconUrl = "url", + contractAddress = USDT_CONTRACT, + ) + + val result = sut.convert(currency = token) as CurrencyIconState.TokenIcon + + assertThat(result.isGrayscale).isFalse() + assertThat(result.url).isEqualTo("url") + } + + // endregion + + // region public API — convertCustom + + @Test + fun `GIVEN coin status with custom flag WHEN convertCustom with forceGrayscale and badge off THEN both flags propagate`() { + val coin = buildCoin(isTestnet = false, isCustom = true, iconUrl = "url") + val status = buildStatus(currency = coin, isError = false) + + val result = sut.convertCustom( + value = status, + forceGrayscale = true, + showCustomTokenBadge = false, + ) as CurrencyIconState.CoinIcon + + assertThat(result.isGrayscale).isTrue() + assertThat(result.shouldShowCustomBadge).isFalse() + } + + @Test + fun `GIVEN token status WHEN convertCustom with forceGrayscale THEN TokenIcon is grayscale`() { + val token = buildToken( + isTestnet = false, + isCustom = false, + iconUrl = "url", + contractAddress = USDT_CONTRACT, + ) + val status = buildStatus(currency = token, isError = false) + + val result = sut.convertCustom( + value = status, + forceGrayscale = true, + showCustomTokenBadge = true, + ) as CurrencyIconState.TokenIcon + + assertThat(result.isGrayscale).isTrue() + } + + // endregion + + // region getIconStateForCoin — isGrayscale matrix + + @Test + fun `GIVEN no override and live data WHEN convert coin THEN isGrayscale is false`() { + val coin = buildCoin(isTestnet = false, isCustom = false, iconUrl = "url") + val status = buildStatus(currency = coin, isError = false) + + val result = sut.convert(status) as CurrencyIconState.CoinIcon + + assertThat(result.isGrayscale).isFalse() + } + + @Test + fun `GIVEN testnet network WHEN convert coin THEN isGrayscale is true`() { + val coin = buildCoin(isTestnet = true, isCustom = false, iconUrl = "url") + val status = buildStatus(currency = coin, isError = false) + + val result = sut.convert(status) as CurrencyIconState.CoinIcon + + assertThat(result.isGrayscale).isTrue() + } + + @Test + fun `GIVEN error status WHEN convert coin THEN isGrayscale is true`() { + val coin = buildCoin(isTestnet = false, isCustom = false, iconUrl = "url") + val status = buildStatus(currency = coin, isError = true) + + val result = sut.convert(status) as CurrencyIconState.CoinIcon + + assertThat(result.isGrayscale).isTrue() + } + + @Test + fun `GIVEN converter not available WHEN convert coin THEN isGrayscale is true`() { + val coin = buildCoin(isTestnet = false, isCustom = false, iconUrl = "url") + val status = buildStatus(currency = coin, isError = false) + + val result = sutUnavailable.convert(status) as CurrencyIconState.CoinIcon + + assertThat(result.isGrayscale).isTrue() + } + + @Test + fun `GIVEN forceGrayscale flag WHEN convertCustom coin THEN isGrayscale is true`() { + val coin = buildCoin(isTestnet = false, isCustom = false, iconUrl = "url") + val status = buildStatus(currency = coin, isError = false) + + val result = sut.convertCustom( + value = status, + forceGrayscale = true, + showCustomTokenBadge = true, + ) as CurrencyIconState.CoinIcon + + assertThat(result.isGrayscale).isTrue() + } + + // endregion + + // region getIconStateForToken — branches + + @Test + fun `GIVEN custom token without iconUrl WHEN convert THEN return CustomTokenIcon`() { + val token = buildToken( + isTestnet = false, + isCustom = true, + iconUrl = null, + contractAddress = USDT_CONTRACT, + ) + val status = buildStatus(currency = token, isError = false) + + val result = sut.convert(status) as CurrencyIconState.CustomTokenIcon + + assertThat(result.topBadgeIconResId).isEqualTo(NETWORK_ICON_RES_ID) + assertThat(result.isGrayscale).isFalse() + assertThat(result.shouldShowCustomBadge).isTrue() + } + + @Test + fun `GIVEN custom token with iconUrl WHEN convert THEN return TokenIcon with custom badge`() { + val token = buildToken( + isTestnet = false, + isCustom = true, + iconUrl = "https://example.com/usdt.png", + contractAddress = USDT_CONTRACT, + ) + val status = buildStatus(currency = token, isError = false) + + val result = sut.convert(status) as CurrencyIconState.TokenIcon + + assertThat(result.url).isEqualTo("https://example.com/usdt.png") + assertThat(result.shouldShowCustomBadge).isTrue() + } + + // endregion + + // region helpers + + private fun buildCoin( + isTestnet: Boolean, + isCustom: Boolean, + iconUrl: String?, + ): CryptoCurrency.Coin { + val network: Network = mockk { every { this@mockk.isTestnet } returns isTestnet } + val coin: CryptoCurrency.Coin = mockk() + every { coin.network } returns network + every { coin.iconUrl } returns iconUrl + every { coin.isCustom } returns isCustom + every { coin.networkIconResId } returns NETWORK_ICON_RES_ID + return coin + } + + private fun buildToken( + isTestnet: Boolean, + isCustom: Boolean, + iconUrl: String?, + contractAddress: String, + ): CryptoCurrency.Token { + val network: Network = mockk { every { this@mockk.isTestnet } returns isTestnet } + val token: CryptoCurrency.Token = mockk() + every { token.network } returns network + every { token.iconUrl } returns iconUrl + every { token.isCustom } returns isCustom + every { token.contractAddress } returns contractAddress + every { token.networkIconResId } returns NETWORK_ICON_RES_ID + return token + } + + private fun buildStatus(currency: CryptoCurrency, isError: Boolean): CryptoCurrencyStatus { + val value: CryptoCurrencyStatus.Value = mockk { every { this@mockk.isError } returns isError } + return CryptoCurrencyStatus(currency = currency, value = value) + } + + // endregion + + private companion object { + const val NETWORK_ICON_RES_ID = 1234 + const val USDT_CONTRACT = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + } +} \ No newline at end of file diff --git a/core/res/src/main/res/values/strings.xml b/core/res/src/main/res/values/strings.xml index 0b2a2f4972..3ed11e5ea1 100644 --- a/core/res/src/main/res/values/strings.xml +++ b/core/res/src/main/res/values/strings.xml @@ -1212,6 +1212,8 @@ Unknown Parameters Credit card or bank account Share your address or QR-code + Sell crypto securely + Send to another wallet Between your portfolios Other Quick top up @@ -1352,8 +1354,6 @@ Target account is not created. Please change the amount to send. The amount to send must be at least %s Leave %s - A trustline for %s is required first. - Can\'t receive token Reduce by %s Reduce to %s Kindly be aware that your transaction may experience delays under specific fee settings @@ -1626,6 +1626,8 @@ not available Not enough liquidity for this trade.\nReduce the amount or choose another provider. Trade too large + Transfer + Transfer... We would be happy to receive your feedback Tangem Pay is now in beta Unable to rename card @@ -1766,6 +1768,8 @@ Pay exactly what you see A separate payment account will be created without disclosing your addresses and assets Unrivaled privacy + And link a payment card to it + We\'ll set up a wallet Get your free Tangem Pay Card in minutes Pay Support Payment account @@ -1815,7 +1819,7 @@ Approval has been revoked. Your funds remain in Yield mode. To perform actions, please go to Yield mode and grant permission again. Available balance Total balance - Earn up to %s a year + Up to %s APR Generate XPUB Hide You are about to hide this token from the main screen. You can add it back anytime through the manage tokens page. @@ -2133,7 +2137,7 @@ MATIC to POL Migration Use your card or ring to get an address for %d network - Use your card or ring to get an addresses for %d networks + Use your card or ring to get addresses for %d networks Some addresses are missing The network is currently unreachable. Please try again later. diff --git a/features/swap/domain/build.gradle.kts b/features/swap/domain/build.gradle.kts index 6631516744..769a037ad0 100644 --- a/features/swap/domain/build.gradle.kts +++ b/features/swap/domain/build.gradle.kts @@ -50,11 +50,7 @@ dependencies { implementation(projects.domain.account.status) implementation(projects.domain.visa) implementation(projects.domain.visa.models) - - implementation(projects.features.swap.api) - implementation(projects.features.swap.domain.api) - implementation(projects.features.swap.domain.models) - implementation(projects.libs.blockchainSdk) + implementation(projects.domain.balanceHiding) /** Core modules */ implementation(projects.core.utils) @@ -63,6 +59,10 @@ dependencies { /** Feature Apis */ implementation(projects.features.wallet.api) + implementation(projects.features.swap.api) + implementation(projects.features.swap.domain.api) + implementation(projects.features.swap.domain.models) + implementation(projects.libs.blockchainSdk) /** Other Libraries **/ implementation(deps.kotlin.coroutines) diff --git a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt index 0e96260f26..963d0ab92d 100644 --- a/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/di/SwapDomainModule.kt @@ -7,6 +7,8 @@ import com.tangem.feature.swap.domain.SwapInteractor import com.tangem.feature.swap.domain.SwapInteractorImpl import com.tangem.feature.swap.domain.api.SwapRepository import com.tangem.features.swap.SwapFeatureToggles +import com.tangem.feature.swap.domain.transfer.SwapTransferInteractor +import com.tangem.feature.swap.domain.transfer.SwapTransferInteractorImpl import dagger.Binds import dagger.Module import dagger.Provides @@ -42,4 +44,8 @@ internal interface SwapDomainBindModule { @Binds @Singleton fun provideSwapInteractor(swapInteractor: SwapInteractorImpl): SwapInteractor + + @Binds + @Singleton + fun provideSwapTransferInteractor(swapTransferInteractor: SwapTransferInteractorImpl): SwapTransferInteractor } \ No newline at end of file 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 562869ee5a..bfafdfeadf 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 @@ -3,7 +3,9 @@ package com.tangem.feature.swap.domain.models.ui import androidx.compose.runtime.Immutable import com.tangem.blockchain.common.transaction.Fee import com.tangem.core.ui.extensions.TextReference +import com.tangem.domain.appcurrency.model.AppCurrency import com.tangem.domain.models.currency.CryptoCurrencyStatus +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.feature.swap.domain.TransactionFeeResult @@ -37,7 +39,20 @@ sealed interface SwapState { val swapProvider: SwapProvider, ) : SwapState - data class EmptyAmountState(val zeroAmountEquivalent: TextReference) : SwapState + data class Transfer( + val userWallet: UserWallet, + val fromTokenInfo: TokenSwapInfo, + val toTokenInfo: TokenSwapInfo, + val txFee: TxFeeState, + val appCurrency: AppCurrency, + val isBalanceHidden: Boolean, + val isAccountsMode: Boolean, + ) : SwapState + + data class EmptyAmountState( + val zeroAmountEquivalent: TextReference, + val isTransferMode: Boolean = false, + ) : SwapState data class SwapError( val fromTokenInfo: TokenSwapInfo, 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 new file mode 100644 index 0000000000..788080bc93 --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractor.kt @@ -0,0 +1,16 @@ +package com.tangem.feature.swap.domain.transfer + +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.feature.swap.domain.models.ui.SwapState + +interface SwapTransferInteractor { + + suspend fun updateTransfer( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + fromTokenAmount: String, + ): SwapState + + fun shouldTransferInsteadOfSwap(fromSwapCurrency: CryptoCurrency, toSwapCurrency: CryptoCurrency): Boolean +} \ No newline at end of file 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 new file mode 100644 index 0000000000..e66862cd6f --- /dev/null +++ b/features/swap/domain/src/main/java/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImpl.kt @@ -0,0 +1,97 @@ +package com.tangem.feature.swap.domain.transfer + +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.core.ui.utils.parseBigDecimalOrNull +import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.extenstions.unwrap +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +import com.tangem.domain.models.currency.CryptoCurrency +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.domain.models.ui.TxFeeState +import com.tangem.features.swap.SwapFeatureToggles +import com.tangem.utils.extensions.orZero +import kotlinx.coroutines.flow.first +import java.math.BigDecimal +import javax.inject.Inject + +class SwapTransferInteractorImpl @Inject constructor( + private val swapFeatureToggles: SwapFeatureToggles, + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase, + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase, + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, +) : SwapTransferInteractor { + + override suspend fun updateTransfer( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + fromTokenAmount: String, + ): SwapState { + val fromToken = fromSwapCurrencyStatus.currency + val toToken = toSwapCurrencyStatus.currency + val appCurrency = getSelectedAppCurrencyUseCase.unwrap() + val isBalanceHidden = getBalanceHidingSettingsUseCase.isBalanceHidden().first() + val isAccountsMode = isAccountsModeEnabledUseCase.invokeSync() + val fromTokenAmountValue = fromTokenAmount.parseBigDecimalOrNull() ?: return createEmptyAmountState(appCurrency) + val fromTokenAmountFiat = fromSwapCurrencyStatus.status.value.fiatRate.orZero() * fromTokenAmountValue + + val fromTokenInfo = TokenSwapInfo( + tokenAmount = SwapAmount(fromTokenAmountValue, fromToken.decimals), + swapCurrencyStatus = fromSwapCurrencyStatus, + amountFiat = fromTokenAmountFiat, + ) + // it is the same with fromToken + val toTokenInfo = TokenSwapInfo( + tokenAmount = SwapAmount(fromTokenAmountValue, toToken.decimals), + swapCurrencyStatus = toSwapCurrencyStatus, + amountFiat = fromTokenAmountFiat, + ) + return SwapState.Transfer( + userWallet = toSwapCurrencyStatus.userWallet, + fromTokenInfo = fromTokenInfo, + toTokenInfo = toTokenInfo, + txFee = TxFeeState.Empty, // todo Will be implemented in [REDACTED_TASK_KEY] + appCurrency = appCurrency, + isBalanceHidden = isBalanceHidden, + isAccountsMode = isAccountsMode, + ) + } + + private fun createEmptyAmountState(appCurrency: AppCurrency): SwapState.EmptyAmountState { + return SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + ), + isTransferMode = true, + ) + } + + override fun shouldTransferInsteadOfSwap( + fromSwapCurrency: CryptoCurrency, + toSwapCurrency: CryptoCurrency, + ): Boolean { + if (swapFeatureToggles.isSwapSwitchToTransferEnabled.not()) return false + val isSameCurrency = when { + fromSwapCurrency is CryptoCurrency.Coin && toSwapCurrency is CryptoCurrency.Coin -> { + fromSwapCurrency.network.rawId == toSwapCurrency.network.rawId + } + fromSwapCurrency is CryptoCurrency.Token && toSwapCurrency is CryptoCurrency.Token -> { + val isContractAddressSame = fromSwapCurrency.contractAddress == toSwapCurrency.contractAddress + fromSwapCurrency.network.rawId == toSwapCurrency.network.rawId && isContractAddressSame + } + else -> false + } + return isSameCurrency + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000..f67cfeb9b2 --- /dev/null +++ b/features/swap/domain/src/test/kotlin/com/tangem/feature/swap/domain/transfer/SwapTransferInteractorImplTest.kt @@ -0,0 +1,287 @@ +package com.tangem.feature.swap.domain.transfer + +import arrow.core.right +import com.google.common.truth.Truth.assertThat +import com.tangem.domain.account.status.usecase.IsAccountsModeEnabledUseCase +import com.tangem.domain.appcurrency.GetSelectedAppCurrencyUseCase +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.balancehiding.GetBalanceHidingSettingsUseCase +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.UserWallet +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.domain.models.ui.TxFeeState +import com.tangem.features.swap.SwapFeatureToggles +import io.mockk.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@OptIn(ExperimentalCoroutinesApi::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapTransferInteractorImplTest { + + private val swapFeatureToggles: SwapFeatureToggles = mockk() + private val getSelectedAppCurrencyUseCase: GetSelectedAppCurrencyUseCase = mockk() + private val getBalanceHidingSettingsUseCase: GetBalanceHidingSettingsUseCase = mockk() + private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase = mockk() + + private val sut = SwapTransferInteractorImpl( + swapFeatureToggles = swapFeatureToggles, + getSelectedAppCurrencyUseCase = getSelectedAppCurrencyUseCase, + getBalanceHidingSettingsUseCase = getBalanceHidingSettingsUseCase, + isAccountsModeEnabledUseCase = isAccountsModeEnabledUseCase, + ) + + @AfterEach + fun tearDown() { + clearAllMocks() + } + + // region updateTransfer + + @Test + fun `GIVEN unparsable amount WHEN updateTransfer THEN return EmptyAmountState in transfer mode`() = runTest { + val appCurrency = AppCurrency(code = "EUR", name = "Euro", symbol = "€") + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + ) + every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right()) + every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(false) + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns false + + val result = sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = "abc", + ) + + assertThat(result).isInstanceOf(SwapState.EmptyAmountState::class.java) + assertThat((result as SwapState.EmptyAmountState).isTransferMode).isTrue() + verify { getSelectedAppCurrencyUseCase() } + verify { getBalanceHidingSettingsUseCase.isBalanceHidden() } + coVerify { isAccountsModeEnabledUseCase.invokeSync() } + } + + @Test + fun `GIVEN valid amount WHEN updateTransfer THEN return Transfer state with mirrored from-and-to swap info`() = + runTest { + val appCurrency = AppCurrency(code = "USD", name = "US Dollar", symbol = "$") + val userWallet: UserWallet = mockk() + val fromCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = FROM_RAW_CURRENCY_ID, + decimals = FROM_DECIMALS, + fiatRate = BigDecimal.TEN, + ) + val toCurrencyStatus = buildCurrencyStatus( + rawCurrencyId = TO_RAW_CURRENCY_ID, + decimals = TO_DECIMALS, + userWallet = userWallet, + ) + every { getSelectedAppCurrencyUseCase() } returns flowOf(appCurrency.right()) + every { getBalanceHidingSettingsUseCase.isBalanceHidden() } returns flowOf(true) + coEvery { isAccountsModeEnabledUseCase.invokeSync() } returns true + + val result = sut.updateTransfer( + fromSwapCurrencyStatus = fromCurrencyStatus, + toSwapCurrencyStatus = toCurrencyStatus, + fromTokenAmount = "1,5", + ) + + val expectedAmount = BigDecimal("1.5") + val expectedFiat = BigDecimal("15.0") + val expected = SwapState.Transfer( + userWallet = userWallet, + fromTokenInfo = TokenSwapInfo( + tokenAmount = SwapAmount(expectedAmount, FROM_DECIMALS), + swapCurrencyStatus = fromCurrencyStatus, + amountFiat = expectedFiat, + ), + toTokenInfo = TokenSwapInfo( + tokenAmount = SwapAmount(expectedAmount, TO_DECIMALS), + swapCurrencyStatus = toCurrencyStatus, + amountFiat = expectedFiat, + ), + txFee = TxFeeState.Empty, + appCurrency = appCurrency, + isBalanceHidden = true, + isAccountsMode = true, + ) + assertThat(result).isEqualTo(expected) + coVerify { isAccountsModeEnabledUseCase.invokeSync() } + verify { getBalanceHidingSettingsUseCase.isBalanceHidden() } + } + + // endregion + + // region shouldTransferInsteadOfSwap + + @Test + fun `GIVEN feature toggle disabled WHEN shouldTransferInsteadOfSwap THEN return false`() { + every { swapFeatureToggles.isSwapSwitchToTransferEnabled } returns false + + val result = sut.shouldTransferInsteadOfSwap( + fromSwapCurrency = buildCoin(networkRawId = ETHEREUM), + toSwapCurrency = buildCoin(networkRawId = ETHEREUM), + ) + + assertThat(result).isFalse() + verify { swapFeatureToggles.isSwapSwitchToTransferEnabled } + } + + @Test + fun `GIVEN both coins on the same network WHEN shouldTransferInsteadOfSwap THEN return true`() { + every { swapFeatureToggles.isSwapSwitchToTransferEnabled } returns true + + val result = sut.shouldTransferInsteadOfSwap( + fromSwapCurrency = buildCoin(networkRawId = ETHEREUM), + toSwapCurrency = buildCoin(networkRawId = ETHEREUM), + ) + + assertThat(result).isTrue() + } + + @Test + fun `GIVEN coins on different networks WHEN shouldTransferInsteadOfSwap THEN return false`() { + every { swapFeatureToggles.isSwapSwitchToTransferEnabled } returns true + + val result = sut.shouldTransferInsteadOfSwap( + fromSwapCurrency = buildCoin(networkRawId = ETHEREUM), + toSwapCurrency = buildCoin(networkRawId = POLYGON), + ) + + assertThat(result).isFalse() + } + + @Test + fun `GIVEN tokens with same network and same contract WHEN shouldTransferInsteadOfSwap THEN return true`() { + every { swapFeatureToggles.isSwapSwitchToTransferEnabled } returns true + + val result = sut.shouldTransferInsteadOfSwap( + fromSwapCurrency = buildToken(networkRawId = ETHEREUM, contractAddress = USDT_CONTRACT), + toSwapCurrency = buildToken(networkRawId = ETHEREUM, contractAddress = USDT_CONTRACT), + ) + + assertThat(result).isTrue() + } + + @Test + fun `GIVEN tokens with same network but different contract WHEN shouldTransferInsteadOfSwap THEN return false`() { + every { swapFeatureToggles.isSwapSwitchToTransferEnabled } returns true + + val result = sut.shouldTransferInsteadOfSwap( + fromSwapCurrency = buildToken(networkRawId = ETHEREUM, contractAddress = USDT_CONTRACT), + toSwapCurrency = buildToken(networkRawId = ETHEREUM, contractAddress = USDC_CONTRACT), + ) + + assertThat(result).isFalse() + } + + @Test + fun `GIVEN tokens with same contract but different network WHEN shouldTransferInsteadOfSwap THEN return false`() { + every { swapFeatureToggles.isSwapSwitchToTransferEnabled } returns true + + val result = sut.shouldTransferInsteadOfSwap( + fromSwapCurrency = buildToken(networkRawId = ETHEREUM, contractAddress = USDT_CONTRACT), + toSwapCurrency = buildToken(networkRawId = POLYGON, contractAddress = USDT_CONTRACT), + ) + + assertThat(result).isFalse() + } + + @Test + fun `GIVEN coin from and token to WHEN shouldTransferInsteadOfSwap THEN return false`() { + every { swapFeatureToggles.isSwapSwitchToTransferEnabled } returns true + + val result = sut.shouldTransferInsteadOfSwap( + fromSwapCurrency = buildCoin(networkRawId = ETHEREUM), + toSwapCurrency = buildToken(networkRawId = ETHEREUM, contractAddress = USDT_CONTRACT), + ) + + assertThat(result).isFalse() + } + + @Test + fun `GIVEN token from and coin to WHEN shouldTransferInsteadOfSwap THEN return false`() { + every { swapFeatureToggles.isSwapSwitchToTransferEnabled } returns true + + val result = sut.shouldTransferInsteadOfSwap( + fromSwapCurrency = buildToken(networkRawId = ETHEREUM, contractAddress = USDT_CONTRACT), + toSwapCurrency = buildCoin(networkRawId = ETHEREUM), + ) + + assertThat(result).isFalse() + } + + // endregion + + // region helpers + + private fun buildCoin(networkRawId: String): CryptoCurrency.Coin { + val network: Network = mockk { every { rawId } returns networkRawId } + return mockk { + every { this@mockk.network } returns network + } + } + + private fun buildToken(networkRawId: String, contractAddress: String): CryptoCurrency.Token { + val network: Network = mockk { every { rawId } returns networkRawId } + return mockk { + every { this@mockk.network } returns network + every { this@mockk.contractAddress } returns contractAddress + } + } + + private fun buildCurrencyStatus( + rawCurrencyId: CryptoCurrency.RawID?, + decimals: Int, + fiatRate: BigDecimal = BigDecimal.ZERO, + userWallet: UserWallet = mockk(), + ): SwapCurrencyStatus { + val currencyId: CryptoCurrency.ID = mockk { + every { this@mockk.rawCurrencyId } returns rawCurrencyId + } + val currency: CryptoCurrency.Coin = mockk { + every { this@mockk.id } returns currencyId + every { this@mockk.decimals } returns decimals + } + val currencyValue: CryptoCurrencyStatus.Value = mockk { + every { this@mockk.fiatRate } returns fiatRate + } + val status: CryptoCurrencyStatus = mockk { + every { this@mockk.value } returns currencyValue + } + return mockk { + every { this@mockk.currency } returns currency + every { this@mockk.userWallet } returns userWallet + every { this@mockk.status } returns status + } + } + + // endregion + + private companion object { + const val ETHEREUM = "ethereum" + const val POLYGON = "polygon" + const val USDT_CONTRACT = "0xdAC17F958D2ee523a2206206994597C13D831ec7" + const val USDC_CONTRACT = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + const val FROM_DECIMALS = 18 + const val TO_DECIMALS = 6 + val USD_QUOTE: BigDecimal = BigDecimal("2000") + val FROM_RAW_CURRENCY_ID = CryptoCurrency.RawID(value = "eth") + val TO_RAW_CURRENCY_ID = CryptoCurrency.RawID(value = "matic") + } +} \ 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 36da88d7be..8f249d3085 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 @@ -81,15 +81,15 @@ import com.tangem.feature.swap.domain.models.ExpressDataError import com.tangem.feature.swap.domain.models.SwapAmount import com.tangem.feature.swap.domain.models.domain.ExchangeProviderType import com.tangem.feature.swap.domain.models.domain.SwapDataModel +import com.tangem.feature.swap.domain.models.domain.SwapPairLeast import com.tangem.feature.swap.domain.models.domain.SwapProvider import com.tangem.feature.swap.domain.models.ui.* -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.domain.transfer.SwapTransferInteractor +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 +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 @@ -142,6 +142,8 @@ internal class SwapModel @Inject constructor( private val shouldShowStoriesUseCase: ShouldShowStoriesUseCase, private val isAccountsModeEnabledUseCase: IsAccountsModeEnabledUseCase, private val swapInteractor: SwapInteractor, + private val swapTransferInteractor: SwapTransferInteractor, + private val swapTransferStateBuilder: SwapTransferStateBuilder, private val urlOpener: UrlOpener, private val getAccountCurrencyStatusUseCase: GetAccountCurrencyStatusUseCase, private val getPaymentAccountCryptoCurrencyStatusUseCase: GetPaymentAccountCryptoCurrencyStatusUseCase, @@ -183,8 +185,9 @@ internal class SwapModel @Inject constructor( ), ) + private val actions = createUiActions() private val stateBuilder = StateBuilder( - actions = createUiActions(), + actions = actions, isBalanceHiddenProvider = Provider { isBalanceHidden }, appCurrencyProvider = Provider(selectedAppCurrencyFlow::value), isAccountsModeProvider = Provider { isAccountsMode }, @@ -565,6 +568,12 @@ internal class SwapModel @Inject constructor( toSwapCurrencyStatus = newToSwapCurrencyStatus, pairs = dataState.pairs, ) + val isUpdatedToTransferMode = isUpdatedToTransferMode( + fromSwapCurrencyStatus = newFromSwapCurrencyStatus, + toSwapCurrencyStatus = newToSwapCurrencyStatus, + fromTokenAmount = lastAmount.value, + ) + if (isUpdatedToTransferMode) return@launch if (toProvidersList.isEmpty()) { handleSwapNotSupported( fromSwapCurrencyStatus = newFromSwapCurrencyStatus, @@ -584,6 +593,12 @@ internal class SwapModel @Inject constructor( } private fun initSwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) { + val isUpdatedToTransferMode = isUpdatedToTransferMode( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + fromTokenAmount = lastAmount.value, + ) + if (isUpdatedToTransferMode) return modelScope.launch { uiState = stateBuilder.createInitialLoadingState( uiStateHolder = uiState, @@ -620,32 +635,11 @@ internal class SwapModel @Inject constructor( toSwapCurrencyStatus = toSwapCurrencyStatus, ) } else { - uiState = stateBuilder.updateCurrenciesState( - uiStateHolder = uiState, - emptyAmountState = SwapState.EmptyAmountState( - zeroAmountEquivalent = stringReference( - BigDecimal.ZERO.format { - fiat( - fiatCurrencyCode = selectedAppCurrencyFlow.value.code, - fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, - ) - }, - ), - ), + updateCurrenciesStateAndStartLoadingQuotes( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, - shouldResetAmount = false, - ) - dataState = dataState.copy( pairs = pairs, - selectedPairProviders = providerList, - ) - startLoadingQuotes( - amount = lastAmount.value, - reduceBalanceBy = lastReducedBalanceBy.value, - fromSwapCurrencyStatus = fromSwapCurrencyStatus, - toSwapCurrencyStatus = toSwapCurrencyStatus, - toProvidersList = providerList, + providerList = providerList, ) } }, @@ -653,6 +647,81 @@ internal class SwapModel @Inject constructor( }.saveIn(swapPairsJobHolder) } + private fun updateCurrenciesStateAndStartLoadingQuotes( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + pairs: List, + providerList: List, + ) { + uiState = stateBuilder.updateCurrenciesState( + uiStateHolder = uiState, + emptyAmountState = SwapState.EmptyAmountState( + zeroAmountEquivalent = stringReference( + BigDecimal.ZERO.format { + fiat( + fiatCurrencyCode = selectedAppCurrencyFlow.value.code, + fiatCurrencySymbol = selectedAppCurrencyFlow.value.symbol, + ) + }, + ), + ), + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + shouldResetAmount = false, + ) + dataState = dataState.copy( + pairs = pairs, + selectedPairProviders = providerList, + ) + startLoadingQuotes( + amount = lastAmount.value, + reduceBalanceBy = lastReducedBalanceBy.value, + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + toProvidersList = providerList, + ) + } + + private fun isUpdatedToTransferMode( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + fromTokenAmount: String, + ): Boolean { + val shouldTransferInsteadOfSwap = swapTransferInteractor.shouldTransferInsteadOfSwap( + fromSwapCurrencyStatus.currency, + toSwapCurrencyStatus.currency, + ) + if (shouldTransferInsteadOfSwap) { + modelScope.launch { + updateTransferUIState(fromSwapCurrencyStatus, toSwapCurrencyStatus, fromTokenAmount) + } + } + return shouldTransferInsteadOfSwap + } + + private suspend fun updateTransferUIState( + fromSwapCurrencyStatus: SwapCurrencyStatus, + toSwapCurrencyStatus: SwapCurrencyStatus, + fromTokenAmount: String, + ) { + val swapState = swapTransferInteractor.updateTransfer( + fromSwapCurrencyStatus, + toSwapCurrencyStatus, + fromTokenAmount, + ) + when (swapState) { + is SwapState.EmptyAmountState -> setupEmptyAmountUiState(swapState, fromSwapCurrencyStatus) + is SwapState.Transfer -> { + uiState = swapTransferStateBuilder.createTransferState( + actions = actions, + transferState = swapState, + uiStateHolder = uiState, + ) + } + is SwapState.QuotesLoadedState, is SwapState.SwapError -> Unit + } + } + private fun retrySwapPairs(fromSwapCurrencyStatus: SwapCurrencyStatus, toSwapCurrencyStatus: SwapCurrencyStatus) { if (swapPairsJobHolder.isActive) return initSwapPairs(fromSwapCurrencyStatus, toSwapCurrencyStatus) @@ -715,6 +784,12 @@ internal class SwapModel @Inject constructor( val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus val amount = dataState.amount if (fromSwapCurrencyStatus != null && toSwapCurrencyStatus != null && amount != null) { + val isUpdatedToTransferMode = isUpdatedToTransferMode( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + fromTokenAmount = lastAmount.value, + ) + if (isUpdatedToTransferMode) return startLoadingQuotes( fromSwapCurrencyStatus = fromSwapCurrencyStatus, toSwapCurrencyStatus = toSwapCurrencyStatus, @@ -834,6 +909,7 @@ internal class SwapModel @Inject constructor( sendAnalyticsForNotifications(provider, fromSwapCurrencyStatus.status, toSwapCurrencyStatus.status) updatePermissionNotificationState(state) } + is SwapState.Transfer -> Unit is SwapState.EmptyAmountState -> { setupEmptyAmountUiState(state, fromSwapCurrencyStatus) lastPermissionNotificationTokens = null @@ -1277,6 +1353,12 @@ internal class SwapModel @Inject constructor( ) if (toSwapCurrencyStatus != null) { + val isUpdatedToTransferMode = isUpdatedToTransferMode( + fromSwapCurrencyStatus = fromSwapCurrencyStatus, + toSwapCurrencyStatus = toSwapCurrencyStatus, + fromTokenAmount = lastAmount.value, + ) + if (isUpdatedToTransferMode) return@launch if (toSwapCurrencyStatus.status.value.amount != null) { isAmountChangedByUser = true } @@ -1430,6 +1512,9 @@ internal class SwapModel @Inject constructor( ) } }, + onTransferClick = { + // TODO: Will be implemented in [REDACTED_TASK_KEY] + }, onChangeCardsClicked = { onChangeCardsClicked() analyticsEventHandler.send(SwapEvents.ButtonSwipeClicked()) @@ -1556,18 +1641,21 @@ internal class SwapModel @Inject constructor( } private fun filterTokensFromSelector() { - if (swapFeatureToggles.isSwapSwitchToTransferEnabled) return val tokenFilter = { accountStatus: AccountStatus, currencyStatus: CryptoCurrencyStatus -> if (currencyStatus.currency.isCustom) { false } else { val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus val toSwapCurrencyStatus = dataState.toSwapCurrencyStatus + val shouldShowSameCoinsWithDifferentAddress = swapFeatureToggles.isSwapSwitchToTransferEnabled && + fromSwapCurrencyStatus?.account?.accountId != accountStatus.accountId && + fromSwapCurrencyStatus?.currency?.network?.rawId == toSwapCurrencyStatus?.currency?.network?.rawId (fromSwapCurrencyStatus?.account?.accountId != accountStatus.accountId || fromSwapCurrencyStatus.currency.id != currencyStatus.currency.id) && (toSwapCurrencyStatus?.account?.accountId != accountStatus.accountId || - toSwapCurrencyStatus.currency.id != currencyStatus.currency.id) + toSwapCurrencyStatus.currency.id != currencyStatus.currency.id) || + shouldShowSameCoinsWithDifferentAddress } } @@ -1867,6 +1955,7 @@ internal class SwapModel @Inject constructor( override suspend fun loadFeeExtended( selectedToken: CryptoCurrencyStatus?, ): Either { + // TODO use getFeeGaselessUsecase in transfer. Will be implemented in [REDACTED_TASK_KEY] val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val selectedProvider = dataStateStateFlow.first { it.selectedProvider != null }.selectedProvider!! @@ -1920,7 +2009,7 @@ internal class SwapModel @Inject constructor( uiState = uiState.copy( swapButton = uiState.swapButton.copy( isEnabled = false, - isInProgress = false, + mode = SwapButton.Mode.SWAP_PROGRESSING, ), ) modelScope.launch { @@ -1939,7 +2028,7 @@ internal class SwapModel @Inject constructor( override suspend fun loadFee(): Either { TangemLogger.e("loadFee: Start loading fee") - + // TODO use getFeeUsecase in transfer. Will be implemented in [REDACTED_TASK_KEY] val fromSwapCurrencyStatus = dataState.fromSwapCurrencyStatus ?: return Either.Left(GetFeeError.UnknownError) val toSwapCurrencyStatus = diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt index 3afeda4ddc..d96f1afc23 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/SwapStateHolder.kt @@ -72,10 +72,20 @@ sealed class SwapCardState { data class SwapButton( @DrawableRes val walletInteractionIcon: Int?, val isEnabled: Boolean, - val isInProgress: Boolean = false, + val mode: Mode = Mode.SWAP, val isHoldToConfirm: Boolean = false, val onClick: () -> Unit, -) +) { + enum class Mode { + SWAP_PROGRESSING, + SWAP, + TRANSFER, + TRANSFER_PROGRESSING, + } + + val isInProgress + get() = mode == Mode.SWAP_PROGRESSING || mode == Mode.TRANSFER_PROGRESSING +} @Immutable sealed interface TransactionCardType { diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt index ac922cd6f9..f9f2353886 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/models/UiActions.kt @@ -8,6 +8,7 @@ internal data class UiActions( val onAmountChanged: (String) -> Unit, val onAmountSelected: (Boolean) -> Unit, val onSwapClick: () -> Unit, + val onTransferClick: () -> Unit, val onChangeCardsClicked: () -> Unit, val onBackClicked: () -> Unit, val onMaxAmountSelected: () -> Unit, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt index 1aeab71402..fe6ade5424 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/StateBuilder.kt @@ -35,6 +35,7 @@ import com.tangem.feature.swap.domain.models.ui.* import com.tangem.feature.swap.model.SwapNotificationsFactory import com.tangem.feature.swap.model.SwapProcessDataState import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.SwapButton.Mode import com.tangem.feature.swap.models.states.* import com.tangem.feature.swap.presentation.R import com.tangem.feature.swap.utils.formatToUIRepresentation @@ -79,7 +80,7 @@ internal class StateBuilder( swapButton = SwapButton( walletInteractionIcon = null, isEnabled = false, - isInProgress = true, + mode = Mode.SWAP_PROGRESSING, isHoldToConfirm = false, onClick = {}, ), @@ -736,6 +737,7 @@ internal class StateBuilder( swapButton = SwapButton( walletInteractionIcon = fromSwapCurrencyStatus?.userWallet?.let(::walletInterationIcon), isEnabled = false, + mode = if (emptyAmountState.isTransferMode) Mode.TRANSFER else Mode.SWAP, isHoldToConfirm = fromSwapCurrencyStatus?.userWallet?.isHotWallet == true, onClick = { }, ), @@ -749,7 +751,7 @@ internal class StateBuilder( return uiState.copy( swapButton = uiState.swapButton.copy( isEnabled = false, - isInProgress = true, + mode = Mode.SWAP_PROGRESSING, ), ) } @@ -880,7 +882,7 @@ internal class StateBuilder( return uiState.copy( swapButton = uiState.swapButton.copy( isEnabled = false, - isInProgress = false, + mode = Mode.SWAP, ), notifications = notificationsFactory.getApprovalInProgressStateNotification(uiState.notifications), ) @@ -1130,7 +1132,7 @@ internal class StateBuilder( ): ProviderState? { val provider = this.key return when (val state = this.value) { - is SwapState.EmptyAmountState -> null + is SwapState.EmptyAmountState, is SwapState.Transfer -> null is SwapState.QuotesLoadedState -> { SwapProviderStateBuilder.buildContentSelectable( provider = provider, diff --git a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt index 4395a9786f..b8006f40bb 100644 --- a/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/SwapScreenContent.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -345,7 +346,7 @@ private fun MainButton(state: SwapStateHolder) { state.swapButton.isHoldToConfirm -> { HoldToConfirmButton( modifier = Modifier.fillMaxWidth(), - text = stringResourceSafe(R.string.swapping_swap_action), + text = getButtonTitle(state.swapButton.mode), enabled = state.swapButton.isEnabled, onConfirm = state.swapButton.onClick, isLoading = state.swapButton.isInProgress, @@ -355,11 +356,7 @@ private fun MainButton(state: SwapStateHolder) { else -> { PrimaryButtonIconEnd( modifier = Modifier.fillMaxWidth(), - text = if (state.swapButton.isInProgress) { - stringResourceSafe(id = R.string.swapping_swap_action_in_progress) - } else { - stringResourceSafe(id = R.string.swapping_swap_action) - }, + text = getButtonTitle(state.swapButton.mode), iconResId = state.swapButton.walletInteractionIcon, enabled = state.swapButton.isEnabled, onClick = state.swapButton.onClick, @@ -368,6 +365,19 @@ private fun MainButton(state: SwapStateHolder) { } } +@Composable +@ReadOnlyComposable +private fun getButtonTitle(mode: SwapButton.Mode): String { + return when (mode) { + SwapButton.Mode.SWAP_PROGRESSING -> stringResourceSafe(id = R.string.swapping_swap_action_in_progress) + SwapButton.Mode.SWAP -> stringResourceSafe(id = R.string.swapping_swap_action) + SwapButton.Mode.TRANSFER -> stringResourceSafe(id = R.string.swapping_transfer_action) + SwapButton.Mode.TRANSFER_PROGRESSING -> stringResourceSafe( + id = R.string.swapping_transfer_action_in_progress, + ) + } +} + // region preview private val state = SwapStateHolder( 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 new file mode 100644 index 0000000000..fa52e874f8 --- /dev/null +++ b/features/swap/impl/src/main/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilder.kt @@ -0,0 +1,172 @@ +package com.tangem.feature.swap.ui.transfer + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.tangem.common.ui.account.AccountIconUM +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.CryptoPortfolioIconConverter +import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.userwallet.ext.walletInterationIcon +import com.tangem.core.ui.extensions.TextReference +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.core.ui.extensions.stringReference +import com.tangem.core.ui.format.bigdecimal.crypto +import com.tangem.core.ui.format.bigdecimal.fiat +import com.tangem.core.ui.format.bigdecimal.format +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.currency.CryptoCurrencyStatus +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.presentation.R +import com.tangem.feature.swap.utils.formatToUIRepresentation +import com.tangem.utils.StringsSigns.DASH_SIGN +import java.math.BigDecimal +import javax.inject.Inject + +internal class SwapTransferStateBuilder @Inject constructor() { + + private val iconConverter by lazy(::CryptoCurrencyToIconStateConverter) + + fun createTransferState( + actions: UiActions, + transferState: SwapState.Transfer, + uiStateHolder: SwapStateHolder, + ): SwapStateHolder { + val fromTokenSwapInfo = transferState.fromTokenInfo + val toTokenSwapInfo = transferState.toTokenInfo + return uiStateHolder.copy( + sendCardData = createSendSwapCardState( + actions = actions, + tokenSwapInfo = fromTokenSwapInfo, + appCurrency = transferState.appCurrency, + isAccountsMode = transferState.isAccountsMode, + isFromCard = true, + isBalanceHidden = transferState.isBalanceHidden, + ), + receiveCardData = createSendSwapCardState( + actions = actions, + tokenSwapInfo = toTokenSwapInfo, + appCurrency = transferState.appCurrency, + isAccountsMode = transferState.isAccountsMode, + isFromCard = false, + isBalanceHidden = transferState.isBalanceHidden, + ), + swapButton = SwapButton( + walletInteractionIcon = walletInterationIcon(transferState.userWallet), + isEnabled = false, + mode = SwapButton.Mode.TRANSFER, + onClick = actions.onTransferClick, + ), + ) + } + + @Suppress("LongParameterList") + private fun createSendSwapCardState( + actions: UiActions, + tokenSwapInfo: TokenSwapInfo, + appCurrency: AppCurrency, + isAccountsMode: Boolean, + isFromCard: Boolean, + isBalanceHidden: Boolean, + ): SwapCardState { + val swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus + val formattedSwapAmount = tokenSwapInfo.tokenAmount.formatToUIRepresentation() + + return SwapCardState.SwapCardData( + type = createSendTransactionCardType( + actions = actions, + swapCurrencyStatus = tokenSwapInfo.swapCurrencyStatus, + isAccountsMode = isAccountsMode, + isFromCard = isFromCard, + ), + currencyIconState = iconConverter.convert( + value = swapCurrencyStatus.status, + ), + tokenSymbol = stringReference(swapCurrencyStatus.currency.symbol), + amountEquivalent = getFormattedFiatAmount( + appCurrency = appCurrency, + amount = tokenSwapInfo.amountFiat, + ), + amountTextFieldValue = TextFieldValue( + text = formattedSwapAmount, + selection = TextRange(index = formattedSwapAmount.length), + ), + balance = swapCurrencyStatus.status.getFormattedAmount(), + isBalanceHidden = isBalanceHidden, + ) + } + + private fun createSendTransactionCardType( + actions: UiActions, + swapCurrencyStatus: SwapCurrencyStatus, + isAccountsMode: Boolean, + isFromCard: Boolean, + ): TransactionCardType { + val type = if (isFromCard) { + TransactionCardType.Inputtable( + onAmountChanged = actions.onAmountChanged, + onFocusChanged = actions.onAmountSelected, + inputError = TransactionCardType.InputError.Empty, + accountTitleUM = getCardAccountTitle( + account = swapCurrencyStatus.account, + isAccountsMode = isAccountsMode, + isFromCard = true, + ), + isEnabled = true, + ) + } else { + TransactionCardType.ReadOnly( + accountTitleUM = getCardAccountTitle( + account = swapCurrencyStatus.account, + isAccountsMode = isAccountsMode, + isFromCard = false, + ), + ) + } + return type + } + + private fun getCardAccountTitle(account: Account?, isAccountsMode: Boolean, isFromCard: Boolean): AccountTitleUM { + val (prefix, placeholder) = if (isFromCard) { + R.string.swapping_from_account_title to R.string.swapping_from_title_v2 + } else { + R.string.swapping_to_account_title to R.string.swapping_to_title + } + return if (account != null && isAccountsMode) { + AccountTitleUM.Account( + prefixText = resourceReference(prefix), + name = account.accountName.toUM().value, + icon = account.toIconUM(), + ) + } else { + AccountTitleUM.Text(resourceReference(placeholder)) + } + } + + private fun getFormattedFiatAmount(appCurrency: AppCurrency, amount: BigDecimal?): TextReference { + return stringReference( + amount.format { + fiat( + fiatCurrencyCode = appCurrency.code, + fiatCurrencySymbol = appCurrency.symbol, + ) + }, + ) + } + + private fun CryptoCurrencyStatus.getFormattedAmount(): String { + val amount = this.value.amount ?: return DASH_SIGN + return amount.format { crypto(symbol = "", decimals = currency.decimals) } + } + + private fun Account.toIconUM(): AccountIconUM { + return when (this) { + is Account.CryptoPortfolio -> CryptoPortfolioIconConverter.convert(icon) + is Account.Payment -> AccountIconUM.Payment + } + } +} \ No newline at end of file diff --git a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt index dde42d0e89..2d15fe9295 100644 --- a/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/StateBuilderSwapDataTest.kt @@ -20,6 +20,8 @@ import kotlinx.collections.immutable.toImmutableList import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource import java.math.BigDecimal internal class StateBuilderSwapDataTest { @@ -330,10 +332,17 @@ internal class StateBuilderSwapDataTest { assertThat(result.swapButton.isEnabled).isFalse() } - @Test - fun `WHEN called THEN swapButton isInProgress is false`() { + @ParameterizedTest + @EnumSource( + value = SwapButton.Mode::class, + mode = EnumSource.Mode.INCLUDE, + names = ["SWAP_PROGRESSING", "TRANSFER_PROGRESSING"], + ) + fun `WHEN called THEN swapButton isInProgress is false`(mode: SwapButton.Mode) { val baseState = buildReadyState(coldWallet).copy( - swapButton = buildReadyState(coldWallet).swapButton.copy(isInProgress = true), + swapButton = buildReadyState(coldWallet).swapButton.copy( + mode = mode, + ), ) val result = sut.loadingPermissionState(baseState) 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 new file mode 100644 index 0000000000..5e3196c4a2 --- /dev/null +++ b/features/swap/impl/src/test/java/com/tangem/feature/swap/ui/transfer/SwapTransferStateBuilderTest.kt @@ -0,0 +1,191 @@ +package com.tangem.feature.swap.ui.transfer + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.google.common.truth.Truth.assertThat +import com.tangem.common.ui.account.AccountTitleUM +import com.tangem.common.ui.account.CryptoPortfolioIconConverter +import com.tangem.common.ui.account.toUM +import com.tangem.common.ui.components.currency.icon.converter.CryptoCurrencyToIconStateConverter +import com.tangem.common.ui.userwallet.ext.walletInterationIcon +import com.tangem.core.ui.extensions.resourceReference +import com.tangem.domain.appcurrency.model.AppCurrency +import com.tangem.domain.models.account.Account +import com.tangem.domain.models.wallet.UserWallet +import com.tangem.domain.models.wallet.UserWalletId +import com.tangem.domain.swap.models.SwapCurrencyStatus +import com.tangem.feature.swap.buildSwapCurrencyStatus +import com.tangem.feature.swap.domain.models.SwapAmount +import com.tangem.feature.swap.domain.models.ui.PriceImpact +import com.tangem.feature.swap.domain.models.ui.SwapState +import com.tangem.feature.swap.domain.models.ui.TokenSwapInfo +import com.tangem.feature.swap.domain.models.ui.TxFeeState +import com.tangem.feature.swap.models.* +import com.tangem.feature.swap.models.states.ProviderState +import com.tangem.feature.swap.presentation.R +import com.tangem.feature.swap.utils.formatToUIRepresentation +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import java.math.BigDecimal + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +internal class SwapTransferStateBuilderTest { + + private val actions: UiActions = mockk(relaxed = true) + private val sut = SwapTransferStateBuilder() + + private val userWalletId = UserWalletId(stringValue = "deadbeef") + private val coldWallet: UserWallet.Cold = mockk(relaxed = true) { + every { walletId } returns userWalletId + } + private val fromCurrencyStatus: SwapCurrencyStatus = buildSwapCurrencyStatus(coldWallet) + private val toCurrencyStatus: SwapCurrencyStatus = buildSwapCurrencyStatus(coldWallet) + private val iconConverter = CryptoCurrencyToIconStateConverter() + private val fromIcon = iconConverter.convert(fromCurrencyStatus.status) + private val toIcon = iconConverter.convert(toCurrencyStatus.status) + + @Test + fun `GIVEN accounts mode enabled WHEN createTransferState THEN cards expose Account titles for from and to`() { + val transferState = buildTransferState( + fromAmount = BigDecimal("1.5"), + toAmount = BigDecimal("1.5"), + isAccountsMode = true, + ) + + val result = sut.createTransferState(actions, transferState, baseStateHolder()) + + val portfolioAccount = fromCurrencyStatus.account as Account.CryptoPortfolio + val expectedAccountIcon = CryptoPortfolioIconConverter.convert(portfolioAccount.icon) + val expectedAccountName = portfolioAccount.accountName.toUM().value + val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable + val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly + assertThat(sendType.accountTitleUM).isEqualTo( + AccountTitleUM.Account( + prefixText = resourceReference(R.string.swapping_from_account_title), + name = expectedAccountName, + icon = expectedAccountIcon, + ), + ) + assertThat(receiveType.accountTitleUM).isEqualTo( + AccountTitleUM.Account( + prefixText = resourceReference(R.string.swapping_to_account_title), + name = expectedAccountName, + icon = expectedAccountIcon, + ), + ) + assertSharedCardShape( + result = result, + transferState = transferState, + ) + } + + @Test + fun `GIVEN accounts mode disabled WHEN createTransferState THEN cards fall back to Text titles for from and to`() { + val transferState = buildTransferState( + fromAmount = BigDecimal("2"), + toAmount = BigDecimal("2"), + isAccountsMode = false, + ) + + val result = sut.createTransferState(actions, transferState, baseStateHolder()) + + val sendType = (result.sendCardData as SwapCardState.SwapCardData).type as TransactionCardType.Inputtable + val receiveType = (result.receiveCardData as SwapCardState.SwapCardData).type as TransactionCardType.ReadOnly + assertThat(sendType.accountTitleUM).isEqualTo( + AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)), + ) + assertThat(receiveType.accountTitleUM).isEqualTo( + AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)), + ) + assertSharedCardShape( + result = result, + transferState = transferState, + ) + } + + private fun assertSharedCardShape( + result: SwapStateHolder, + transferState: SwapState.Transfer, + ) { + val sendCard = result.sendCardData as SwapCardState.SwapCardData + val receiveCard = result.receiveCardData as SwapCardState.SwapCardData + val expectedFromText = transferState.fromTokenInfo.tokenAmount.formatToUIRepresentation() + val expectedToText = transferState.toTokenInfo.tokenAmount.formatToUIRepresentation() + assertThat(sendCard.amountTextFieldValue).isEqualTo( + TextFieldValue(text = expectedFromText, selection = TextRange(index = expectedFromText.length)), + ) + assertThat(receiveCard.amountTextFieldValue).isEqualTo( + TextFieldValue(text = expectedToText, selection = TextRange(index = expectedToText.length)), + ) + assertThat(sendCard.currencyIconState).isEqualTo(fromIcon) + assertThat(receiveCard.currencyIconState).isEqualTo(toIcon) + assertThat(sendCard.isBalanceHidden).isEqualTo(transferState.isBalanceHidden) + assertThat(receiveCard.isBalanceHidden).isEqualTo(transferState.isBalanceHidden) + assertThat((sendCard.type is TransactionCardType.Inputtable)).isTrue() + assertThat(receiveCard.type).isInstanceOf(TransactionCardType.ReadOnly::class.java) + assertThat(result.swapButton).isEqualTo( + SwapButton( + walletInteractionIcon = walletInterationIcon(transferState.userWallet), + isEnabled = false, + mode = SwapButton.Mode.TRANSFER, + onClick = actions.onTransferClick, + ), + ) + } + + private fun buildTransferState( + fromAmount: BigDecimal, + toAmount: BigDecimal, + isAccountsMode: Boolean, + ): SwapState.Transfer { + val fromInfo = TokenSwapInfo( + tokenAmount = SwapAmount(value = fromAmount, decimals = fromCurrencyStatus.currency.decimals), + amountFiat = fromAmount * QUOTE, + swapCurrencyStatus = fromCurrencyStatus, + ) + val toInfo = TokenSwapInfo( + tokenAmount = SwapAmount(value = toAmount, decimals = toCurrencyStatus.currency.decimals), + amountFiat = toAmount * QUOTE, + swapCurrencyStatus = toCurrencyStatus, + ) + return SwapState.Transfer( + userWallet = coldWallet, + fromTokenInfo = fromInfo, + toTokenInfo = toInfo, + txFee = TxFeeState.Empty, + appCurrency = AppCurrency.Default, + isBalanceHidden = false, + isAccountsMode = isAccountsMode, + ) + } + + private fun baseStateHolder(): SwapStateHolder = SwapStateHolder( + sendCardData = SwapCardState.Loading( + type = TransactionCardType.ReadOnly( + accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.swapping_from_title_v2)), + ), + ), + receiveCardData = SwapCardState.Loading( + type = TransactionCardType.ReadOnly( + accountTitleUM = AccountTitleUM.Text(resourceReference(R.string.swapping_to_title)), + ), + ), + isInsufficientFunds = false, + changeCardsButtonState = ChangeCardsButtonState.ENABLED, + providerState = ProviderState.Empty(), + priceImpact = PriceImpact.Empty, + swapButton = SwapButton(walletInteractionIcon = null, isEnabled = false, onClick = {}), + shouldShowMaxAmount = false, + onRefresh = {}, + onBackClicked = {}, + onChangeCardsClicked = {}, + onSelectTokenClick = {}, + onSuccess = {}, + ) + + private companion object { + val QUOTE: BigDecimal = BigDecimal("2000") + } +} \ No newline at end of file